=> {
- if (files.length === 0) return []
- if (!services?.attachmentApi) {
- throw new Error(t('todo.attachments_unavailable', '附件服务当前不可用'))
- }
- return Promise.all(files.map(file => services.attachmentApi!.uploadAttachment(file)))
- }
-
- const createDraft = (values: TodoCreateValues, attachments: Attachment[]) => {
- const now = new Date().toISOString()
- const draft: TodoDraft = {
- id: `draft-${crypto.randomUUID()}`,
- projectId: values.projectId,
- state: values.state,
- title: deriveTodoTitle(values.markdown),
- markdown: values.markdown.trim(),
- goal: values.goal.trim(),
- priority: values.priority,
- assignee: values.assignee,
- launchMode: values.launchMode,
- dueDate: values.dueDate,
- attachments,
- createdAt: now,
- updatedAt: now,
- }
- setDrafts(current => [draft, ...current])
- selectProject(values.projectId)
- return draft
- }
-
- const submitTodo = async (values: TodoCreateValues, runImmediately: boolean) => {
- const project = projectEntries.find(entry => entry.project.id === values.projectId)?.project
- if (!project) throw new Error(t('todo.no_project', '未选择项目'))
- if (runImmediately && values.assignee === 'unassigned') {
- throw new Error(t('todo.executor_required', '请先选择员工或 AI 智能体作为执行者'))
- }
- const attachments = await uploadFiles(values.files)
-
- if (!runImmediately || values.assignee === 'human') {
- createDraft(
- {
- ...values,
- state: runImmediately && values.assignee === 'human' ? 'started' : values.state,
- },
- attachments
- )
- setCreateDialogState(null)
- return
- }
- if (!onRunTodo) throw new Error(t('todo.run_unavailable', '运行服务当前不可用'))
- const address = await onRunTodo({
- project,
- message: values.markdown.trim(),
- goal: values.goal.trim() || undefined,
- attachments,
- })
- if (!address) throw new Error(t('todo.create_failed', 'TODO 创建失败'))
- selectProject(values.projectId)
- setCreateDialogState(null)
- }
-
- const runDraft = async (item: TodoItem) => {
- const draft = drafts.find(entry => entry.id === item.id)
- if (!draft) return
- const project = projectEntries.find(entry => entry.project.id === draft.projectId)?.project
- if (!project || !onRunTodo) throw new Error(t('todo.run_unavailable', '运行服务当前不可用'))
- if (draft.assignee === 'unassigned') {
- throw new Error(t('todo.executor_required', '请先选择员工或 AI 智能体作为执行者'))
- }
- if (draft.assignee === 'human') {
- setDrafts(current =>
- current.map(entry =>
- entry.id === draft.id
- ? { ...entry, state: 'started', updatedAt: new Date().toISOString() }
- : entry
- )
- )
- setSelectedItemId(null)
- return
- }
- const address = await onRunTodo({
- project,
- message: draft.markdown,
- goal: draft.goal || undefined,
- attachments: draft.attachments,
- })
- if (!address) throw new Error(t('todo.create_failed', 'TODO 创建失败'))
- setDrafts(current => current.filter(entry => entry.id !== draft.id))
- setSelectedItemId(null)
- }
-
- const updateDraftAttachments = (draftId: string, attachments: Attachment[]) => {
- setDrafts(current =>
- current.map(draft =>
- draft.id === draftId
- ? { ...draft, attachments, updatedAt: new Date().toISOString() }
- : draft
- )
- )
- }
-
- const deleteDraft = (draftId: string) => {
- setDrafts(current => current.filter(draft => draft.id !== draftId))
- setSelectedItemId(null)
- }
-
- const openWeworkProjects = (createProject = false) => {
- navigateTo('/')
- if (createProject) {
- window.setTimeout(() => requestProjectCreateMode('scratch'), 0)
- }
- }
-
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
- event.preventDefault()
- event.stopPropagation()
- setProjectMenuOpen(true)
- return
- }
- if (event.key === 'Escape') {
- setProjectMenuOpen(false)
- setSearchOpen(false)
- setDisplayOpen(false)
- }
- }
- window.addEventListener('keydown', handleKeyDown, true)
- return () => window.removeEventListener('keydown', handleKeyDown, true)
- }, [])
-
- return (
-
-
setSidebarCollapsed(value => !value)}
- onSelectProject={selectProject}
- onToggleProject={projectId =>
- setExpandedProjectId(current => (current === projectId ? null : projectId))
- }
- onSelectView={setProjectView}
- onCreate={() => setCreateDialogState('backlog')}
- onSearch={() => setSearchOpen(true)}
- onAddProject={() => openWeworkProjects(true)}
- onOpenProjects={() => openWeworkProjects(false)}
- />
-
-
-
-
-
- {sidebarCollapsed && (
-
- setSidebarCollapsed(false)}
- toggleTestId="todo-expand-sidebar"
- className="gap-1"
- />
-
- navigateTo(
- app === 'wework'
- ? '/'
- : app === 'todo'
- ? '/todo'
- : app === 'wegent'
- ? '/app/wegent'
- : '/apps'
- )
- }
- testIds={{
- wework: 'todo-collapsed-app-wework',
- todo: 'todo-collapsed-app-current',
- apps: 'todo-collapsed-app-apps',
- wegent: 'todo-collapsed-app-wegent',
- }}
- />
-
- )}
-
-
- {projectView === 'overview' ? (
-
- ) : (
-
- )}
-
- {projectView === 'overview' ? t('todo.overview', '总览') : 'Work items'}
-
-
- {items.length}
-
-
-
-
- {projectView === 'work-items' && (
- <>
-
- setLayout('board')}
- />
- setLayout('list')}
- />
-
-
0}
- badge={countActiveTodoFilters(filters)}
- onClick={() => {
- setDisplayOpen(false)
- setFiltersOpen(value => !value)
- }}
- />
- setDisplayOpen(value => !value)}
- />
- >
- )}
-
-
-
-
- {projectView === 'work-items' ? (
- setDisplayOpen(false)}
- onSelectItem={item => setSelectedItemId(item.id)}
- onCreate={state => setCreateDialogState(state)}
- />
- ) : (
- setProjectView('work-items')}
- onSelectItem={item => setSelectedItemId(item.id)}
- />
- )}
-
- {projectMenuOpen && (
- setProjectMenuOpen(false)}
- onSelectProject={projectId => {
- selectProject(projectId)
- setProjectMenuOpen(false)
- setProjectSearch('')
- }}
- onViewAll={() => openWeworkProjects(false)}
- />
- )}
- {searchOpen && (
- setSearchOpen(false)}
- onSelectItem={item => {
- setSelectedItemId(item.id)
- setSearchOpen(false)
- }}
- />
- )}
- {selectedItem && (selectedItem.kind === 'draft' || onOpenRuntimeTask) && (
- setSelectedItemId(null)}
- onOpenRuntimeTask={onOpenRuntimeTask}
- onRun={selectedItem.kind === 'draft' ? () => runDraft(selectedItem) : undefined}
- onDelete={
- selectedItem.kind === 'draft' ? () => deleteDraft(selectedItem.id) : undefined
- }
- onAttachmentsChange={
- selectedItem.kind === 'draft'
- ? attachments => updateDraftAttachments(selectedItem.id, attachments)
- : undefined
- }
- />
- )}
-
- {createDialogState && selectedProject && (
- entry.project)}
- initialProjectId={selectedProject.project.id}
- initialState={createDialogState}
- modelName={modelName}
- onClose={() => setCreateDialogState(null)}
- onSubmit={submitTodo}
- />
- )}
-
- )
-}
-
-function ViewButton({
- testId,
- label,
- icon: Icon,
- active = false,
- onClick,
-}: {
- testId: string
- label: string
- icon: typeof Grid2X2
- active?: boolean
- onClick: () => void
-}) {
- return (
-
- )
-}
-
-function ToolbarButton({
- testId,
- icon: Icon,
- label,
- active = false,
- badge = 0,
- onClick,
-}: {
- testId: string
- icon: typeof Filter
- label: string
- active?: boolean
- badge?: number
- onClick: () => void
-}) {
- return (
-
- )
-}
diff --git a/wework/src/features/todo/localTodoProjects.test.ts b/wework/src/features/todo/localTodoProjects.test.ts
new file mode 100644
index 0000000000..1a5daa8a76
--- /dev/null
+++ b/wework/src/features/todo/localTodoProjects.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest'
+import type { ProjectWithTasks, RuntimeWorkListResponse } from '@/types/api'
+import { resolveLocalTodoProjects } from './localTodoProjects'
+
+describe('resolveLocalTodoProjects', () => {
+ it('includes local projects discovered from runtime work', () => {
+ const runtimeWork: RuntimeWorkListResponse = {
+ projects: [
+ {
+ project: {
+ key: 'local:11',
+ id: 11,
+ name: 'Wegent',
+ kind: 'local',
+ source: 'local_project',
+ },
+ deviceWorkspaces: [],
+ },
+ ],
+ chats: [],
+ totalTasks: 0,
+ }
+
+ expect(resolveLocalTodoProjects([], runtimeWork)).toEqual([
+ expect.objectContaining({ id: 11, name: 'Wegent' }),
+ ])
+ })
+
+ it('keeps stored metadata and excludes remote project descriptors', () => {
+ const stored: ProjectWithTasks = {
+ id: 11,
+ name: 'Stored name',
+ config: { mode: 'workspace' },
+ tasks: [],
+ }
+ const runtimeWork: RuntimeWorkListResponse = {
+ projects: [
+ {
+ project: { key: 'local:11', id: 11, name: 'Runtime name', kind: 'local' },
+ deviceWorkspaces: [],
+ },
+ {
+ project: { key: 'remote:12', id: 12, name: 'Remote', kind: 'remote' },
+ deviceWorkspaces: [],
+ },
+ ],
+ chats: [],
+ totalTasks: 0,
+ }
+
+ expect(resolveLocalTodoProjects([stored], runtimeWork)).toEqual([
+ expect.objectContaining({
+ id: 11,
+ name: 'Runtime name',
+ config: stored.config,
+ }),
+ ])
+ })
+})
diff --git a/wework/src/features/todo/localTodoProjects.ts b/wework/src/features/todo/localTodoProjects.ts
new file mode 100644
index 0000000000..065d924d4f
--- /dev/null
+++ b/wework/src/features/todo/localTodoProjects.ts
@@ -0,0 +1,30 @@
+import type { ProjectWithTasks, RuntimeWorkListResponse } from '@/types/api'
+
+export function resolveLocalTodoProjects(
+ projects: ProjectWithTasks[],
+ runtimeWork: RuntimeWorkListResponse | null
+): ProjectWithTasks[] {
+ const resolved = new Map(projects.map(project => [project.id, project]))
+
+ for (const runtimeProject of runtimeWork?.projects ?? []) {
+ const project = runtimeProject.project
+ if (
+ typeof project.id !== 'number' ||
+ project.kind === 'remote' ||
+ project.source === 'remote_project'
+ ) {
+ continue
+ }
+ const existing = resolved.get(project.id)
+ resolved.set(project.id, {
+ ...existing,
+ id: project.id,
+ name: project.name,
+ description: project.description ?? existing?.description,
+ color: project.color ?? existing?.color,
+ tasks: existing?.tasks ?? [],
+ })
+ }
+
+ return [...resolved.values()]
+}
diff --git a/wework/src/features/todo/taskDescription.ts b/wework/src/features/todo/taskDescription.ts
new file mode 100644
index 0000000000..47b2f5877b
--- /dev/null
+++ b/wework/src/features/todo/taskDescription.ts
@@ -0,0 +1,6 @@
+export function normalizeTaskDescription(value: string): string {
+ const trimmed = value.trim()
+ if (!trimmed) return ''
+ const decoded = trimmed.replaceAll('<', '<').replaceAll('>', '>').replaceAll(' ', ' ')
+ return /^(?:(?:\s|
)*<\/p>)+$/i.test(decoded) ? '' : value
+}
diff --git a/wework/src/features/todo/todoModel.test.ts b/wework/src/features/todo/todoModel.test.ts
new file mode 100644
index 0000000000..e1134d7939
--- /dev/null
+++ b/wework/src/features/todo/todoModel.test.ts
@@ -0,0 +1,61 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import {
+ DEFAULT_TODO_WORKFLOW,
+ loadLocalWorkItems,
+ loadTodoWorkflow,
+ saveTodoWorkflow,
+} from './todoModel'
+
+describe('todoModel', () => {
+ beforeEach(() => window.localStorage.clear())
+
+ it('migrates legacy drafts to root work items once', () => {
+ window.localStorage.setItem(
+ 'wework:todo:drafts:1',
+ JSON.stringify([
+ {
+ id: 'draft-1',
+ projectId: 7,
+ state: 'backlog',
+ title: 'Legacy item',
+ markdown: 'Legacy context',
+ goal: 'Ship safely',
+ priority: 'high',
+ assignee: 'ai',
+ attachments: [],
+ createdAt: '2026-07-16T00:00:00Z',
+ updatedAt: '2026-07-16T00:00:00Z',
+ },
+ ])
+ )
+
+ expect(loadLocalWorkItems(1)).toEqual([
+ expect.objectContaining({
+ id: 'draft-1',
+ description: 'Legacy context',
+ objective: 'Ship safely',
+ assignee: { type: 'ai' },
+ }),
+ ])
+ expect(window.localStorage.getItem('wework:todo:drafts:1')).toBeNull()
+ expect(window.localStorage.getItem('wework:todo:work-items:1')).toContain('Legacy item')
+ })
+
+ it('stores workflow configuration per project', () => {
+ const config = {
+ ...DEFAULT_TODO_WORKFLOW,
+ workTypes: [
+ ...DEFAULT_TODO_WORKFLOW.workTypes,
+ {
+ key: 'security',
+ name: 'Security',
+ dependsOn: [],
+ defaultAssignee: { type: 'human' as const, name: 'Security owner' },
+ },
+ ],
+ }
+ saveTodoWorkflow(7, config)
+ expect(loadTodoWorkflow(7)).toEqual(config)
+ expect(loadTodoWorkflow(9)).toEqual(DEFAULT_TODO_WORKFLOW)
+ })
+})
diff --git a/wework/src/features/todo/todoModel.ts b/wework/src/features/todo/todoModel.ts
new file mode 100644
index 0000000000..f33a6a5791
--- /dev/null
+++ b/wework/src/features/todo/todoModel.ts
@@ -0,0 +1,358 @@
+import { invoke } from '@tauri-apps/api/core'
+import { isTauriRuntime } from '@/lib/runtime-environment'
+import type { Attachment, RuntimeTaskAddress } from '@/types/api'
+import type { TodoViewState } from './TodoDetailPanel'
+
+export type TodoPrincipalType = 'unassigned' | 'human' | 'ai'
+
+export interface TodoPrincipal {
+ type: TodoPrincipalType
+ id?: string
+ name?: string
+}
+
+export interface TodoWorkType {
+ key: string
+ name: string
+ dependsOn: string[]
+ defaultAssignee: TodoPrincipal
+}
+
+export interface TodoWorkflowStatus {
+ key: TodoViewState
+ name: string
+}
+
+export interface TodoWorkflowConfig {
+ version: 1
+ statuses: TodoWorkflowStatus[]
+ workTypes: TodoWorkType[]
+}
+
+export interface LocalWorkItem {
+ id: string
+ projectId: number
+ parentId?: string
+ title: string
+ objective: string
+ description: string
+ state: TodoViewState
+ workTypeKey?: string
+ workTypeSnapshot?: TodoWorkType
+ assignee: TodoPrincipal
+ collaborators: TodoPrincipal[]
+ confirmer?: TodoPrincipal
+ blocker: string
+ nextAction: string
+ priority: 'none' | 'low' | 'normal' | 'high' | 'urgent'
+ dueDate?: string
+ attachments: Attachment[]
+ runtimeRefs: RuntimeTaskAddress[]
+ events: TodoWorkItemEvent[]
+ sortOrder: number
+ createdAt: string
+ updatedAt: string
+}
+
+export interface TodoWorkItemEvent {
+ id: string
+ type: 'created' | 'updated' | 'run-linked' | 'confirmed'
+ summary: string
+ createdAt: string
+}
+
+export interface TodoWorkspaceEntry {
+ path: string
+ name: string
+ nodeType: 'file' | 'directory'
+ size: number
+ modifiedAtMs: number
+ absolutePath: string
+}
+
+export const DEFAULT_TODO_WORKFLOW: TodoWorkflowConfig = {
+ version: 1,
+ statuses: [
+ { key: 'inbox', name: '收集箱' },
+ { key: 'backlog', name: '待开始' },
+ { key: 'started', name: '进行中' },
+ { key: 'review', name: '待确认' },
+ { key: 'completed', name: '已完成' },
+ ],
+ workTypes: [],
+}
+
+export const TODO_WORKFLOW_TEMPLATES: Array<{
+ key: string
+ name: string
+ workTypes: TodoWorkType[]
+}> = [
+ { key: 'personal', name: '个人事项', workTypes: [] },
+ {
+ key: 'software',
+ name: '软件交付',
+ workTypes: [
+ { key: 'discovery', name: '需求', dependsOn: [], defaultAssignee: { type: 'human' } },
+ {
+ key: 'implementation',
+ name: '实现',
+ dependsOn: ['discovery'],
+ defaultAssignee: { type: 'ai' },
+ },
+ {
+ key: 'verification',
+ name: '验证',
+ dependsOn: ['implementation'],
+ defaultAssignee: { type: 'human' },
+ },
+ ],
+ },
+ {
+ key: 'content',
+ name: '内容发布',
+ workTypes: [
+ { key: 'draft', name: '撰写', dependsOn: [], defaultAssignee: { type: 'human' } },
+ {
+ key: 'review',
+ name: '审核',
+ dependsOn: ['draft'],
+ defaultAssignee: { type: 'human' },
+ },
+ {
+ key: 'publish',
+ name: '发布',
+ dependsOn: ['review'],
+ defaultAssignee: { type: 'ai' },
+ },
+ ],
+ },
+]
+
+const STORE_PREFIX = 'wework:todo:work-items'
+const WORKFLOW_PREFIX = 'wework:todo:workflow'
+
+function scope(userId: number | undefined): string {
+ return String(userId ?? 'local').replace(/[^a-zA-Z0-9_-]/g, '_')
+}
+
+function storageKey(userId: number | undefined): string {
+ return `${STORE_PREFIX}:${scope(userId)}`
+}
+
+function migrateLegacyDrafts(userId: number | undefined): LocalWorkItem[] {
+ try {
+ const legacyKey = `wework:todo:drafts:${userId ?? 'local'}`
+ const drafts = JSON.parse(window.localStorage.getItem(legacyKey) ?? '[]') as Array<{
+ id: string
+ projectId: number
+ state: TodoViewState
+ title: string
+ markdown?: string
+ goal?: string
+ priority?: LocalWorkItem['priority']
+ assignee?: TodoPrincipalType
+ dueDate?: string
+ attachments?: Attachment[]
+ createdAt: string
+ updatedAt: string
+ }>
+ if (!Array.isArray(drafts) || drafts.length === 0) return []
+ const items = drafts.map(
+ (draft): LocalWorkItem => ({
+ id: draft.id,
+ projectId: draft.projectId,
+ state: draft.state,
+ title: draft.title,
+ description: draft.markdown ?? '',
+ objective: draft.goal ?? '',
+ priority: draft.priority ?? 'none',
+ assignee: { type: draft.assignee ?? 'unassigned' },
+ collaborators: [],
+ blocker: '',
+ nextAction: '',
+ dueDate: draft.dueDate,
+ attachments: draft.attachments ?? [],
+ runtimeRefs: [],
+ events: [
+ {
+ id: createLocalWorkItemId(),
+ type: 'created',
+ summary: 'Migrated from local draft',
+ createdAt: draft.createdAt,
+ },
+ ],
+ sortOrder: 0,
+ createdAt: draft.createdAt,
+ updatedAt: draft.updatedAt,
+ })
+ )
+ window.localStorage.setItem(storageKey(userId), JSON.stringify(items))
+ window.localStorage.removeItem(legacyKey)
+ return items
+ } catch {
+ return []
+ }
+}
+
+export function loadLocalWorkItems(userId: number | undefined): LocalWorkItem[] {
+ try {
+ const raw = window.localStorage.getItem(storageKey(userId))
+ if (!raw) return migrateLegacyDrafts(userId)
+ const value = JSON.parse(raw)
+ return Array.isArray(value) ? value.map(normalizeWorkItem) : []
+ } catch {
+ return []
+ }
+}
+
+export async function hydrateLocalWorkItems(userId: number | undefined): Promise {
+ if (!isTauriRuntime()) return loadLocalWorkItems(userId)
+ try {
+ const raw = await invoke('load_todo_store', { scope: scope(userId) })
+ if (!raw) return loadLocalWorkItems(userId)
+ const items = (JSON.parse(raw) as LocalWorkItem[]).map(normalizeWorkItem)
+ window.localStorage.setItem(storageKey(userId), JSON.stringify(items))
+ return items
+ } catch {
+ return loadLocalWorkItems(userId)
+ }
+}
+
+export function saveLocalWorkItems(userId: number | undefined, items: LocalWorkItem[]): void {
+ const contents = JSON.stringify(items)
+ window.localStorage.setItem(storageKey(userId), contents)
+ if (isTauriRuntime()) {
+ void invoke('save_todo_store', { scope: scope(userId), contents }).catch(error => {
+ console.error('Failed to persist local TODO store', error)
+ })
+ }
+}
+
+export async function ensureTodoWorkspace(item: LocalWorkItem): Promise {
+ if (!isTauriRuntime()) return null
+ try {
+ return await invoke('ensure_todo_workspace', {
+ itemId: item.id,
+ title: item.title,
+ objective: item.objective || item.description,
+ })
+ } catch {
+ return null
+ }
+}
+
+export async function ensureTodoWorkDirectory(
+ rootItemId: string,
+ workType: string
+): Promise {
+ if (!isTauriRuntime()) return null
+ try {
+ return await invoke('ensure_todo_work_directory', { itemId: rootItemId, workType })
+ } catch {
+ return null
+ }
+}
+
+export async function listTodoWorkspace(itemId: string): Promise {
+ if (!isTauriRuntime()) return []
+ try {
+ return await invoke('list_todo_workspace', { itemId })
+ } catch {
+ return []
+ }
+}
+
+export async function writeTodoWorkspaceFile(
+ itemId: string,
+ relativePath: string,
+ file: File
+): Promise {
+ if (!isTauriRuntime()) return null
+ try {
+ return await invoke('write_todo_workspace_file', {
+ itemId,
+ relativePath,
+ bytes: [...new Uint8Array(await file.arrayBuffer())],
+ })
+ } catch {
+ return null
+ }
+}
+
+export async function renameTodoWorkspaceEntry(
+ itemId: string,
+ fromPath: string,
+ toPath: string
+): Promise {
+ if (!isTauriRuntime()) return
+ await invoke('rename_todo_workspace_entry', { itemId, fromPath, toPath })
+}
+
+export async function deleteTodoWorkspaceEntry(
+ itemId: string,
+ relativePath: string
+): Promise {
+ if (!isTauriRuntime()) return
+ await invoke('delete_todo_workspace_entry', { itemId, relativePath })
+}
+
+export async function getTodoWorkspacePath(itemId: string): Promise {
+ if (!isTauriRuntime()) return null
+ try {
+ return await invoke('get_todo_workspace_path', { itemId })
+ } catch {
+ return null
+ }
+}
+
+export function createLocalWorkItemId(): string {
+ return (
+ globalThis.crypto?.randomUUID?.() ?? `todo-${Date.now()}-${Math.random().toString(36).slice(2)}`
+ )
+}
+
+export function loadTodoWorkflow(projectId: number | null): TodoWorkflowConfig {
+ if (projectId == null) return DEFAULT_TODO_WORKFLOW
+ try {
+ const value = JSON.parse(
+ window.localStorage.getItem(`${WORKFLOW_PREFIX}:${projectId}`) ?? 'null'
+ ) as TodoWorkflowConfig | null
+ return value?.version === 1
+ ? {
+ ...value,
+ workTypes: value.workTypes.map(workType => ({
+ ...workType,
+ dependsOn: workType.dependsOn ?? [],
+ defaultAssignee: workType.defaultAssignee ?? { type: 'unassigned' },
+ })),
+ }
+ : DEFAULT_TODO_WORKFLOW
+ } catch {
+ return DEFAULT_TODO_WORKFLOW
+ }
+}
+
+function normalizeWorkItem(item: LocalWorkItem): LocalWorkItem {
+ return {
+ ...item,
+ assignee: item.assignee ?? { type: 'unassigned' },
+ collaborators: item.collaborators ?? [],
+ blocker: item.blocker ?? '',
+ nextAction: item.nextAction ?? '',
+ attachments: item.attachments ?? [],
+ runtimeRefs: item.runtimeRefs ?? [],
+ events: item.events ?? [],
+ sortOrder: item.sortOrder ?? 0,
+ workTypeSnapshot: item.workTypeSnapshot
+ ? {
+ ...item.workTypeSnapshot,
+ dependsOn: item.workTypeSnapshot.dependsOn ?? [],
+ defaultAssignee: item.workTypeSnapshot.defaultAssignee ?? { type: 'unassigned' },
+ }
+ : undefined,
+ }
+}
+
+export function saveTodoWorkflow(projectId: number, config: TodoWorkflowConfig): void {
+ window.localStorage.setItem(`${WORKFLOW_PREFIX}:${projectId}`, JSON.stringify(config))
+}
diff --git a/wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts b/wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts
index 03de6b6d39..277a233e36 100644
--- a/wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts
+++ b/wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts
@@ -472,8 +472,15 @@ export function useWorkbenchRuntimeMessaging({
activeDeviceId?: string,
options?: Pick<
SendCurrentInputOptions,
- 'clientMessageId' | 'initialGoal' | 'onError' | 'onRuntimeTaskOptimisticOpen'
+ | 'clientMessageId'
+ | 'initialGoal'
+ | 'onError'
+ | 'onRuntimeTaskOptimisticOpen'
+ | 'additionalContext'
> & {
+ collaborationMode?: 'default' | 'plan'
+ deliveryId?: string
+ cloudProjectId?: string
ephemeral?: boolean
openInMainPane?: boolean
refreshWorkListsOnResolve?: boolean
@@ -570,7 +577,12 @@ export function useWorkbenchRuntimeMessaging({
title: buildRuntimeTaskTitle(displayMessage, payload.title),
modelId: payload.force_override_bot_model,
modelType: payload.force_override_bot_model_type ?? null,
- modelOptions: payload.model_options ?? {},
+ modelOptions: {
+ ...(payload.model_options ?? {}),
+ ...(options && 'collaborationMode' in options && options.collaborationMode
+ ? { collaborationMode: options.collaborationMode }
+ : {}),
+ },
modelSelection: selectedModel
? {
modelName: selectedModel.name,
@@ -585,6 +597,9 @@ export function useWorkbenchRuntimeMessaging({
...(options?.ephemeral ? { ephemeral: true } : {}),
...(options?.sideSource ? { sideSource: options.sideSource } : {}),
...(options?.initialGoal ? { initialGoal: options.initialGoal } : {}),
+ ...(options?.deliveryId ? { deliveryId: options.deliveryId } : {}),
+ ...(options?.cloudProjectId ? { cloudProjectId: options.cloudProjectId } : {}),
+ ...(options?.additionalContext ? { additionalContext: options.additionalContext } : {}),
}
debugRuntimeCreateFlow('create-request-built', {
taskId,
@@ -834,6 +849,7 @@ export function useWorkbenchRuntimeMessaging({
...runtimeModelFields,
...(attachmentIds.length > 0 ? { attachmentIds } : {}),
...(attachments.length > 0 ? { attachments } : {}),
+ ...(options?.additionalContext ? { additionalContext: options.additionalContext } : {}),
},
options
)
@@ -910,6 +926,7 @@ export function useWorkbenchRuntimeMessaging({
onError: options?.onError,
onRuntimeTaskOptimisticOpen: options?.onRuntimeTaskOptimisticOpen,
clientMessageId: options?.clientMessageId,
+ additionalContext: options?.additionalContext,
}
)
if (sent) {
@@ -1097,6 +1114,9 @@ export function useWorkbenchRuntimeMessaging({
return sendPreparedRuntimeMessage(message, prepared.payload, prepared.activeDeviceId, {
initialGoal: options.initialGoal,
+ collaborationMode: options.collaborationMode,
+ deliveryId: options.deliveryId,
+ cloudProjectId: options.cloudProjectId,
onError: options.onError,
openInMainPane: false,
})
diff --git a/wework/src/features/workbench/workbenchContextTypes.ts b/wework/src/features/workbench/workbenchContextTypes.ts
index f946da91a0..c7c5603ecd 100644
--- a/wework/src/features/workbench/workbenchContextTypes.ts
+++ b/wework/src/features/workbench/workbenchContextTypes.ts
@@ -19,6 +19,7 @@ import type {
ProjectWithTasks,
RuntimeGoalClearResponse,
RuntimeGoalCreateInput,
+ RuntimeAdditionalContext,
RuntimeGoalGetResponse,
RuntimeGoalSetRequest,
RuntimeGoalSetResponse,
@@ -82,6 +83,7 @@ export interface SendCurrentInputOptions {
address: RuntimeTaskAddress,
context?: { previousAddress?: RuntimeTaskAddress }
) => void
+ additionalContext?: RuntimeAdditionalContext
}
export interface CreateTemporaryRuntimeTaskOptions {
@@ -95,6 +97,9 @@ export interface CreateProjectRuntimeTaskOptions {
project: ProjectWithTasks
attachments?: Attachment[]
initialGoal?: RuntimeGoalCreateInput | null
+ collaborationMode?: 'default' | 'plan'
+ deliveryId?: string
+ cloudProjectId?: string
onError?: (error: string) => void
}
diff --git a/wework/src/features/workbench/workbenchReducer.test.ts b/wework/src/features/workbench/workbenchReducer.test.ts
index 343d24e77d..1d3bfb5508 100644
--- a/wework/src/features/workbench/workbenchReducer.test.ts
+++ b/wework/src/features/workbench/workbenchReducer.test.ts
@@ -29,7 +29,7 @@ describe('workbenchReducer', () => {
expect(state.projects.map(project => project.id)).toEqual([8])
expect(state.currentProject).toBeNull()
})
-
+
test('removes a runtime project even while its optimistic task is fresh', () => {
const runtimeWork = {
projects: [
diff --git a/wework/src/features/workbench/workbenchServices.ts b/wework/src/features/workbench/workbenchServices.ts
index 89330c1368..120ee4edfb 100644
--- a/wework/src/features/workbench/workbenchServices.ts
+++ b/wework/src/features/workbench/workbenchServices.ts
@@ -1,4 +1,5 @@
import { createDeviceApi } from '@/api/devices'
+import { createDeliveryApi } from '@/api/deliveries'
import {
createExecutorClientFromApis,
type ExecutorClient,
@@ -69,6 +70,7 @@ export interface WorkbenchServices {
typeof createDeviceApi
>['createDockerRemoteDeviceCommand']
}
+ deliveryApi?: ReturnType
imSessionApi?: ReturnType
runtimeWorkApi?: ReturnType
attachmentApi?: {
diff --git a/wework/src/i18n/locales/en/common.json b/wework/src/i18n/locales/en/common.json
index c8875b3cbe..783ca2fe44 100644
--- a/wework/src/i18n/locales/en/common.json
+++ b/wework/src/i18n/locales/en/common.json
@@ -15,6 +15,8 @@
"app_wegent_requires_cloud": "Available after connecting to cloud",
"app_wework_description": "Use AI to solve specific problems",
"app_weloop_description": "AI project planning, execution, and feedback",
+ "app_weloop_requires_cloud": "Available after connecting to cloud",
+ "cloud_board_loading": "Loading cloud Kanban…",
"apps": "Apps",
"new_chat": "New chat",
"search": "Search",
@@ -1549,9 +1551,35 @@
"feedback_report_id": "Report ID",
"feedback_close": "Close"
},
+ "delivery": {
+ "action": "Deliver",
+ "title": "Delivery",
+ "entire_chat": "Entire conversation",
+ "selected_chat": "Select messages",
+ "no_chat": "No conversation",
+ "message_count": "{{count}} messages",
+ "select_files": "Files",
+ "select_folder": "Folder",
+ "file_count": "{{count}} files",
+ "remove_file": "Remove file",
+ "markdown_placeholder": "Describe the outcome, key decisions, and suggested next steps…",
+ "immutable_hint": "Confirming completes the current task and creates an immutable snapshot.",
+ "confirm": "Confirm delivery",
+ "uploading": "Delivering…",
+ "failed": "Delivery failed. Try again.",
+ "completed": "Delivery complete",
+ "completed_hint": "The current task is complete. This delivery snapshot can no longer change.",
+ "completed_activity": "Task delivered and completed",
+ "back_to_todo": "Back to task",
+ "snapshot": "Delivery",
+ "snapshot_count": "{{count}} snapshots",
+ "immutable_snapshot": "Immutable delivery snapshot",
+ "asset_count": "{{count}} files",
+ "start_follow_up": "Start a new task"
+ },
"todo": {
- "navigation": "TODO",
- "create_action": "New TODO",
+ "navigation": "Tasks",
+ "create_action": "New task",
"overview": "Overview",
"other_projects": "Other projects",
"add_project": "Add project",
@@ -1576,7 +1604,8 @@
"assignee_all": "Assignee: all",
"priority_all": "Priority: all",
"grouping_hint": "Grouped by state · Manual order",
- "state_backlog": "Backlog",
+ "state_inbox": "Inbox",
+ "state_backlog": "Ready",
"state_started": "In progress",
"state_review": "In review",
"state_completed": "Completed",
@@ -1605,11 +1634,11 @@
"attachments": "Attachments",
"add_attachment": "Add attachment",
"no_attachments": "No attachments",
- "activity_completed": "Task completed and synced to TODO",
+ "activity_completed": "Task completed and synced",
"activity_review": "Task is waiting for confirmation",
"activity_started": "Task is running",
- "activity_created": "TODO created",
- "run": "Run TODO",
+ "activity_created": "Task created",
+ "run": "Run task",
"create_into_state": "Create in “{{state}}”",
"create_state_source": "Prefilled from the current board column",
"goal": "Goal",
@@ -1636,12 +1665,67 @@
"model_automatic": "Model: automatic",
"launch_manual": "Launch: manual",
"launch_automatic": "Launch: automatic",
- "creation_principle": "Keep creation free-form; structure automatically at runtime",
- "create_only": "Create TODO",
+ "creation_principle": "Describe what needs to be done; AI can help break it into steps later",
+ "items_scope": "Items",
+ "my_work": "My work",
+ "stages": "stages",
+ "stage": "Stage",
+ "blocked": "Blocked",
+ "next_action": "Next",
+ "collaboration_files": "collaboration files",
+ "workflow": "Item workflow",
+ "workflow_not_applied": "This item has not applied the project workflow",
+ "workflow_apply_hint": "Applying it creates {{count}} stages and assigns their owners.",
+ "apply_project_workflow": "Apply project workflow",
+ "workflow_not_configured": "This project does not have an item workflow yet",
+ "workflow_configure_hint": "Simple items need no workflow. Start from a template when collaboration is needed.",
+ "configure_project_workflow": "Set up project workflow",
+ "shared_workspace": "Shared workspace",
+ "simple_item_hint": "Simple items need no breakdown. Add tasks only when multiple people or stages are involved.",
+ "work_type_design": "Design",
+ "work_type_frontend": "Frontend",
+ "work_type_backend": "Backend",
+ "work_type_test": "Test",
+ "workflow_settings": "Project item workflow",
+ "item_statuses": "Item statuses",
+ "statuses_fixed_hint": "The standalone first release uses five stable statuses. Work types are configurable per project.",
+ "work_types": "Work types",
+ "quick_create_placeholder": "Enter an item title and press Enter",
+ "default_assignee": "Default owner",
+ "assignee_name_placeholder": "Person or agent name",
+ "remove_work_type": "Remove work type",
+ "work_type_placeholder": "For example: Security review",
+ "restore_defaults": "Restore defaults",
+ "clear_workflow": "Clear workflow",
+ "add": "Add",
+ "local_project": "Local items",
+ "local_project_description": "Items stored only on this device",
+ "all_stages_completed": "All complete",
+ "blocker_placeholder": "No blocker",
+ "next_action_placeholder": "Write a clear next action",
+ "depends_on": "Depends on",
+ "waiting_for": "Waiting for",
+ "dependencies_incomplete": "Prerequisite stages are not complete",
+ "workspace_write_failed": "Failed to write to the shared workspace",
+ "rename_to": "Rename to",
+ "delete_workspace_entry_confirm": "Delete this workspace entry?",
+ "add_file": "Add file",
+ "open_directory": "Open directory",
+ "open_file": "Open file",
+ "rename": "Rename",
+ "delete": "Delete",
+ "needs_my_action": "Needs my action",
+ "ai_running": "AI is running",
+ "waiting_confirmation": "Waiting for confirmation",
+ "blocked_items": "Blocked",
+ "no_items_in_group": "No items right now",
+ "belongs_to": "Belongs to",
+ "confirm_complete": "Confirm complete",
+ "create_only": "Create task",
"create_and_run": "Create and run",
"creating": "Creating…",
"create_content_required": "Enter task information",
- "create_failed": "Failed to create TODO",
+ "create_failed": "Failed to create task",
"run_unavailable": "The runtime service is unavailable",
"attachments_unavailable": "The attachment service is unavailable",
"filter_conditions": "Filter conditions",
@@ -1663,14 +1747,14 @@
"display_saved_per_project": "Settings apply to this project view and save automatically",
"expand_column": "Expand group",
"collapse_column": "Collapse group",
- "no_matching_items": "No matching TODOs",
+ "no_matching_items": "No matching tasks",
"search_todos": "Search title, ID, or task content…",
"open_in_wework": "Open in Wework",
- "copy_todo": "Copy TODO",
- "delete_todo": "Delete TODO",
+ "copy_todo": "Copy task",
+ "delete_todo": "Delete task",
"side_view": "Side view",
"full_screen": "Full screen",
- "run_failed": "Failed to run TODO",
+ "run_failed": "Failed to run task",
"attachment_upload_failed": "Failed to upload attachment",
"attachment_remove_failed": "Failed to remove attachment",
"uploading": "Uploading…",
diff --git a/wework/src/i18n/locales/zh-CN/common.json b/wework/src/i18n/locales/zh-CN/common.json
index ada446d427..8b0309b535 100644
--- a/wework/src/i18n/locales/zh-CN/common.json
+++ b/wework/src/i18n/locales/zh-CN/common.json
@@ -15,6 +15,8 @@
"app_wegent_requires_cloud": "连接云端后可用",
"app_wework_description": "使用 AI 解决具体问题",
"app_weloop_description": "用 AI 管理项目的规划、执行与反馈",
+ "app_weloop_requires_cloud": "连接云端后可用",
+ "cloud_board_loading": "正在加载云端看板…",
"apps": "应用",
"new_chat": "新对话",
"search": "搜索",
@@ -1548,9 +1550,35 @@
"feedback_report_id": "问题编号",
"feedback_close": "关闭"
},
+ "delivery": {
+ "action": "交付",
+ "title": "交付",
+ "entire_chat": "整个会话",
+ "selected_chat": "选择消息",
+ "no_chat": "不含聊天",
+ "message_count": "{{count}} 条消息",
+ "select_files": "文件",
+ "select_folder": "文件夹",
+ "file_count": "{{count}} 个文件",
+ "remove_file": "移除文件",
+ "markdown_placeholder": "写下交付说明、关键结论和后续建议…",
+ "immutable_hint": "确认后将完成当前任务,并生成不可变快照。",
+ "confirm": "确认交付",
+ "uploading": "正在交付…",
+ "failed": "交付失败,请重试",
+ "completed": "交付完成",
+ "completed_hint": "当前任务已完成,交付快照不会再被修改。",
+ "completed_activity": "任务已交付并完成",
+ "back_to_todo": "返回任务",
+ "snapshot": "交付",
+ "snapshot_count": "{{count}} 份快照",
+ "immutable_snapshot": "不可变交付快照",
+ "asset_count": "{{count}} 个文件",
+ "start_follow_up": "开启新任务"
+ },
"todo": {
- "navigation": "TODO",
- "create_action": "新建 TODO",
+ "navigation": "任务",
+ "create_action": "新建任务",
"overview": "总览",
"other_projects": "其他项目",
"add_project": "添加项目",
@@ -1575,7 +1603,8 @@
"assignee_all": "负责人:全部",
"priority_all": "优先级:全部",
"grouping_hint": "按状态分组 · 手动排序",
- "state_backlog": "待处理",
+ "state_inbox": "收集箱",
+ "state_backlog": "待开始",
"state_started": "进行中",
"state_review": "待确认",
"state_completed": "已完成",
@@ -1604,11 +1633,11 @@
"attachments": "附件",
"add_attachment": "添加附件",
"no_attachments": "暂无附件",
- "activity_completed": "任务已完成并同步到 TODO",
+ "activity_completed": "任务已完成并同步",
"activity_review": "任务正在等待确认",
"activity_started": "任务正在运行",
- "activity_created": "TODO 已创建",
- "run": "运行 TODO",
+ "activity_created": "任务已创建",
+ "run": "运行任务",
"create_into_state": "将创建到「{{state}}」",
"create_state_source": "由当前看板列预填",
"goal": "目标",
@@ -1635,12 +1664,67 @@
"model_automatic": "Model:自动",
"launch_manual": "启动方式:手动",
"launch_automatic": "启动方式:自动",
- "creation_principle": "内容保持自由,运行时自动结构化",
- "create_only": "创建 TODO",
+ "creation_principle": "先描述要完成的事情,稍后可由 AI 帮你拆分步骤",
+ "items_scope": "事项",
+ "my_work": "我的工作",
+ "stages": "阶段",
+ "stage": "阶段",
+ "blocked": "阻塞",
+ "next_action": "下一步",
+ "collaboration_files": "个协作文件",
+ "workflow": "事项流程",
+ "workflow_not_applied": "这个事项还没有应用项目流程",
+ "workflow_apply_hint": "应用后会创建 {{count}} 个阶段,并分配对应负责人。",
+ "apply_project_workflow": "应用项目流程",
+ "workflow_not_configured": "当前项目还没有设置事项流程",
+ "workflow_configure_hint": "简单事项可以不设置;需要多人协作时,可从模板开始配置。",
+ "configure_project_workflow": "设置项目流程",
+ "shared_workspace": "共享工作区",
+ "simple_item_hint": "简单事项无需拆分;需要多人或多阶段协作时再添加任务。",
+ "work_type_design": "设计",
+ "work_type_frontend": "前端",
+ "work_type_backend": "后端",
+ "work_type_test": "测试",
+ "workflow_settings": "项目事项流程",
+ "item_statuses": "事项状态",
+ "statuses_fixed_hint": "单机第一版使用五个稳定状态,专业工作类型可按项目配置。",
+ "work_types": "专业工作类型",
+ "quick_create_placeholder": "输入事项标题,回车创建",
+ "default_assignee": "默认负责人",
+ "assignee_name_placeholder": "姓名或智能体名称",
+ "remove_work_type": "删除工作类型",
+ "work_type_placeholder": "例如:安全评审",
+ "restore_defaults": "恢复默认",
+ "clear_workflow": "清空流程",
+ "add": "添加",
+ "local_project": "本地事项",
+ "local_project_description": "仅存储在这台设备上的事项",
+ "all_stages_completed": "全部完成",
+ "blocker_placeholder": "没有阻塞",
+ "next_action_placeholder": "写下明确的下一步",
+ "depends_on": "前置阶段",
+ "waiting_for": "等待",
+ "dependencies_incomplete": "前置阶段尚未完成",
+ "workspace_write_failed": "写入共享工作区失败",
+ "rename_to": "重命名为",
+ "delete_workspace_entry_confirm": "确定删除这个工作区文件吗?",
+ "add_file": "添加文件",
+ "open_directory": "打开目录",
+ "open_file": "打开文件",
+ "rename": "重命名",
+ "delete": "删除",
+ "needs_my_action": "需要我处理",
+ "ai_running": "AI 正在执行",
+ "waiting_confirmation": "等待确认",
+ "blocked_items": "被阻塞",
+ "no_items_in_group": "当前没有事项",
+ "belongs_to": "属于",
+ "confirm_complete": "确认完成",
+ "create_only": "创建任务",
"create_and_run": "创建并运行",
"creating": "创建中…",
"create_content_required": "请填写任务信息",
- "create_failed": "TODO 创建失败",
+ "create_failed": "任务创建失败",
"run_unavailable": "运行服务当前不可用",
"attachments_unavailable": "附件服务当前不可用",
"filter_conditions": "筛选条件",
@@ -1662,14 +1746,14 @@
"display_saved_per_project": "设置仅影响当前项目视图,并自动保存",
"expand_column": "展开分组",
"collapse_column": "收起分组",
- "no_matching_items": "暂无符合条件的 TODO",
+ "no_matching_items": "暂无符合条件的任务",
"search_todos": "搜索标题、编号或任务内容…",
"open_in_wework": "在 Wework 中打开",
- "copy_todo": "复制 TODO",
- "delete_todo": "删除 TODO",
+ "copy_todo": "复制任务",
+ "delete_todo": "删除任务",
"side_view": "侧边查看",
"full_screen": "全屏查看",
- "run_failed": "TODO 运行失败",
+ "run_failed": "任务运行失败",
"attachment_upload_failed": "附件上传失败",
"attachment_remove_failed": "附件移除失败",
"uploading": "上传中…",
diff --git a/wework/src/styles/globals.css b/wework/src/styles/globals.css
index c2418bf036..a14d167c80 100644
--- a/wework/src/styles/globals.css
+++ b/wework/src/styles/globals.css
@@ -63,6 +63,29 @@
--z-system-popover: 1010;
}
+.task-description-editor .tiptap p.is-editor-empty:first-child::before {
+ color: rgb(var(--color-text-muted));
+ content: attr(data-placeholder);
+ float: left;
+ height: 0;
+ pointer-events: none;
+}
+
+.task-description-editor .tiptap ul[data-type='taskList'] {
+ list-style: none;
+ padding-left: 0;
+}
+
+.task-description-editor .tiptap ul[data-type='taskList'] li {
+ align-items: flex-start;
+ display: flex;
+ gap: 8px;
+}
+
+.task-description-editor .tiptap ul[data-type='taskList'] li > div {
+ flex: 1;
+}
+
@keyframes suffix-roll-out-up {
to {
transform: translateY(-110%);
diff --git a/wework/src/tauri/droppedFiles.ts b/wework/src/tauri/droppedFiles.ts
index fb75bf50b9..f5edf7953b 100644
--- a/wework/src/tauri/droppedFiles.ts
+++ b/wework/src/tauri/droppedFiles.ts
@@ -2,10 +2,23 @@ import { invoke } from '@tauri-apps/api/core'
interface NativeDroppedFile {
name: string
+ relativePath: string
bytes: number[]
}
-export async function readDroppedFiles(paths: string[]): Promise {
+export interface SelectedDeliveryFile {
+ file: File
+ relativePath: string
+}
+
+export async function readSelectedDeliveryFiles(paths: string[]): Promise {
const files = await invoke('read_dropped_files', { paths })
- return files.map(file => new File([new Uint8Array(file.bytes)], file.name))
+ return files.map(file => ({
+ file: new File([new Uint8Array(file.bytes)], file.name),
+ relativePath: file.relativePath,
+ }))
+}
+
+export async function readDroppedFiles(paths: string[]): Promise {
+ return (await readSelectedDeliveryFiles(paths)).map(item => item.file)
}
diff --git a/wework/src/types/api.ts b/wework/src/types/api.ts
index 9b7337024d..1c86ab51dd 100644
--- a/wework/src/types/api.ts
+++ b/wework/src/types/api.ts
@@ -1035,6 +1035,9 @@ export interface RuntimeTaskCreateRequest {
initialGoal?: RuntimeGoalCreateInput | null
ephemeral?: boolean
sideSource?: RuntimeTaskAddress | null
+ deliveryId?: string
+ cloudProjectId?: string
+ additionalContext?: RuntimeAdditionalContext
}
export interface RuntimeTaskCreateResponse {