@harness-mix/cli 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/NOTICE +14 -0
  3. package/README.md +4 -24
  4. package/docs/multi-agent-collaboration.md +1 -1
  5. package/licenses/anthropic-codex-theme-MIT.txt +21 -0
  6. package/licenses/codex-dream-skin-MIT.txt +21 -0
  7. package/output/native-build/renderer-extension.js +703 -58
  8. package/package.json +192 -189
  9. package/scripts/collaboration-test.cjs +262 -200
  10. package/scripts/collaboration-ui-smoke.cjs +85 -77
  11. package/scripts/core-concurrency-test.cjs +23 -1
  12. package/scripts/e2e-pi-cancel.cjs +99 -0
  13. package/scripts/native-file-change-test.cjs +42 -2
  14. package/scripts/native-sidebar-test.cjs +8 -0
  15. package/scripts/native-vendor-adapters-test.cjs +67 -1
  16. package/scripts/pets-test.cjs +63 -0
  17. package/scripts/pi-cancel-race-test.cjs +102 -0
  18. package/scripts/stuck-turn-test.cjs +73 -0
  19. package/scripts/workspace-test.cjs +9 -0
  20. package/src/assets/skins/dream-skin/NOTICE.md +13 -0
  21. package/src/assets/skins/dream-skin/themes/gothic-void-crusade/hero.webp +0 -0
  22. package/src/assets/skins/dream-skin/themes/gothic-void-crusade/theme.json +34 -0
  23. package/src/main/adapters/claude.js +9 -6
  24. package/src/main/adapters/pi-family.js +41 -8
  25. package/src/main/harness-adapter/event-normalizer.js +270 -258
  26. package/src/main/host/collaboration-mcp.cjs +8 -1
  27. package/src/main/host/collaboration-tools.js +1 -1
  28. package/src/main/host/collaboration.js +81 -11
  29. package/src/main/host/runtime.js +1462 -1341
  30. package/src/main/native/pets.js +79 -2
  31. package/src/main/native/protocol.js +1164 -1145
  32. package/src/main/native/thread-list.js +6 -1
  33. package/src/main/workspace/core-review.js +75 -0
  34. package/src/main/workspace/diff.js +5 -1
  35. package/src/main/workspace/file-changes.js +9 -4
  36. package/src/main/workspace/review-controller.js +115 -102
  37. package/src/main/workspace/review.js +90 -88
  38. package/src/native-ui/renderer-extension/dist/contract-audit.js +16637 -0
  39. package/src/native-ui/renderer-extension/dist/index.js +34853 -0
  40. package/src/native-ui/renderer-extension/dist/production.js +34263 -0
  41. package/src/native-ui/renderer-extension/dist/renderer-binding-probe.js +34254 -0
  42. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  43. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +11 -0
  44. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  45. package/src/native-ui/renderer-extension/dist/types/renderer-pet-switcher.d.ts +73 -0
  46. package/src/native-ui/renderer-extension/dist/types/renderer-pet-switcher.d.ts.map +1 -0
  47. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts +1 -0
  48. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
  49. package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts.map +1 -1
  50. package/src/native-ui/renderer-extension/dist/types/settings/collaboration-page.d.ts +12 -0
  51. package/src/native-ui/renderer-extension/dist/types/settings/collaboration-page.d.ts.map +1 -0
  52. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts +1 -1
  53. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts.map +1 -1
  54. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
  55. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +5 -3
  56. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
  57. package/src/native-ui/renderer-extension/dist/types/settings/pet-market.d.ts +1 -1
  58. package/src/native-ui/renderer-extension/dist/types/settings/pet-market.d.ts.map +1 -1
  59. package/src/native-ui/renderer-extension/dist/types/settings/pets-client.d.ts +9 -0
  60. package/src/native-ui/renderer-extension/dist/types/settings/pets-client.d.ts.map +1 -1
  61. package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts +1 -1
  62. package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts.map +1 -1
  63. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  64. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3181 -3171
  65. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +14 -0
  66. package/src/native-ui/renderer-extension/src/renderer-pet-switcher.ts +308 -0
  67. package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +2 -0
  68. package/src/native-ui/renderer-extension/src/renderer-team-cards.ts +76 -21
  69. package/src/native-ui/renderer-extension/src/settings/collaboration-page.ts +139 -0
  70. package/src/native-ui/renderer-extension/src/settings/icons.ts +155 -153
  71. package/src/native-ui/renderer-extension/src/settings/localization.ts +785 -783
  72. package/src/native-ui/renderer-extension/src/settings/pages.ts +694 -681
  73. package/src/native-ui/renderer-extension/src/settings/pet-market.css +110 -1
  74. package/src/native-ui/renderer-extension/src/settings/pet-market.ts +276 -22
  75. package/src/native-ui/renderer-extension/src/settings/pets-client.ts +32 -0
  76. package/src/native-ui/renderer-extension/src/settings/shell.css +2294 -2255
  77. package/src/native-ui/renderer-extension/src/settings/skin-runtime.ts +51 -1
  78. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +2 -0
  79. package/src/native-ui/renderer-extension/test/renderer-pet-switcher.test.ts +400 -0
  80. package/src/native-ui/renderer-extension/test/settings/localization.test.ts +80 -79
  81. package/src/native-ui/renderer-extension/test/settings/pet-market.test.ts +378 -0
  82. package/src/native-ui/renderer-extension/test/settings/pets-client.test.ts +83 -0
  83. package/src/native-ui/renderer-extension/test/settings/shell.test.ts +3 -0
  84. package/src/native-ui/renderer-extension/test/settings/skin-runtime.test.ts +15 -1
@@ -183,6 +183,8 @@ export interface RendererModelClient extends Partial<RendererSessionImportClient
183
183
  inspectStorage?(): Promise<import('./settings/storage-page.js').RendererStorageInspection>;
184
184
  optimizeStorage?(): Promise<{ before: import('./settings/storage-page.js').RendererStorageInspection; after: import('./settings/storage-page.js').RendererStorageInspection }>;
185
185
  listCollaborationAgents?(): Promise<Array<{ id: string; name: string; available: boolean; lead: boolean }>>;
186
+ getCollaborationPreferences?(): Promise<{ collaboration: boolean; agentTeam: boolean }>;
187
+ saveCollaborationPreferences?(input: { collaboration?: boolean; agentTeam?: boolean }): Promise<{ collaboration: boolean; agentTeam: boolean }>;
186
188
  currentHostId?(): string | null;
187
189
  listHarnessPlugins?(): Promise<HarnessPluginListResult>;
188
190
  clientForHost?(hostId: string): RendererModelClient | null;
