diff --git a/build_image.sh b/build_image.sh index d04f464bc0..b14031177c 100755 --- a/build_image.sh +++ b/build_image.sh @@ -15,7 +15,7 @@ DEFAULT_VERSION="1.0.0" # Function to show help show_help() { - echo "Build docker images for Wegent components" + echo "Build docker images for Wegent components"aass echo "" echo "Usage: $0 [OPTIONS]" echo "" diff --git a/wework/src-tauri/src/lib.rs b/wework/src-tauri/src/lib.rs index 40e0e8cea5..0b9c86dec2 100644 --- a/wework/src-tauri/src/lib.rs +++ b/wework/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod embedded_browser; mod local_executor; mod local_terminal; mod process_environment; +mod todo_store; use std::collections::{HashMap, HashSet}; #[cfg(desktop)] @@ -3787,6 +3788,15 @@ pub fn run() { open_local_workspace, read_dropped_files, save_local_attachment_file, + todo_store::ensure_todo_work_directory, + todo_store::ensure_todo_workspace, + todo_store::get_todo_workspace_path, + todo_store::list_todo_workspace, + todo_store::load_todo_store, + todo_store::save_todo_store, + todo_store::delete_todo_workspace_entry, + todo_store::rename_todo_workspace_entry, + todo_store::write_todo_workspace_file, local_terminal::resize_local_terminal, local_terminal::start_local_terminal, local_terminal::write_local_terminal diff --git a/wework/src-tauri/src/todo_store.rs b/wework/src-tauri/src/todo_store.rs new file mode 100644 index 0000000000..57e03a6330 --- /dev/null +++ b/wework/src-tauri/src/todo_store.rs @@ -0,0 +1,312 @@ +use std::path::{Component, Path, PathBuf}; + +use serde::Serialize; +use tauri::Manager; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TodoWorkspaceEntry { + path: String, + name: String, + node_type: &'static str, + size: u64, + modified_at_ms: u128, + absolute_path: String, +} + +fn store_root(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|path| path.join("todo")) + .map_err(|error| format!("Failed to resolve app data directory: {error}")) +} + +fn safe_key(value: &str) -> Result<&str, String> { + if value.is_empty() + || !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + { + return Err("Invalid TODO storage key".to_string()); + } + Ok(value) +} + +fn safe_relative_path(value: &str) -> Result { + let path = Path::new(value); + if path.as_os_str().is_empty() || path.is_absolute() { + return Err("Workspace path must be relative".to_string()); + } + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err("Workspace path escapes the TODO directory".to_string()); + } + Ok(path.to_path_buf()) +} + +fn workspace_root(app: &tauri::AppHandle, item_id: &str) -> Result { + Ok(store_root(app)?.join("workspaces").join(safe_key(item_id)?)) +} + +fn reject_symlink_components(root: &Path, relative: &Path) -> Result<(), String> { + let mut current = root.to_path_buf(); + for component in relative.components() { + current.push(component.as_os_str()); + match std::fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err("Workspace paths cannot traverse symbolic links".to_string()); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("Failed to inspect workspace path: {error}")), + } + } + Ok(()) +} + +#[tauri::command] +pub fn load_todo_store(app: tauri::AppHandle, scope: String) -> Result, String> { + let path = store_root(&app)?.join(format!("{}.json", safe_key(&scope)?)); + if !path.exists() { + return Ok(None); + } + std::fs::read_to_string(path) + .map(Some) + .map_err(|error| format!("Failed to read TODO store: {error}")) +} + +#[tauri::command] +pub fn save_todo_store( + app: tauri::AppHandle, + scope: String, + contents: String, +) -> Result<(), String> { + serde_json::from_str::(&contents) + .map_err(|error| format!("TODO store must contain valid JSON: {error}"))?; + let root = store_root(&app)?; + std::fs::create_dir_all(&root) + .map_err(|error| format!("Failed to create TODO store directory: {error}"))?; + let target = root.join(format!("{}.json", safe_key(&scope)?)); + let temporary = target.with_extension("json.tmp"); + std::fs::write(&temporary, contents) + .map_err(|error| format!("Failed to write TODO store: {error}"))?; + std::fs::rename(temporary, target) + .map_err(|error| format!("Failed to commit TODO store: {error}")) +} + +#[tauri::command] +pub fn ensure_todo_workspace( + app: tauri::AppHandle, + item_id: String, + title: String, + objective: String, +) -> Result { + let root = workspace_root(&app, &item_id)?; + std::fs::create_dir_all(root.join("context")) + .and_then(|_| std::fs::create_dir_all(root.join("work"))) + .map_err(|error| format!("Failed to initialize TODO workspace: {error}"))?; + let readme = root.join("README.md"); + if !readme.exists() { + std::fs::write(&readme, format!("# {title}\n\n{objective}\n")) + .map_err(|error| format!("Failed to create TODO README: {error}"))?; + } + Ok(root.to_string_lossy().into_owned()) +} + +#[tauri::command] +pub fn ensure_todo_work_directory( + app: tauri::AppHandle, + item_id: String, + work_type: String, +) -> Result { + let directory = workspace_root(&app, &item_id)? + .join("work") + .join(safe_key(&work_type)?); + std::fs::create_dir_all(&directory) + .map_err(|error| format!("Failed to create work directory: {error}"))?; + Ok(directory.to_string_lossy().into_owned()) +} + +#[tauri::command] +pub fn write_todo_workspace_file( + app: tauri::AppHandle, + item_id: String, + relative_path: String, + bytes: Vec, +) -> Result { + let root = workspace_root(&app, &item_id)?; + let relative = safe_relative_path(&relative_path)?; + reject_symlink_components(&root, &relative)?; + let target = root.join(relative); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create workspace directory: {error}"))?; + } + std::fs::write(&target, bytes) + .map_err(|error| format!("Failed to write workspace file: {error}"))?; + Ok(target.to_string_lossy().into_owned()) +} + +fn is_protected_workspace_path(path: &Path) -> bool { + matches!( + path.to_string_lossy().as_ref(), + "README.md" | "context" | "work" + ) +} + +#[tauri::command] +pub fn rename_todo_workspace_entry( + app: tauri::AppHandle, + item_id: String, + from_path: String, + to_path: String, +) -> Result<(), String> { + let root = workspace_root(&app, &item_id)?; + let from = safe_relative_path(&from_path)?; + let to = safe_relative_path(&to_path)?; + reject_symlink_components(&root, &from)?; + reject_symlink_components(&root, &to)?; + if is_protected_workspace_path(&from) || is_protected_workspace_path(&to) { + return Err("Core TODO workspace entries cannot be renamed".to_string()); + } + let source = root.join(from); + let target = root.join(to); + if target.exists() { + return Err("Workspace destination already exists".to_string()); + } + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create workspace directory: {error}"))?; + } + std::fs::rename(source, target) + .map_err(|error| format!("Failed to rename workspace entry: {error}")) +} + +#[tauri::command] +pub fn delete_todo_workspace_entry( + app: tauri::AppHandle, + item_id: String, + relative_path: String, +) -> Result<(), String> { + let root = workspace_root(&app, &item_id)?; + let relative = safe_relative_path(&relative_path)?; + reject_symlink_components(&root, &relative)?; + if is_protected_workspace_path(&relative) { + return Err("Core TODO workspace entries cannot be deleted".to_string()); + } + let target = root.join(relative); + if target.is_dir() { + std::fs::remove_dir_all(target) + .map_err(|error| format!("Failed to delete workspace directory: {error}")) + } else { + std::fs::remove_file(target) + .map_err(|error| format!("Failed to delete workspace file: {error}")) + } +} + +#[tauri::command] +pub fn get_todo_workspace_path(app: tauri::AppHandle, item_id: String) -> Result { + let root = workspace_root(&app, &item_id)?; + if !root.exists() { + return Err("TODO workspace does not exist".to_string()); + } + Ok(root.to_string_lossy().into_owned()) +} + +fn collect_entries( + root: &Path, + directory: &Path, + entries: &mut Vec, +) -> Result<(), String> { + for result in std::fs::read_dir(directory) + .map_err(|error| format!("Failed to read TODO workspace: {error}"))? + { + let entry = result.map_err(|error| format!("Failed to read workspace entry: {error}"))?; + let path = entry.path(); + let metadata = entry + .metadata() + .map_err(|error| format!("Failed to inspect workspace entry: {error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("Failed to inspect workspace entry type: {error}"))?; + let relative = path + .strip_prefix(root) + .map_err(|error| format!("Failed to resolve workspace entry: {error}"))?; + entries.push(TodoWorkspaceEntry { + path: relative.to_string_lossy().replace('\\', "/"), + name: entry.file_name().to_string_lossy().into_owned(), + node_type: if file_type.is_dir() { + "directory" + } else { + "file" + }, + size: metadata.len(), + modified_at_ms: metadata + .modified() + .ok() + .and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok()) + .map_or(0, |value| value.as_millis()), + absolute_path: path.to_string_lossy().into_owned(), + }); + if file_type.is_dir() && !file_type.is_symlink() { + collect_entries(root, &path, entries)?; + } + } + Ok(()) +} + +#[tauri::command] +pub fn list_todo_workspace( + app: tauri::AppHandle, + item_id: String, +) -> Result, String> { + let root = workspace_root(&app, &item_id)?; + if !root.exists() { + return Ok(Vec::new()); + } + let mut entries = Vec::new(); + collect_entries(&root, &root, &mut entries)?; + entries.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::{is_protected_workspace_path, reject_symlink_components, safe_relative_path}; + use std::path::Path; + + #[test] + fn workspace_paths_cannot_escape() { + assert!(safe_relative_path("context/brief.md").is_ok()); + assert!(safe_relative_path("../secret").is_err()); + assert!(safe_relative_path("/tmp/secret").is_err()); + } + + #[test] + fn core_workspace_entries_are_protected() { + assert!(is_protected_workspace_path(Path::new("README.md"))); + assert!(is_protected_workspace_path(Path::new("context"))); + assert!(!is_protected_workspace_path(Path::new("context/brief.md"))); + } + + #[cfg(unix)] + #[test] + fn workspace_paths_cannot_traverse_symbolic_links() { + let root = std::env::temp_dir().join(format!("wework-todo-{}", std::process::id())); + let outside = root.with_extension("outside"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&outside); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("linked")).unwrap(); + + assert!(reject_symlink_components(&root, Path::new("linked/secret.txt")).is_err()); + + std::fs::remove_dir_all(&root).unwrap(); + std::fs::remove_dir_all(&outside).unwrap(); + } +} diff --git a/wework/src/components/layout/DesktopWorkbenchLayout.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.tsx index e292314818..161f397e69 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.tsx @@ -44,7 +44,6 @@ export function DesktopWorkbenchLayout() { const { logout: onLogout } = useAuth() const { state, - projectChat, cloudWorkStatus, upgradingDevices, selectProject: onSelectProject, @@ -599,12 +598,12 @@ export function DesktopWorkbenchLayout() { runtimeWork={state.runtimeWork} currentProjectId={state.currentProject?.id} services={services} - modelName={projectChat.selectedModel?.displayName ?? projectChat.selectedModel?.name} - onRunTodo={({ project, message, goal, attachments }) => + onRunTodo={({ project, message, goal, attachments, collaborationMode }) => onCreateProjectRuntimeTask(message, { project, attachments, initialGoal: goal ? { objective: goal } : null, + collaborationMode, }) } onOpenRuntimeTask={async address => { diff --git a/wework/src/features/todo/TodoDetailPanel.tsx b/wework/src/features/todo/TodoDetailPanel.tsx index 37e93144d1..3a155a5486 100644 --- a/wework/src/features/todo/TodoDetailPanel.tsx +++ b/wework/src/features/todo/TodoDetailPanel.tsx @@ -6,6 +6,7 @@ import { CalendarDays, Circle, CircleDot, + CircleCheck, Copy, Clock3, Ellipsis, @@ -19,6 +20,11 @@ import { Trash2, TriangleAlert, X, + FileText, + Folder, + Plus, + FolderOpen, + Pencil, } from 'lucide-react' import { useEscapeKey } from '@/hooks/useEscapeKey' import { useTranslation } from '@/hooks/useTranslation' @@ -26,8 +32,18 @@ import { copyTextToClipboard } from '@/lib/clipboard' import { buildRuntimeTaskRoute, toBrowserPath } from '@/lib/navigation' import type { WorkbenchServices } from '@/features/workbench/workbenchServices' import type { Attachment, RuntimeGoal, RuntimeTaskAddress, RuntimeTaskSummary } from '@/types/api' +import { + deleteTodoWorkspaceEntry, + getTodoWorkspacePath, + listTodoWorkspace, + renameTodoWorkspaceEntry, + writeTodoWorkspaceFile, + type TodoWorkspaceEntry, +} from './todoModel' +import type { TodoWorkType } from './todoModel' +import { openLocalFile, revealLocalFile } from '@/lib/local-terminal' -export type TodoViewState = 'backlog' | 'started' | 'review' | 'completed' +export type TodoViewState = 'inbox' | 'backlog' | 'started' | 'review' | 'completed' export interface TodoDetailItem { id: string @@ -49,6 +65,17 @@ export interface TodoDetailItem { updatedAt?: string | number | null address?: RuntimeTaskAddress task?: RuntimeTaskSummary + parentId?: string + children?: TodoDetailItem[] + workTypeKey?: string + workTypeName?: string + blocker?: string + nextAction?: string + collaborators?: Array<{ type: 'human' | 'ai' | 'unassigned'; id?: string; name?: string }> + confirmer?: string + workspaceItemId?: string + waitingFor?: string[] + events?: Array<{ id: string; summary: string; createdAt: string }> } interface TodoDetailPanelProps { @@ -60,10 +87,23 @@ interface TodoDetailPanelProps { onRun?: () => Promise | void onDelete?: () => void onAttachmentsChange?: (attachments: Attachment[]) => void + onAddChild?: (workTypeKey: string, workTypeName: string) => void + onApplyWorkflow?: () => void + onConfigureWorkflow?: () => void + workTypes?: TodoWorkType[] + onSelectChild?: (item: TodoDetailItem) => void + onUpdateItem?: (patch: { + state?: TodoViewState + assigneeType?: 'unassigned' | 'ai' | 'human' + blocker?: string + nextAction?: string + }) => void + onConfirm?: () => void } const STATE_DETAILS: Record = { + inbox: { labelKey: 'todo.state_inbox', fallback: '收集箱', color: '#858E97' }, backlog: { labelKey: 'todo.state_backlog', fallback: '待处理', color: '#858E97' }, started: { labelKey: 'todo.state_started', fallback: '进行中', color: '#F59E0B' }, review: { labelKey: 'todo.state_review', fallback: '待确认', color: '#8B5CF6' }, @@ -79,9 +119,17 @@ export function TodoDetailPanel({ onRun, onDelete, onAttachmentsChange, + onAddChild, + onApplyWorkflow, + onConfigureWorkflow, + workTypes = [], + onSelectChild, + onUpdateItem, + onConfirm, }: TodoDetailPanelProps) { const { t } = useTranslation('common') const fileInputRef = useRef(null) + const workspaceFileInputRef = useRef(null) const [goal, setGoal] = useState(null) const [copied, setCopied] = useState(false) const [fullScreen, setFullScreen] = useState(false) @@ -90,6 +138,8 @@ export function TodoDetailPanel({ const [runError, setRunError] = useState(null) const [uploading, setUploading] = useState(false) const [attachmentError, setAttachmentError] = useState(null) + const [workspaceEntries, setWorkspaceEntries] = useState([]) + const [workspaceError, setWorkspaceError] = useState(null) const state = STATE_DETAILS[item.state] useEscapeKey(onClose, true) @@ -107,6 +157,66 @@ export function TodoDetailPanel({ } }, [item.address, services?.runtimeWorkApi]) + useEffect(() => { + if (!item.workspaceItemId) return + void listTodoWorkspace(item.workspaceItemId) + .then(setWorkspaceEntries) + .catch(() => setWorkspaceEntries([])) + }, [item.workspaceItemId, item.updatedAt]) + + const refreshWorkspace = async () => { + if (!item.workspaceItemId) return + setWorkspaceEntries(await listTodoWorkspace(item.workspaceItemId)) + } + + const uploadWorkspaceFile = async (file: File | undefined) => { + if (!file || !item.workspaceItemId) return + setWorkspaceError(null) + const safeName = file.name.replaceAll('/', '_').replaceAll('\\', '_') + const path = item.parentId + ? `work/${item.workTypeKey ?? 'general'}/${safeName}` + : `context/${safeName}` + const result = await writeTodoWorkspaceFile(item.workspaceItemId, path, file) + if (!result) { + setWorkspaceError(t('todo.workspace_write_failed', '写入共享工作区失败')) + return + } + await refreshWorkspace() + } + + const renameWorkspaceEntry = async (entry: TodoWorkspaceEntry) => { + if (!item.workspaceItemId) return + const nextName = window.prompt(t('todo.rename_to', '重命名为'), entry.name)?.trim() + if (!nextName || nextName === entry.name) return + const parent = entry.path.includes('/') + ? entry.path.slice(0, entry.path.lastIndexOf('/') + 1) + : '' + try { + await renameTodoWorkspaceEntry(item.workspaceItemId, entry.path, `${parent}${nextName}`) + await refreshWorkspace() + } catch (error) { + setWorkspaceError(error instanceof Error ? error.message : String(error)) + } + } + + const deleteWorkspaceEntry = async (entry: TodoWorkspaceEntry) => { + if (!item.workspaceItemId) return + if (!window.confirm(t('todo.delete_workspace_entry_confirm', '确定删除这个工作区文件吗?'))) + return + try { + await deleteTodoWorkspaceEntry(item.workspaceItemId, entry.path) + await refreshWorkspace() + } catch (error) { + setWorkspaceError(error instanceof Error ? error.message : String(error)) + } + } + + const revealWorkspace = async () => { + if (!item.workspaceItemId) return + const path = await getTodoWorkspacePath(item.workspaceItemId) + if (path) await revealLocalFile(path) + } + const copyTaskLink = async () => { const content = item.address ? `${window.location.origin}${toBrowserPath(buildRuntimeTaskRoute(item.address))}` @@ -308,18 +418,298 @@ export function TodoDetailPanel({ )} + {onUpdateItem && ( +
+ + +
+ )} + + {!onUpdateItem && (item.blocker || item.nextAction) && ( +
+ {item.blocker && ( +
+

+ {t('todo.blocked', '阻塞')} +

+

{item.blocker}

+
+ )} + {item.nextAction && ( +
+

+ {t('todo.next_action', '下一步')} +

+

{item.nextAction}

+
+ )} +
+ )} + + {item.kind === 'draft' && ( +
+
+

+ {t('todo.workflow', '工作流')} +

+ {onAddChild && item.children && item.children.length > 0 && ( +
+ {workTypes.map(({ key, name }) => ( + + ))} +
+ )} +
+ {item.children && item.children.length > 0 ? ( +
+ {item.children.map(child => ( + + ))} +
+ ) : workTypes.length > 0 ? ( +
+

+ {t('todo.workflow_not_applied', '这个事项还没有应用项目流程')} +

+

+ {t( + 'todo.workflow_apply_hint', + '应用后会创建 {{count}} 个阶段,并分配对应负责人。', + { + count: workTypes.length, + } + )} +

+ +
+ ) : ( +
+

+ {t('todo.workflow_not_configured', '当前项目还没有设置事项流程')} +

+

+ {t( + 'todo.workflow_configure_hint', + '简单事项可以不设置;需要多人协作时,可从模板开始配置。' + )} +

+ +
+ )} +
+ )} + + {item.workspaceItemId && ( +
+
+

+ {t('todo.shared_workspace', '共享工作区')} +

+
+ + + { + void uploadWorkspaceFile(event.target.files?.[0]) + event.target.value = '' + }} + /> +
+
+
+ {workspaceEntries.map(entry => ( +
+ {entry.nodeType === 'directory' ? ( + + ) : ( + + )} + + {entry.path} + + {entry.nodeType === 'file' && ( + {formatBytes(entry.size)} + )} + {entry.nodeType === 'file' && ( + + )} + {!['README.md', 'context', 'work'].includes(entry.path) && ( + <> + + + + )} +
+ ))} +
+ {workspaceError && ( +

+ {workspaceError} +

+ )} +
+ )} +

