fix(taskthread): stabilize workspace binding follow-ups

This commit is contained in:
Haitao Pan 2026-03-29 09:28:51 +08:00
parent 76fe331985
commit e4ff2de29f
17 changed files with 684 additions and 249 deletions

View File

@ -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 选中线程后,当前线程是谁

View File

@ -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 本地线程会把工作目录落到:
- `<settings.workspacePath>/.xworkmate/threads/<sanitized-threadId>`
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<br/>workspacePath = workspaceRoot/.xworkmate/threads/<threadId><br/>createSync(recursive: true)"]
D -->|local / remote gateway| F["构造远端 WorkspaceBinding<br/>/owners/<realm>/<subjectType>/<subjectId>/threads/<threadId>"]
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["追加错误消息<br/>当前线程缺少工作路径,无法运行"]
K -->|no| M["按 TaskThread 构造执行请求<br/>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<br/>workspaceKind=remoteFs<br/>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 为中心的说明,已经降级为归档材料,不应再作为新改动的设计依据。

View File

@ -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,

View File

@ -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();
}

View File

@ -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(

View File

@ -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);

View File

@ -302,13 +302,11 @@ List<String> assistantModelChoicesForSessionThreadSessionInternal(
if (singleAgentUsesAiGatewayFallback) {
return controller.aiGatewayConversationModelChoices;
}
final selectedModel =
controller
.assistantThreadRecordsInternal[normalizedSessionKey]
?.assistantModelId
.trim();
if (selectedModel?.isNotEmpty == true) {
return <String>[selectedModel!];
final runtimeModel = controller.singleAgentRuntimeModelForSession(
normalizedSessionKey,
);
if (runtimeModel.isNotEmpty) {
return <String>[runtimeModel];
}
return const <String>[];
}

View File

@ -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] =

View File

@ -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),

View File

@ -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', <String>[workspacePath]);
return;
}
if (Platform.isLinux) {
await Process.run('xdg-open', <String>[workspacePath]);
return;
}
if (Platform.isWindows) {
await Process.run('explorer.exe', <String>[workspacePath]);
}
},
loadSnapshot: () =>
controller.loadAssistantArtifactSnapshot(),
loadPreview: (entry) =>

View File

@ -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,

View File

@ -516,6 +516,7 @@ class WebAssistantPageStateInternal extends State<WebAssistantPage> {
artifactPaneCollapsedInternal = true;
});
},
onOpenWorkspace: null,
loadSnapshot: () =>
controller.loadAssistantArtifactSnapshot(),
loadPreview: (entry) =>

View File

@ -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<AssistantArtifactSnapshot> Function();
typedef AssistantArtifactPreviewLoader =
Future<AssistantArtifactPreview> Function(AssistantArtifactEntry entry);
typedef AssistantArtifactOpenWorkspace = Future<void> 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<AssistantArtifactSidebar> createState() =>
@ -80,6 +84,12 @@ class _AssistantArtifactSidebarState extends State<AssistantArtifactSidebar> {
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<AssistantArtifactSidebar> {
),
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<AssistantArtifactSidebar> {
);
}
Future<void> _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<void> _openWorkspace() async {
if (widget.onOpenWorkspace == null) {
return;
}
await widget.onOpenWorkspace!.call();
}
Widget _buildTabBody(
BuildContext context, {
required AssistantArtifactSnapshot? snapshot,

View File

@ -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(

View File

@ -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(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
@ -94,8 +94,16 @@ void main() {
),
);
expect(controller.resolvedAssistantModel, 'gpt-5.4');
expect(controller.assistantModelChoices, const <String>['gpt-5.4']);
expect(
controller.assistantExecutionTargetForSession(
controller.currentSessionKey,
),
AssistantExecutionTarget.singleAgent,
);
expect(controller.resolvedAssistantModel, 'qwen2.5-coder:latest');
expect(controller.assistantModelChoices, const <String>[
'qwen2.5-coder:latest',
]);
},
);

View File

@ -311,4 +311,249 @@ void main() {
);
},
);
test(
'AppController recreates recorded local thread directories during restore',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
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>[
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: <GatewayChatMessage>[],
selectedModelId: '',
selectedSkillKeys: <String>[],
importedSkills: <AssistantThreadSkillEntry>[],
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(<String, Object>{});
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(<String, Object>{});
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',
);
},
);
}

View File

@ -14,6 +14,7 @@ void main() {
required AssistantArtifactSnapshot snapshot,
required AssistantArtifactPreview Function(AssistantArtifactEntry entry)
previewForEntry,
Future<void> 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 <AssistantArtifactEntry>[],
fileEntries: const <AssistantArtifactEntry>[],
);
await pumpSidebar(
tester,
snapshot: snapshot,
previewForEntry: (_) => const AssistantArtifactPreview.empty(),
onOpenWorkspace: () async {
openCount += 1;
},
);
final copyButton = tester.widget<IconButton>(
find.byKey(
const Key('assistant-artifact-pane-copy-workspace-ref'),
),
);
copyButton.onPressed!.call();
final openButton = tester.widget<IconButton>(
find.byKey(
const Key('assistant-artifact-pane-open-workspace-ref'),
),
);
openButton.onPressed!.call();
expect(openCount, 1);
});
}