@nextclaw/ui 0.13.12 → 0.13.13

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 (147) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/assets/{api-DmxQnm30.js → api-D0hARyyl.js} +1 -1
  3. package/dist/assets/app-presenter-provider-C7EsUuEJ.js +3 -0
  4. package/dist/assets/arrow-left-CzSkR5L6.js +1 -0
  5. package/dist/assets/book-open-DmeQPqkd.js +1 -0
  6. package/dist/assets/{channels-list-page-D-WYFmcG.js → channels-list-page-B0yiiH01.js} +2 -2
  7. package/dist/assets/chat-page-ChPjx_X9.js +105 -0
  8. package/dist/assets/{config-split-page-CdJEkWxv.js → config-split-page-BzfZSyAr.js} +1 -1
  9. package/dist/assets/{confirm-dialog-BWRNsRgb.js → confirm-dialog-CTcaBp-8.js} +1 -1
  10. package/dist/assets/desktop-update-config-CExpdkqD.js +1 -0
  11. package/dist/assets/{dist-CyWdaJvr.js → dist-C59BKqbX.js} +1 -1
  12. package/dist/assets/{dist-DzM2ZEVN.js → dist-Di6EHp2x.js} +1 -1
  13. package/dist/assets/{doc-browser-DewXa6oX.js → doc-browser-EZbnEcdk.js} +1 -1
  14. package/dist/assets/doc-browser-XIwe1Tg-.js +1 -0
  15. package/dist/assets/{doc-browser-context-CpL2m4Sp.js → doc-browser-context-BzEA1lm1.js} +1 -1
  16. package/dist/assets/{ellipsis-BDgTYNDX.js → ellipsis-hXUNwi63.js} +1 -1
  17. package/dist/assets/{es2015-WqekV0OK.js → es2015-CJlVSTh-.js} +1 -1
  18. package/dist/assets/{external-link-BLjo8z2a.js → external-link-J0fCK-vu.js} +1 -1
  19. package/dist/assets/host-capabilities-DjiRQ0Q9.js +1 -0
  20. package/dist/assets/index-DfZTO6Pi.js +101 -0
  21. package/dist/assets/index-mIb_YMrc.css +1 -0
  22. package/dist/assets/{key-round-Du8x3sWa.js → key-round-BzYZ0Nno.js} +1 -1
  23. package/dist/assets/loader-circle-BbdIlBbQ.js +1 -0
  24. package/dist/assets/mcp-marketplace-page-L22aCzHX.js +40 -0
  25. package/dist/assets/mcp-marketplace-page-f80Jmv37.js +1 -0
  26. package/dist/assets/model-config-DkD7vSQT.js +1 -0
  27. package/dist/assets/{notice-card-3a7FmAz-.js → notice-card-CD9Ok62G.js} +1 -1
  28. package/dist/assets/play-DHuSeKs8.js +1 -0
  29. package/dist/assets/plus-DrV685KC.js +1 -0
  30. package/dist/assets/{popover-BlJLguiZ.js → popover-CoBu3mbV.js} +1 -1
  31. package/dist/assets/{provider-scoped-model-input-IMD4W0jD.js → provider-scoped-model-input-DZlmTCJU.js} +1 -1
  32. package/dist/assets/providers-list-CVkWiqIy.js +1 -0
  33. package/dist/assets/{react-BVjl1ZaK.js → react-C7HsxuKH.js} +1 -1
  34. package/dist/assets/{refresh-cw-DFR87zei.js → refresh-cw-CVo4nWpq.js} +1 -1
  35. package/dist/assets/remote-Bznw9iji.js +1 -0
  36. package/dist/assets/rotate-cw-CXDpRO6D.js +1 -0
  37. package/dist/assets/{runtime-config-page-DzbTVebD.js → runtime-config-page-5IycbIxs.js} +1 -1
  38. package/dist/assets/{save-BDmIlHvN.js → save-Z1QTovIt.js} +1 -1
  39. package/dist/assets/search-8nY6PGm0.js +1 -0
  40. package/dist/assets/{search-config-CJ6OPxAf.js → search-config-DEH80WkC.js} +1 -1
  41. package/dist/assets/{secrets-config-CYOHWL7P.js → secrets-config-Gzo5nDJG.js} +1 -1
  42. package/dist/assets/{status-dot-SVPjIyG5.js → status-dot-C1-FKgvv.js} +1 -1
  43. package/dist/assets/{tabs-Czd2mj6z.js → tabs-BqkKwx1R.js} +1 -1
  44. package/dist/assets/{tabs-custom-Ix4nkp2h.js → tabs-custom-DOkaJZmt.js} +1 -1
  45. package/dist/assets/{tag-chip-Cdwb3JSH.js → tag-chip-3kpiMTST.js} +1 -1
  46. package/dist/assets/{tooltip-CKGxLGnG.js → tooltip-Bw-mXQAM.js} +1 -1
  47. package/dist/assets/{trash-2-yLA8BEcE.js → trash-2-CmIwAJIT.js} +1 -1
  48. package/dist/assets/x-CSRTOZnM.js +1 -0
  49. package/dist/index.html +27 -25
  50. package/package.json +8 -8
  51. package/src/features/account/components/account-panel.tsx +2 -2
  52. package/src/features/account/managers/account.manager.ts +6 -5
  53. package/src/features/chat/components/chat-session-workspace-panel-nav.tsx +45 -13
  54. package/src/features/chat/components/chat-session-workspace-panel.tsx +17 -8
  55. package/src/features/chat/components/conversation/chat-conversation-panel.test.tsx +69 -0
  56. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +3 -0
  57. package/src/features/chat/components/providers/chat-presenter.provider.tsx +2 -0
  58. package/src/features/chat/managers/chat-session-list.manager.test.ts +4 -0
  59. package/src/features/chat/managers/chat-session-list.manager.ts +2 -0
  60. package/src/features/chat/managers/ncp-chat-thread.manager.test.ts +153 -25
  61. package/src/features/chat/managers/ncp-chat-thread.manager.ts +153 -7
  62. package/src/features/chat/stores/chat-thread.store.test.ts +148 -0
  63. package/src/features/chat/stores/chat-thread.store.ts +287 -9
  64. package/src/features/marketplace/components/marketplace-catalog-grid.tsx +19 -1
  65. package/src/features/marketplace/components/marketplace-external-skill-source-action.tsx +14 -0
  66. package/src/features/marketplace/components/marketplace-page-parts.tsx +10 -2
  67. package/src/features/marketplace/components/marketplace-page.test.tsx +37 -1
  68. package/src/features/marketplace/components/marketplace-page.tsx +38 -16
  69. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +1 -0
  70. package/src/features/marketplace/hooks/use-marketplace.test.tsx +109 -0
  71. package/src/features/marketplace/hooks/use-marketplace.ts +3 -5
  72. package/src/features/side-dock/components/side-dock.test.tsx +25 -0
  73. package/src/features/side-dock/components/side-dock.tsx +13 -0
  74. package/src/features/system-status/components/desktop-update-config.tsx +2 -1
  75. package/src/platforms/desktop/types/desktop-update.types.ts +9 -0
  76. package/src/shared/components/config/provider-form.tsx +2 -2
  77. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +39 -32
  78. package/src/shared/components/ui/action-link.tsx +23 -1
  79. package/src/shared/lib/host-capabilities/host-capability.manager.test.ts +50 -0
  80. package/src/shared/lib/host-capabilities/host-capability.manager.ts +62 -0
  81. package/src/shared/lib/host-capabilities/index.ts +1 -0
  82. package/src/shared/lib/i18n/README.md +16 -3
  83. package/src/shared/lib/i18n/index.ts +93 -426
  84. package/src/shared/lib/i18n/locales/en-US/agents.json +49 -0
  85. package/src/shared/lib/i18n/locales/en-US/channel-auth.json +51 -0
  86. package/src/shared/lib/i18n/locales/en-US/channels.json +50 -0
  87. package/src/shared/lib/i18n/locales/en-US/chat.json +181 -0
  88. package/src/shared/lib/i18n/locales/en-US/core.json +310 -0
  89. package/src/shared/lib/i18n/locales/en-US/cron.json +32 -0
  90. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +90 -0
  91. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +87 -0
  92. package/src/shared/lib/i18n/locales/en-US/marketplace.json +72 -0
  93. package/src/shared/lib/i18n/locales/en-US/path-picker.json +12 -0
  94. package/src/shared/lib/i18n/locales/en-US/pwa.json +26 -0
  95. package/src/shared/lib/i18n/locales/en-US/remote.json +168 -0
  96. package/src/shared/lib/i18n/locales/en-US/runtime-control.json +105 -0
  97. package/src/shared/lib/i18n/locales/en-US/search.json +28 -0
  98. package/src/shared/lib/i18n/locales/zh-CN/agents.json +49 -0
  99. package/src/shared/lib/i18n/locales/zh-CN/channel-auth.json +51 -0
  100. package/src/shared/lib/i18n/locales/zh-CN/channels.json +50 -0
  101. package/src/shared/lib/i18n/locales/zh-CN/chat.json +181 -0
  102. package/src/shared/lib/i18n/locales/zh-CN/core.json +310 -0
  103. package/src/shared/lib/i18n/locales/zh-CN/cron.json +32 -0
  104. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +90 -0
  105. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +87 -0
  106. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +72 -0
  107. package/src/shared/lib/i18n/locales/zh-CN/path-picker.json +12 -0
  108. package/src/shared/lib/i18n/locales/zh-CN/pwa.json +26 -0
  109. package/src/shared/lib/i18n/locales/zh-CN/remote.json +168 -0
  110. package/src/shared/lib/i18n/locales/zh-CN/runtime-control.json +105 -0
  111. package/src/shared/lib/i18n/locales/zh-CN/search.json +28 -0
  112. package/src/shared/lib/navigation-history/index.ts +1 -0
  113. package/src/shared/lib/navigation-history/navigation-history.utils.test.ts +60 -0
  114. package/src/shared/lib/navigation-history/navigation-history.utils.ts +105 -0
  115. package/dist/assets/app-presenter-provider-CDSjiqfE.js +0 -3
  116. package/dist/assets/arrow-left-Dd45Mvr1.js +0 -1
  117. package/dist/assets/chat-page-D1X8YNTC.js +0 -105
  118. package/dist/assets/desktop-update-config-DOi5R1fX.js +0 -1
  119. package/dist/assets/doc-browser-D5FJcAyz.js +0 -1
  120. package/dist/assets/index-Q891JiF_.css +0 -1
  121. package/dist/assets/index-ZDJP_uJc.js +0 -101
  122. package/dist/assets/loader-circle-Dz4FssYC.js +0 -1
  123. package/dist/assets/mcp-marketplace-page-LmMhKH5a.js +0 -1
  124. package/dist/assets/mcp-marketplace-page-RqISBSqO.js +0 -40
  125. package/dist/assets/model-config-JUsW1Khb.js +0 -1
  126. package/dist/assets/play-BziM-Y34.js +0 -1
  127. package/dist/assets/plus-DrpyArC8.js +0 -1
  128. package/dist/assets/providers-list-BsDpxNzy.js +0 -1
  129. package/dist/assets/remote-DjhVMpVd.js +0 -1
  130. package/dist/assets/rotate-cw-DMBnsYKm.js +0 -1
  131. package/dist/assets/search-CjHGYvTI.js +0 -1
  132. package/dist/assets/x-dpDISboO.js +0 -1
  133. package/src/shared/lib/i18n/agents.ts +0 -85
  134. package/src/shared/lib/i18n/channel-auth.constants.ts +0 -87
  135. package/src/shared/lib/i18n/channels.ts +0 -53
  136. package/src/shared/lib/i18n/chat-labels.utils.ts +0 -206
  137. package/src/shared/lib/i18n/cron-labels.utils.ts +0 -32
  138. package/src/shared/lib/i18n/desktop-update-labels.utils.ts +0 -165
  139. package/src/shared/lib/i18n/marketplace-labels.utils.ts +0 -78
  140. package/src/shared/lib/i18n/pwa.ts +0 -62
  141. package/src/shared/lib/i18n/remote.ts +0 -273
  142. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +0 -120
  143. package/src/shared/lib/i18n/runtime/i18n.path-picker.ts +0 -12
  144. package/src/shared/lib/i18n/runtime-control-labels.utils.ts +0 -180
  145. package/src/shared/lib/i18n/search.ts +0 -37
  146. /package/dist/assets/{config-hints-Ceiol9x4.js → config-hints-fGnUjDe9.js} +0 -0
  147. /package/dist/assets/{provider-models-C_yOh6DE.js → provider-models-C9VRroK3.js} +0 -0