Properties

- + {onUpdateItem ? ( + + + {item.waitingFor && item.waitingFor.length > 0 && ( + + {t('todo.waiting_for', '等待')}:{item.waitingFor.join('、')} + + )} + + ) : ( + + )} - + {onUpdateItem ? ( + + + + ) : ( + + )} Activity -
- -
-

- {activityText(item, t)} -

-

{formatDetailDate(item.updatedAt)}

-
+
+ {(item.events?.length + ? [...item.events].reverse() + : [ + { + id: 'current', + summary: activityText(item, t), + createdAt: String(item.updatedAt), + }, + ] + ).map(event => ( +
+ +
+

+ {event.summary} +

+

+ {formatDetailDate(event.createdAt)} +

+
+
+ ))}
@@ -458,6 +884,17 @@ export function TodoDetailPanel({ {running ? t('todo.running', '运行中…') : t('todo.run', '运行 TODO')} )} + {onConfirm && item.state === 'review' && ( + + )} @@ -525,6 +962,26 @@ function PropertyRow({ children }: { children: ReactNode }) { return
{children}
} +function EditableProperty({ + icon: Icon, + label, + children, +}: { + icon: typeof Circle + label: string + children: ReactNode +}) { + return ( + + ) +} + function Property({ icon: Icon, label, @@ -567,6 +1024,12 @@ function formatDetailDate(value: string | number | null | undefined): string { }).format(date) } +function formatBytes(value: number): string { + if (value < 1024) return `${value} B` + if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB` + return `${(value / 1024 / 1024).toFixed(1)} MB` +} + function activityText(item: TodoDetailItem, t: ReturnType['t']): string { if (item.state === 'completed') return t('todo.activity_completed', '任务已完成并同步到 TODO') if (item.state === 'review') return t('todo.activity_review', '任务正在等待确认') diff --git a/wework/src/features/todo/TodoMyWork.tsx b/wework/src/features/todo/TodoMyWork.tsx new file mode 100644 index 0000000000..2fc477c1fb --- /dev/null +++ b/wework/src/features/todo/TodoMyWork.tsx @@ -0,0 +1,108 @@ +import { Bot, CircleAlert, CircleCheck, UserRound } from 'lucide-react' +import { useTranslation } from '@/hooks/useTranslation' +import type { TodoDetailItem } from './TodoDetailPanel' + +interface TodoMyWorkProps { + items: TodoDetailItem[] + onSelectItem: (item: TodoDetailItem) => void +} + +interface MyWorkEntry { + item: TodoDetailItem + parent?: TodoDetailItem +} + +export function TodoMyWork({ items, onSelectItem }: TodoMyWorkProps) { + const { t } = useTranslation('common') + const entries: MyWorkEntry[] = items.flatMap(parent => [ + { item: parent }, + ...(parent.children ?? []).map(item => ({ item, parent })), + ]) + const groups = [ + { + key: 'action', + label: t('todo.needs_my_action', '需要我处理'), + icon: UserRound, + entries: entries.filter( + ({ item }) => item.assigneeType === 'human' && !['review', 'completed'].includes(item.state) + ), + }, + { + key: 'ai', + label: t('todo.ai_running', 'AI 正在执行'), + icon: Bot, + entries: entries.filter(({ item }) => item.assigneeType === 'ai' && item.state === 'started'), + }, + { + key: 'review', + label: t('todo.waiting_confirmation', '等待确认'), + icon: CircleCheck, + entries: entries.filter(({ item }) => item.state === 'review'), + }, + { + key: 'blocked', + label: t('todo.blocked_items', '被阻塞'), + icon: CircleAlert, + entries: entries.filter( + ({ item }) => Boolean(item.blocker) || Boolean(item.waitingFor?.length) + ), + }, + ] + + return ( +
+
+ {groups.map(group => { + const Icon = group.icon + return ( +
+
+ +

{group.label}

+ + {group.entries.length} + +
+ {group.entries.length === 0 ? ( +

+ {t('todo.no_items_in_group', '当前没有事项')} +

+ ) : ( +
+ {group.entries.map(({ item, parent }) => ( + + ))} +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/wework/src/features/todo/TodoNavigation.tsx b/wework/src/features/todo/TodoNavigation.tsx index 02aca7dfdc..f8825a4da5 100644 --- a/wework/src/features/todo/TodoNavigation.tsx +++ b/wework/src/features/todo/TodoNavigation.tsx @@ -9,6 +9,7 @@ import { ListTodo, Plus, Search, + Settings2, UserRound, } from 'lucide-react' import { DesktopAppSwitcher } from '@/components/layout/DesktopAppSwitcher' @@ -247,6 +248,7 @@ export function TodoSidebar({ onSearch, onAddProject, onOpenProjects, + onConfigureWorkflow, }: { user: UserProfile | null projects: TodoProject[] @@ -263,6 +265,7 @@ export function TodoSidebar({ onSearch: () => void onAddProject: () => void onOpenProjects: () => void + onConfigureWorkflow: () => void }) { const { t } = useTranslation('common') const [projectActionsOpen, setProjectActionsOpen] = useState(false) @@ -410,6 +413,13 @@ export function TodoSidebar({ active={activeView === 'work-items'} onClick={() => onSelectView('work-items')} /> + )} diff --git a/wework/src/features/todo/TodoWorkItems.tsx b/wework/src/features/todo/TodoWorkItems.tsx index bf767d92e9..ddc8e31996 100644 --- a/wework/src/features/todo/TodoWorkItems.tsx +++ b/wework/src/features/todo/TodoWorkItems.tsx @@ -38,11 +38,14 @@ interface TodoWorkItemsProps { onDisplayChange: (display: TodoDisplaySettings) => void onCloseDisplay: () => void onSelectItem: (item: TodoDetailItem) => void - onCreate: (state: TodoViewState) => void + onCreate: (state: TodoViewState, title: string) => void + createRequest?: { state: TodoViewState; token: number } | null + onMoveItem?: (itemId: string, state: TodoViewState) => void } -const STATES: TodoViewState[] = ['backlog', 'started', 'review', 'completed'] +const STATES: TodoViewState[] = ['inbox', 'backlog', 'started', 'review', 'completed'] const STATE_META: Record = { + inbox: { labelKey: 'todo.state_inbox', fallback: '收集箱', color: '#858E97' }, backlog: { labelKey: 'todo.state_backlog', fallback: '待处理', color: '#858E97' }, started: { labelKey: 'todo.state_started', fallback: '进行中', color: '#F59E0B' }, review: { labelKey: 'todo.state_review', fallback: '待确认', color: '#8B5CF6' }, @@ -69,6 +72,8 @@ export function TodoWorkItems({ onCloseDisplay, onSelectItem, onCreate, + createRequest, + onMoveItem, }: TodoWorkItemsProps) { const filteredItems = useMemo( () => filterAndSortItems(items, filters, display.order), @@ -85,6 +90,8 @@ export function TodoWorkItems({ display={display} onSelectItem={onSelectItem} onCreate={onCreate} + createRequest={createRequest} + onMoveItem={onMoveItem} /> ) : ( void - onCreate: (state: TodoViewState) => void + onCreate: (state: TodoViewState, title: string) => void + createRequest?: { state: TodoViewState; token: number } | null + onMoveItem?: (itemId: string, state: TodoViewState) => void }) { const [collapsedStates, setCollapsedStates] = useState>(new Set()) const states = (stateFilter === 'all' ? STATES : [stateFilter]).filter( @@ -394,10 +405,10 @@ function TodoBoard({ data-testid="todo-board-scroll" className="min-h-0 flex-1 overflow-auto bg-[#F7F8F9] p-3 dark:bg-background" > -
+
{states.map(state => ( item.state === state)} display={display} @@ -411,7 +422,9 @@ function TodoBoard({ }) } onSelectItem={onSelectItem} - onCreate={() => onCreate(state)} + onCreate={title => onCreate(state, title)} + forceCreateToken={createRequest?.state === state ? createRequest.token : 0} + onMoveItem={onMoveItem} /> ))}
@@ -427,6 +440,8 @@ function TodoColumn({ onToggleCollapsed, onSelectItem, onCreate, + forceCreateToken, + onMoveItem, }: { state: TodoViewState items: TodoDetailItem[] @@ -434,11 +449,22 @@ function TodoColumn({ collapsed: boolean onToggleCollapsed: () => void onSelectItem: (item: TodoDetailItem) => void - onCreate: () => void + onCreate: (title: string) => void + forceCreateToken: number + onMoveItem?: (itemId: string, state: TodoViewState) => void }) { const { t } = useTranslation('common') const [menuOpen, setMenuOpen] = useState(false) + const [quickCreateOpen, setQuickCreateOpen] = useState(forceCreateToken > 0) + const [quickCreateTitle, setQuickCreateTitle] = useState('') const meta = STATE_META[state] + const submitQuickCreate = () => { + const title = quickCreateTitle.trim() + if (!title) return + onCreate(title) + setQuickCreateTitle('') + setQuickCreateOpen(false) + } if (collapsed) { return (
+
{ + if (onMoveItem) event.preventDefault() + }} + onDrop={event => { + event.preventDefault() + const itemId = event.dataTransfer.getData('text/work-item-id') + if (itemId) onMoveItem?.(itemId, state) + }} + >
@@ -476,8 +513,8 @@ function TodoColumn({
)}
+ {quickCreateOpen && ( +
+ setQuickCreateTitle(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') submitQuickCreate() + if (event.key === 'Escape') { + setQuickCreateTitle('') + setQuickCreateOpen(false) + } + }} + onBlur={() => { + if (!quickCreateTitle.trim()) setQuickCreateOpen(false) + }} + placeholder={t('todo.quick_create_placeholder', '输入事项标题,回车创建')} + className="h-8 min-w-0 flex-1 bg-transparent px-1 text-[12px] text-text-primary outline-none placeholder:text-text-muted" + /> + +
+ )} {items.map(item => ( onSelectItem(item)} + onMoveItem={onMoveItem} /> ))}
+ {quickCreateState === state && ( +
+ setQuickCreateTitle(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter' && quickCreateTitle.trim()) { + onCreate(state, quickCreateTitle.trim()) + setQuickCreateTitle('') + setQuickCreateState(null) + } + if (event.key === 'Escape') { + setQuickCreateTitle('') + setQuickCreateState(null) + } + }} + placeholder={t('todo.quick_create_placeholder', '输入事项标题,回车创建')} + className="h-8 w-full rounded-md border border-primary/40 bg-background px-3 text-[11px] outline-none" + /> +
+ )} {stateItems.length === 0 ? (
{t('todo.no_matching_items', '暂无符合条件的 TODO')} @@ -636,16 +732,32 @@ function TodoCard({ item, display, onClick, + onMoveItem, }: { item: TodoDetailItem display: TodoDisplaySettings onClick: () => void + onMoveItem?: (itemId: string, state: TodoViewState) => void }) { + const { t } = useTranslation('common') const labelColor = item.runtime.toLowerCase().includes('codex') ? '#14B8A6' : '#9B6BE8' const initials = item.assigneeType === 'human' ? 'HY' : 'AI' + const children = item.children ?? [] + const completedChildren = children.filter(child => child.state === 'completed').length + const activeChildren = children.filter(child => child.state === 'started') + const currentChildren = + activeChildren.length > 0 + ? activeChildren + : children.filter(child => child.state !== 'completed').slice(0, 2) + const fileCount = item.attachments?.length ?? 0 return ( + +
+

+ {t('todo.item_statuses', '事项状态')} +

+
+ {initialConfig.statuses.map(status => ( + + {status.name} + + ))} +
+

+ {t( + 'todo.statuses_fixed_hint', + '单机第一版使用五个稳定状态,专业工作类型可按项目配置。' + )} +

+ +

+ {t('todo.work_types', '专业工作类型')} +

+
+ {TODO_WORKFLOW_TEMPLATES.map(template => ( + + ))} +
+
+ {workTypes.map(workType => ( +
+
+ renameWorkType(workType.key, event.target.value)} + className="h-7 min-w-0 flex-1 rounded-md border border-transparent bg-transparent px-1 text-[12px] text-text-primary outline-none hover:border-border focus:border-primary" + /> + {workType.key} + +
+
+ + {t('todo.depends_on', '前置阶段')} + + {workTypes + .filter(candidate => candidate.key !== workType.key) + .map(candidate => { + const active = workType.dependsOn.includes(candidate.key) + return ( + + ) + })} +
+
+ + {t('todo.default_assignee', '默认负责人')} + + + {workType.defaultAssignee.type !== 'unassigned' && ( + + updateAssignee( + workType.key, + workType.defaultAssignee.type, + event.target.value + ) + } + placeholder={t('todo.assignee_name_placeholder', '姓名或智能体名称')} + className="h-7 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-[10px] outline-none focus:border-primary" + /> + )} +
+
+ ))} +
+
+ setNewName(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') addWorkType() + }} + placeholder={t('todo.work_type_placeholder', '例如:安全评审')} + className="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-3 text-[12px] outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
+
+
+ + + +
+
+
+ ) +} diff --git a/wework/src/features/todo/TodoWorkspace.test.tsx b/wework/src/features/todo/TodoWorkspace.test.tsx index 36029afbc6..b1450f000d 100644 --- a/wework/src/features/todo/TodoWorkspace.test.tsx +++ b/wework/src/features/todo/TodoWorkspace.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import '@/i18n' @@ -60,10 +60,31 @@ const runtimeWork: RuntimeWorkListResponse = { describe('TodoWorkspace V4-01', () => { beforeEach(() => window.localStorage.clear()) + async function quickCreate(title: string, state = 'inbox') { + await userEvent.click(screen.getByTestId(`todo-column-add-${state}`)) + const input = screen.getByTestId(`todo-quick-create-${state}`) + await userEvent.type(input, `${title}{enter}`) + expect(await screen.findByText(title)).toBeInTheDocument() + } + afterEach(() => { delete (window as typeof window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ }) + it('provides a local project when no backend project exists', async () => { + render( + + ) + + expect(screen.getAllByText('本地事项').length).toBeGreaterThan(0) + await quickCreate('Offline item') + expect(window.localStorage.getItem('wework:todo:work-items:1')).toContain('Offline item') + }) + it('renders the four Pencil V4 kanban columns and maps runtime states', () => { render( { expect(screen.getByTestId('todo-column-started')).toHaveTextContent('1') expect(screen.getByTestId('todo-column-review')).toHaveTextContent('1') expect(screen.getByTestId('todo-column-completed')).toHaveTextContent('1') - expect(screen.getByTestId('todo-board-scroll')).toHaveClass('overflow-auto') - expect(screen.getByTestId('todo-board-grid')).toHaveClass('min-w-[900px]', 'flex') + expect(screen.getByTestId('todo-board-scroll')).toHaveClass('overflow-auto', 'bg-[#F7F8F9]') + expect(screen.getByTestId('todo-board-grid')).toHaveClass('min-w-[1100px]', 'flex') expect(screen.getByText('Synchronize runtime state')).toBeInTheDocument() }) @@ -232,7 +253,7 @@ describe('TodoWorkspace V4-01', () => { expect(screen.queryByTestId('todo-detail-panel')).not.toBeInTheDocument() }) - it('opens the V4-04 dialog from a board column and creates a persisted TODO draft', async () => { + it('creates a persisted TODO inline from a board column', async () => { render( { /> ) - await userEvent.click(screen.getByTestId('todo-column-add-review')) + await quickCreate('Fix login redirect', 'review') + expect(screen.getByTestId('todo-sidebar-work-items')).toHaveTextContent('4') + expect(window.localStorage.getItem('wework:todo:work-items:1')).toContain('Fix login redirect') + }) - expect(screen.getByTestId('todo-create-dialog')).toBeInTheDocument() - expect(screen.getByTestId('todo-create-state')).toHaveValue('review') - await userEvent.type(screen.getByTestId('todo-create-goal'), 'Keep login stable') - await userEvent.type( - screen.getByTestId('todo-create-markdown'), - '## Fix login redirect\n\nPreserve the original route.' + it('opens inline creation from the global create action', async () => { + render( + ) - await userEvent.click(screen.getByTestId('todo-create-submit')) - await waitFor(() => expect(screen.queryByTestId('todo-create-dialog')).not.toBeInTheDocument()) - expect(screen.getByText('Fix login redirect')).toBeInTheDocument() - expect(screen.getByTestId('todo-sidebar-work-items')).toHaveTextContent('4') - expect(window.localStorage.getItem('wework:todo:drafts:1')).toContain('Keep login stable') + await userEvent.click(screen.getByTestId('todo-create-button')) + expect(screen.getByTestId('todo-quick-create-inbox')).toHaveFocus() + await userEvent.type(screen.getByTestId('todo-quick-create-inbox'), 'Mouse-created item') + await userEvent.click(screen.getByTestId('todo-quick-create-submit-inbox')) + expect(await screen.findByText('Mouse-created item')).toBeInTheDocument() }) - it('creates and runs a TODO through the existing Wework runtime flow', async () => { const onRunTodo = vi.fn(async () => ({ deviceId: 'local', @@ -275,22 +300,26 @@ describe('TodoWorkspace V4-01', () => { /> ) - await userEvent.click(screen.getByTestId('todo-create-button')) - await userEvent.type(screen.getByTestId('todo-create-goal'), 'Ship a verified fix') - await userEvent.type(screen.getByTestId('todo-create-markdown'), 'Investigate and fix the bug') - await userEvent.selectOptions(screen.getByTestId('todo-create-executor'), 'ai') - await userEvent.click(screen.getByTestId('todo-create-and-run')) + await quickCreate('Investigate and fix the bug') + await userEvent.click(screen.getByText('Investigate and fix the bug')) + await userEvent.selectOptions(screen.getByTestId('todo-detail-assignee-select'), 'ai') + await userEvent.click(screen.getByTestId('todo-detail-run')) await waitFor(() => expect(onRunTodo).toHaveBeenCalledTimes(1)) expect(onRunTodo).toHaveBeenCalledWith( expect.objectContaining({ project: expect.objectContaining({ id: 7 }), message: 'Investigate and fix the bug', - goal: 'Ship a verified fix', + goal: undefined, attachments: [], + collaborationMode: 'plan', }) ) - await waitFor(() => expect(screen.queryByTestId('todo-create-dialog')).not.toBeInTheDocument()) + await waitFor(() => { + const stored = window.localStorage.getItem('wework:todo:work-items:1') + expect(stored).toContain('created-task') + expect(stored).toContain('已关联 AI 执行会话') + }) }) it('switches between the board and list layouts', async () => { @@ -390,10 +419,8 @@ describe('TodoWorkspace V4-01', () => { /> ) - await userEvent.click(screen.getByTestId('todo-create-button')) - await userEvent.type(screen.getByTestId('todo-create-markdown'), 'Unassigned draft') - await userEvent.click(screen.getByTestId('todo-create-submit')) - await userEvent.click(await screen.findByText('Unassigned draft')) + await quickCreate('Unassigned draft') + await userEvent.click(screen.getByText('Unassigned draft')) await userEvent.click(screen.getByTestId('todo-detail-run')) expect(await screen.findByTestId('todo-detail-run-error')).toHaveTextContent( @@ -413,10 +440,8 @@ describe('TodoWorkspace V4-01', () => { /> ) - await userEvent.click(screen.getByTestId('todo-create-button')) - await userEvent.type(screen.getByTestId('todo-create-markdown'), 'Disposable draft') - await userEvent.click(screen.getByTestId('todo-create-submit')) - await userEvent.click(await screen.findByText('Disposable draft')) + await quickCreate('Disposable draft') + await userEvent.click(screen.getByText('Disposable draft')) await userEvent.click(screen.getByTestId('todo-detail-preview-mode')) expect(screen.getByTestId('todo-detail-panel')).toHaveClass('left-0', 'w-full') @@ -426,4 +451,143 @@ describe('TodoWorkspace V4-01', () => { await userEvent.click(screen.getByTestId('todo-detail-menu-delete')) expect(screen.queryByText('Disposable draft')).not.toBeInTheDocument() }) + + it('configures a reusable project flow and assigns each role independently', async () => { + render( + + ) + + await userEvent.click(screen.getByTestId('todo-sidebar-workflow-settings')) + await userEvent.click(screen.getByTestId('todo-workflow-template-software')) + await userEvent.clear(screen.getByTestId('todo-workflow-name-discovery')) + await userEvent.type(screen.getByTestId('todo-workflow-name-discovery'), '产品澄清') + await userEvent.type(screen.getByTestId('todo-workflow-assignee-name-discovery'), '王产品') + await userEvent.type(screen.getByTestId('todo-workflow-assignee-name-implementation'), 'Codex') + await userEvent.click(screen.getByTestId('todo-workflow-save')) + + expect(window.localStorage.getItem('wework:todo:workflow:7')).toContain('产品澄清') + expect(window.localStorage.getItem('wework:todo:workflow:7')).toContain('王产品') + await quickCreate('新版登录流程上线') + await userEvent.click(screen.getByText('新版登录流程上线')) + expect(screen.getByTestId('todo-detail-workflow')).toHaveTextContent('产品澄清') + expect(screen.getByTestId('todo-detail-workflow')).toHaveTextContent('实现') + expect(window.localStorage.getItem('wework:todo:work-items:1')).toContain('Codex') + }) + + it('explains an empty workflow and lets an existing item apply the configured project flow', async () => { + render( + + ) + + await quickCreate('既有事项') + await userEvent.click(screen.getByText('既有事项')) + expect(screen.getByTestId('todo-detail-workflow')).toHaveTextContent( + '当前项目还没有设置事项流程' + ) + + await userEvent.click(screen.getByTestId('todo-configure-workflow')) + await userEvent.click(screen.getByTestId('todo-workflow-template-software')) + await userEvent.click(screen.getByTestId('todo-workflow-save')) + expect(screen.getByTestId('todo-detail-workflow')).toHaveTextContent( + '这个事项还没有应用项目流程' + ) + + await userEvent.click(screen.getByTestId('todo-apply-workflow')) + expect(screen.getByTestId('todo-detail-workflow')).toHaveTextContent('既有事项 · 需求') + expect(screen.getByTestId('todo-detail-workflow')).toHaveTextContent('既有事项 · 实现') + }) + + it('adds an execution task below a complete item without adding another board card', async () => { + window.localStorage.setItem( + 'wework:todo:workflow:7', + JSON.stringify({ + version: 1, + statuses: [ + { key: 'inbox', name: '收集箱' }, + { key: 'backlog', name: '待开始' }, + { key: 'started', name: '进行中' }, + { key: 'review', name: '待确认' }, + { key: 'completed', name: '已完成' }, + ], + workTypes: [ + { + key: 'discovery', + name: '需求', + dependsOn: [], + defaultAssignee: { type: 'human', name: '王产品' }, + }, + { + key: 'implementation', + name: '实现', + dependsOn: ['discovery'], + defaultAssignee: { type: 'ai', name: 'Codex' }, + }, + ], + }) + ) + render( + + ) + + await quickCreate('新版登录流程上线') + await userEvent.click(screen.getByText('新版登录流程上线')) + expect(screen.getByTestId('todo-detail-workflow')).toHaveTextContent('新版登录流程上线 · 实现') + expect(screen.getAllByTestId(/todo-card-/)).toHaveLength(4) + expect(window.localStorage.getItem('wework:todo:work-items:1')).toContain('"parentId"') + + await userEvent.click(screen.getByText('新版登录流程上线 · 实现')) + await userEvent.selectOptions(screen.getByTestId('todo-detail-state-select'), 'started') + await userEvent.selectOptions(screen.getByTestId('todo-detail-assignee-select'), 'ai') + await userEvent.type(screen.getByTestId('todo-detail-blocker-input'), '等待 API 定稿') + fireEvent.blur(screen.getByTestId('todo-detail-blocker-input')) + + await waitFor(() => { + const stored = window.localStorage.getItem('wework:todo:work-items:1') + expect(stored).toContain('等待 API 定稿') + expect(stored).toContain('"state":"backlog"') + expect(stored).toContain('"type":"ai"') + }) + expect(screen.getByTestId('todo-detail-dependency-warning')).toHaveTextContent('需求') + }) + + it('groups human work and supports explicit completion confirmation', async () => { + render( + + ) + + await quickCreate('确认发布范围') + await userEvent.click(screen.getByText('确认发布范围')) + await userEvent.selectOptions(screen.getByTestId('todo-detail-assignee-select'), 'human') + await userEvent.click(screen.getByTestId('todo-detail-close')) + + await userEvent.click(screen.getByTestId('todo-scope-mine')) + expect(screen.getByTestId('todo-my-work')).toHaveTextContent('确认发布范围') + await userEvent.click(screen.getByText('确认发布范围')) + await userEvent.selectOptions(screen.getByTestId('todo-detail-state-select'), 'review') + await userEvent.click(screen.getByTestId('todo-detail-confirm')) + + await waitFor(() => { + expect(window.localStorage.getItem('wework:todo:work-items:1')).toContain('事项已确认完成') + }) + }) }) diff --git a/wework/src/features/todo/TodoWorkspace.tsx b/wework/src/features/todo/TodoWorkspace.tsx index a2fe104c24..76accd20e2 100644 --- a/wework/src/features/todo/TodoWorkspace.tsx +++ b/wework/src/features/todo/TodoWorkspace.tsx @@ -27,7 +27,7 @@ import type { RuntimeWorkListResponse, User as UserProfile, } from '@/types/api' -import { TodoCreateDialog, type TodoCreateValues } from './TodoCreateDialog' +import type { TodoCreateValues } from './TodoCreateDialog' import { TodoDetailPanel, type TodoDetailItem, type TodoViewState } from './TodoDetailPanel' import { ProjectSwitcherOverlay, @@ -37,6 +37,7 @@ import { type TodoProjectView, } from './TodoNavigation' import { TodoOverview } from './TodoOverview' +import { TodoMyWork } from './TodoMyWork' import { TodoWorkItems } from './TodoWorkItems' import { projectColor } from './todoProject' import { @@ -48,6 +49,20 @@ import { type TodoLayout, } from './todoViewSettings' import { requestProjectCreateMode } from '@/components/layout/workbenchShellEvents' +import { + createLocalWorkItemId, + ensureTodoWorkDirectory, + ensureTodoWorkspace, + hydrateLocalWorkItems, + loadLocalWorkItems, + loadTodoWorkflow, + saveLocalWorkItems, + saveTodoWorkflow, + type TodoWorkflowConfig, + writeTodoWorkspaceFile, + type LocalWorkItem, +} from './todoModel' +import { TodoWorkflowDialog } from './TodoWorkflowDialog' type TodoState = TodoViewState @@ -58,7 +73,6 @@ interface TodoWorkspaceProps { currentProjectId?: number | null services?: WorkbenchServices onOpenRuntimeTask?: (address: RuntimeTaskAddress) => Promise | void - modelName?: string | null onRunTodo?: (request: TodoRunRequest) => Promise } @@ -67,33 +81,19 @@ export interface TodoRunRequest { message: string goal?: string attachments: Attachment[] + collaborationMode?: 'default' | 'plan' } type TodoItem = TodoDetailItem -interface TodoDraft { - id: string - projectId: number - state: TodoState - title: string - markdown: string - goal: string - priority: TodoCreateValues['priority'] - assignee: TodoCreateValues['assignee'] - launchMode: TodoCreateValues['launchMode'] - dueDate?: string - attachments: Attachment[] - createdAt: string - updatedAt: string -} - const TODO_PROJECT_STORAGE_KEY = 'wework:todo:selected-project' -const TODO_DRAFTS_STORAGE_KEY = 'wework:todo:drafts' const TODO_VIEW_STORAGE_KEY = 'wework:todo:view' function collectProjects( projects: ProjectWithTasks[], - runtimeWork: RuntimeWorkListResponse | null + runtimeWork: RuntimeWorkListResponse | null, + localProjectName: string, + localProjectDescription: string ): TodoProject[] { const entries = new Map() projects.forEach(project => entries.set(project.id, { project, workspaces: [] })) @@ -105,6 +105,17 @@ function collectProjects( workspaces: projectWork.deviceWorkspaces, }) }) + if (entries.size === 0) { + entries.set(-1, { + project: { + id: -1, + name: localProjectName, + description: localProjectDescription, + client_origin: 'wework', + }, + workspaces: [], + }) + } return [...entries.values()] } @@ -126,6 +137,15 @@ function resolveTodoState(task: RuntimeTaskSummary): TodoState { return 'completed' } +function deriveRootState(current: TodoState, children: LocalWorkItem[]): TodoState { + if (current === 'completed' || children.length === 0) return current + if (children.every(child => child.state === 'completed')) return 'review' + if (children.some(child => ['started', 'review', 'completed'].includes(child.state))) { + return 'started' + } + return current === 'inbox' ? 'backlog' : current +} + function buildTodoItems(project: TodoProject | null): TodoItem[] { if (!project) return [] return project.workspaces @@ -158,17 +178,6 @@ function buildTodoItems(project: TodoProject | null): TodoItem[] { })) } -function loadTodoDrafts(userId: number | undefined): TodoDraft[] { - try { - const raw = window.localStorage.getItem(`${TODO_DRAFTS_STORAGE_KEY}:${userId ?? 'local'}`) - if (!raw) return [] - const value = JSON.parse(raw) as TodoDraft[] - return Array.isArray(value) ? value : [] - } catch { - return [] - } -} - function deriveTodoTitle(markdown: string): string { const firstLine = markdown .split('\n') @@ -229,13 +238,14 @@ export function TodoWorkspace({ currentProjectId, services, onOpenRuntimeTask, - modelName, onRunTodo, }: TodoWorkspaceProps) { const { t } = useTranslation('common') + const localProjectName = t('todo.local_project', '本地事项') + const localProjectDescription = t('todo.local_project_description', '仅存储在这台设备上的事项') const projectEntries = useMemo( - () => collectProjects(projects, runtimeWork), - [projects, runtimeWork] + () => collectProjects(projects, runtimeWork, localProjectName, localProjectDescription), + [localProjectDescription, localProjectName, projects, runtimeWork] ) const [selectedProjectId, setSelectedProjectId] = useState(() => loadSelectedProjectId( @@ -246,6 +256,7 @@ export function TodoWorkspace({ ) const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [projectView, setProjectView] = useState('work-items') + const [workScope, setWorkScope] = useState<'items' | 'mine'>('items') const [expandedProjectId, setExpandedProjectId] = useState(selectedProjectId) const initialViewSettings = loadTodoViewSettings(user?.id, selectedProjectId) const [layout, setLayout] = useState(initialViewSettings.layout) @@ -257,60 +268,129 @@ export function TodoWorkspace({ const [projectMenuOpen, setProjectMenuOpen] = useState(false) const [projectSearch, setProjectSearch] = useState('') const [selectedItemId, setSelectedItemId] = useState(null) - const [createDialogState, setCreateDialogState] = useState(null) - const [drafts, setDrafts] = useState(() => loadTodoDrafts(user?.id)) + const [quickCreateRequest, setQuickCreateRequest] = useState<{ + state: TodoState + token: number + } | null>(null) + const [workflowDialogOpen, setWorkflowDialogOpen] = useState(false) + const [workItems, setWorkItems] = useState(() => loadLocalWorkItems(user?.id)) const usesOverlayTitlebar = isTauriRuntime() const selectedProject = projectEntries.find(entry => entry.project.id === selectedProjectId) ?? projectEntries[0] ?? null - const runtimeItems = useMemo(() => buildTodoItems(selectedProject), [selectedProject]) - const draftItems = useMemo( + const [workflowConfig, setWorkflowConfig] = useState(() => + loadTodoWorkflow(selectedProjectId) + ) + const runtimeItems = useMemo(() => { + const linkedTaskIds = new Set( + workItems.flatMap(item => item.runtimeRefs.map(ref => ref.taskId)) + ) + return buildTodoItems(selectedProject).filter(item => !linkedTaskIds.has(item.id)) + }, [selectedProject, workItems]) + const localItems = useMemo( () => - drafts - .filter(draft => draft.projectId === selectedProject?.project.id) - .map(draft => ({ - id: draft.id, - kind: 'draft', - code: `TODO-${draft.id.slice(-4).toUpperCase()}`, - title: draft.title, - state: draft.state, - runtime: draft.assignee === 'human' ? t('todo.assignee_human', '员工') : 'TODO', - workspace: selectedProject?.project.name ?? '', - description: draft.markdown, - objective: draft.goal, - priority: - draft.priority === 'none' - ? t('todo.priority_none_short', '无') - : t(`todo.priority_${draft.priority}`, draft.priority), - priorityValue: draft.priority, - assignee: - draft.assignee === 'human' - ? t('todo.assignee_human', '员工') - : draft.assignee === 'ai' - ? t('todo.assignee_ai', 'AI 智能体') - : t('todo.assignee_unassigned_short', '未指定'), - assigneeType: draft.assignee, - dueDate: draft.dueDate, - attachments: draft.attachments, - createdAt: draft.createdAt, - updatedAt: draft.updatedAt, - })), - [drafts, selectedProject?.project.id, selectedProject?.project.name, t] + workItems + .filter(item => item.projectId === selectedProject?.project.id && !item.parentId) + .map(item => { + const children = workItems.filter(child => child.parentId === item.id) + const derivedState = deriveRootState(item.state, children) + const blockedChild = children.find(child => child.blocker) + const currentChild = + children.find(child => child.state === 'started') ?? + children.find(child => child.state !== 'completed') + return { + id: item.id, + kind: 'draft', + code: `TODO-${item.id.slice(-4).toUpperCase()}`, + title: item.title, + state: derivedState, + runtime: item.assignee.type === 'human' ? t('todo.assignee_human', '员工') : 'TODO', + workspace: selectedProject?.project.name ?? '', + description: item.description, + objective: item.objective, + priority: + item.priority === 'none' + ? t('todo.priority_none_short', '无') + : t(`todo.priority_${item.priority}`, item.priority), + priorityValue: item.priority, + assignee: + item.assignee.name || + (item.assignee.type === 'human' + ? t('todo.assignee_human', '员工') + : item.assignee.type === 'ai' + ? t('todo.assignee_ai', 'AI 智能体') + : t('todo.assignee_unassigned_short', '未指定')), + assigneeType: item.assignee.type, + dueDate: item.dueDate, + attachments: item.attachments, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + events: item.events, + blocker: item.blocker || blockedChild?.blocker, + nextAction: item.nextAction || currentChild?.nextAction, + collaborators: item.collaborators, + confirmer: item.confirmer?.name, + workspaceItemId: item.id, + address: item.runtimeRefs.at(-1), + children: children.map(child => { + const waitingFor = (child.workTypeSnapshot?.dependsOn ?? []).flatMap(key => { + const dependency = children.find(candidate => candidate.workTypeKey === key) + if (dependency?.state === 'completed') return [] + return [workflowConfig.workTypes.find(type => type.key === key)?.name ?? key] + }) + return { + id: child.id, + parentId: child.parentId, + kind: 'draft' as const, + code: `TODO-${child.id.slice(-4).toUpperCase()}`, + title: child.title, + state: child.state, + runtime: child.assignee.name || child.assignee.type, + workspace: selectedProject?.project.name ?? '', + description: child.description, + objective: child.objective, + priorityValue: child.priority, + assignee: child.assignee.name, + assigneeType: child.assignee.type, + workTypeKey: child.workTypeKey, + workTypeName: child.workTypeSnapshot?.name, + blocker: child.blocker, + nextAction: child.nextAction, + workspaceItemId: item.id, + createdAt: child.createdAt, + updatedAt: child.updatedAt, + waitingFor, + events: child.events, + address: child.runtimeRefs.at(-1), + } + }), + } + }), + [ + workItems, + selectedProject?.project.id, + selectedProject?.project.name, + t, + workflowConfig.workTypes, + ] ) - const items = useMemo(() => [...draftItems, ...runtimeItems], [draftItems, runtimeItems]) + const items = useMemo(() => [...localItems, ...runtimeItems], [localItems, runtimeItems]) const projectItemCounts = useMemo( () => Object.fromEntries( projectEntries.map(entry => [ entry.project.id, entry.workspaces.reduce((total, workspace) => total + workspace.tasks.length, 0) + - drafts.filter(draft => draft.projectId === entry.project.id).length, + workItems.filter(item => item.projectId === entry.project.id && !item.parentId).length, ]) ), - [drafts, projectEntries] + [workItems, projectEntries] ) - const selectedItem = items.find(item => item.id === selectedItemId) ?? null + const selectedItem = + items + .flatMap(item => [item, ...(item.children ?? [])]) + .find(item => item.id === selectedItemId) ?? null const selectProject = (projectId: number) => { setSelectedProjectId(projectId) setExpandedProjectId(projectId) @@ -321,6 +401,7 @@ export function TodoWorkspace({ const viewSettings = loadTodoViewSettings(user?.id, projectId) setLayout(viewSettings.layout) setDisplay(viewSettings.display) + setWorkflowConfig(loadTodoWorkflow(projectId)) try { window.localStorage.setItem( `${TODO_PROJECT_STORAGE_KEY}:${user?.id ?? 'local'}`, @@ -332,15 +413,12 @@ export function TodoWorkspace({ } useEffect(() => { - try { - window.localStorage.setItem( - `${TODO_DRAFTS_STORAGE_KEY}:${user?.id ?? 'local'}`, - JSON.stringify(drafts) - ) - } catch { - // Drafts remain available for the current session. - } - }, [drafts, user?.id]) + void hydrateLocalWorkItems(user?.id).then(items => { + if (items.length > 0) setWorkItems(items) + }) + }, [user?.id]) + + useEffect(() => saveLocalWorkItems(user?.id, workItems), [user?.id, workItems]) useEffect(() => { if (selectedProjectId == null) return @@ -354,77 +432,114 @@ export function TodoWorkspace({ } }, [display, layout, selectedProjectId, user?.id]) - const uploadFiles = async (files: File[]): Promise => { - 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()}`, + const draft: LocalWorkItem = { + id: createLocalWorkItemId(), projectId: values.projectId, state: values.state, title: deriveTodoTitle(values.markdown), - markdown: values.markdown.trim(), - goal: values.goal.trim(), + description: values.markdown.trim(), + objective: values.goal.trim(), priority: values.priority, - assignee: values.assignee, - launchMode: values.launchMode, + assignee: { type: values.assignee }, + collaborators: [], + blocker: '', + nextAction: '', dueDate: values.dueDate, attachments, + runtimeRefs: [], + events: [ + { id: createLocalWorkItemId(), type: 'created', summary: '事项已创建', createdAt: now }, + ], + sortOrder: 0, 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( + const stages: LocalWorkItem[] = workflowConfig.workTypes.map((workType, index) => ({ + id: createLocalWorkItemId(), + projectId: values.projectId, + parentId: draft.id, + title: `${draft.title} · ${workType.name}`, + objective: draft.objective, + description: '', + state: 'backlog', + workTypeKey: workType.key, + workTypeSnapshot: workType, + assignee: workType.defaultAssignee, + collaborators: [], + blocker: '', + nextAction: '', + priority: draft.priority, + attachments: [], + runtimeRefs: [], + events: [ { - ...values, - state: runImmediately && values.assignee === 'human' ? 'started' : values.state, + id: createLocalWorkItemId(), + type: 'created', + summary: '由项目流程自动创建', + createdAt: now, }, - attachments + ], + sortOrder: index, + createdAt: now, + updatedAt: now, + })) + setWorkItems(current => [draft, ...stages, ...current]) + void ensureTodoWorkspace(draft).then(() => + Promise.all( + values.files.map(file => + writeTodoWorkspaceFile( + draft.id, + `context/${file.name.replaceAll('/', '_').replaceAll('\\', '_')}`, + file + ) + ) ) - 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, + ) + stages.forEach(stage => { + if (stage.workTypeKey) void ensureTodoWorkDirectory(draft.id, stage.workTypeKey) }) - if (!address) throw new Error(t('todo.create_failed', 'TODO 创建失败')) selectProject(values.projectId) - setCreateDialogState(null) + return draft + } + + const quickCreate = (state: TodoState, title: string) => { + createDraft( + { + projectId: selectedProject?.project.id ?? -1, + state, + goal: '', + markdown: title, + priority: 'none', + assignee: 'unassigned', + launchMode: 'manual', + dueDate: '', + files: [], + }, + [] + ) } const runDraft = async (item: TodoItem) => { - const draft = drafts.find(entry => entry.id === item.id) + const draft = workItems.find(entry => entry.id === item.id) if (!draft) return + if (draft.parentId && draft.workTypeSnapshot?.dependsOn.length) { + const siblings = workItems.filter(entry => entry.parentId === draft.parentId) + const incomplete = draft.workTypeSnapshot.dependsOn.filter( + key => !siblings.some(entry => entry.workTypeKey === key && entry.state === 'completed') + ) + if (incomplete.length > 0) { + throw new Error(t('todo.dependencies_incomplete', '前置阶段尚未完成')) + } + } 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') { + if (draft.assignee.type === 'unassigned') { throw new Error(t('todo.executor_required', '请先选择员工或 AI 智能体作为执行者')) } - if (draft.assignee === 'human') { - setDrafts(current => + if (draft.assignee.type === 'human') { + setWorkItems(current => current.map(entry => entry.id === draft.id ? { ...entry, state: 'started', updatedAt: new Date().toISOString() } @@ -436,30 +551,98 @@ export function TodoWorkspace({ } const address = await onRunTodo({ project, - message: draft.markdown, - goal: draft.goal || undefined, + message: draft.description, + goal: draft.objective || undefined, attachments: draft.attachments, + collaborationMode: draft.runtimeRefs.length === 0 ? 'plan' : 'default', }) if (!address) throw new Error(t('todo.create_failed', 'TODO 创建失败')) - setDrafts(current => current.filter(entry => entry.id !== draft.id)) + setWorkItems(current => + current.map(entry => + entry.id === draft.id + ? { + ...entry, + state: 'started', + runtimeRefs: [...entry.runtimeRefs, address], + events: [ + ...entry.events, + { + id: createLocalWorkItemId(), + type: 'run-linked', + summary: '已关联 AI 执行会话', + createdAt: new Date().toISOString(), + }, + ], + updatedAt: new Date().toISOString(), + } + : entry + ) + ) setSelectedItemId(null) } const updateDraftAttachments = (draftId: string, attachments: Attachment[]) => { - setDrafts(current => - current.map(draft => - draft.id === draftId - ? { ...draft, attachments, updatedAt: new Date().toISOString() } - : draft + setWorkItems(current => + current.map(item => + item.id === draftId ? { ...item, attachments, updatedAt: new Date().toISOString() } : item ) ) } const deleteDraft = (draftId: string) => { - setDrafts(current => current.filter(draft => draft.id !== draftId)) + setWorkItems(current => + current.filter(item => item.id !== draftId && item.parentId !== draftId) + ) setSelectedItemId(null) } + const addChildWorkItem = (parentId: string, workTypeKey: string, workTypeName: string) => { + const parent = workItems.find(item => item.id === parentId) + if (!parent) return + if (workItems.some(item => item.parentId === parentId && item.workTypeKey === workTypeKey)) + return + const now = new Date().toISOString() + const child: LocalWorkItem = { + id: createLocalWorkItemId(), + projectId: parent.projectId, + parentId, + title: `${parent.title} · ${workTypeName}`, + objective: parent.objective, + description: '', + state: 'backlog', + workTypeKey, + workTypeSnapshot: workflowConfig.workTypes.find(type => type.key === workTypeKey) ?? { + key: workTypeKey, + name: workTypeName, + dependsOn: [], + defaultAssignee: { type: 'unassigned' }, + }, + assignee: + workflowConfig.workTypes.find(type => type.key === workTypeKey)?.defaultAssignee ?? + ({ type: 'unassigned' } as const), + collaborators: [], + blocker: '', + nextAction: '', + priority: parent.priority, + attachments: [], + runtimeRefs: [], + events: [ + { id: createLocalWorkItemId(), type: 'created', summary: '执行任务已创建', createdAt: now }, + ], + sortOrder: workItems.filter(item => item.parentId === parentId).length, + createdAt: now, + updatedAt: now, + } + setWorkItems(current => [...current, child]) + void ensureTodoWorkDirectory(parentId, workTypeKey) + } + + const applyWorkflowToItem = (parentId: string) => { + workflowConfig.workTypes.forEach(workType => { + addChildWorkItem(parentId, workType.key, workType.name) + }) + } + const openWeworkProjects = (createProject = false) => { navigateTo('/') if (createProject) { @@ -504,10 +687,11 @@ export function TodoWorkspace({ setExpandedProjectId(current => (current === projectId ? null : projectId)) } onSelectView={setProjectView} - onCreate={() => setCreateDialogState('backlog')} + onCreate={() => setQuickCreateRequest({ state: 'inbox', token: Date.now() })} onSearch={() => setSearchOpen(true)} onAddProject={() => openWeworkProjects(true)} onOpenProjects={() => openWeworkProjects(false)} + onConfigureWorkflow={() => setWorkflowDialogOpen(true)} />
@@ -626,16 +810,49 @@ export function TodoWorkspace({ + {projectView === 'work-items' && ( +
+ + +
+ )} - {projectView === 'work-items' ? ( + {projectView === 'work-items' && workScope === 'items' ? ( setDisplayOpen(false)} onSelectItem={item => setSelectedItemId(item.id)} - onCreate={state => setCreateDialogState(state)} + onCreate={quickCreate} + createRequest={quickCreateRequest} + onMoveItem={(itemId, state) => + setWorkItems(current => + current.map(item => + item.id === itemId + ? { ...item, state, updatedAt: new Date().toISOString() } + : item + ) + ) + } /> + ) : projectView === 'work-items' ? ( + setSelectedItemId(item.id)} /> ) : ( updateDraftAttachments(selectedItem.id, attachments) : undefined } + onAddChild={ + selectedItem.kind === 'draft' && !selectedItem.parentId + ? (workTypeKey, workTypeName) => + addChildWorkItem(selectedItem.id, workTypeKey, workTypeName) + : undefined + } + workTypes={workflowConfig.workTypes} + onApplyWorkflow={ + selectedItem.kind === 'draft' && !selectedItem.parentId + ? () => applyWorkflowToItem(selectedItem.id) + : undefined + } + onConfigureWorkflow={() => setWorkflowDialogOpen(true)} + onSelectChild={child => setSelectedItemId(child.id)} + onUpdateItem={ + selectedItem.kind === 'draft' + ? patch => + setWorkItems(current => + current.map(item => + item.id === selectedItem.id + ? { + ...item, + ...(patch.state && + !( + patch.state === 'started' && + selectedItem.waitingFor && + selectedItem.waitingFor.length > 0 + ) + ? { state: patch.state } + : {}), + ...(patch.assigneeType + ? { assignee: { type: patch.assigneeType } } + : {}), + ...(patch.blocker !== undefined ? { blocker: patch.blocker } : {}), + ...(patch.nextAction !== undefined + ? { nextAction: patch.nextAction } + : {}), + events: [ + ...item.events, + { + id: createLocalWorkItemId(), + type: 'updated' as const, + summary: '事项信息已更新', + createdAt: new Date().toISOString(), + }, + ], + updatedAt: new Date().toISOString(), + } + : item + ) + ) + : undefined + } + onConfirm={ + selectedItem.kind === 'draft' && !selectedItem.parentId + ? () => + setWorkItems(current => + current.map(item => + item.id === selectedItem.id + ? { + ...item, + state: 'completed', + events: [ + ...item.events, + { + id: createLocalWorkItemId(), + type: 'confirmed' as const, + summary: '事项已确认完成', + createdAt: new Date().toISOString(), + }, + ], + updatedAt: new Date().toISOString(), + } + : item + ) + ) + : undefined + } /> )}
- {createDialogState && selectedProject && ( - entry.project)} - initialProjectId={selectedProject.project.id} - initialState={createDialogState} - modelName={modelName} - onClose={() => setCreateDialogState(null)} - onSubmit={submitTodo} + {workflowDialogOpen && selectedProject && ( + setWorkflowDialogOpen(false)} + onSave={config => { + setWorkflowConfig(config) + saveTodoWorkflow(selectedProject.project.id, config) + setWorkflowDialogOpen(false) + }} /> )} 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 ab2789d846..c1ef25e8e9 100644 --- a/wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts +++ b/wework/src/features/workbench/useWorkbenchRuntimeMessaging.ts @@ -448,6 +448,7 @@ export function useWorkbenchRuntimeMessaging({ SendCurrentInputOptions, 'initialGoal' | 'onError' | 'onRuntimeTaskOptimisticOpen' > & { + collaborationMode?: 'default' | 'plan' ephemeral?: boolean openInMainPane?: boolean refreshWorkListsOnResolve?: boolean @@ -543,7 +544,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 } + : {}), + }, additionalSkills: payload.additional_skills ?? [], attachmentIds: payload.attachment_ids ?? [], attachments: payload.attachments ?? [], @@ -1056,6 +1062,7 @@ export function useWorkbenchRuntimeMessaging({ return sendPreparedRuntimeMessage(message, prepared.payload, prepared.activeDeviceId, { initialGoal: options.initialGoal, + collaborationMode: options.collaborationMode, onError: options.onError, openInMainPane: false, }) diff --git a/wework/src/features/workbench/workbenchContextTypes.ts b/wework/src/features/workbench/workbenchContextTypes.ts index df4d2e2d30..1ebed02272 100644 --- a/wework/src/features/workbench/workbenchContextTypes.ts +++ b/wework/src/features/workbench/workbenchContextTypes.ts @@ -93,6 +93,7 @@ export interface CreateProjectRuntimeTaskOptions { project: ProjectWithTasks attachments?: Attachment[] initialGoal?: RuntimeGoalCreateInput | null + collaborationMode?: 'default' | 'plan' onError?: (error: string) => void } diff --git a/wework/src/i18n/locales/en/common.json b/wework/src/i18n/locales/en/common.json index de0f5946f2..3491cc1f78 100644 --- a/wework/src/i18n/locales/en/common.json +++ b/wework/src/i18n/locales/en/common.json @@ -1379,7 +1379,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", @@ -1439,7 +1440,62 @@ "model_automatic": "Model: automatic", "launch_manual": "Launch: manual", "launch_automatic": "Launch: automatic", - "creation_principle": "Keep creation free-form; structure automatically at runtime", + "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 TODO", "create_and_run": "Create and run", "creating": "Creating…", diff --git a/wework/src/i18n/locales/zh-CN/common.json b/wework/src/i18n/locales/zh-CN/common.json index 95a8dc00c9..85f7ba0112 100644 --- a/wework/src/i18n/locales/zh-CN/common.json +++ b/wework/src/i18n/locales/zh-CN/common.json @@ -1378,7 +1378,8 @@ "assignee_all": "负责人:全部", "priority_all": "优先级:全部", "grouping_hint": "按状态分组 · 手动排序", - "state_backlog": "待处理", + "state_inbox": "收集箱", + "state_backlog": "待开始", "state_started": "进行中", "state_review": "待确认", "state_completed": "已完成", @@ -1438,7 +1439,62 @@ "model_automatic": "Model:自动", "launch_manual": "启动方式:手动", "launch_automatic": "启动方式:自动", - "creation_principle": "内容保持自由,运行时自动结构化", + "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": "创建 TODO", "create_and_run": "创建并运行", "creating": "创建中…",