From 8641def3471f4e508bad12a203b7507060a11ad0 Mon Sep 17 00:00:00 2001 From: idevlab Date: Tue, 1 Sep 2026 20:39:24 +0800 Subject: [PATCH 1/3] feat: add shared task collaboration workspace --- apps/desktop/src-host/src/remote.rs | 142 +- apps/desktop/src/App.tsx | 6 +- apps/desktop/src/bridge.ts | 6 + apps/desktop/src/i18n/strings.ts | 12 + apps/desktop/src/sidebar/SessionRail.tsx | 198 ++- apps/desktop/src/styles.css | 5 +- .../src/taskboard/TaskBoardCollection.tsx | 71 + .../desktop/src/taskboard/TaskBoardHeader.tsx | 13 + .../desktop/src/taskboard/TaskBoardKanban.tsx | 188 +++ apps/desktop/src/taskboard/TaskBoardPage.tsx | 20 +- apps/desktop/src/taskboard/task-board.css | 42 +- .../src/taskboard/useTaskBoardActions.ts | 17 +- .../desktop/src/taskboard/useTaskBoardView.ts | 30 + apps/desktop/src/taskboard/workspaceTypes.ts | 2 + .../tests/composerGeometryContract.test.ts | 9 + .../tests/desktopPerformanceContract.test.ts | 3 + .../tests/sessionRailRendered.test.tsx | 130 +- apps/desktop/tests/taskBoardRendered.test.tsx | 109 ++ crates/core/examples/live_demo.rs | 3 +- crates/core/src/engine.rs | 46 +- crates/core/src/event.rs | 9 + crates/core/src/lib.rs | 17 +- crates/core/src/store.rs | 9 + crates/core/src/task.rs | 146 ++ crates/core/src/task_store.rs | 1245 ++++++++++++++++- crates/core/tests/collaboration_v1.rs | 250 ++++ crates/server/src/auth.rs | 95 +- crates/server/src/lib.rs | 683 ++++++++- crates/server/tests/team_collaboration.rs | 472 +++++++ crates/tui/src/app.rs | 1 + .../change.md | 202 +++ .../change.md | 99 ++ .../change.md | 104 ++ .../change.md | 173 +++ .../change.md | 196 +++ .../2026-09-01-unify-task-row-style/change.md | 96 ++ 36 files changed, 4659 insertions(+), 190 deletions(-) create mode 100644 apps/desktop/src/taskboard/TaskBoardCollection.tsx create mode 100644 apps/desktop/src/taskboard/TaskBoardKanban.tsx create mode 100644 apps/desktop/src/taskboard/useTaskBoardView.ts create mode 100644 crates/core/tests/collaboration_v1.rs create mode 100644 crates/server/tests/team_collaboration.rs create mode 100644 docs/sdlc/changes/2026-09-01-align-nested-task-provenance/change.md create mode 100644 docs/sdlc/changes/2026-09-01-default-project-group/change.md create mode 100644 docs/sdlc/changes/2026-09-01-empty-heading-project-typography/change.md create mode 100644 docs/sdlc/changes/2026-09-01-shared-task-collaboration/change.md create mode 100644 docs/sdlc/changes/2026-09-01-taskboard-view-switching/change.md create mode 100644 docs/sdlc/changes/2026-09-01-unify-task-row-style/change.md diff --git a/apps/desktop/src-host/src/remote.rs b/apps/desktop/src-host/src/remote.rs index 729989a4..b677c576 100644 --- a/apps/desktop/src-host/src/remote.rs +++ b/apps/desktop/src-host/src/remote.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use codetwo_core::{Engine, Event, Store}; +use codetwo_core::{Engine, Event, Member, MemberId, Store, WorkspaceId, WorkspaceRole}; use codetwo_kernel::{async_trait, Context, Injection, Plugin, PluginError, PluginResult}; use codetwo_plugins::{CanvasService, EngineService, EventBus, StoreService}; use serde::{Deserialize, Serialize}; @@ -39,6 +39,14 @@ struct RemotePairingLink { token: String, expires_in: u64, qr_svg: String, + #[serde(skip_serializing_if = "Option::is_none")] + member_id: Option, +} + +#[derive(Serialize)] +struct TeamInvite { + member: Member, + pairing: RemotePairingLink, } struct RemoteHandle { @@ -207,6 +215,37 @@ impl RemoteRuntime { fn shutdown(&self) { self.lifecycle.lock().unwrap().shutdown(); } + + fn member_pairing_link( + &self, + member_id: &MemberId, + ttl: std::time::Duration, + endpoint_id: Option<&str>, + ) -> Result { + let (port, auth) = self + .lifecycle + .lock() + .unwrap() + .running_access() + .ok_or_else(|| "turn on network access first".to_string())?; + let endpoints = Self::endpoints(port); + let endpoint = codetwo_server::select_pairing_endpoint(&endpoints, endpoint_id)?; + let token = auth.issue_member_pairing_token(member_id.as_str(), ttl); + let url = codetwo_server::pairing_url_for_endpoint(&endpoint.url, &token); + let qr_svg = if endpoint.qr_shareable { + codetwo_server::pairing_qr_svg(&url).unwrap_or_default() + } else { + String::new() + }; + Ok(RemotePairingLink { + endpoint_id: endpoint.id.clone(), + url, + token, + expires_in: ttl.as_secs(), + qr_svg, + member_id: Some(member_id.as_str().to_string()), + }) + } } fn take_args(value: Value) -> Result { @@ -223,6 +262,14 @@ fn json(value: T) -> Result { serde_json::to_value(value).map_err(PluginError::new) } +fn now_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(i64::MAX as u128) as i64 +} + #[async_trait] impl Plugin for RemotePlugin { fn name(&self) -> &str { @@ -404,10 +451,103 @@ impl Plugin for RemotePlugin { token, expires_in: ttl.as_secs(), qr_svg, + member_id: None, }) } })?; + #[derive(Deserialize)] + struct TeamBootstrapArgs { + workspace_name: String, + admin_name: String, + #[serde(default)] + ttl_secs: Option, + #[serde(default)] + endpoint_id: Option, + } + let service = runtime.clone(); + ctx.command("remote.team_bootstrap", move |args| { + let service = service.clone(); + async move { + let args: TeamBootstrapArgs = take_args(args)?; + let now_ms = now_millis(); + let member = match service.store.workspace().map_err(PluginError::new)? { + Some(_) => service + .store + .list_members() + .map_err(PluginError::new)? + .into_iter() + .find(|member| member.active && member.role == WorkspaceRole::Admin) + .ok_or_else(|| PluginError::new("Workspace has no active Admin"))?, + None => { + let workspace_id = + WorkspaceId::new(format!("workspace-{}", uuid::Uuid::new_v4())); + service + .store + .create_workspace(workspace_id.clone(), &args.workspace_name, now_ms) + .map_err(PluginError::new)?; + service + .store + .create_member( + &workspace_id, + MemberId::new(format!("member-{}", uuid::Uuid::new_v4())), + &args.admin_name, + WorkspaceRole::Admin, + now_ms, + ) + .map_err(PluginError::new)? + } + }; + let ttl = std::time::Duration::from_secs( + args.ttl_secs + .unwrap_or(codetwo_server::DEFAULT_PAIRING_TTL.as_secs()), + ); + let pairing = service + .member_pairing_link(&member.id, ttl, args.endpoint_id.as_deref()) + .map_err(PluginError::new)?; + json(TeamInvite { member, pairing }) + } + })?; + + #[derive(Deserialize)] + struct TeamInviteArgs { + display_name: String, + #[serde(default)] + ttl_secs: Option, + #[serde(default)] + endpoint_id: Option, + } + let service = runtime.clone(); + ctx.command("remote.team_invite", move |args| { + let service = service.clone(); + async move { + let args: TeamInviteArgs = take_args(args)?; + let workspace = service + .store + .workspace() + .map_err(PluginError::new)? + .ok_or_else(|| PluginError::new("bootstrap the Workspace first"))?; + let member = service + .store + .create_member( + &workspace.id, + MemberId::new(format!("member-{}", uuid::Uuid::new_v4())), + &args.display_name, + WorkspaceRole::Member, + now_millis(), + ) + .map_err(PluginError::new)?; + let ttl = std::time::Duration::from_secs( + args.ttl_secs + .unwrap_or(codetwo_server::DEFAULT_PAIRING_TTL.as_secs()), + ); + let pairing = service + .member_pairing_link(&member.id, ttl, args.endpoint_id.as_deref()) + .map_err(PluginError::new)?; + json(TeamInvite { member, pairing }) + } + })?; + let service = runtime.clone(); ctx.command("remote.devices", move |_| { let service = service.clone(); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 5a456153..0b57e556 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -2897,6 +2897,9 @@ export default function App() { let unlisten: (() => void) | null = null; void (async () => { unlisten = await onEngineEvent((ev: CoreEvent) => { + // Shared Task clients refetch the revisioned Task snapshot. The production TaskBoard is + // still local in this P0 and must not render this control-plane event as transcript data. + if (ev.event === "task_snapshot_changed") return; if (ev.event === "session_created") { const refreshed = refreshSessions(); if (!matchesSessionCreation(ev, awaitingSessionRef.current)) return; @@ -8408,7 +8411,7 @@ export default function App() { type="button" variant="link" size="compact" - className="h-auto px-0 py-0 text-inherit decoration-muted-foreground/40 decoration-dotted underline-offset-[7px]" + className="h-auto px-0 py-0 text-inherit [font-size:inherit] [font-weight:inherit] [letter-spacing:inherit] [line-height:inherit] decoration-muted-foreground/40 decoration-dotted underline-offset-[7px]" title={activeProject ?? undefined} > {activeProjectName ?? t("rail.noProject")} @@ -8441,6 +8444,7 @@ export default function App() { + {" "} {t("transcript.greetingEnd")} )} diff --git a/apps/desktop/src/bridge.ts b/apps/desktop/src/bridge.ts index 1337f892..718e285e 100644 --- a/apps/desktop/src/bridge.ts +++ b/apps/desktop/src/bridge.ts @@ -816,6 +816,12 @@ export function describeBlock(b: DocBlock): string { /// Mirrors core `Event` (tagged by `event`, snake_case). export type CoreEvent = + | { + event: "task_snapshot_changed"; + session: null; + task_id: string; + revision: number; + } | { event: "session_created"; session: string; diff --git a/apps/desktop/src/i18n/strings.ts b/apps/desktop/src/i18n/strings.ts index cb4e71dc..79c9af7e 100644 --- a/apps/desktop/src/i18n/strings.ts +++ b/apps/desktop/src/i18n/strings.ts @@ -2021,6 +2021,12 @@ export const en = { "taskboard.openPullRequestsHeader": "Open PRs", "taskboard.updatedHeader": "Updated", "taskboard.taskList": "Task list", + "taskboard.boardView": "Task board view", + "taskboard.viewSwitcher": "Task view", + "taskboard.view.list": "List", + "taskboard.view.board": "Board", + "taskboard.selectTaskCard": "Select task: {title}", + "taskboard.cardPullRequests": "PR {count}", "taskboard.emptyList": "No tasks yet", "taskboard.expandTask": "Expand task: {title}", "taskboard.collapseTask": "Collapse task: {title}", @@ -4626,6 +4632,12 @@ export const zhCN: Record = { "taskboard.openPullRequestsHeader": "打开的 PR", "taskboard.updatedHeader": "更新时间", "taskboard.taskList": "任务列表", + "taskboard.boardView": "任务看板视图", + "taskboard.viewSwitcher": "任务视图", + "taskboard.view.list": "列表", + "taskboard.view.board": "看板", + "taskboard.selectTaskCard": "选择任务:{title}", + "taskboard.cardPullRequests": "PR {count}", "taskboard.emptyList": "还没有任务", "taskboard.expandTask": "展开任务:{title}", "taskboard.collapseTask": "收起任务:{title}", diff --git a/apps/desktop/src/sidebar/SessionRail.tsx b/apps/desktop/src/sidebar/SessionRail.tsx index 42839466..d0bd97ad 100644 --- a/apps/desktop/src/sidebar/SessionRail.tsx +++ b/apps/desktop/src/sidebar/SessionRail.tsx @@ -585,6 +585,10 @@ export function SessionRail({ [assignedProjectSection, projectEntries, projectOrganization.order, taskSections.sections], ); + const [allProjectsOpen, setAllProjectsOpen] = usePersistedBoolean( + "rail.allProjectsOpen", + true, + ); // Folded by default: archived threads are reference material, not the working set, so they // shouldn't compete with the live rows for attention. The fold survives a restart. const [archivedOpen, setArchivedOpen] = usePersistedBoolean("rail.archivedOpen", false); @@ -929,6 +933,39 @@ export function SessionRail({ : pullRequest?.state === "ci_running" ? "text-warning" : "text-muted-foreground"; + const checkoutBadge = ( + + + ); + const pullRequestBadge = pullRequest && pullRequestLabel ? ( + + {pullRequest.state === "merged" ? ( + + ) : null; + const provenanceInSummary = pullRequest === null; + const showWorkspaceLine = pullRequest !== null; const commitRename = () => { if (renaming?.id !== s.id) return; @@ -1185,9 +1222,12 @@ export function SessionRail({ onKeyDown={onRowKeyDown} className="absolute inset-0 z-0 h-auto rounded-control p-0 hover:bg-transparent" /> -
+
{/* Title owns the row. Routine controls appear on demand. */} -
+
{renaming?.id === s.id ? (
- {/* The glanceable second line: who answered, what they said, and when. */} + {/* Keep the preview aligned with the title; trail provider and timing metadata. */}
- - - {hasUsefulPreview ? (
{/* Workspace identity and Git provenance close the hierarchy. */} -
- {showProjectIdentity ? ( - <> -
+ {showProjectIdentity ? ( + <> +
+ )}
} @@ -1569,7 +1583,7 @@ export function SessionRail({ } data-project-toggle={project.path} title={t(open ? "rail.hideProject" : "rail.showProject", { name: project.name })} - className="flex min-w-0 flex-1 items-center gap-2 rounded-control px-2 text-ui leading-4 outline-none focus-visible:focus-ring-inset" + className="flex min-w-0 flex-1 items-center gap-2 rounded-control px-2 text-ui leading-4 outline-none hover:bg-transparent focus-visible:focus-ring-inset dark:hover:bg-transparent" >