From e4ff2de29f63f7382b1f992cecb1f93768565de3 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 29 Mar 2026 09:28:51 +0800 Subject: [PATCH] fix(taskthread): stabilize workspace binding follow-ups --- ...assistant-thread-working-directory-flow.md | 7 + .../assistant-thread-target-model-20260328.md | 318 ++++++++---------- lib/app/app_controller_desktop_settings.dart | 2 +- ...p_controller_desktop_settings_runtime.dart | 10 + ...app_controller_desktop_thread_binding.dart | 21 +- ...pp_controller_desktop_thread_sessions.dart | 6 +- ...op_thread_sessions_collaboration_impl.dart | 12 +- ...app_controller_desktop_thread_storage.dart | 17 +- ...ontroller_desktop_workspace_execution.dart | 16 + .../assistant_page_state_closure.dart | 21 ++ .../settings/settings_page_sections.dart | 8 - lib/web/web_assistant_page_core.dart | 1 + lib/widgets/assistant_artifact_sidebar.dart | 175 +++++++--- test/features/settings_page_suite.dart | 22 +- ...pp_controller_ai_gateway_models_suite.dart | 14 +- ...ntroller_assistant_workspace_ref_test.dart | 245 ++++++++++++++ .../assistant_artifact_sidebar_test.dart | 38 +++ 17 files changed, 684 insertions(+), 249 deletions(-) diff --git a/docs/architecture/archive/assistant-thread-working-directory-flow.md b/docs/architecture/archive/assistant-thread-working-directory-flow.md index 24e6f1c8..f6c74db5 100644 --- a/docs/architecture/archive/assistant-thread-working-directory-flow.md +++ b/docs/architecture/archive/assistant-thread-working-directory-flow.md @@ -2,6 +2,13 @@ 更新时间:2026-03-28 +> 已过时:本文记录的是 `workspaceRef / workspaceRefKind / cwd fallback` 主导时期的线程目录流转。 +> +> 当前实现请优先参考: +> [docs/architecture/assistant-thread-target-model-20260328.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/xworkmate/docs/architecture/assistant-thread-target-model-20260328.md) +> +> 新文档已经把 TaskThread 的主流程图和状态图重画为基于 `workspaceBinding / executionBinding / lifecycleState` 的 Mermaid 版本。 + 本文记录 XWorkmate 中“任务线程独立工作目录”的变量流转关系,重点覆盖: - UI 选中线程后,当前线程是谁 diff --git a/docs/architecture/assistant-thread-target-model-20260328.md b/docs/architecture/assistant-thread-target-model-20260328.md index bdcd0271..88d63bbf 100644 --- a/docs/architecture/assistant-thread-target-model-20260328.md +++ b/docs/architecture/assistant-thread-target-model-20260328.md @@ -1,124 +1,29 @@ -# Assistant 任务线程目标模型(2026-03-28) +# Assistant TaskThread 当前模型(2026-03-28) -本文定义新的 Assistant 任务线程目标模型,满足以下约束: +本文以当前代码实现为准,描述 XWorkmate 里 `TaskThread` 的真实结构、主链路和状态语义。 -- 保持 UI 不变 -- 彻底移除全部旧数据兼容设计 -- 任务线程必须稳定、独立、可理解、原子化 -- 任务线程可归属于本地租户/用户、在线租户/用户 -- 任务线程可调度给不同工作模式执行 -- 最后对话上下文可交给本地 agent、OpenClaw Gateway(本地/远程)执行 +这份文档的目标不是描述“理想终态”,而是给现在的实现一份能直接对照代码的说明,避免旧的 `workspaceRef` 时代文档继续误导后续改动。 -本文不描述旧实现如何继续兼容;旧数据被视为一次性清理对象,不再进入主链路。 +## 0. 当前结论 -## 0. 硬约束 +1. `TaskThread` 是当前任务线程的持久化主对象,规范字段已经是: + - `ownerScope` + - `workspaceBinding` + - `executionBinding` + - `contextState` + - `lifecycleState` +2. desktop 发送消息前会先执行一次 `ensureDesktopTaskThreadBindingInternal(...)`,然后检查 `workspaceBinding.workspacePath`;为空则直接 fail-fast,不允许运行。 +3. single-agent 本地线程会把工作目录落到: + - `/.xworkmate/threads/` +4. 右侧边栏展示路径来自 `workspaceBinding.displayPath`;本地线程当前会把 `displayPath` 与 `workspacePath` 对齐,复制/打开也基于同一条绑定。 +5. 当前 `ThreadLifecycleState.status` 在 desktop 主链路里实际只使用: + - `needs_workspace` + - `ready` +6. `archived` 是单独的布尔标记,不是第三个 `status` 枚举值。 -以下三条是新模型的不可退让约束: +## 1. 当前结构 -1. 线程没有 `workspacePath` 就不能运行 -2. 运行时绝不再从全局配置推导线程目录 -3. UI 显示的工作路径必须和执行时使用的路径完全一致 - -## 1. 目标模型(Mermaid) - -```mermaid -flowchart LR - UI["UI 选择任务线程"] --> T["TaskThread"] - - T --> ID["threadId"] - T --> OWNER["ownerScope"] - T --> WS["workspaceBinding"] - T --> EXEC["executionBinding"] - T --> CTX["contextState"] - - OWNER --> OWNER2["realm + subjectType + subjectId"] - WS --> WS2["workspaceId + workspaceKind + workspacePath"] - EXEC --> EXEC2["executionMode + executorId + providerId"] - CTX --> CTX2["messages + model + skills + permission + viewMode"] - - WS2 --> RUN["构造执行请求"] - EXEC2 --> RUN - CTX2 --> RUN - - RUN --> LOCAL["本地 Agent"] - RUN --> GWL["OpenClaw Gateway(本地)"] - RUN --> GWR["OpenClaw Gateway(远程)"] - - LOCAL --> RESULT["执行结果"] - GWL --> RESULT - GWR --> RESULT - - RESULT --> UPDATE_CTX["回写 contextState"] - RESULT --> UPDATE_WS["必要时回写 workspaceBinding"] - - UPDATE_CTX --> STORE["持久化 TaskThread"] - UPDATE_WS --> STORE - - STORE --> SIDEBAR["右侧边栏显示当前任务工作路径"] - STORE --> CHAT["中间对话区"] -``` - -## 2. 设计原则 - -### 2.1 线程是原子对象 - -任务线程不是“会话 + 一些全局推导状态”的组合,而是一个完整的领域对象。 - -一个线程必须自己拥有: - -- 明确的归属 -- 明确的工作目录 -- 明确的执行模式 -- 明确的上下文 - -线程在运行时不再依赖: - -- `settings.workspacePath` 的动态推导 -- `Directory.current.path` 的运行兜底 -- 旧目录迁移分支 -- endpoint 形态决定线程目录来源 - -### 2.2 线程归属与执行通道解耦 - -线程归属决定“谁拥有这个线程”: - -- 本地租户 -- 本地用户 -- 在线租户 -- 在线用户 - -执行通道决定“由谁执行这个线程上下文”: - -- 本地 agent -- OpenClaw Gateway(本地) -- OpenClaw Gateway(远程) - -这两个维度必须是并列字段,不能混成一个 mode。 - -### 2.3 工作目录必须是线程状态,不是运行推导 - -线程工作目录必须在创建线程时就固定为线程状态的一部分。 - -运行时: - -- 只读取线程绑定的工作目录 -- 不再根据全局配置推导 -- 不再在解析失败时回退到进程 cwd - -### 2.4 UI 显示值与执行值必须同源 - -右侧边栏显示的“当前任务工作路径”和 runner 使用的 cwd,必须来自同一个绑定对象: - -- `TaskThread.workspaceBinding` - -不允许出现: - -- UI 显示一个值 -- 实际执行又跑到另一个目录 - -## 3. 目标结构变量文档 - -### 3.1 顶层对象:TaskThread +### 1.1 顶层对象:TaskThread ```text TaskThread @@ -130,31 +35,38 @@ TaskThread - contextState: ThreadContextState - lifecycleState: ThreadLifecycleState - createdAtMs: double -- updatedAtMs: double +- updatedAtMs: double? ``` -### 3.2 归属结构:ThreadOwnerScope +### 1.2 归属:ThreadOwnerScope ```text ThreadOwnerScope -- realm: ThreadRealm // local | remote -- subjectType: ThreadSubjectType // tenant | user +- realm: ThreadRealm // local | remote +- subjectType: ThreadSubjectType // tenant | user - subjectId: String - displayName: String ``` -### 3.3 工作空间绑定:WorkspaceBinding +### 1.3 工作空间绑定:WorkspaceBinding ```text WorkspaceBinding - workspaceId: String -- workspaceKind: WorkspaceKind // local_fs | remote_fs +- workspaceKind: WorkspaceKind // localFs | remoteFs - workspacePath: String - displayPath: String - writable: bool ``` -### 3.4 执行绑定:ExecutionBinding +说明: + +- `workspacePath` 是执行时真正依赖的路径。 +- `displayPath` 是 UI 展示值。 +- 对当前 desktop 本地线程,二者应保持一致。 +- 对远端线程,`displayPath` 可以和 `workspacePath` 一样,也可以是更适合展示的字符串,但它们仍然来自同一个 `WorkspaceBinding`。 + +### 1.4 执行绑定:ExecutionBinding ```text ExecutionBinding @@ -164,7 +76,13 @@ ExecutionBinding - endpointId: String ``` -### 3.5 上下文状态:ThreadContextState +当前 `executionMode` 与 UI 目标的映射关系: + +- `localAgent` -> `singleAgent` +- `gatewayLocal` -> `local` +- `gatewayRemote` -> `remote` + +### 1.5 上下文:ThreadContextState ```text ThreadContextState @@ -175,84 +93,130 @@ ThreadContextState - permissionLevel: AssistantPermissionLevel - messageViewMode: AssistantMessageViewMode - latestResolvedRuntimeModel: String +- gatewayEntryState: String? ``` -### 3.6 生命周期状态:ThreadLifecycleState +### 1.6 生命周期:ThreadLifecycleState ```text ThreadLifecycleState - archived: bool -- status: String +- status: String // 当前主链路只用 ready | needs_workspace - lastRunAtMs: double? - lastResultCode: String? ``` -## 4. 任务工作流(唯一主链路) +说明: -唯一主链路固定为: +- `lastRunAtMs` / `lastResultCode` 已经是模型字段,但当前 desktop TaskThread 主链路还没有把它们扩展成更细的“运行中 / 成功 / 失败”状态机。 +- 现在真正决定“能不能发消息”的核心条件仍然是 `workspaceBinding.workspacePath` 是否为空。 -`UI 选择线程 -> 读取 TaskThread -> 校验可运行性 -> 构造执行请求 -> 派发执行 -> 执行结果 -> 回写线程 -> UI 显示` +## 2. TaskThread 主流程图(Mermaid) + +下面这张图对应当前 desktop 的真实主链路,覆盖线程初始化、工作目录绑定、运行前校验、执行与回写。 ```mermaid -flowchart LR - UI["UI 选择线程"] --> READ["读取 TaskThread"] - READ --> ID["threadId"] - READ --> OWNER["ownerScope"] - READ --> WS["workspaceBinding"] - READ --> EXEC["executionBinding"] - READ --> CTX["contextState"] +flowchart TD + A["新建线程 / 切换线程"] --> B["upsertTaskThreadInternal(threadId, ...)"] + B --> C["ensureDesktopTaskThreadBindingInternal(threadId)"] - ID --> CHECK["校验可运行性"] - OWNER --> CHECK - WS --> CHECK - EXEC --> REQ["构造执行请求"] - CTX --> REQ - CHECK --> REQ + C --> D{"executionTarget"} + D -->|singleAgent| E["构造本地 WorkspaceBinding
workspacePath = workspaceRoot/.xworkmate/threads/
createSync(recursive: true)"] + D -->|local / remote gateway| F["构造远端 WorkspaceBinding
/owners////threads/"] - REQ --> RUN["派发执行"] - RUN --> RESULT["执行结果"] - RESULT --> WRITE["回写线程"] - WRITE --> VIEW["UI 显示"] + E --> G["持久化 TaskThread"] + F --> G + G --> H["右栏读取 workspaceBinding.displayPath"] + + H --> I["用户发送消息"] + I --> J["再次 ensureDesktopTaskThreadBindingInternal(threadId)"] + J --> K{"workspaceBinding.workspacePath 为空?"} + + K -->|yes| L["追加错误消息
当前线程缺少工作路径,无法运行"] + K -->|no| M["按 TaskThread 构造执行请求
workspaceBinding + executionBinding + contextState"] + + M --> N{"executionMode"} + N -->|localAgent| O["singleAgentRunner.run(...)"] + N -->|gatewayLocal / gatewayRemote| P["Gateway / ACP 会话执行"] + + O --> Q["执行结果 / 消息 / resolvedWorkingDirectory"] + P --> Q + + Q --> R{"返回新的远端 workingDirectory?"} + R -->|yes| S["回写 workspaceBinding
workspaceKind=remoteFs
status=ready"] + R -->|no| T["回写 contextState / updatedAtMs"] + + S --> T + T --> U["持久化当前 TaskThread"] + U --> V["对话区 / 右栏 / 文件面板刷新"] ``` -8 步行为说明: +这张图里有三点最重要: -1. UI 只负责选中 `threadId` -2. controller/runtime 只读取该 `TaskThread` -3. 运行前先检查 `workspaceBinding.workspacePath` -4. 没有 `workspacePath` 直接失败,不允许运行 -5. 执行请求只从 `workspaceBinding`、`executionBinding`、`contextState` 构造 -6. runner / gateway 按请求执行,不再推导线程目录 -7. 执行结果只允许回写当前线程的 `contextState` / `workspaceBinding` -8. UI 展示只读取当前线程绑定对象,显示值与执行值同源 +1. `TaskThread` 先绑定,再运行,不是运行时临时猜目录。 +2. `workspaceBinding.workspacePath` 为空会直接失败,不会继续执行。 +3. UI 展示路径和执行路径都来自同一个 `WorkspaceBinding`。 -本节三条硬约束直接适用于主链路: +## 3. TaskThread 状态图(Mermaid) -- 无 `workspacePath` 不运行 -- 运行时不推导目录 -- UI 显示值与执行值同源 +下面这张图刻画的是当前实现真正存在的状态,不再把它画成一个比代码更复杂的“理想状态机”。 -## 5. 彻底去掉的旧设计 +```mermaid +stateDiagram-v2 + state "Needs Workspace (status=needs_workspace)" as NeedsWorkspace + state "Ready (status=ready)" as Ready -以下旧实现不再属于主模型描述,只保留为待删除旧实现或考古材料: + [*] --> Created -- `workspaceRef` / `workspaceRefKind` 作为主导模型 -- `defaultWorkspaceRefForSessionInternal(...)` -- `defaultLocalWorkspaceRefForSessionInternal(...)` -- `syncAssistantWorkspaceRefForSessionInternal(...)` -- `shouldMigrateWorkspaceRefInternal(...)` -- `usesLegacySharedWorkspaceRefInternal(...)` -- `usesDefaultThreadWorkspaceRefFromAnotherRootInternal(...)` -- `usesMissingWorkspaceRefInternal(...)` -- `Directory.current.path` 作为线程 cwd fallback -- web `object://thread/...` 线程目录语义 + Created --> NeedsWorkspace: workspacePath == "" + Created --> Ready: workspacePath != "" -这些旧设计只允许出现在归档文档中,不再作为运行主链路的一部分。 + NeedsWorkspace --> Ready: initialize / rebind 写入 workspaceBinding.workspacePath + Ready --> NeedsWorkspace: workspacePath 被清空或无法建立有效绑定 -## 6. 强约束 + NeedsWorkspace --> NeedsWorkspace: sendChatMessage fail-fast\n追加缺少工作路径错误消息 + Ready --> Ready: 切换模型 / 技能 / provider / message 回写 / 远端路径回写 -1. 线程没有 `workspaceBinding.workspacePath` 不允许运行 -2. 线程切换只切换 `threadId`,不做目录推导 -3. 线程创建必须一次性写入 owner、workspace、execution、context 默认值 -4. 运行结果只能回写当前线程,不允许写全局默认目录 -5. 右栏展示路径与 runner 使用路径必须来自同一线程绑定对象,且保持完全一致 + Ready --> Archived: saveAssistantTaskArchived(true) + NeedsWorkspace --> Archived: saveAssistantTaskArchived(true) + + Archived --> Ready: saveAssistantTaskArchived(false)\nworkspacePath != "" + Archived --> NeedsWorkspace: saveAssistantTaskArchived(false)\nworkspacePath == "" + + note right of Archived + archived 对应 ThreadLifecycleState.archived。 + 它是独立的归档标记,不等价于 lifecycle.status。 + end note +``` + +状态图里的关键现实约束: + +1. 当前 desktop 链路没有单独维护 `running / succeeded / failed` 这些 TaskThread 生命周期状态。 +2. “能不能运行”由 `workspacePath` 是否有效决定,所以 `needs_workspace` / `ready` 才是当前最重要的主状态。 +3. `Archived` 更像“列表可见性 / 激活资格”开关,而不是替代 `status` 的主生命周期状态。 + +## 4. 当前实现里仍然存在的兼容痕迹 + +虽然持久化 canonical schema 已经是 `workspaceBinding` / `executionBinding` 这一套,但当前代码里仍然保留了少量旧入口作为适配层,例如: + +- `TaskThread(...)` 构造器仍接受 `workspaceRef` +- `TaskThread(...)` 构造器仍接受 `workspaceRefKind` +- `TaskThread(...)` 构造器仍接受 `sessionKey` + +这些字段现在主要用于: + +- 老测试夹具 +- 旧调用点平滑过渡 +- 构造器内部映射到新结构 + +因此,当前最准确的理解方式是: + +1. 运行时主对象已经是新结构。 +2. 构造器层还残留少量旧参数适配。 +3. 文档和后续重构都应以 `workspaceBinding` / `executionBinding` / `contextState` / `lifecycleState` 为主。 + +## 5. 文档边界 + +本文只描述当前 TaskThread 的主模型与主链路。 + +历史上那套以 `workspaceRef` / `workspaceRefKind` / fallback cwd 为中心的说明,已经降级为归档材料,不应再作为新改动的设计依据。 diff --git a/lib/app/app_controller_desktop_settings.dart b/lib/app/app_controller_desktop_settings.dart index 419b6f83..044273e0 100644 --- a/lib/app/app_controller_desktop_settings.dart +++ b/lib/app/app_controller_desktop_settings.dart @@ -208,7 +208,7 @@ extension AppControllerDesktopSettings on AppController { assistantThreadTurnQueuesInternal.clear(); multiAgentRunPendingInternal = false; setActiveAppLanguage(defaults.appLanguage); - await settingsControllerInternal.resetSnapshot(defaults); + await settingsControllerInternal.saveSnapshot(defaults); multiAgentOrchestratorInternal.updateConfig(defaults.multiAgent); agentsControllerInternal.restoreSelection( defaults.primaryRemoteGatewayProfile.selectedAgentId, diff --git a/lib/app/app_controller_desktop_settings_runtime.dart b/lib/app/app_controller_desktop_settings_runtime.dart index 0d98f42e..b22a5a88 100644 --- a/lib/app/app_controller_desktop_settings_runtime.dart +++ b/lib/app/app_controller_desktop_settings_runtime.dart @@ -707,6 +707,16 @@ extension AppControllerDesktopSettingsRuntime on AppController { await refreshSingleAgentSkillsForSession(currentSessionKey); } } + if (previous.workspacePath != current.workspacePath) { + await ensureDesktopTaskThreadBindingInternal(currentSessionKey); + if (disposedInternal) { + return; + } + if (assistantExecutionTargetForSession(currentSessionKey) == + AssistantExecutionTarget.singleAgent) { + await refreshSingleAgentSkillsForSession(currentSessionKey); + } + } if (refreshAfterSave) { recomputeTasksInternal(); } diff --git a/lib/app/app_controller_desktop_thread_binding.dart b/lib/app/app_controller_desktop_thread_binding.dart index 7c9033f0..db2b0be9 100644 --- a/lib/app/app_controller_desktop_thread_binding.dart +++ b/lib/app/app_controller_desktop_thread_binding.dart @@ -139,13 +139,24 @@ extension AppControllerDesktopThreadBinding on AppController { }) { if (executionTarget == AssistantExecutionTarget.singleAgent) { if (existingBinding != null && - existingBinding.workspaceKind == WorkspaceKind.localFs && existingBinding.workspacePath.trim().isNotEmpty) { - return existingBinding.copyWith( - displayPath: existingBinding.displayPath.trim().isEmpty - ? existingBinding.workspacePath - : null, + if (existingBinding.workspaceKind == WorkspaceKind.localFs) { + ensureLocalWorkspaceDirectoryInternal(existingBinding.workspacePath); + return existingBinding.copyWith( + displayPath: existingBinding.workspacePath, + ); + } + final defaultRemotePath = remoteThreadWorkspacePathInternal( + sessionKey, + ownerScope, ); + if (existingBinding.workspacePath.trim() != defaultRemotePath) { + return existingBinding.copyWith( + displayPath: existingBinding.displayPath.trim().isEmpty + ? existingBinding.workspacePath + : null, + ); + } } final localPath = localThreadWorkspacePathInternal(sessionKey); return WorkspaceBinding( diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index bee32b13..538d51ec 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -113,7 +113,11 @@ extension AppControllerDesktopThreadSessions on AppController { assistantThreadRecordsInternal[normalizedSessionKey]?.assistantModelId .trim() ?? ''; - if (recordModel.isNotEmpty) { + final availableChoices = assistantModelChoicesForSessionInternal( + normalizedSessionKey, + ); + if (recordModel.isNotEmpty && + (availableChoices.isEmpty || availableChoices.contains(recordModel))) { return recordModel; } return resolvedAssistantModelForTargetInternal(target); diff --git a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart index c6aad0ff..732d39fc 100644 --- a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart +++ b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart @@ -302,13 +302,11 @@ List assistantModelChoicesForSessionThreadSessionInternal( if (singleAgentUsesAiGatewayFallback) { return controller.aiGatewayConversationModelChoices; } - final selectedModel = - controller - .assistantThreadRecordsInternal[normalizedSessionKey] - ?.assistantModelId - .trim(); - if (selectedModel?.isNotEmpty == true) { - return [selectedModel!]; + final runtimeModel = controller.singleAgentRuntimeModelForSession( + normalizedSessionKey, + ); + if (runtimeModel.isNotEmpty) { + return [runtimeModel]; } return const []; } diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart index 98b2eb46..ab420b19 100644 --- a/lib/app/app_controller_desktop_thread_storage.dart +++ b/lib/app/app_controller_desktop_thread_storage.dart @@ -680,14 +680,27 @@ extension AppControllerDesktopThreadStorage on AppController { ) : record.gatewayEntryState, workspacePath: record.workspacePath.trim(), - displayPath: record.displayPath.trim().isEmpty + displayPath: record.workspaceKind == WorkspaceKind.localFs ? record.workspacePath.trim() - : record.displayPath.trim(), + : (record.displayPath.trim().isEmpty + ? record.workspacePath.trim() + : record.displayPath.trim()), workspaceKind: record.workspaceKind, lifecycleStatus: record.workspacePath.trim().isEmpty ? 'needs_workspace' : record.lifecycleState.status, ); + if (normalizedRecord.workspaceKind == WorkspaceKind.localFs && + normalizedRecord.workspacePath.trim().isNotEmpty) { + try { + Directory(normalizedRecord.workspacePath).createSync( + recursive: true, + ); + } catch (_) { + // Best effort only. The thread should still restore even when the + // directory cannot be recreated immediately. + } + } assistantThreadRecordsInternal[sessionKey] = normalizedRecord; if (normalizedRecord.messages.isNotEmpty) { assistantThreadMessagesInternal[sessionKey] = diff --git a/lib/app/app_controller_desktop_workspace_execution.dart b/lib/app/app_controller_desktop_workspace_execution.dart index d697abfc..3d644c8c 100644 --- a/lib/app/app_controller_desktop_workspace_execution.dart +++ b/lib/app/app_controller_desktop_workspace_execution.dart @@ -272,10 +272,26 @@ extension AppControllerDesktopWorkspaceExecution on AppController { final resolvedTarget = executionTarget ?? assistantExecutionTargetForSession(currentSessionKey); + final initialWorkspaceBinding = + resolvedTarget == AssistantExecutionTarget.singleAgent + ? (() { + final localPath = localThreadWorkspacePathInternal( + normalizedSessionKey, + ); + return WorkspaceBinding( + workspaceId: normalizedSessionKey, + workspaceKind: WorkspaceKind.localFs, + workspacePath: localPath, + displayPath: localPath, + writable: true, + ); + })() + : null; upsertTaskThreadInternal( normalizedSessionKey, title: title.trim(), executionTarget: resolvedTarget, + workspaceBinding: initialWorkspaceBinding, messageViewMode: messageViewMode ?? assistantMessageViewModeForSession(currentSessionKey), diff --git a/lib/features/assistant/assistant_page_state_closure.dart b/lib/features/assistant/assistant_page_state_closure.dart index 9b8cdb7a..c5d08754 100644 --- a/lib/features/assistant/assistant_page_state_closure.dart +++ b/lib/features/assistant/assistant_page_state_closure.dart @@ -330,6 +330,27 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { artifactPaneCollapsedInternal = true; }); }, + onOpenWorkspace: () async { + final workspacePath = controller + .assistantWorkspaceRefForSession( + controller.currentSessionKey, + ) + .trim(); + if (workspacePath.isEmpty) { + return; + } + if (Platform.isMacOS) { + await Process.run('open', [workspacePath]); + return; + } + if (Platform.isLinux) { + await Process.run('xdg-open', [workspacePath]); + return; + } + if (Platform.isWindows) { + await Process.run('explorer.exe', [workspacePath]); + } + }, loadSnapshot: () => controller.loadAssistantArtifactSnapshot(), loadPreview: (entry) => diff --git a/lib/features/settings/settings_page_sections.dart b/lib/features/settings/settings_page_sections.dart index 7d34ee1e..2621d2cc 100644 --- a/lib/features/settings/settings_page_sections.dart +++ b/lib/features/settings/settings_page_sections.dart @@ -536,14 +536,6 @@ extension SettingsPageSectionsMixinInternal on SettingsPageStateInternal { settings.copyWith(workspacePath: value), ), ), - EditableFieldInternal( - label: appText('远程项目根目录', 'Remote Project Root'), - value: settings.remoteProjectRoot, - onSubmitted: (value) => saveSettingsInternal( - controller, - settings.copyWith(remoteProjectRoot: value), - ), - ), EditableFieldInternal( label: appText('CLI 路径', 'CLI Path'), value: settings.cliPath, diff --git a/lib/web/web_assistant_page_core.dart b/lib/web/web_assistant_page_core.dart index 3620d469..a4fefa36 100644 --- a/lib/web/web_assistant_page_core.dart +++ b/lib/web/web_assistant_page_core.dart @@ -516,6 +516,7 @@ class WebAssistantPageStateInternal extends State { artifactPaneCollapsedInternal = true; }); }, + onOpenWorkspace: null, loadSnapshot: () => controller.loadAssistantArtifactSnapshot(), loadPreview: (entry) => diff --git a/lib/widgets/assistant_artifact_sidebar.dart b/lib/widgets/assistant_artifact_sidebar.dart index 46f69c9c..a1530d2d 100644 --- a/lib/widgets/assistant_artifact_sidebar.dart +++ b/lib/widgets/assistant_artifact_sidebar.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:markdown/markdown.dart' as md; @@ -17,6 +18,7 @@ typedef AssistantArtifactSnapshotLoader = Future Function(); typedef AssistantArtifactPreviewLoader = Future Function(AssistantArtifactEntry entry); +typedef AssistantArtifactOpenWorkspace = Future Function(); enum AssistantArtifactSidebarTab { files, preview } @@ -30,6 +32,7 @@ class AssistantArtifactSidebar extends StatefulWidget { required this.onCollapse, required this.loadSnapshot, required this.loadPreview, + this.onOpenWorkspace, }); final String sessionKey; @@ -39,6 +42,7 @@ class AssistantArtifactSidebar extends StatefulWidget { final VoidCallback onCollapse; final AssistantArtifactSnapshotLoader loadSnapshot; final AssistantArtifactPreviewLoader loadPreview; + final AssistantArtifactOpenWorkspace? onOpenWorkspace; @override State createState() => @@ -80,6 +84,12 @@ class _AssistantArtifactSidebarState extends State { final snapshot = _snapshot; final entriesForPreview = _previewCandidates(snapshot); final selectedEntry = _selectedEntry; + final workspaceRef = widget.workspaceRef.trim(); + final canCopyWorkspace = workspaceRef.isNotEmpty; + final canOpenWorkspace = + canCopyWorkspace && + widget.workspaceRefKind == WorkspaceRefKind.localPath && + widget.onOpenWorkspace != null; return SurfaceCard( key: const Key('assistant-artifact-pane'), @@ -123,55 +133,108 @@ class _AssistantArtifactSidebarState extends State { ), const SizedBox(height: AppSpacing.xxs), Tooltip( - message: widget.workspaceRef.trim(), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.xs, - vertical: AppSpacing.xxs, + message: workspaceRef, + child: GestureDetector( + key: const Key( + 'assistant-artifact-pane-workspace-ref-container', ), - decoration: BoxDecoration( - color: palette.chromeSurface.withValues( - alpha: 0.72, + onDoubleTap: canOpenWorkspace + ? () => unawaited(_openWorkspace()) + : null, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + vertical: AppSpacing.xxs, ), - borderRadius: BorderRadius.circular( - AppRadius.button, - ), - border: Border.all(color: palette.chromeStroke), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 1), - child: Icon( - widget.workspaceRefKind == - WorkspaceRefKind.localPath - ? Icons.folder_open_rounded - : Icons.cloud_queue_rounded, - size: 14, - color: palette.textSecondary, - ), + decoration: BoxDecoration( + color: palette.chromeSurface.withValues( + alpha: 0.72, ), - const SizedBox(width: AppSpacing.xxs), - Expanded( - child: Text( - _workspaceSummary( - widget.workspaceRef, - widget.workspaceRefKind, - ), - key: const Key( - 'assistant-artifact-pane-workspace-ref', - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( + borderRadius: BorderRadius.circular( + AppRadius.button, + ), + border: Border.all(color: palette.chromeStroke), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon( + widget.workspaceRefKind == + WorkspaceRefKind.localPath + ? Icons.folder_open_rounded + : Icons.cloud_queue_rounded, + size: 14, color: palette.textSecondary, - height: 1.25, ), ), - ), - ], + const SizedBox(width: AppSpacing.xxs), + Expanded( + child: Text( + _workspaceSummary( + widget.workspaceRef, + widget.workspaceRefKind, + ), + key: const Key( + 'assistant-artifact-pane-workspace-ref', + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: palette.textSecondary, + height: 1.25, + ), + ), + ), + const SizedBox(width: AppSpacing.xxs), + IconButton( + key: const Key( + 'assistant-artifact-pane-copy-workspace-ref', + ), + tooltip: appText( + '复制工作路径', + 'Copy workspace path', + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 24, + minHeight: 24, + ), + onPressed: canCopyWorkspace + ? _copyWorkspace + : null, + icon: const Icon( + Icons.content_copy_rounded, + size: 14, + ), + ), + if (canOpenWorkspace) + IconButton( + key: const Key( + 'assistant-artifact-pane-open-workspace-ref', + ), + tooltip: appText( + '在文件浏览器中打开', + 'Open in file browser', + ), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 24, + minHeight: 24, + ), + onPressed: () => + unawaited(_openWorkspace()), + icon: const Icon( + Icons.open_in_new_rounded, + size: 14, + ), + ), + ], + ), ), ), ), @@ -241,6 +304,32 @@ class _AssistantArtifactSidebarState extends State { ); } + Future _copyWorkspace() async { + final workspaceRef = widget.workspaceRef.trim(); + if (workspaceRef.isEmpty) { + return; + } + await Clipboard.setData(ClipboardData(text: workspaceRef)); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + appText('工作路径已复制', 'Workspace path copied'), + ), + duration: const Duration(milliseconds: 1200), + ), + ); + } + + Future _openWorkspace() async { + if (widget.onOpenWorkspace == null) { + return; + } + await widget.onOpenWorkspace!.call(); + } + Widget _buildTabBody( BuildContext context, { required AssistantArtifactSnapshot? snapshot, diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index a2a80a66..e0904e59 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -199,6 +199,24 @@ void main() { expect(find.text('账号本地模式'), findsOneWidget); }); + testWidgets('SettingsPage workspace tab no longer exposes remote project root', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + + await pumpPage( + tester, + child: SettingsPage(controller: controller), + platform: TargetPlatform.macOS, + ); + + await tester.tap(find.text('工作区')); + await tester.pumpAndSettle(); + + expect(find.text('远程项目根目录'), findsNothing); + expect(find.text('Remote Project Root'), findsNothing); + }); + testWidgets('SettingsPage integration tab exposes unified gateway controls', ( WidgetTester tester, ) async { @@ -301,8 +319,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('外部 ACP Server Endpoint'), findsOneWidget); - expect(find.text('Codex'), findsWidgets); - expect(find.text('OpenCode'), findsWidgets); + expect(find.textContaining('Codex'), findsWidgets); + expect(find.textContaining('OpenCode'), findsWidgets); expect(find.text('Claude'), findsNothing); expect(find.text('Gemini'), findsNothing); expect( diff --git a/test/runtime/app_controller_ai_gateway_models_suite.dart b/test/runtime/app_controller_ai_gateway_models_suite.dart index 7b476bba..223b1b59 100644 --- a/test/runtime/app_controller_ai_gateway_models_suite.dart +++ b/test/runtime/app_controller_ai_gateway_models_suite.dart @@ -49,7 +49,7 @@ void main() { ); test( - 'AppController switches assistant model source with the execution mode', + 'AppController keeps the current thread model source when only the global default target changes', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( @@ -94,8 +94,16 @@ void main() { ), ); - expect(controller.resolvedAssistantModel, 'gpt-5.4'); - expect(controller.assistantModelChoices, const ['gpt-5.4']); + expect( + controller.assistantExecutionTargetForSession( + controller.currentSessionKey, + ), + AssistantExecutionTarget.singleAgent, + ); + expect(controller.resolvedAssistantModel, 'qwen2.5-coder:latest'); + expect(controller.assistantModelChoices, const [ + 'qwen2.5-coder:latest', + ]); }, ); diff --git a/test/runtime/app_controller_assistant_workspace_ref_test.dart b/test/runtime/app_controller_assistant_workspace_ref_test.dart index 3072c5d0..5083a2d3 100644 --- a/test/runtime/app_controller_assistant_workspace_ref_test.dart +++ b/test/runtime/app_controller_assistant_workspace_ref_test.dart @@ -311,4 +311,249 @@ void main() { ); }, ); + + test( + 'AppController recreates recorded local thread directories during restore', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-workspace-restore-create-', + ); + final workspaceRoot = Directory('${tempDirectory.path}/workspace'); + await workspaceRoot.create(recursive: true); + final missingThreadWorkspace = Directory( + '${workspaceRoot.path}/.xworkmate/threads/draft-restored-thread', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith(workspacePath: workspaceRoot.path), + ); + await store.saveTaskThreads([ + TaskThread( + threadId: 'draft:restored-thread', + title: 'Restored Thread', + ownerScope: const ThreadOwnerScope( + realm: ThreadRealm.local, + subjectType: ThreadSubjectType.user, + subjectId: 'device-task', + displayName: 'device-task', + ), + workspaceBinding: WorkspaceBinding( + workspaceId: 'draft:restored-thread', + workspaceKind: WorkspaceKind.localFs, + workspacePath: missingThreadWorkspace.path, + displayPath: '/stale/display/path', + writable: true, + ), + executionBinding: const ExecutionBinding( + executionMode: ThreadExecutionMode.localAgent, + executorId: 'auto', + providerId: 'auto', + endpointId: '', + ), + contextState: const ThreadContextState( + messages: [], + selectedModelId: '', + selectedSkillKeys: [], + importedSkills: [], + permissionLevel: AssistantPermissionLevel.defaultAccess, + messageViewMode: AssistantMessageViewMode.rendered, + latestResolvedRuntimeModel: '', + ), + lifecycleState: const ThreadLifecycleState( + archived: false, + status: 'ready', + lastRunAtMs: null, + lastResultCode: null, + ), + createdAtMs: 1, + updatedAtMs: 1, + ), + ]); + + final controller = AppController(store: store); + addTearDown(controller.dispose); + await waitForControllerInternal(controller); + + expect(await missingThreadWorkspace.exists(), isTrue); + expect( + controller.assistantWorkspaceRefForSession('draft:restored-thread'), + missingThreadWorkspace.path, + ); + expect( + controller.assistantWorkspaceDisplayPathForSession( + 'draft:restored-thread', + ), + missingThreadWorkspace.path, + ); + }, + ); + + test( + 'AppController creates the local thread workspace immediately when initializing a new task thread', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-workspace-new-thread-', + ); + final workspaceRoot = Directory('${tempDirectory.path}/workspace'); + await workspaceRoot.create(recursive: true); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith(workspacePath: workspaceRoot.path), + ); + + final controller = AppController(store: store); + addTearDown(controller.dispose); + await waitForControllerInternal(controller); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + + controller.initializeAssistantThreadContext( + 'draft:created-thread', + title: 'Created Thread', + executionTarget: AssistantExecutionTarget.singleAgent, + ); + + final threadWorkspace = Directory( + '${workspaceRoot.path}/.xworkmate/threads/draft-created-thread', + ); + expect(await threadWorkspace.exists(), isTrue); + expect( + controller.assistantWorkspaceRefForSession('draft:created-thread'), + threadWorkspace.path, + ); + expect( + controller.assistantWorkspaceDisplayPathForSession( + 'draft:created-thread', + ), + threadWorkspace.path, + ); + }, + ); + + test( + 'AppController rebinds the current single-agent thread after configuring a workspace root', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-workspace-configure-root-', + ); + final workspaceRoot = Directory('${tempDirectory.path}/workspace'); + await workspaceRoot.create(recursive: true); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith(workspacePath: ''), + ); + final controller = AppController(store: store); + addTearDown(controller.dispose); + await waitForControllerInternal(controller); + final existingMain = + controller.assistantThreadRecordsInternal[controller.currentSessionKey]!; + controller.assistantThreadRecordsInternal[controller.currentSessionKey] = + existingMain.copyWith( + workspaceBinding: const WorkspaceBinding( + workspaceId: 'main', + workspaceKind: WorkspaceKind.localFs, + workspacePath: '', + displayPath: '', + writable: true, + ), + lifecycleState: existingMain.lifecycleState.copyWith( + status: 'needs_workspace', + ), + executionTarget: AssistantExecutionTarget.singleAgent, + ); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + controller.assistantThreadRecordsInternal[controller.currentSessionKey] = + controller + .assistantThreadRecordsInternal[controller.currentSessionKey]! + .copyWith( + workspaceBinding: const WorkspaceBinding( + workspaceId: 'main', + workspaceKind: WorkspaceKind.localFs, + workspacePath: '', + displayPath: '', + writable: true, + ), + lifecycleState: controller + .assistantThreadRecordsInternal[controller.currentSessionKey]! + .lifecycleState + .copyWith(status: 'needs_workspace'), + ); + + expect( + controller.assistantWorkspaceRefForSession(controller.currentSessionKey), + isEmpty, + ); + expect( + controller + .assistantThreadRecordsInternal[controller.currentSessionKey] + ?.lifecycleState + .status, + 'needs_workspace', + ); + + await controller.saveSettings( + controller.settings.copyWith(workspacePath: workspaceRoot.path), + ); + + expect( + controller.assistantWorkspaceRefForSession(controller.currentSessionKey), + '${workspaceRoot.path}/.xworkmate/threads/main', + ); + expect( + controller + .assistantThreadRecordsInternal[controller.currentSessionKey] + ?.displayPath, + '${workspaceRoot.path}/.xworkmate/threads/main', + ); + expect( + controller + .assistantThreadRecordsInternal[controller.currentSessionKey] + ?.lifecycleState + .status, + 'ready', + ); + }, + ); } diff --git a/test/widgets/assistant_artifact_sidebar_test.dart b/test/widgets/assistant_artifact_sidebar_test.dart index f334d5f5..227b1c01 100644 --- a/test/widgets/assistant_artifact_sidebar_test.dart +++ b/test/widgets/assistant_artifact_sidebar_test.dart @@ -14,6 +14,7 @@ void main() { required AssistantArtifactSnapshot snapshot, required AssistantArtifactPreview Function(AssistantArtifactEntry entry) previewForEntry, + Future Function()? onOpenWorkspace, }) async { await tester.pumpWidget( MaterialApp( @@ -29,6 +30,7 @@ void main() { onCollapse: () {}, loadSnapshot: () async => snapshot, loadPreview: (entry) async => previewForEntry(entry), + onOpenWorkspace: onOpenWorkspace, ), ), ), @@ -114,4 +116,40 @@ void main() { ); expect(find.text('HTML Preview'), findsOneWidget); }); + + testWidgets('AssistantArtifactSidebar copies and opens local workspace paths', ( + WidgetTester tester, + ) async { + var openCount = 0; + final snapshot = AssistantArtifactSnapshot( + workspaceRef: '/tmp/thread', + workspaceRefKind: WorkspaceRefKind.localPath, + resultEntries: const [], + fileEntries: const [], + ); + + await pumpSidebar( + tester, + snapshot: snapshot, + previewForEntry: (_) => const AssistantArtifactPreview.empty(), + onOpenWorkspace: () async { + openCount += 1; + }, + ); + + final copyButton = tester.widget( + find.byKey( + const Key('assistant-artifact-pane-copy-workspace-ref'), + ), + ); + copyButton.onPressed!.call(); + + final openButton = tester.widget( + find.byKey( + const Key('assistant-artifact-pane-open-workspace-ref'), + ), + ); + openButton.onPressed!.call(); + expect(openCount, 1); + }); }