@@ -361,6 +363,18 @@ export function createRendererModelClient(
361
363
  if (!Array.isArray(result) || !result.every(a => a && typeof a.id === 'string' && typeof a.name === 'string' && typeof a.available === 'boolean' && typeof a.lead === 'boolean')) throw new Error('Invalid collaboration catalog');
362
364
  return result;
363
365
  },
366
+ async getCollaborationPreferences(): Promise<{ collaboration: boolean; agentTeam: boolean }> {
367
+ const result = await manager.sendRequest('harnessmix/collaboration/preferences', {});
368
+ const value = result as { collaboration?: unknown; agentTeam?: unknown } | null;
369
+ if (!value || typeof value.collaboration !== 'boolean' || typeof value.agentTeam !== 'boolean') throw new Error('Invalid collaboration preferences');
370
+ return { collaboration: value.collaboration, agentTeam: value.agentTeam };
371
+ },
372
+ async saveCollaborationPreferences(input: { collaboration?: boolean; agentTeam?: boolean }): Promise<{ collaboration: boolean; agentTeam: boolean }> {
373
+ const result = await manager.sendRequest('harnessmix/collaboration/preferences/save', input);
374
+ const value = result as { collaboration?: unknown; agentTeam?: unknown } | null;
375
+ if (!value || typeof value.collaboration !== 'boolean' || typeof value.agentTeam !== 'boolean') throw new Error('Invalid collaboration preferences');
376
+ return { collaboration: value.collaboration, agentTeam: value.agentTeam };
377
+ },
364
378
  ...createRendererIntegrationsClient((method, params) => manager.sendRequest(method, params)),