@@ -16,6 +16,8 @@ const mocks = vi.hoisted(() => ({
16
16
  goToChatRoot: vi.fn(),
17
17
  goToSession: vi.fn(),
18
18
  openSessionCronPanel: vi.fn(),
19
+ goBackWorkspacePanel: vi.fn(),
20
+ goForwardWorkspacePanel: vi.fn(),
19
21
  deleteCronJob: vi.fn(),
20
22
  cronJobs: [] as CronJobView[],
21
23
  setSelectedAgentId: vi.fn(),
@@ -119,6 +121,8 @@ vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
119
121
  selectWorkspaceFile: vi.fn(),
120
122
  closeWorkspaceFile: vi.fn(),
121
123
  closeWorkspacePanel: vi.fn(),
124
+ goBackWorkspacePanel: mocks.goBackWorkspacePanel,
125
+ goForwardWorkspacePanel: mocks.goForwardWorkspacePanel,
122
126
  goToParentSession: vi.fn(),
123
127
  },
124
128
  chatSessionListManager: {
@@ -200,8 +204,25 @@ function resetChatConversationPanelTestState() {
200
204
  mocks.goToChatRoot.mockReset();
201
205
  mocks.goToSession.mockReset();
202
206
  mocks.openSessionCronPanel.mockReset();
207
+ mocks.goBackWorkspacePanel.mockReset();
208
+ mocks.goForwardWorkspacePanel.mockReset();
203
209
  mocks.deleteCronJob.mockReset();
204
210
  mocks.cronJobs = [];
211
+ mocks.resolvedChildTabs = [
212
+ {
213
+ sessionKey: "child-session-1",
214
+ parentSessionKey: "parent-session-1",
215
+ title: "北京天气",
216
+ agentId: "weather",
217
+ updatedAt: "2026-04-10T09:00:00.000Z",
218
+ lastMessageAt: "2026-04-10T09:00:00.000Z",
219
+ readAt: "2026-04-10T09:00:00.000Z",
220
+ sessionTypeLabel: "Codex",
221
+ preferredModel: "openai/gpt-5.3-codex",
222
+ projectName: "project-alpha",
223
+ projectRoot: "/Users/demo/project-alpha",
224
+ },
225
+ ];
205
226
  mocks.setSelectedAgentId.mockReset();
206
227
  mocks.setPendingSessionType.mockReset();
207
228
  mocks.stickyBottomScroll.mockClear();
@@ -247,6 +268,8 @@ function resetChatConversationPanelTestState() {
247
268
  activeChildSessionKey: null,
248
269
  workspaceFileTabs: [],
249
270
  activeWorkspaceFileKey: null,
271
+ workspaceNavigationHistory: [],
272
+ workspaceNavigationHistoryIndex: 0,
250
273
  },
251
274
  });
252
275
  useChatSessionListStore.setState({
@@ -406,6 +429,11 @@ describe("ChatConversationPanel", () => {
406
429
  messageCount: "1",
407
430
  sending: "true",
408
431
  });
432
+ expect(
433
+ screen
434
+ .getByTestId("chat-message-list")
435
+ .closest('[data-chat-scroll-container="true"]'),
436
+ ).toBeTruthy();
409
437
  expect(
410
438
  screen.queryByText("No messages yet. Send one to start."),
411
439
  ).toBeNull();
@@ -520,6 +548,8 @@ describe("ChatConversationPanel", () => {
520
548
  });
521
549
 
522
550
  describe("ChatSessionWorkspacePanel", () => {
551
+ beforeEach(resetChatConversationPanelTestState);
552
+
523
553
  it("renders child session tabs and active child metadata in the workspace sidebar", () => {
524
554
  mocks.resolvedChildTabs = [
525
555
  {
@@ -661,6 +691,45 @@ describe("ChatSessionWorkspacePanel", () => {
661
691
  ).toContain("workspace-horizontal-scrollbar");
662
692
  });
663
693
 
694
+ it("uses workspace-local backward and forward history actions in the tab bar", async () => {
695
+ const user = userEvent.setup();
696
+
697
+ render(
698
+ <ChatSessionWorkspacePanel
699
+ sessionKey="parent-session-1"
700
+ childSessionTabs={[]}
701
+ activeChildSessionKey={null}
702
+ workspaceFileTabs={[
703
+ {
704
+ key: "parent-session-1::preview::README.md",
705
+ parentSessionKey: "parent-session-1",
706
+ path: "README.md",
707
+ label: "README.md",
708
+ viewMode: "preview",
709
+ },
710
+ ]}
711
+ activeWorkspaceFileKey="parent-session-1::preview::README.md"
712
+ workspaceNavigationHistory={[
713
+ { kind: "child-session", key: "child-session-1" },
714
+ { kind: "file", key: "parent-session-1::preview::README.md" },
715
+ { kind: "cron" },
716
+ ]}
717
+ workspaceNavigationHistoryIndex={1}
718
+ sessionProjectRoot="/Users/demo/project-alpha"
719
+ sessionWorkingDir="/Users/demo/project-alpha"
720
+ />,
721
+ );
722
+
723
+ await user.click(screen.getByRole("button", { name: "Back in workspace" }));
724
+ await user.click(
725
+ screen.getByRole("button", { name: "Forward in workspace" }),
726
+ );
727
+
728
+ expect(mocks.goBackWorkspacePanel).toHaveBeenCalledTimes(1);
729
+ expect(mocks.goForwardWorkspacePanel).toHaveBeenCalledTimes(1);
730
+ expect(screen.queryByRole("button", { name: "Back to parent" })).toBeNull();
731
+ });
732
+
664
733
  it("renders session cron jobs in the workspace sidebar and deletes with a neutral confirmation", async () => {
665
734
  const user = userEvent.setup();
666
735
  const job: CronJobView = {
@@ -194,6 +194,7 @@ function ChatConversationContent() {
194
194
  <div
195
195
  ref={threadRef}
196
196
  onScroll={onScroll}
197
+ data-chat-scroll-container="true"
197
198
  className="flex-1 min-h-0 overflow-y-auto custom-scrollbar"
198
199
  >
199
200
  {showWelcome ? (
@@ -363,6 +364,8 @@ function ChatSessionWorkspacePanelContainer({
363
364
  activeChildSessionKey={snapshot.activeChildSessionKey ?? null}
364
365
  workspaceFileTabs={workspaceFileTabs}
365
366
  activeWorkspaceFileKey={snapshot.activeWorkspaceFileKey ?? null}
367
+ workspaceNavigationHistory={snapshot.workspaceNavigationHistory}
368
+ workspaceNavigationHistoryIndex={snapshot.workspaceNavigationHistoryIndex}
366
369
  activePanelKind={snapshot.activeWorkspacePanelKind ?? null}
367
370
  sessionCronJobs={sessionCronJobs}
368
371
  sessionProjectRoot={snapshot.sessionProjectRoot ?? null}
@@ -42,6 +42,8 @@ export type ChatThreadManagerLike = {
42
42
  selectWorkspaceFile: (fileKey: string) => void;
43
43
  closeWorkspaceFile: (fileKey: string) => void;
44
44
  closeWorkspacePanel: () => void;
45
+ goBackWorkspacePanel: () => void;
46
+ goForwardWorkspacePanel: () => void;
45
47
  goToParentSession: () => void;
46
48
  };
47
49
 
@@ -57,6 +57,10 @@ describe('ChatSessionListManager', () => {
57
57
  activeWorkspacePanelKind: 'child-session',
58
58
  activeChildSessionKey: 'child-session-1',
59
59
  activeWorkspaceFileKey: null,
60
+ workspaceNavigationHistory: [
61
+ { kind: 'child-session', key: 'child-session-1' },
62
+ ],
63
+ workspaceNavigationHistoryIndex: 0,
60
64
  },
61
65
  });
62
66
  });
@@ -30,6 +30,8 @@ export class ChatSessionListManager {
30
30
  childSessionTabs: [],
31
31
  activeChildSessionKey: null,
32
32
  activeWorkspaceFileKey: null,
33
+ workspaceNavigationHistory: [],
34
+ workspaceNavigationHistoryIndex: 0,
33
35
  });
34
36
  };
35
37
 
@@ -19,33 +19,37 @@ vi.mock('@/shared/lib/api', async (importOriginal) => {
19
19
  };
20
20
  });
21
21
 
22
- describe('NcpChatThreadManager', () => {
23
- beforeEach(() => {
24
- useChatSessionListStore.setState({
25
- optimisticReadAtBySessionKey: {},
26
- snapshot: {
27
- ...useChatSessionListStore.getState().snapshot,
28
- selectedSessionKey: 'parent-session-1',
29
- },
30
- });
31
- useChatThreadStore.setState({
32
- snapshot: {
33
- ...useChatThreadStore.getState().snapshot,
34
- sessionKey: 'parent-session-1',
35
- workspacePanelParentKey: null,
36
- childSessionTabs: [
37
- {
38
- sessionKey: 'child-session-1',
39
- parentSessionKey: 'parent-session-1',
40
- label: 'Child Session 1',
41
- agentId: 'reviewer',
42
- },
43
- ],
44
- activeChildSessionKey: null,
45
- },
46
- });
22
+ beforeEach(() => {
23
+ useChatSessionListStore.setState({
24
+ optimisticReadAtBySessionKey: {},
25
+ snapshot: {
26
+ ...useChatSessionListStore.getState().snapshot,
27
+ selectedSessionKey: 'parent-session-1',
28
+ },
47
29
  });
30
+ useChatThreadStore.setState({
31
+ snapshot: {
32
+ ...useChatThreadStore.getState().snapshot,
33
+ sessionKey: 'parent-session-1',
34
+ workspacePanelParentKey: null,
35
+ childSessionTabs: [
36
+ {
37
+ sessionKey: 'child-session-1',
38
+ parentSessionKey: 'parent-session-1',
39
+ label: 'Child Session 1',
40
+ agentId: 'reviewer',
41
+ },
42
+ ],
43
+ activeChildSessionKey: null,
44
+ workspaceFileTabs: [],
45
+ activeWorkspaceFileKey: null,
46
+ workspaceNavigationHistory: [],
47
+ workspaceNavigationHistoryIndex: 0,
48
+ },
49
+ });
50
+ });
48
51
 
52
+ describe('NcpChatThreadManager', () => {
49
53
  it('opens the child-session panel for the requested parent session and keeps focus on the chosen child', () => {
50
54
  const uiManager = {
51
55
  goToSession: vi.fn(),
@@ -190,7 +194,129 @@ describe('NcpChatThreadManager', () => {
190
194
  ]),
191
195
  );
192
196
  });
197
+ });
198
+
199
+ describe('NcpChatThreadManager workspace navigation', () => {
200
+ it('navigates backward and forward through workspace selections', () => {
201
+ const uiManager = {
202
+ goToSession: vi.fn(),
203
+ goToChatRoot: vi.fn(),
204
+ goToProviders: vi.fn(),
205
+ confirm: vi.fn(),
206
+ } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[0];
207
+ const manager = new NcpChatThreadManager(
208
+ uiManager,
209
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[1],
210
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[2],
211
+ );
212
+
213
+ manager.openChildSessionPanel({
214
+ parentSessionKey: 'parent-session-1',
215
+ activeChildSessionKey: 'child-session-1',
216
+ });
217
+ manager.openFilePreview({
218
+ path: 'README.md',
219
+ label: 'README.md',
220
+ viewMode: 'preview',
221
+ });
222
+ manager.openSessionCronPanel('parent-session-1');
223
+
224
+ expect(useChatThreadStore.getState().snapshot.workspaceNavigationHistory).toEqual([
225
+ { kind: 'child-session', key: 'child-session-1' },
226
+ { kind: 'file', key: 'parent-session-1::preview::README.md' },
227
+ { kind: 'cron' },
228
+ ]);
229
+
230
+ manager.goBackWorkspacePanel();
231
+ expect(useChatThreadStore.getState().snapshot).toMatchObject({
232
+ activeWorkspacePanelKind: 'file',
233
+ activeWorkspaceFileKey: 'parent-session-1::preview::README.md',
234
+ workspaceNavigationHistoryIndex: 1,
235
+ });
236
+
237
+ manager.goBackWorkspacePanel();
238
+ expect(useChatThreadStore.getState().snapshot).toMatchObject({
239
+ activeWorkspacePanelKind: 'child-session',
240
+ activeChildSessionKey: 'child-session-1',
241
+ workspaceNavigationHistoryIndex: 0,
242
+ });
243
+
244
+ manager.goForwardWorkspacePanel();
245
+ expect(useChatThreadStore.getState().snapshot).toMatchObject({
246
+ activeWorkspacePanelKind: 'file',
247
+ activeWorkspaceFileKey: 'parent-session-1::preview::README.md',
248
+ workspaceNavigationHistoryIndex: 1,
249
+ });
250
+ });
251
+
252
+ it('truncates workspace forward history after selecting a new entry', () => {
253
+ useChatThreadStore.getState().setSnapshot({
254
+ childSessionTabs: [
255
+ ...useChatThreadStore.getState().snapshot.childSessionTabs,
256
+ {
257
+ sessionKey: 'child-session-2',
258
+ parentSessionKey: 'parent-session-1',
259
+ label: 'Child Session 2',
260
+ agentId: 'writer',
261
+ },
262
+ ],
263
+ });
264
+ const manager = new NcpChatThreadManager(
265
+ { goToSession: vi.fn() } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[0],
266
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[1],
267
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[2],
268
+ );
269
+
270
+ manager.openChildSessionPanel({
271
+ parentSessionKey: 'parent-session-1',
272
+ activeChildSessionKey: 'child-session-1',
273
+ });
274
+ manager.openFilePreview({
275
+ path: 'README.md',
276
+ label: 'README.md',
277
+ viewMode: 'preview',
278
+ });
279
+ manager.goBackWorkspacePanel();
280
+ manager.selectChildSessionDetail('child-session-2');
281
+
282
+ expect(useChatThreadStore.getState().snapshot.workspaceNavigationHistory).toEqual([
283
+ { kind: 'child-session', key: 'child-session-1' },
284
+ { kind: 'child-session', key: 'child-session-2' },
285
+ ]);
286
+ expect(useChatThreadStore.getState().snapshot.workspaceNavigationHistoryIndex).toBe(1);
287
+ });
288
+
289
+ it('removes a closed active file from workspace history and restores the previous entry', () => {
290
+ const manager = new NcpChatThreadManager(
291
+ { goToSession: vi.fn() } as unknown as ConstructorParameters<typeof NcpChatThreadManager>[0],
292
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[1],
293
+ {} as ConstructorParameters<typeof NcpChatThreadManager>[2],
294
+ );
295
+
296
+ manager.openChildSessionPanel({
297
+ parentSessionKey: 'parent-session-1',
298
+ activeChildSessionKey: 'child-session-1',
299
+ });
300
+ manager.openFilePreview({
301
+ path: 'README.md',
302
+ label: 'README.md',
303
+ viewMode: 'preview',
304
+ });
305
+ manager.closeWorkspaceFile('parent-session-1::preview::README.md');
306
+
307
+ expect(useChatThreadStore.getState().snapshot).toMatchObject({
308
+ activeWorkspacePanelKind: 'child-session',
309
+ activeChildSessionKey: 'child-session-1',
310
+ activeWorkspaceFileKey: null,
311
+ workspaceNavigationHistory: [
312
+ { kind: 'child-session', key: 'child-session-1' },
313
+ ],
314
+ workspaceNavigationHistoryIndex: 0,
315
+ });
316
+ });
317
+ });
193
318
 
319
+ describe('NcpChatThreadManager deletion', () => {
194
320
  it('clears the selected thread state after deleting the current session', async () => {
195
321
  const removeQueries = vi.spyOn(appQueryClient, 'removeQueries').mockImplementation(async () => undefined);
196
322
  const uiManager = {
@@ -228,6 +354,8 @@ describe('NcpChatThreadManager', () => {
228
354
  activeChildSessionKey: null,
229
355
  workspaceFileTabs: [],
230
356
  activeWorkspaceFileKey: null,
357
+ workspaceNavigationHistory: [],
358
+ workspaceNavigationHistoryIndex: 0,
231
359
  });
232
360
  expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
233
361
  expect(deleteSummaryMock).toHaveBeenCalledWith(appQueryClient, 'parent-session-1');
@@ -11,11 +11,30 @@ import type { ChatUiManager } from '@/features/chat/managers/chat-ui.manager';
11
11
  import { useChatSessionListStore } from '@/features/chat/stores/chat-session-list.store';
12
12
  import type {
13
13
  ChatThreadSnapshot,
14
+ ChatWorkspaceNavigationEntry,
14
15
  ChatWorkspaceFileTab,
15
16
  } from '@/features/chat/stores/chat-thread.store';
16
17
  import { useChatThreadStore } from '@/features/chat/stores/chat-thread.store';
17
18
  import { viewportLayoutManager } from '@/app/managers/viewport-layout.manager';
18
19
  import { t } from '@/shared/lib/i18n';
20
+ import {
21
+ filterNavigationHistoryEntries,
22
+ pushNavigationHistoryEntry,
23
+ stepNavigationHistory,
24
+ } from '@/shared/lib/navigation-history';
25
+
26
+ function areWorkspaceNavigationEntriesEqual(
27
+ current: ChatWorkspaceNavigationEntry,
28
+ next: ChatWorkspaceNavigationEntry,
29
+ ): boolean {
30
+ if (current.kind !== next.kind) {
31
+ return false;
32
+ }
33
+ if (current.kind === 'cron') {
34
+ return true;
35
+ }
36
+ return next.kind !== 'cron' && current.key === next.key;
37
+ }
19
38
 
20
39
  export class NcpChatThreadManager {
21
40
  constructor(
@@ -67,6 +86,8 @@ export class NcpChatThreadManager {
67
86
  activeChildSessionKey: null,
68
87
  workspaceFileTabs: [],
69
88
  activeWorkspaceFileKey: null,
89
+ workspaceNavigationHistory: [],
90
+ workspaceNavigationHistoryIndex: 0,
70
91
  });
71
92
  };
72
93
 
@@ -134,6 +155,57 @@ export class NcpChatThreadManager {
134
155
  }
135
156
  };
136
157
 
158
+ private createWorkspaceSelectionPatch = (
159
+ entry: ChatWorkspaceNavigationEntry,
160
+ snapshot: ChatThreadSnapshot,
161
+ ): Partial<ChatThreadSnapshot> | null => {
162
+ if (entry.kind === 'cron') {
163
+ return {
164
+ activeWorkspacePanelKind: 'cron',
165
+ activeChildSessionKey: null,
166
+ activeWorkspaceFileKey: null,
167
+ };
168
+ }
169
+ if (entry.kind === 'file') {
170
+ if (!snapshot.workspaceFileTabs.some((tab) => tab.key === entry.key)) {
171
+ return null;
172
+ }
173
+ return {
174
+ activeWorkspacePanelKind: 'file',
175
+ activeChildSessionKey: null,
176
+ activeWorkspaceFileKey: entry.key,
177
+ };
178
+ }
179
+ if (!entry.key) {
180
+ return null;
181
+ }
182
+ return {
183
+ activeWorkspacePanelKind: 'child-session',
184
+ activeChildSessionKey: entry.key,
185
+ activeWorkspaceFileKey: null,
186
+ };
187
+ };
188
+
189
+ private setWorkspaceSelection = (
190
+ patch: Partial<ChatThreadSnapshot>,
191
+ entry: ChatWorkspaceNavigationEntry,
192
+ ) => {
193
+ const { snapshot } = useChatThreadStore.getState();
194
+ const history = pushNavigationHistoryEntry(
195
+ {
196
+ entries: snapshot.workspaceNavigationHistory,
197
+ index: snapshot.workspaceNavigationHistoryIndex,
198
+ },
199
+ entry,
200
+ areWorkspaceNavigationEntriesEqual,
201
+ );
202
+ useChatThreadStore.getState().setSnapshot({
203
+ ...patch,
204
+ workspaceNavigationHistory: [...history.entries],
205
+ workspaceNavigationHistoryIndex: history.index,
206
+ });
207
+ };
208
+
137
209
  deleteSession = () => {
138
210
  void this.deleteCurrentSession();
139
211
  };
@@ -155,12 +227,20 @@ export class NcpChatThreadManager {
155
227
  return;
156
228
  }
157
229
  const activeChildSessionKey = params.activeChildSessionKey?.trim() || null;
158
- useChatThreadStore.getState().setSnapshot({
230
+ const patch: Partial<ChatThreadSnapshot> = {
159
231
  workspacePanelParentKey: parentSessionKey,
160
232
  activeWorkspacePanelKind: 'child-session',
161
233
  activeChildSessionKey,
162
234
  activeWorkspaceFileKey: null,
163
- });
235
+ };
236
+ if (activeChildSessionKey) {
237
+ this.setWorkspaceSelection(patch, {
238
+ kind: 'child-session',
239
+ key: activeChildSessionKey,
240
+ });
241
+ } else {
242
+ useChatThreadStore.getState().setSnapshot(patch);
243
+ }
164
244
  this.ensureWorkspaceParentRoute(parentSessionKey);
165
245
  };
166
246
 
@@ -170,12 +250,15 @@ export class NcpChatThreadManager {
170
250
  if (!nextTab) {
171
251
  return;
172
252
  }
173
- useChatThreadStore.getState().setSnapshot({
253
+ this.setWorkspaceSelection({
174
254
  workspacePanelParentKey: parentSessionKey,
175
255
  activeWorkspacePanelKind: 'file',
176
256
  workspaceFileTabs: this.upsertWorkspaceFileTab(nextTab),
177
257
  activeWorkspaceFileKey: nextTab.key,
178
258
  activeChildSessionKey: null,
259
+ }, {
260
+ kind: 'file',
261
+ key: nextTab.key,
179
262
  });
180
263
  this.ensureWorkspaceParentRoute(parentSessionKey);
181
264
  };
@@ -202,6 +285,8 @@ export class NcpChatThreadManager {
202
285
  activeWorkspacePanelKind: null,
203
286
  activeChildSessionKey: null,
204
287
  activeWorkspaceFileKey: null,
288
+ workspaceNavigationHistory: [],
289
+ workspaceNavigationHistoryIndex: 0,
205
290
  });
206
291
  this.uiManager.goToSession(action.sessionId);
207
292
  };
@@ -215,10 +300,13 @@ export class NcpChatThreadManager {
215
300
  if (!childSessionTabs.some((tab) => tab.sessionKey === normalizedSessionKey)) {
216
301
  return;
217
302
  }
218
- useChatThreadStore.getState().setSnapshot({
303
+ this.setWorkspaceSelection({
219
304
  activeChildSessionKey: normalizedSessionKey,
220
305
  activeWorkspaceFileKey: null,
221
306
  activeWorkspacePanelKind: 'child-session',
307
+ }, {
308
+ kind: 'child-session',
309
+ key: normalizedSessionKey,
222
310
  });
223
311
  };
224
312
 
@@ -231,10 +319,13 @@ export class NcpChatThreadManager {
231
319
  if (!workspaceFileTabs.some((tab) => tab.key === normalizedFileKey)) {
232
320
  return;
233
321
  }
234
- useChatThreadStore.getState().setSnapshot({
322
+ this.setWorkspaceSelection({
235
323
  activeWorkspaceFileKey: normalizedFileKey,
236
324
  activeChildSessionKey: null,
237
325
  activeWorkspacePanelKind: 'file',
326
+ }, {
327
+ kind: 'file',
328
+ key: normalizedFileKey,
238
329
  });
239
330
  };
240
331
 
@@ -248,11 +339,31 @@ export class NcpChatThreadManager {
248
339
  const nextTabs = workspaceFileTabs.filter(
249
340
  (tab) => tab.key !== normalizedFileKey,
250
341
  );
342
+ const history = filterNavigationHistoryEntries(
343
+ {
344
+ entries: snapshot.workspaceNavigationHistory,
345
+ index: snapshot.workspaceNavigationHistoryIndex,
346
+ },
347
+ (entry) => entry.kind !== 'file' || entry.key !== normalizedFileKey,
348
+ );
251
349
  const nextPatch: Partial<ChatThreadSnapshot> = {
252
350
  workspaceFileTabs: nextTabs,
351
+ workspaceNavigationHistory: [...history.entries],
352
+ workspaceNavigationHistoryIndex: history.index,
253
353
  };
254
354
  if (activeWorkspaceFileKey === normalizedFileKey) {
255
- nextPatch.activeWorkspaceFileKey = null;
355
+ const nextSnapshot = {
356
+ ...snapshot,
357
+ workspaceFileTabs: nextTabs,
358
+ };
359
+ const restoreEntry = history.entries[history.index];
360
+ const restorePatch = restoreEntry
361
+ ? this.createWorkspaceSelectionPatch(restoreEntry, nextSnapshot)
362
+ : null;
363
+ Object.assign(nextPatch, restorePatch ?? {
364
+ activeWorkspacePanelKind: null,
365
+ activeWorkspaceFileKey: null,
366
+ });
256
367
  }
257
368
  useChatThreadStore.getState().setSnapshot(nextPatch);
258
369
  };
@@ -263,6 +374,8 @@ export class NcpChatThreadManager {
263
374
  activeWorkspacePanelKind: null,
264
375
  activeChildSessionKey: null,
265
376
  activeWorkspaceFileKey: null,
377
+ workspaceNavigationHistory: [],
378
+ workspaceNavigationHistoryIndex: 0,
266
379
  });
267
380
  };
268
381
 
@@ -271,15 +384,48 @@ export class NcpChatThreadManager {
271
384
  if (!parentSessionKey) {
272
385
  return;
273
386
  }
274
- useChatThreadStore.getState().setSnapshot({
387
+ this.setWorkspaceSelection({
275
388
  workspacePanelParentKey: parentSessionKey,
276
389
  activeWorkspacePanelKind: 'cron',
277
390
  activeChildSessionKey: null,
278
391
  activeWorkspaceFileKey: null,
392
+ }, {
393
+ kind: 'cron',
279
394
  });
280
395
  this.ensureWorkspaceParentRoute(parentSessionKey);
281
396
  };
282
397
 
398
+ goBackWorkspacePanel = () => {
399
+ this.restoreWorkspaceNavigationStep('back');
400
+ };
401
+
402
+ goForwardWorkspacePanel = () => {
403
+ this.restoreWorkspaceNavigationStep('forward');
404
+ };
405
+
406
+ private restoreWorkspaceNavigationStep = (direction: 'back' | 'forward') => {
407
+ const { snapshot } = useChatThreadStore.getState();
408
+ const step = stepNavigationHistory(
409
+ {
410
+ entries: snapshot.workspaceNavigationHistory,
411
+ index: snapshot.workspaceNavigationHistoryIndex,
412
+ },
413
+ direction,
414
+ );
415
+ if (!step) {
416
+ return;
417
+ }
418
+ const selectionPatch = this.createWorkspaceSelectionPatch(step.entry, snapshot);
419
+ if (!selectionPatch) {
420
+ return;
421
+ }
422
+ useChatThreadStore.getState().setSnapshot({
423
+ ...selectionPatch,
424
+ workspaceNavigationHistory: [...step.history.entries],
425
+ workspaceNavigationHistoryIndex: step.history.index,
426
+ });
427
+ };
428
+
283
429
  closeChildSessionDetail = () => {
284
430
  this.closeWorkspacePanel();
285
431
  };