365
379
  ...createRendererSessionImportClient(async (method, params) =>
366
380
  manager.sendRequest(method, params),
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Drives Codex Desktop's OFFICIAL pet selection from the Harness Mix pet market.
3
+ *
4
+ * The official pet is the account-level mascot (the app's own Settings → Pets
5
+ * grid). This module performs pure in-page UI automation — genuine DOM clicks
6
+ * on the app's own elements only:
7
+ *
8
+ * 1. open the app's profile menu (avatar button, bottom-left of the sidebar),
9
+ * 2. click its Settings item (the one carrying the Ctrl+, shortcut hint),
10
+ * 3. click the "pets" settings nav (`data-settings-panel-slug="pets"`),
11
+ * 4. click the target pet card (`[data-avatar-id]` inside `button[aria-pressed]`),
12
+ * 5. confirm via the card's `aria-pressed="true"` state,
13
+ * 6. click the settings header back button, leaving every surface it opened closed.
14
+ *
15
+ * HARD CONSTRAINT: this never calls account APIs, never reads tokens or
16
+ * credentials, and never proxies `accessory_id` — the app's own click handler
17
+ * performs the account mutation with the app's own auth.
18
+ *
19
+ * Selector facts LIVE-VERIFIED against the installed Codex Desktop
20
+ * 26.908.4834.0 (via CDP Runtime.evaluate in the app webview):
21
+ * - profile menu trigger: `button[aria-haspopup="menu"]` wrapping an avatar
22
+ * `<img>`, docked at the left edge of the sidebar; opens a Radix menu on
23
+ * pointerdown (hence the pointer sequence, not a bare .click());
24
+ * - the Settings menu item is a `[role="menuitem"]` whose text carries the
25
+ * locale-independent shortcut hint `Ctrl+,`;
26
+ * - settings sections render nav buttons with `data-settings-panel-slug`
27
+ * ("general-settings", "pets", …) — locale-independent;
28
+ * - pet cards are `<button aria-pressed="…">` wrapping artwork with
29
+ * `data-avatar-id="<id>"`; built-in ids are bare (`codex`, `bsod`, …)
30
+ * while pets installed under ~/.codex/pets/<dir>/ appear as `custom:<dir>`;
31
+ * - the settings surface is a `main > header` layout whose first
32
+ * `button[aria-label]` is the back button; clicking it returns to the app.
33
+ * - NOTE: there is NO working Ctrl/Cmd+K command menu in this build — an
34
+ * earlier revision of this module relied on it after static app.asar
35
+ * analysis; live verification proved it never opens (cmdk nodes do not
36
+ * exist in the DOM). Do not reintroduce it without re-verifying live.
37
+ */
38
+
39
+ export interface RendererPetSwitchTarget {
40
+ /** Harness Mix market id (directory name under ~/.codex/pets for custom pets). */
41
+ readonly id: string;
42
+ readonly displayName: string;
43
+ }
44
+
45
+ export interface RendererPetSwitchResult {
46
+ /** The official `data-avatar-id` that ended up selected. */
47
+ readonly avatarId: string;
48
+ /** True when the pet was already the selected one (no click was needed). */
49
+ readonly alreadySelected: boolean;
50
+ }
51
+
52
+ export interface RendererPetSwitcherTiming {
53
+ /** Poll interval for all waits. */
54
+ readonly pollMs: number;
55
+ /** Waiting for the profile menu / settings surface to open. */
56
+ readonly openTimeoutMs: number;
57
+ /** Waiting for the pets grid after navigation. */
58
+ readonly gridTimeoutMs: number;
59
+ /** Waiting for aria-pressed confirmation after the click. */
60
+ readonly confirmTimeoutMs: number;
61
+ /** Best-effort wait for surfaces to close during cleanup. */
62
+ readonly closeTimeoutMs: number;
63
+ /** Settle delay after opening the profile menu before reading its items. */
64
+ readonly menuSettleMs: number;
65
+ }
66
+
67
+ export interface RendererPetSwitcherOptions {
68
+ readonly ownerDocument?: Document;
69
+ readonly timing?: Partial<RendererPetSwitcherTiming>;
70
+ }
71
+
72
+ const DEFAULT_TIMING: RendererPetSwitcherTiming = {
73
+ pollMs: 120,
74
+ openTimeoutMs: 3500,
75
+ gridTimeoutMs: 5000,
76
+ confirmTimeoutMs: 5000,
77
+ closeTimeoutMs: 2000,
78
+ menuSettleMs: 350,
79
+ };
80
+
81
+ const PROFILE_MENU_TRIGGER = 'button[aria-haspopup="menu"]';
82
+ const MENU_ITEM = '[role="menuitem"]';
83
+ const SETTINGS_NAV_ITEM = "[data-settings-panel-slug]";
84
+ const PETS_NAV_ITEM = '[data-settings-panel-slug="pets"]';
85
+ const AVATAR_ARTWORK = "[data-avatar-id]";
86
+ /** 设置菜单项:文本里带与语言无关的快捷键提示 Ctrl+, */
87
+ const SETTINGS_SHORTCUT_HINT = /ctrl\s*\+\s*,/i;
88
+
89
+ function sleep(ms: number): Promise<void> {
90
+ return new Promise((resolve) => setTimeout(resolve, ms));
91
+ }
92
+
93
+ async function waitFor(
94
+ probe: () => boolean,
95
+ timeoutMs: number,
96
+ pollMs: number,
97
+ description: string,
98
+ ): Promise<void> {
99
+ const deadline = Date.now() + timeoutMs;
100
+ for (;;) {
101
+ if (probe()) return;
102
+ if (Date.now() >= deadline) throw new Error(`Timed out: ${description}`);
103
+ await sleep(pollMs);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Radix 下拉菜单在 pointerdown 上展开,因此菜单交互用完整的指针事件序列;
109
+ * 事件构造优先用页面自己的 PointerEvent(测试环境没有就退回普通 Event)。
110
+ */
111
+ function dispatchPointerSequence(element: HTMLElement): void {
112
+ const view = element.ownerDocument?.defaultView;
113
+ const PointerCtor = view?.PointerEvent;
114
+ for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "click"] as const) {
115
+ const event = PointerCtor
116
+ ? new PointerCtor(type, { bubbles: true, cancelable: true, view, button: 0, buttons: 1 })
117
+ : new Event(type, { bubbles: true, cancelable: true });
118
+ element.dispatchEvent(event);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * 个人菜单按钮:侧栏左下角带头像 <img> 的 aria-haspopup="menu" 按钮。
124
+ * 结构特征定位,不依赖任何语言文案(模型选择器同样带 img,但停靠在右下)。
125
+ */
126
+ function findProfileMenuButton(ownerDocument: Document): HTMLElement | null {
127
+ let best: { element: HTMLElement; x: number } | null = null;
128
+ for (const button of ownerDocument.querySelectorAll<HTMLElement>(PROFILE_MENU_TRIGGER)) {
129
+ if (button.querySelector("img") === null) continue;
130
+ const rect = button.getBoundingClientRect();
131
+ if (rect.width === 0 && rect.height === 0) continue;
132
+ if (best === null || rect.x < best.x) best = { element: button, x: rect.x };
133
+ }
134
+ return best?.element ?? null;
135
+ }
136
+
137
+ /** 个人菜单里的设置项:文本带 Ctrl+, 快捷键提示(与语言无关) */
138
+ function findSettingsMenuItem(ownerDocument: Document): HTMLElement | null {
139
+ for (const item of ownerDocument.querySelectorAll<HTMLElement>(MENU_ITEM)) {
140
+ if (SETTINGS_SHORTCUT_HINT.test(item.textContent ?? "")) return item;
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /** 官方设置页的返回按钮:main > header 里的第一个带 aria-label 的按钮 */
146
+ function findSettingsBackButton(ownerDocument: Document): HTMLElement | null {
147
+ const main = ownerDocument.querySelector("main");
148
+ const header = main?.querySelector("header");
149
+ return header?.querySelector<HTMLElement>("button[aria-label]") ?? null;
150
+ }
151
+
152
+ /** 官方宠物卡片:button[aria-pressed] 包裹 [data-avatar-id] 的 artwork */
153
+ function petCardButtons(ownerDocument: Document): HTMLElement[] {
154
+ const buttons: HTMLElement[] = [];
155
+ for (const artwork of ownerDocument.querySelectorAll(AVATAR_ARTWORK)) {
156
+ const button = artwork.closest("button");
157
+ if (button?.hasAttribute("aria-pressed")) buttons.push(button as HTMLElement);
158
+ }
159
+ return buttons;
160
+ }
161
+
162
+ function findTargetCard(
163
+ ownerDocument: Document,
164
+ target: RendererPetSwitchTarget,
165
+ ): { button: HTMLElement; avatarId: string } | null {
166
+ // 官方预载宠物用裸 id;~/.codex/pets/<dir>/ 下的本地宠物在官方网格里是 custom:<dir>
167
+ for (const avatarId of [`custom:${target.id}`, target.id]) {
168
+ const artwork = ownerDocument.querySelector(`[data-avatar-id="${avatarId}"]`);
169
+ const button = artwork?.closest("button");
170
+ if (button) return { button: button as HTMLElement, avatarId };
171
+ }
172
+ // 兜底:按显示名匹配(官方网格的卡片 label 是 displayName)
173
+ const name = target.displayName.trim().toLowerCase();
174
+ if (name) {
175
+ for (const button of petCardButtons(ownerDocument)) {
176
+ if ((button.textContent ?? "").trim().toLowerCase().includes(name)) {
177
+ const artwork = button.querySelector(AVATAR_ARTWORK);
178
+ return { button, avatarId: artwork?.getAttribute("data-avatar-id") ?? target.id };
179
+ }
180
+ }
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function cardIsSelected(ownerDocument: Document, target: RendererPetSwitchTarget): boolean {
186
+ const found = findTargetCard(ownerDocument, target);
187
+ return found?.button.getAttribute("aria-pressed") === "true";
188
+ }
189
+
190
+ let activeSwitch: Promise<RendererPetSwitchResult> | null = null;
191
+
192
+ /**
193
+ * Switch Codex Desktop's official pet by driving the app's own settings UI.
194
+ * Rejects when another switch is still running (the market page additionally
195
+ * disables its buttons while a switch is in flight).
196
+ */
197
+ export function switchOfficialCodexPet(
198
+ target: RendererPetSwitchTarget,
199
+ options: RendererPetSwitcherOptions = {},
200
+ ): Promise<RendererPetSwitchResult> {
201
+ if (activeSwitch) {
202
+ return Promise.reject(new Error("An official pet switch is already in progress"));
203
+ }
204
+ const run = runOfficialCodexPetSwitch(target, options).finally(() => {
205
+ activeSwitch = null;
206
+ });
207
+ activeSwitch = run;
208
+ return run;
209
+ }
210
+
211
+ async function runOfficialCodexPetSwitch(
212
+ target: RendererPetSwitchTarget,
213
+ options: RendererPetSwitcherOptions,
214
+ ): Promise<RendererPetSwitchResult> {
215
+ const ownerDocument = options.ownerDocument ?? document;
216
+ const timing: RendererPetSwitcherTiming = { ...DEFAULT_TIMING, ...options.timing };
217
+ const settingsAlreadyOpen = ownerDocument.querySelector(SETTINGS_NAV_ITEM) !== null;
218
+ let openedSettings = false;
219
+ try {
220
+ if (!settingsAlreadyOpen) {
221
+ // 打开个人菜单(侧栏左下角头像按钮),点击其中带 Ctrl+, 快捷键提示的设置项
222
+ const profileButton = findProfileMenuButton(ownerDocument);
223
+ if (!profileButton) throw new Error("Codex profile menu button not found");
224
+ dispatchPointerSequence(profileButton);
225
+ let settingsItem: HTMLElement | null = null;
226
+ try {
227
+ await waitFor(
228
+ () => {
229
+ settingsItem = findSettingsMenuItem(ownerDocument);
230
+ return settingsItem !== null;
231
+ },
232
+ timing.openTimeoutMs,
233
+ timing.pollMs,
234
+ "the Codex profile menu did not open",
235
+ );
236
+ } catch (error) {
237
+ // 菜单可能本就已展开(上次失败残留):再触发一次尝试恢复后仍失败才报错
238
+ dispatchPointerSequence(profileButton);
239
+ await waitFor(
240
+ () => {
241
+ settingsItem = findSettingsMenuItem(ownerDocument);
242
+ return settingsItem !== null;
243
+ },
244
+ timing.menuSettleMs,
245
+ timing.pollMs,
246
+ (error as Error).message,
247
+ );
248
+ }
249
+ dispatchPointerSequence(settingsItem as unknown as HTMLElement);
250
+ await waitFor(
251
+ () => ownerDocument.querySelector(SETTINGS_NAV_ITEM) !== null,
252
+ timing.openTimeoutMs,
253
+ timing.pollMs,
254
+ "the Codex settings did not open",
255
+ );
256
+ openedSettings = true;
257
+ }
258
+ if (!findTargetCard(ownerDocument, target)) {
259
+ // 设置已打开但不在桌宠页:走设置侧边栏的桌宠项(选择器与语言无关)
260
+ const navItem = ownerDocument.querySelector<HTMLElement>(PETS_NAV_ITEM);
261
+ if (navItem) navItem.click();
262
+ }
263
+
264
+ await waitFor(
265
+ () => petCardButtons(ownerDocument).length > 0,
266
+ timing.gridTimeoutMs,
267
+ timing.pollMs,
268
+ "the official Pets grid did not render",
269
+ );
270
+ const found = findTargetCard(ownerDocument, target);
271
+ if (!found) {
272
+ throw new Error(
273
+ `Pet "${target.displayName}" (${target.id}) was not found in the official Codex Pets settings`,
274
+ );
275
+ }
276
+ if (found.button.getAttribute("aria-pressed") === "true") {
277
+ return { avatarId: found.avatarId, alreadySelected: true };
278
+ }
279
+ found.button.click();
280
+ await waitFor(
281
+ // React 可能重渲染出新节点,每次轮询都重新定位卡片
282
+ () => cardIsSelected(ownerDocument, target),
283
+ timing.confirmTimeoutMs,
284
+ timing.pollMs,
285
+ `selecting "${target.displayName}" was not confirmed by the official Pets settings`,
286
+ );
287
+ return { avatarId: found.avatarId, alreadySelected: false };
288
+ } finally {
289
+ // 收场:只关闭本次由我们打开的官方设置(用户自己开着的设置保持原样)。
290
+ // 不用 Escape —— Harness Mix 设置对话框也是 modal <dialog>,Escape 会先关掉我们自己的界面。
291
+ if (openedSettings && ownerDocument.querySelector(SETTINGS_NAV_ITEM) !== null) {
292
+ const backButton = findSettingsBackButton(ownerDocument);
293
+ if (backButton) {
294
+ backButton.click();
295
+ try {
296
+ await waitFor(
297
+ () => ownerDocument.querySelector(SETTINGS_NAV_ITEM) === null,
298
+ timing.closeTimeoutMs,
299
+ timing.pollMs,
300
+ "the official settings did not close",
301
+ );
302
+ } catch {
303
+ // 关闭失败不掩盖主流程结果(设置页留在官方桌宠页,用户可手动返回)
304
+ }
305
+ }
306
+ }
307
+ }
308
+ }
@@ -34,6 +34,7 @@ export interface RendererSettingsLifecycleOptions {
34
34
  getSessionImportClient?(): RendererSessionImportClient | null;
35
35
  getStorageClient?(): RendererStorageClient | null;
36
36
  getPetClient?(): RendererPetsClient | null;
37
+ getCollaborationClient?(): import('./settings/pages.js').RendererCollaborationClient | null;
37
38
  openImportedThread?: RendererImportedThreadOpener;
38
39
  onLocaleChange?(locale: RendererSettingsLocale): void;
39
40
  }
@@ -84,6 +85,7 @@ export function installRendererSettingsLifecycle(
84
85
  options.getIntegrationsClient,
85
86
  options.getStorageClient,
86
87
  options.getPetClient,
88
+ options.getCollaborationClient,
87
89
  );
88
90
  const nextShell = installRendererSettingsShell(definitions, messages, ownerWindow.document);
89
91
  const nextTrigger = installRendererSettingsHeaderTrigger({
@@ -59,11 +59,13 @@ const TEAM_STYLE = `
59
59
  .harness-mix-team-body{display:grid;grid-template-columns:minmax(0,1fr) clamp(228px,24vw,300px);min-height:0;overflow:hidden}
60
60
  .harness-mix-team-lanes{display:flex;gap:16px;min-height:0;box-sizing:border-box;overflow-x:auto;overflow-y:hidden;padding:18px 16px 10px;justify-content:safe center;scroll-snap-type:x proximity}
61
61
  .harness-mix-team-lane{position:relative;flex:1 1 0;min-width:216px;max-width:300px;display:flex;flex-direction:column;min-height:0;padding-top:16px;scroll-snap-align:start}
62
- .harness-mix-team-lane::before{content:'';position:absolute;top:0;left:50%;height:16px;border-left:2px dashed color-mix(in srgb,currentColor 20%,transparent)}
63
- .harness-mix-team-lane::after{content:'';position:absolute;top:0;left:-9px;right:-9px;border-top:2px dashed color-mix(in srgb,currentColor 20%,transparent)}
62
+ .harness-mix-team-lane::before{content:'';position:absolute;top:0;left:50%;width:2px;height:16px;margin-left:-1px;background:repeating-linear-gradient(to bottom,color-mix(in srgb,currentColor 22%,transparent) 0 3px,transparent 3px 7px);animation:harness-mix-team-flow-y 1.4s linear infinite}
63
+ .harness-mix-team-lane::after{content:'';position:absolute;top:0;left:-9px;right:-9px;height:2px;background:repeating-linear-gradient(to right,color-mix(in srgb,currentColor 22%,transparent) 0 3px,transparent 3px 7px);animation:harness-mix-team-flow-x 1.4s linear infinite}
64
64
  .harness-mix-team-lane:first-child::after{left:calc(50% - 1px)}
65
65
  .harness-mix-team-lane:last-child::after{right:calc(50% - 1px)}
66
66
  .harness-mix-team-lane:only-child::after{display:none}
67
+ .harness-mix-team-lane[data-status="working"]::before{background-image:repeating-linear-gradient(to bottom,color-mix(in srgb,var(--lane-color,currentColor) 85%,transparent) 0 3px,transparent 3px 7px);animation-duration:.55s}
68
+ .harness-mix-team-lane[data-status="working"]::after{background-image:repeating-linear-gradient(to right,color-mix(in srgb,var(--lane-color,currentColor) 60%,transparent) 0 3px,transparent 3px 7px);animation-duration:.55s}
67
69
  .harness-mix-team-task-list{display:flex;flex-direction:column;gap:6px;flex:1;min-height:0;overflow-y:auto;padding:2px 8px 8px;scrollbar-width:none}
68
70
  .harness-mix-team-task-list::-webkit-scrollbar{display:none}
69
71
  .harness-mix-team-feed{display:flex;flex-direction:column;min-height:0;min-width:0;border-left:1px solid color-mix(in srgb,currentColor 9%,transparent)}
@@ -71,11 +73,34 @@ const TEAM_STYLE = `
71
73
  .harness-mix-team-clamp2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
72
74
  .harness-mix-team-clickable{transition:border-color .15s ease,box-shadow .15s ease}
73
75
  .harness-mix-team-clickable:hover{border-color:color-mix(in srgb,currentColor 28%,transparent)!important;box-shadow:0 4px 14px color-mix(in srgb,#000 10%,transparent)!important}
76
+ .harness-mix-team-open-failed{border-color:#d14343!important;box-shadow:0 0 0 3px color-mix(in srgb,#d14343 25%,transparent)!important}
77
+ .harness-mix-team-member[data-status="working"],.harness-mix-team-lead[data-status="working"]{border-color:color-mix(in srgb,var(--lane-color,#2878e3) 38%,transparent)!important;box-shadow:0 0 12px color-mix(in srgb,var(--lane-color,#2878e3) 18%,transparent)}
78
+ .harness-mix-team-avatar[data-status="working"]{animation:harness-mix-team-breathe 2s ease-in-out infinite}
79
+ .harness-mix-team-status-dot[data-status="working"]{animation:harness-mix-team-pulse 1.6s ease-in-out infinite}
80
+ @keyframes harness-mix-team-flow-y{to{background-position:0 7px}}
81
+ @keyframes harness-mix-team-flow-x{to{background-position:7px 0}}
82
+ @keyframes harness-mix-team-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--lane-color,#2878e3) 40%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--lane-color,#2878e3) 0%,transparent)}}
83
+ @keyframes harness-mix-team-breathe{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--lane-color,#2878e3) 28%,transparent)}50%{box-shadow:0 0 10px 2px color-mix(in srgb,var(--lane-color,#2878e3) 38%,transparent)}}
74
84
  @container (max-width:720px){
75
85
  .harness-mix-team-body{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr) auto}
76
86
  .harness-mix-team-feed{border-left:0;border-top:1px solid color-mix(in srgb,currentColor 9%,transparent);max-height:136px}
77
87
  }`;
78
88
 
89
+ // 成员卡片/chip 的「打开原生会话」接线:可点态 + hover 反馈 + 失败可视提示(不静默)。
90
+ function wireOpen(node: HTMLElement, childId: string | undefined, name: string, openThread?: TeamCardOptions['openThread']) {
91
+ if (!childId || !openThread) return;
92
+ node.style.cursor = 'pointer';
93
+ node.classList.add('harness-mix-team-clickable');
94
+ node.title = `打开 ${name} 的原生 Harness 会话`;
95
+ node.addEventListener('click', async () => {
96
+ try { await openThread(childId); } catch (error) {
97
+ console.warn('[TeamCards] 打开成员会话失败:', error);
98
+ node.classList.add('harness-mix-team-open-failed');
99
+ setTimeout(() => node.classList.remove('harness-mix-team-open-failed'), 1200);
100
+ }
101
+ });
102
+ }
103
+
79
104
  function metrics(payload: TeamCardPayload) {
80
105
  const node = el('div', 'harness-mix-team-metrics', 'display:flex;gap:18px;align-items:center');
81
106
  const values: Array<[string, string, string]> = [[`${payload.tasks.filter(task => task.status === 'completed').length}/${payload.tasks.length}`, '完成', '#1f9d68'], [`${payload.tasks.filter(task => task.status === 'in_progress').length}`, '进行中', '#2878e3'], [`${payload.tasks.filter(task => task.status === 'blocked').length}`, '等待', '#c17022']];
@@ -101,18 +126,20 @@ function headerStats(payload: TeamCardPayload) {
101
126
  }
102
127
  const pillCss = (status?: string) => { const color = stateColor(status); return `font-size:8px;font-weight:750;padding:2px 7px;border-radius:99px;white-space:nowrap;color:${color};background:color-mix(in srgb,${color} 15%,transparent)`; };
103
128
  function statusPill(status?: string) { return text('span', stateLabel(status), pillCss(status)); }
104
- function statusDot(status: string | undefined, size = 7) { return el('span', undefined, `width:${size}px;height:${size}px;border-radius:50%;flex:none;background:${stateColor(status)}`); }
129
+ function statusDot(status: string | undefined, size = 7) { const dot = el('span', 'harness-mix-team-status-dot', `width:${size}px;height:${size}px;border-radius:50%;flex:none;background:${stateColor(status)}`); dot.dataset.status = status ?? ''; return dot; }
130
+ const actionIcon = (action?: string) => ({ team_created: '🎬', task_assigned: '📋', task_updated: '🔄', task_started: '🚀', member_session_ready: '🔌', task_settled: '✅', task_failed: '❌', task_cancelled: '⛔', task_interrupted: '⏸', task_resumed: '▶️', task_followup: '💬' }[action ?? ''] ?? '•');
105
131
 
106
- function renderBoard(payload: TeamCardPayload, openThread?: TeamCardOptions['openThread']) {
132
+ function renderBoard(payload: TeamCardPayload, openThread?: TeamCardOptions['openThread'], snapshots: TeamSnapshot[] = []) {
107
133
  const board = el('main', 'harness-mix-team-board harness-mix-team-body', 'height:100%;min-height:0;color:inherit');
108
134
  const lead = payload.lead ?? { id: 'lead', name: 'Team Lead', role: '协调与验收', agent: 'codex', display_status: 'working' };
109
135
  const tasksOf = (memberId: string) => payload.tasks.filter(task => task.assignee === memberId);
110
136
 
111
137
  const leadCard = () => {
112
138
  const button = el('button', 'harness-mix-team-lead', 'appearance:none;border:1px solid color-mix(in srgb,#c35b24 34%,transparent);background:color-mix(in srgb,#c35b24 7%,transparent);box-shadow:0 2px 10px color-mix(in srgb,#c35b24 10%,transparent);color:inherit;font:inherit;display:flex;align-items:center;gap:10px;padding:8px 16px;border-radius:13px;cursor:default'); button.type = 'button'; button.dataset.agent = lead.agent;
113
- const childId = lead.childId ?? lead.child_thread_id;
114
- if (childId && openThread) { button.style.cursor = 'pointer'; button.classList.add('harness-mix-team-clickable'); button.title = `打开 ${lead.name} 的原生 Harness 会话`; button.addEventListener('click', () => void openThread(childId)); }
115
- const avatar = el('span', undefined, 'position:relative;display:grid;place-items:center;width:42px;height:42px;flex:none;border-radius:12px;border:2px solid color-mix(in srgb,#c35b24 45%,transparent);background:Canvas');
139
+ button.dataset.status = lead.display_status ?? '';
140
+ wireOpen(button, lead.childId ?? lead.child_thread_id, lead.name, openThread);
141
+ const avatar = el('span', 'harness-mix-team-avatar', 'position:relative;display:grid;place-items:center;width:42px;height:42px;flex:none;border-radius:12px;border:2px solid color-mix(in srgb,#c35b24 45%,transparent);background:Canvas');
142
+ avatar.dataset.status = lead.display_status ?? '';
116
143
  avatar.append(collaborationIcon(lead.agent, lead.name, 32), text('span', '👑', 'position:absolute;top:-12px;right:-10px;font-size:13px;filter:drop-shadow(0 1px 1px rgb(0 0 0/.25))'));
117
144
  const copy = el('span', undefined, 'display:grid;min-width:0;text-align:left;line-height:1.3');
118
145
  copy.append(text('span', '主导者', 'font-size:8.5px;font-weight:800;letter-spacing:.14em;color:#c35b24'), text('strong', lead.name, 'font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'));
@@ -123,10 +150,12 @@ function renderBoard(payload: TeamCardPayload, openThread?: TeamCardOptions['ope
123
150
  const memberCard = (member: TeamMemberPayload, index: number) => {
124
151
  const color = memberColor(index), assigned = tasksOf(member.id), done = assigned.filter(task => task.status === 'completed').length;
125
152
  const button = el('button', 'harness-mix-team-member', 'appearance:none;width:100%;box-sizing:border-box;border:1px solid color-mix(in srgb,currentColor 10%,transparent);background:color-mix(in srgb,Canvas 92%,transparent);box-shadow:0 2px 8px color-mix(in srgb,#000 6%,transparent);color:inherit;font:inherit;display:grid;grid-template-columns:40px minmax(0,1fr);align-items:center;gap:9px;padding:9px 10px;border-radius:12px;text-align:left;cursor:default'); button.type = 'button'; button.dataset.agent = member.agent;
126
- const childId = member.childId ?? member.child_thread_id;
127
- if (childId && openThread) { button.style.cursor = 'pointer'; button.classList.add('harness-mix-team-clickable'); button.title = `打开 ${member.name} 的原生 Harness 会话`; button.addEventListener('click', () => void openThread(childId)); }
128
- const avatar = el('span', undefined, `position:relative;display:grid;place-items:center;width:40px;height:40px;flex:none;border-radius:11px;border:2px solid ${color};background:Canvas`);
129
- avatar.append(collaborationIcon(member.agent, member.name, 30), el('span', undefined, `position:absolute;right:-4px;bottom:-4px;width:11px;height:11px;box-sizing:border-box;border-radius:50%;background:${stateColor(member.display_status)};border:2.5px solid Canvas`));
153
+ button.dataset.status = member.display_status ?? ''; button.style.setProperty('--lane-color', color ?? '');
154
+ wireOpen(button, member.childId ?? member.child_thread_id, member.name, openThread);
155
+ const avatar = el('span', 'harness-mix-team-avatar', `position:relative;display:grid;place-items:center;width:40px;height:40px;flex:none;border-radius:11px;border:2px solid ${color};background:Canvas`);
156
+ avatar.dataset.status = member.display_status ?? '';
157
+ const dot = el('span', 'harness-mix-team-status-dot', `position:absolute;right:-4px;bottom:-4px;width:11px;height:11px;box-sizing:border-box;border-radius:50%;background:${stateColor(member.display_status)};border:2.5px solid Canvas`); dot.dataset.status = member.display_status ?? '';
158
+ avatar.append(collaborationIcon(member.agent, member.name, 30), dot);
130
159
  const copy = el('span', undefined, 'display:grid;min-width:0;line-height:1.25;gap:2px');
131
160
  copy.append(text('strong', member.name, 'font-size:12px;font-weight:680;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'));
132
161
  const status = el('span', undefined, 'display:flex;align-items:center;gap:5px;min-width:0');
@@ -138,6 +167,7 @@ function renderBoard(payload: TeamCardPayload, openThread?: TeamCardOptions['ope
138
167
  const laneOf = (member: TeamMemberPayload, index: number) => {
139
168
  const color = memberColor(index), assigned = tasksOf(member.id), done = assigned.filter(task => task.status === 'completed').length;
140
169
  const lane = el('div', 'harness-mix-team-lane'); lane.dataset.memberId = member.id;
170
+ lane.dataset.status = member.display_status ?? ''; lane.style.setProperty('--lane-color', color ?? '');
141
171
  lane.append(memberCard(member, index));
142
172
  const column = el('div', undefined, `margin-top:9px;flex:1;min-height:0;display:flex;flex-direction:column;border-radius:12px;border:1px solid color-mix(in srgb,${color} 30%,transparent);background:color-mix(in srgb,${color} 7%,transparent)`);
143
173
  const columnHead = el('div', undefined, 'display:flex;align-items:flex-start;gap:6px;padding:8px 10px 5px');
@@ -168,10 +198,24 @@ function renderBoard(payload: TeamCardPayload, openThread?: TeamCardOptions['ope
168
198
  left.append(org, lanes);
169
199
 
170
200
  const feed = el('aside', 'harness-mix-team-feed');
201
+ // 团队动态 = 成员消息 + 团队历史事件(任务分配/开始执行/会话就绪/结算等),
202
+ // 按时间倒序合并;message_sent 事件已由消息条目承载,避免重复。
203
+ const events = snapshots.filter(snapshot => snapshot.action !== 'message_sent');
204
+ const entries = [
205
+ ...payload.messages.map(message => ({ at: message.at || 0, kind: 'message' as const, message })),
206
+ ...events.map(snapshot => ({ at: snapshot.at || 0, kind: 'event' as const, snapshot })),
207
+ ].sort((a, b) => b.at - a.at);
171
208
  const feedHead = el('div', undefined, 'display:flex;align-items:center;gap:6px;padding:11px 12px 8px;flex:none');
172
- feedHead.append(text('span', '团队动态', 'font-size:10px;font-weight:720;letter-spacing:.06em;opacity:.55'), text('span', String(payload.messages.length), 'font-size:8.5px;font-weight:700;opacity:.6;background:color-mix(in srgb,currentColor 8%,transparent);border-radius:99px;padding:1px 7px'));
209
+ feedHead.append(text('span', '团队动态', 'font-size:10px;font-weight:720;letter-spacing:.06em;opacity:.55'), text('span', String(entries.length), 'font-size:8.5px;font-weight:700;opacity:.6;background:color-mix(in srgb,currentColor 8%,transparent);border-radius:99px;padding:1px 7px'));
173
210
  const feedList = el('div', 'harness-mix-team-feed-list');
174
- for (const message of payload.messages.slice().reverse()) {
211
+ for (const entry of entries) {
212
+ if (entry.kind === 'event') {
213
+ const item = el('article', undefined, 'display:flex;align-items:center;gap:6px;padding:7px 0;border-top:1px solid color-mix(in srgb,currentColor 7%,transparent)');
214
+ item.append(text('span', actionIcon(entry.snapshot.action), 'font-size:11px;flex:none'), text('span', actionLabel(entry.snapshot.action), 'font-size:10px;font-weight:650;opacity:.7;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'));
215
+ if (entry.snapshot.at > 0) item.append(text('span', new Date(entry.snapshot.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), 'font-size:8.5px;opacity:.4;flex:none'));
216
+ feedList.append(item); continue;
217
+ }
218
+ const message = entry.message;
175
219
  const item = el('article', undefined, 'padding:9px 0;border-top:1px solid color-mix(in srgb,currentColor 7%,transparent)');
176
220
  const sender = message.from === 'lead' ? lead : payload.members.find(member => member.id === message.from), receiver = message.to === 'lead' ? lead : payload.members.find(member => member.id === message.to);
177
221
  const route = el('div', undefined, 'display:flex;align-items:center;gap:5px;min-width:0;font-size:10.5px;font-weight:700');
@@ -182,12 +226,12 @@ function renderBoard(payload: TeamCardPayload, openThread?: TeamCardOptions['ope
182
226
  if (message.at > 0) route.append(text('span', new Date(message.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), 'margin-left:auto;font-size:8.5px;font-weight:400;opacity:.4;flex:none'));
183
227
  item.append(route, text('div', message.body, 'font-size:11px;line-height:1.5;margin-top:4px;white-space:pre-wrap;overflow-wrap:anywhere;opacity:.88')); feedList.append(item);
184
228
  }
185
- if (!payload.messages.length) feedList.append(text('div', '成员交接、审查请求和结果会实时显示在这里。', 'font-size:10px;line-height:1.5;opacity:.45;padding:10px 0'));
229
+ if (!entries.length) feedList.append(text('div', '成员交接、审查请求和结果会实时显示在这里。', 'font-size:10px;line-height:1.5;opacity:.45;padding:10px 0'));
186
230
  feed.append(feedHead, feedList);
187
231
  board.append(left, feed); return board;
188
232
  }
189
233
 
190
- function renderSummary(payload: TeamCardPayload, open: () => void) {
234
+ function renderSummary(payload: TeamCardPayload, open: () => void, openThread?: TeamCardOptions['openThread']) {
191
235
  const panel = el('section', 'harness-mix-team-panel', 'box-sizing:border-box;border:1px solid color-mix(in srgb,currentColor 12%,transparent);border-radius:14px;background:color-mix(in srgb,Canvas 90%,transparent);box-shadow:0 8px 28px color-mix(in srgb,#000 7%,transparent);backdrop-filter:blur(18px);color:inherit;font:13px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;padding:12px 14px;display:grid;gap:10px;min-width:0'); panel.dataset.teamId = payload.team_id; panel.dataset.teamStatus = payload.status;
192
236
  const top = el('div', undefined, 'display:flex;align-items:center;gap:12px;min-width:0');
193
237
  const identity = el('div', undefined, 'display:grid;grid-template-columns:38px minmax(0,1fr);align-items:center;gap:10px;min-width:0;flex:1');
@@ -202,8 +246,13 @@ function renderSummary(payload: TeamCardPayload, open: () => void) {
202
246
  payload.members.forEach((member, index) => {
203
247
  const color = memberColor(index);
204
248
  const chip = el('span', undefined, 'display:grid;grid-template-columns:26px minmax(0,1fr);align-items:center;gap:7px;min-width:128px;max-width:190px;padding:5px 9px;border-radius:10px;border:1px solid color-mix(in srgb,currentColor 8%,transparent);background:color-mix(in srgb,currentColor 3%,transparent)'); chip.title = `${member.name} · ${member.role}`;
205
- const icon = el('span', undefined, `position:relative;display:grid;place-items:center;width:26px;height:26px;flex:none;border-radius:8px;border:1.5px solid ${color};background:Canvas`);
206
- icon.append(collaborationIcon(member.agent, member.name, 18), el('span', undefined, `position:absolute;right:-3px;bottom:-3px;width:8px;height:8px;box-sizing:border-box;border-radius:50%;background:${stateColor(member.display_status)};border:2px solid Canvas`));
249
+ chip.dataset.status = member.display_status ?? ''; chip.style.setProperty('--lane-color', color ?? '');
250
+ wireOpen(chip, member.childId ?? member.child_thread_id, member.name, openThread);
251
+ if (chip.classList.contains('harness-mix-team-clickable')) chip.title = `${member.name} · ${member.role} · 点击打开原生会话`;
252
+ const icon = el('span', 'harness-mix-team-avatar', `position:relative;display:grid;place-items:center;width:26px;height:26px;flex:none;border-radius:8px;border:1.5px solid ${color};background:Canvas`);
253
+ icon.dataset.status = member.display_status ?? '';
254
+ const dot = el('span', 'harness-mix-team-status-dot', `position:absolute;right:-3px;bottom:-3px;width:8px;height:8px;box-sizing:border-box;border-radius:50%;background:${stateColor(member.display_status)};border:2px solid Canvas`); dot.dataset.status = member.display_status ?? '';
255
+ icon.append(collaborationIcon(member.agent, member.name, 18), dot);
207
256
  const label = el('span', undefined, 'display:grid;min-width:0;line-height:1.2');
208
257
  label.append(text('strong', member.name, 'font-size:10px;font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'), text('span', member.role, 'font-size:8.5px;opacity:.52;overflow:hidden;text-overflow:ellipsis;white-space:nowrap'), text('span', stateLabel(member.display_status), `font-size:8.5px;color:${stateColor(member.display_status)}`));
209
258
  chip.append(icon, label); members.append(chip);
@@ -246,10 +295,16 @@ export function installTeamCards(options: TeamCardOptions = {}) {
246
295
  const play = el('button', undefined, 'border:1px solid color-mix(in srgb,currentColor 15%,transparent);border-radius:7px;background:color-mix(in srgb,currentColor 6%,transparent);color:inherit;font:600 10px system-ui;padding:5px 8px;cursor:pointer'); play.type = 'button'; play.textContent = '回放';
247
296
  const collapse = el('button', 'harness-mix-team-back', 'border:0;background:transparent;color:inherit;font:650 10px system-ui;cursor:pointer;padding:6px 7px;opacity:.68'); collapse.type = 'button'; collapse.textContent = '收起详情'; collapse.addEventListener('click', close); timeline.append(event, range, live, play, collapse); header.append(title, statsWrap, progressWrap, timeline);
248
297
  const content = el('div', undefined, 'min-height:0'); workbench.append(header, content); source.after(workbench);
298
+ let renderedSignature = '';
249
299
  const render = () => {
250
300
  const snapshot = index >= 0 ? snapshots[index] : undefined;
251
301
  const visible = snapshot?.team ?? current;
252
- content.replaceChildren(renderBoard(visible, async threadId => { close(); await options.openThread?.(threadId); }));
302
+ const feedSnapshots = index >= 0 ? snapshots.slice(0, index + 1) : snapshots;
303
+ // 1.5s 轮询下无变化就跳过重渲染,避免面板闪烁、feed/任务列表滚动位置丢失
304
+ const signature = JSON.stringify([visible.updated_at, visible.status, visible.lead?.display_status, visible.members.map(member => member.display_status), visible.tasks.map(task => `${task.id}:${task.status}`), visible.messages.length, snapshots.length, index]);
305
+ if (signature === renderedSignature) return;
306
+ renderedSignature = signature;
307
+ content.replaceChildren(renderBoard(visible, async threadId => { close(); await options.openThread?.(threadId); }, feedSnapshots));
253
308
  teamName.textContent = visible.name; subtitle.textContent = visible.goal; subtitle.title = visible.goal;
254
309
  teamPill.textContent = stateLabel(visible.status); teamPill.style.cssText = pillCss(visible.status);
255
310
  statsWrap.replaceChildren(...headerStats(visible));
@@ -267,7 +322,7 @@ export function installTeamCards(options: TeamCardOptions = {}) {
267
322
  const scan = () => {
268
323
  if (disposed) return;
269
324
  const selector = '[data-testid*="tool"], [data-turn-key], [data-local-conversation-item-target-ids], pre[data-testid*="tool"]';
270
- for (const candidate of document.querySelectorAll<HTMLElement>(selector)) { if (candidate.closest('.harness-mix-team-panel,.harness-mix-team-workbench')) continue; const payload = parseTeamPayload(candidate.textContent ?? ''); if (!payload) continue; if ([...candidate.querySelectorAll<HTMLElement>(selector)].some(child => parseTeamPayload(child.textContent ?? ''))) continue; const signature = `${payload.updated_at ?? 0}:${payload.tasks.length}:${payload.messages.length}`; if (signatures.get(candidate) === signature) continue; const panel = renderSummary(payload, () => open(payload)); if (candidate.tagName === 'PRE') { if (!candidate.dataset.harnessMixTeamDisplay) candidate.dataset.harnessMixTeamDisplay = candidate.style.display || '__empty__'; candidate.style.display = 'none'; if (candidate.nextElementSibling?.classList.contains('harness-mix-team-panel')) candidate.nextElementSibling.remove(); candidate.after(panel); } else { candidate.querySelector(':scope > .harness-mix-team-panel')?.remove(); candidate.append(panel); } signatures.set(candidate, signature); }
325
+ for (const candidate of document.querySelectorAll<HTMLElement>(selector)) { if (candidate.closest('.harness-mix-team-panel,.harness-mix-team-workbench')) continue; const payload = parseTeamPayload(candidate.textContent ?? ''); if (!payload) continue; if ([...candidate.querySelectorAll<HTMLElement>(selector)].some(child => parseTeamPayload(child.textContent ?? ''))) continue; const signature = `${payload.updated_at ?? 0}:${payload.tasks.length}:${payload.messages.length}:${payload.members.map(member => member.display_status).join(',')}`; if (signatures.get(candidate) === signature) continue; const panel = renderSummary(payload, () => open(payload), options.openThread); if (candidate.tagName === 'PRE') { if (!candidate.dataset.harnessMixTeamDisplay) candidate.dataset.harnessMixTeamDisplay = candidate.style.display || '__empty__'; candidate.style.display = 'none'; if (candidate.nextElementSibling?.classList.contains('harness-mix-team-panel')) candidate.nextElementSibling.remove(); candidate.after(panel); } else { candidate.querySelector(':scope > .harness-mix-team-panel')?.remove(); candidate.append(panel); } signatures.set(candidate, signature); }
271
326
  };
272
327
  const removeActivePanel = () => { if (workbench?.dataset.teamSource === 'active-thread') close(); activePanel?.remove(); activePanel = null; activeSignature = ''; activeThreadId = ''; };
273
328
  const refreshActiveTeam = async () => {
@@ -282,14 +337,14 @@ export function installTeamCards(options: TeamCardOptions = {}) {
282
337
  if (!latest || latest.threadId !== context.threadId || !latest.anchor.isConnected) { removeActivePanel(); return; }
283
338
  if (!result) { removeActivePanel(); return; }
284
339
  const payload = result.team;
285
- const signature = `${context.threadId}:${payload.team_id}:${payload.updated_at ?? 0}:${payload.tasks.length}:${payload.messages.length}`;
340
+ const signature = `${context.threadId}:${payload.team_id}:${payload.updated_at ?? 0}:${payload.tasks.length}:${payload.messages.length}:${payload.lead?.display_status}:${payload.members.map(member => member.display_status).join(',')}:${payload.tasks.map(task => task.status).join(',')}`;
286
341
  const inlinePanel = [...document.querySelectorAll<HTMLElement>('.harness-mix-team-panel')]
287
342
  .find(node => node !== activePanel && node.dataset.teamId === payload.team_id);
288
343
  if (inlinePanel) { removeActivePanel(); return; }
289
344
  if (activePanel?.isConnected && activeSignature === signature && activeThreadId === context.threadId) return;
290
345
  const reopen = workbench?.dataset.teamId === payload.team_id && workbench.dataset.teamSource === 'active-thread';
291
346
  removeActivePanel();
292
- activePanel = renderSummary(payload, () => open(payload));
347
+ activePanel = renderSummary(payload, () => open(payload), options.openThread);
293
348
  activePanel.classList.add('harness-mix-team-launcher');
294
349
  activePanel.dataset.teamSource = 'active-thread';
295
350
  activePanel.style.cssText += ';margin:8px 16px 0;flex:none;position:relative;z-index:11';