@nextclaw/ui 0.14.1 → 0.14.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 (165) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/assets/{api-DPcvCLfa.js → api-a14tR_z1.js} +7 -7
  3. package/dist/assets/channels-list-page-CFGkm4DT.js +8 -0
  4. package/dist/assets/chat-page-CEdavJXc.js +105 -0
  5. package/dist/assets/{config-split-page-BCJhqdK9.js → config-split-page-B5b9B0uE.js} +1 -1
  6. package/dist/assets/{confirm-dialog-CWh5FfK2.js → confirm-dialog-ClH_Ec7n.js} +1 -1
  7. package/dist/assets/{desktop-update-config-DsRhBizi.js → desktop-update-config-DPNDnVpk.js} +1 -1
  8. package/dist/assets/{dist-DPrgcEC0.js → dist-BtCS2Ucq.js} +1 -1
  9. package/dist/assets/{dist-DsE4OsSS.js → dist-CLGGLm8b.js} +1 -1
  10. package/dist/assets/doc-browser-5nMWYKBD.js +1 -0
  11. package/dist/assets/doc-browser-BJRLhztv.js +1 -0
  12. package/dist/assets/doc-browser-context-D4JwMMxK.js +1 -0
  13. package/dist/assets/{ellipsis-B81FStLm.js → ellipsis-CtJXjIol.js} +1 -1
  14. package/dist/assets/{external-link-C_dmG-WJ.js → external-link-CdJiG13p.js} +1 -1
  15. package/dist/assets/index-BDN1zzMQ.js +105 -0
  16. package/dist/assets/index-g3uSZXoc.css +1 -0
  17. package/dist/assets/mcp-marketplace-page-BU0kIjhR.js +40 -0
  18. package/dist/assets/mcp-marketplace-page-sMNFSq_T.js +1 -0
  19. package/dist/assets/middleware-CkFT3R4v.js +1 -0
  20. package/dist/assets/model-config-page-D6hYXROd.js +1 -0
  21. package/dist/assets/plus-DPjb0piO.js +1 -0
  22. package/dist/assets/provider-scoped-model-input-DAX2HgmR.js +1 -0
  23. package/dist/assets/providers-config-page-BzAeaACA.js +1 -0
  24. package/dist/assets/{react-sZz5HSCm.js → react-C6cZgP44.js} +1 -1
  25. package/dist/assets/remote-B0G80uPr.js +1 -0
  26. package/dist/assets/rotate-cw-DnEB1Rju.js +1 -0
  27. package/dist/assets/runtime-config-page-C5xKaV3i.js +1 -0
  28. package/dist/assets/{save-MzjoiHri.js → save-DKA0x4zk.js} +1 -1
  29. package/dist/assets/{search-B29dE4GN.js → search-BNlcoj5c.js} +1 -1
  30. package/dist/assets/{search-config-page-Dl5sZzob.js → search-config-page-Cq9uJ-N8.js} +1 -1
  31. package/dist/assets/{secrets-config-page-C_l-IFCK.js → secrets-config-page-CxSbZLBo.js} +2 -2
  32. package/dist/assets/{select-BKmh05hd.js → select-CvTlDZTQ.js} +1 -1
  33. package/dist/assets/{tag-chip-BS_7Ueom.js → tag-chip-8ffF2y2C.js} +1 -1
  34. package/dist/assets/use-config-DmT0o1gv.js +1 -0
  35. package/dist/assets/x-BiSSpgqB.js +1 -0
  36. package/dist/index.html +15 -14
  37. package/package.json +9 -9
  38. package/src/app/components/app-presenter-provider.tsx +11 -2
  39. package/src/app/components/layout/__tests__/app-layout.test.tsx +11 -2
  40. package/src/app/components/layout/__tests__/sidebar.layout.test.tsx +110 -79
  41. package/src/app/components/layout/app-layout.tsx +18 -4
  42. package/src/app/components/layout/sidebar-items.tsx +194 -71
  43. package/src/app/components/layout/sidebar-rail.styles.ts +15 -0
  44. package/src/app/components/layout/sidebar.tsx +340 -83
  45. package/src/app/managers/viewport-layout.manager.ts +10 -0
  46. package/src/app/stores/viewport-layout.store.ts +39 -3
  47. package/src/features/agents/components/__tests__/agents-page.test.tsx +62 -39
  48. package/src/features/agents/components/agents-page.tsx +10 -6
  49. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +145 -0
  50. package/src/features/chat/components/conversation/__tests__/chat-conversation-panel.test.tsx +69 -762
  51. package/src/features/chat/components/conversation/__tests__/chat-conversation-workspace-section.test.tsx +132 -0
  52. package/src/features/chat/components/conversation/chat-conversation-content.tsx +22 -15
  53. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +14 -15
  54. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +18 -18
  55. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +10 -4
  56. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +52 -11
  57. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +16 -14
  58. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +247 -0
  59. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +80 -7
  60. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +82 -25
  61. package/src/features/chat/components/layout/chat-sidebar.tsx +59 -92
  62. package/src/features/chat/components/providers/chat-presenter.provider.tsx +0 -6
  63. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +289 -0
  64. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +427 -0
  65. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +204 -0
  66. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-attachments.ts +66 -0
  67. package/src/features/chat/features/{input/hooks/use-chat-input-bar-query-state.ts → conversation/hooks/use-session-conversation-input-query.ts} +32 -17
  68. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-state.ts +281 -0
  69. package/src/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils.ts +158 -0
  70. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +56 -0
  71. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +117 -0
  72. package/src/features/chat/features/input/input-surface-plugins/__tests__/panel-app-reference-plugin.test.ts +77 -0
  73. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +108 -0
  74. package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +10 -0
  75. package/src/features/chat/features/input/input-surface-plugins/input-surface-search.utils.ts +108 -0
  76. package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +108 -0
  77. package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +161 -0
  78. package/src/features/chat/features/input/utils/__tests__/chat-composer-state.utils.test.ts +22 -1
  79. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +26 -13
  80. package/src/features/chat/features/input/utils/__tests__/ncp-chat-input-availability.utils.test.ts +7 -28
  81. package/src/features/chat/features/input/utils/chat-composer-state.utils.ts +6 -0
  82. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +26 -51
  83. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +2 -0
  84. package/src/features/chat/features/input/utils/ncp-chat-input-availability.utils.ts +5 -6
  85. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +47 -12
  86. package/src/features/chat/features/message/components/chat-message-list.container.tsx +93 -12
  87. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +31 -12
  88. package/src/features/chat/features/message/utils/chat-message-markdown-part.utils.ts +38 -0
  89. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +1 -1
  90. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +2 -2
  91. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +2 -2
  92. package/src/features/chat/features/session/hooks/__tests__/use-chat-session-project.test.tsx +3 -30
  93. package/src/features/chat/features/session/hooks/use-chat-session-project.ts +0 -5
  94. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +8 -0
  95. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-menu.test.tsx +2 -2
  96. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-option-item.test.tsx +2 -0
  97. package/src/features/chat/features/session-type/components/chat-session-type-menu.tsx +2 -2
  98. package/src/features/chat/features/session-type/components/chat-session-type-option-item.tsx +2 -2
  99. package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +49 -31
  100. package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +2 -2
  101. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +21 -10
  102. package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +2 -2
  103. package/src/features/chat/features/welcome/components/chat-welcome.tsx +2 -2
  104. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +54 -0
  105. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +44 -76
  106. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-nav.tsx +5 -1
  107. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +7 -1
  108. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +40 -31
  109. package/src/features/chat/features/workspace/utils/__tests__/chat-workspace-panel-view-model.utils.test.ts +59 -1
  110. package/src/features/chat/features/workspace/utils/chat-thread-workspace-session.utils.ts +109 -0
  111. package/src/features/chat/features/workspace/utils/chat-workspace-panel-view-model.utils.ts +44 -4
  112. package/src/features/chat/index.ts +0 -1
  113. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +51 -99
  114. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +135 -21
  115. package/src/features/chat/managers/chat-session-list.manager.ts +12 -43
  116. package/src/features/chat/managers/chat-thread.manager.ts +108 -34
  117. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +48 -1
  118. package/src/features/chat/pages/__tests__/ncp-chat-page.test.tsx +72 -0
  119. package/src/features/chat/pages/ncp-chat-page.tsx +0 -122
  120. package/src/features/chat/presenters/chat.presenter.ts +2 -19
  121. package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +31 -0
  122. package/src/features/chat/stores/chat-thread.store.ts +42 -26
  123. package/src/features/panel-apps/hooks/use-panel-apps.ts +2 -1
  124. package/src/platforms/desktop/components/__tests__/desktop-app-shell.test.tsx +50 -10
  125. package/src/platforms/desktop/components/desktop-app-shell.tsx +34 -10
  126. package/src/platforms/desktop/components/desktop-window-chrome.tsx +41 -16
  127. package/src/shared/components/ui/tab-strip/__tests__/compact-tab-strip.test.tsx +24 -1
  128. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +8 -4
  129. package/src/shared/lib/i18n/locales/en-US/chat.json +20 -2
  130. package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
  131. package/src/shared/lib/i18n/locales/zh-CN/chat.json +20 -2
  132. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
  133. package/src/shared/lib/ui-document-title/__tests__/ui-document-title.test.ts +49 -0
  134. package/src/shared/lib/ui-document-title/index.ts +50 -21
  135. package/dist/assets/channels-list-page-D6jtziu4.js +0 -8
  136. package/dist/assets/chat-page-BBnNJoKG.js +0 -105
  137. package/dist/assets/doc-browser-C8xOF_9F.js +0 -1
  138. package/dist/assets/doc-browser-DgQedeAn.js +0 -1
  139. package/dist/assets/doc-browser-context-B46tQGmO.js +0 -1
  140. package/dist/assets/index-7o1WdUbU.js +0 -103
  141. package/dist/assets/index-DQWmEWzk.css +0 -1
  142. package/dist/assets/mcp-marketplace-page-CWZN7ohK.js +0 -1
  143. package/dist/assets/mcp-marketplace-page-Dzull_4-.js +0 -40
  144. package/dist/assets/model-config-page-Cv0O3BIj.js +0 -1
  145. package/dist/assets/plus-Bnt56oqG.js +0 -1
  146. package/dist/assets/provider-scoped-model-input-Duuh7i3h.js +0 -1
  147. package/dist/assets/providers-config-page-CPDz0Lxc.js +0 -1
  148. package/dist/assets/remote-B0LMs8u7.js +0 -1
  149. package/dist/assets/rotate-cw-BfyG_KEF.js +0 -1
  150. package/dist/assets/runtime-config-page-q0fvv8ME.js +0 -1
  151. package/dist/assets/use-config-DZkBWIdL.js +0 -1
  152. package/dist/assets/x-CW2KO-Hz.js +0 -1
  153. package/src/features/chat/components/conversation/chat-conversation-alerts.tsx +0 -37
  154. package/src/features/chat/features/input/components/__tests__/chat-attachment-upload-limit.test.ts +0 -38
  155. package/src/features/chat/features/input/components/chat-input-bar.container.tsx +0 -496
  156. package/src/features/chat/features/message/utils/chat-message-inline-content.utils.ts +0 -95
  157. package/src/features/chat/managers/__tests__/chat-input.manager.test.ts +0 -448
  158. package/src/features/chat/managers/__tests__/chat-run-snapshot.manager.test.ts +0 -129
  159. package/src/features/chat/managers/__tests__/chat-run.manager.test.ts +0 -236
  160. package/src/features/chat/managers/__tests__/chat-session-preference-sync.manager.test.ts +0 -219
  161. package/src/features/chat/managers/chat-input.manager.ts +0 -430
  162. package/src/features/chat/managers/chat-run.manager.ts +0 -110
  163. package/src/features/chat/managers/chat-session-preference-sync.manager.ts +0 -142
  164. package/src/features/chat/stores/chat-input.store.ts +0 -104
  165. /package/dist/assets/{config-hints-CTqBnCDp.js → config-hints-BkYBc7z0.js} +0 -0
@@ -1,8 +1,10 @@
1
- import { render, screen, within } from '@testing-library/react';
2
- import { MemoryRouter } from 'react-router-dom';
3
- import { describe, expect, it, vi } from 'vitest';
4
- import { Sidebar } from '@/app/components/layout/sidebar';
5
- import type * as RemoteFeature from '@/features/remote';
1
+ import { render, screen, within } from "@testing-library/react";
2
+ import { fireEvent } from "@testing-library/react";
3
+ import { MemoryRouter } from "react-router-dom";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { Sidebar } from "@/app/components/layout/sidebar";
6
+ import { viewportLayoutManager } from "@/app/managers/viewport-layout.manager";
7
+ import type * as RemoteFeature from "@/features/remote";
6
8
 
7
9
  const mocks = vi.hoisted(() => ({
8
10
  openAccountPanel: vi.fn(),
@@ -11,133 +13,162 @@ const mocks = vi.hoisted(() => ({
11
13
  data: {
12
14
  account: {
13
15
  loggedIn: true,
14
- email: 'user@example.com'
15
- }
16
- }
17
- }
16
+ email: "user@example.com",
17
+ },
18
+ },
19
+ },
18
20
  }));
19
21
 
20
- vi.mock('@/shared/components/doc-browser', () => ({
22
+ vi.mock("@/shared/components/doc-browser", () => ({
21
23
  useDocBrowser: () => ({
22
- open: mocks.docOpen
23
- })
24
+ open: mocks.docOpen,
25
+ }),
24
26
  }));
25
27
 
26
- vi.mock('@/app/components/app-presenter-provider', () => ({
28
+ vi.mock("@/app/components/app-presenter-provider", () => ({
27
29
  useAppPresenter: () => ({
28
30
  accountManager: {
29
- openAccountPanel: mocks.openAccountPanel
30
- }
31
- })
31
+ openAccountPanel: mocks.openAccountPanel,
32
+ },
33
+ }),
32
34
  }));
33
35
 
34
- vi.mock('@/features/remote', async () => {
35
- const actual = await vi.importActual<typeof RemoteFeature>('@/features/remote');
36
+ vi.mock("@/features/remote", async () => {
37
+ const actual =
38
+ await vi.importActual<typeof RemoteFeature>("@/features/remote");
36
39
  return {
37
40
  ...actual,
38
- useRemoteStatus: () => mocks.remoteStatus
41
+ useRemoteStatus: () => mocks.remoteStatus,
39
42
  };
40
43
  });
41
44
 
42
- vi.mock('@/app/components/i18n-provider', () => ({
45
+ vi.mock("@/app/components/i18n-provider", () => ({
43
46
  useI18n: () => ({
44
- language: 'en',
45
- setLanguage: vi.fn()
46
- })
47
+ language: "en",
48
+ setLanguage: vi.fn(),
49
+ }),
47
50
  }));
48
51
 
49
- vi.mock('@/app/components/theme-provider', () => ({
52
+ vi.mock("@/app/components/theme-provider", () => ({
50
53
  useTheme: () => ({
51
- theme: 'warm',
52
- setTheme: vi.fn()
53
- })
54
+ theme: "warm",
55
+ setTheme: vi.fn(),
56
+ }),
54
57
  }));
55
58
 
56
- describe('Sidebar', () => {
57
- it('keeps the settings sidebar bounded and lets the navigation scroll independently', () => {
59
+ describe("Sidebar", () => {
60
+ beforeEach(() => {
61
+ window.localStorage.clear();
62
+ viewportLayoutManager.resetForTests();
63
+ });
64
+
65
+ it("keeps the settings sidebar bounded and lets the navigation scroll independently", () => {
58
66
  const { container } = render(
59
- <MemoryRouter initialEntries={['/model']}>
67
+ <MemoryRouter initialEntries={["/model"]}>
60
68
  <Sidebar mode="settings" />
61
- </MemoryRouter>
69
+ </MemoryRouter>,
62
70
  );
63
71
 
64
- const aside = container.querySelector('aside');
65
- const nav = container.querySelector('nav');
66
-
67
- expect(aside?.className).toContain('min-h-0');
68
- expect(aside?.className).toContain('overflow-hidden');
69
- expect(screen.getByTestId('settings-sidebar-header').parentElement?.className).toContain('py-2.5');
70
- expect(aside?.className).not.toContain('py-6');
71
- expect(nav?.className).toContain('flex-1');
72
- expect(nav?.className).toContain('min-h-0');
73
- expect(nav?.className).toContain('overflow-y-auto');
74
- expect(screen.getByRole('link', { current: 'page' }).className).not.toContain('font-semibold');
72
+ const aside = container.querySelector("aside");
73
+ const nav = container.querySelector("nav");
74
+
75
+ expect(aside?.className).toContain("min-h-0");
76
+ expect(aside?.className).toContain("overflow-hidden");
77
+ expect(
78
+ screen.getByTestId("settings-sidebar-header").parentElement?.className,
79
+ ).toMatch(/(?:^|\s)py-2(?:\s|$)/);
80
+ expect(aside?.className).not.toContain("py-6");
81
+ expect(nav?.className).toContain("flex-1");
82
+ expect(nav?.className).toContain("min-h-0");
83
+ expect(nav?.className).toContain("overflow-y-auto");
84
+ expect(
85
+ screen.getByRole("link", { current: "page" }).className,
86
+ ).not.toContain("font-semibold");
75
87
  });
76
88
 
77
- it('keeps the original compact single-row header in settings mode', () => {
89
+ it("keeps the original compact single-row header in settings mode", () => {
78
90
  render(
79
- <MemoryRouter initialEntries={['/model']}>
91
+ <MemoryRouter initialEntries={["/model"]}>
80
92
  <Sidebar mode="settings" />
81
- </MemoryRouter>
93
+ </MemoryRouter>,
82
94
  );
83
95
 
84
- const header = screen.getByTestId('settings-sidebar-header');
85
- const backLink = screen.getByRole('link', { name: 'Back to Main' });
96
+ const header = screen.getByTestId("settings-sidebar-header");
97
+ const backLink = screen.getByRole("link", { name: "Back to Main" });
86
98
 
87
99
  expect(header).toBeTruthy();
88
- expect(screen.getByRole('heading', { name: 'Settings' })).toBeTruthy();
100
+ expect(screen.getByRole("heading", { name: "Settings" })).toBeTruthy();
89
101
  expect(backLink).toBeTruthy();
90
- expect(header.className).not.toContain('bg-white');
91
- expect(header.className).not.toContain('rounded-2xl');
92
- expect(backLink.className).toContain('hover:bg-gray-200/60');
102
+ expect(header.className).not.toContain("bg-white");
103
+ expect(header.className).not.toContain("rounded-2xl");
104
+ expect(backLink.className).toContain("hover:bg-gray-200/60");
93
105
  });
94
106
 
95
- it('keeps the settings navigation in the expected product order', () => {
107
+ it("keeps the settings navigation in the expected product order", () => {
96
108
  const { container } = render(
97
- <MemoryRouter initialEntries={['/model']}>
109
+ <MemoryRouter initialEntries={["/model"]}>
98
110
  <Sidebar mode="settings" />
99
- </MemoryRouter>
111
+ </MemoryRouter>,
100
112
  );
101
113
 
102
- const nav = container.querySelector('nav');
114
+ const nav = container.querySelector("nav");
103
115
  if (!(nav instanceof HTMLElement)) {
104
- throw new Error('settings nav not found');
116
+ throw new Error("settings nav not found");
105
117
  }
106
118
 
107
119
  const linkTexts = within(nav)
108
- .getAllByRole('link')
109
- .map((link) => link.textContent?.trim() || '');
120
+ .getAllByRole("link")
121
+ .map((link) => link.textContent?.trim() || "");
110
122
 
111
123
  expect(linkTexts).toEqual([
112
- 'Model',
113
- 'Providers',
114
- 'Channels',
115
- 'Search Channels',
116
- 'Routing & Runtime',
117
- 'Updates',
118
- 'Remote Access',
119
- 'Security',
120
- 'Secrets',
121
- 'MCP'
124
+ "Model",
125
+ "Providers",
126
+ "Channels",
127
+ "Search Channels",
128
+ "Routing & Runtime",
129
+ "Updates",
130
+ "Remote Access",
131
+ "Security",
132
+ "Secrets",
133
+ "MCP",
122
134
  ]);
123
135
  });
124
136
 
125
- it('keeps the footer utilities compact without changing the top header structure', () => {
137
+ it("keeps the footer utilities compact without changing the top header structure", () => {
126
138
  render(
127
- <MemoryRouter initialEntries={['/model']}>
139
+ <MemoryRouter initialEntries={["/model"]}>
128
140
  <Sidebar mode="settings" />
129
- </MemoryRouter>
141
+ </MemoryRouter>,
130
142
  );
131
143
 
132
- const accountEntry = screen.getByTestId('settings-sidebar-account-entry');
133
- const accountStatus = screen.getByTestId('settings-sidebar-account-status');
144
+ const accountEntry = screen.getByTestId("settings-sidebar-account-entry");
145
+ const accountStatus = screen.getByTestId("settings-sidebar-account-status");
134
146
 
135
147
  expect(accountEntry).toBeTruthy();
136
- expect(accountEntry.textContent).toContain('Account');
137
- expect(screen.getByText('user@example.com')).toBeTruthy();
138
- expect(accountEntry.className).toContain('py-2');
139
- expect(accountEntry.className).toContain('text-gray-600');
140
- expect(accountEntry.className).toContain('hover:bg-gray-200/60');
141
- expect(accountStatus.className).toContain('text-[11px]');
148
+ expect(accountEntry.textContent).toContain("Account");
149
+ expect(screen.getByText("user@example.com")).toBeTruthy();
150
+ expect(accountEntry.className).toContain("py-2");
151
+ expect(accountEntry.className).toContain("text-gray-600");
152
+ expect(accountEntry.className).toContain("hover:bg-gray-200/60");
153
+ expect(accountStatus.className).toContain("text-[11px]");
154
+ });
155
+
156
+ it("persists the shared sidebar collapsed state through the viewport layout store", () => {
157
+ const { container } = render(
158
+ <MemoryRouter initialEntries={["/model"]}>
159
+ <Sidebar mode="settings" />
160
+ </MemoryRouter>,
161
+ );
162
+
163
+ fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
164
+
165
+ const aside = container.querySelector("aside");
166
+ const persistedState = JSON.parse(
167
+ window.localStorage.getItem("nextclaw.app.viewport-layout") ?? "{}",
168
+ ) as { state?: { isSidebarCollapsed?: boolean } };
169
+
170
+ expect(aside?.getAttribute("data-sidebar-collapsed")).toBe("true");
171
+ expect(persistedState.state?.isSidebarCollapsed).toBe(true);
172
+ expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeTruthy();
142
173
  });
143
174
  });
@@ -10,7 +10,11 @@ import { useViewportLayout } from "@/app/hooks/use-viewport-layout";
10
10
  import { DesktopAppShell, getDesktopHostPlatform } from "@/platforms/desktop";
11
11
  import { MobileAppShell } from "@/platforms/mobile";
12
12
  import { PANEL_APPS_DOC_BROWSER_RENDERERS } from "@/features/panel-apps";
13
- import { SideDock, type SideDockManager, useSideDockStore } from "@/features/side-dock";
13
+ import {
14
+ SideDock,
15
+ type SideDockManager,
16
+ useSideDockStore,
17
+ } from "@/features/side-dock";
14
18
  import { getPresenter } from "@/app/presenters/app.presenter";
15
19
  import { resolveUiDocumentTitle } from "@/shared/lib/ui-document-title";
16
20
  import type { DocBrowserDockControls } from "@/shared/components/doc-browser/doc-browser-context";
@@ -37,12 +41,20 @@ function AppLayoutInner({
37
41
  };
38
42
 
39
43
  useEffect(() => {
40
- document.title = resolveUiDocumentTitle(pathname);
44
+ document.title = resolveUiDocumentTitle(pathname, window.location);
41
45
  }, [pathname, language]);
42
46
 
43
47
  if (isMobile && desktopHostPlatform !== "win32") {
44
48
  return (
45
- <MobileAppShell pathname={pathname} isDocBrowserOpen={isOpen} docBrowserDockControls={docBrowserDockControls} docBrowserRenderers={PANEL_APPS_DOC_BROWSER_RENDERERS} topbarLeadingInset={desktopHostPlatform === "darwin" ? "4.75rem" : undefined}>
49
+ <MobileAppShell
50
+ pathname={pathname}
51
+ isDocBrowserOpen={isOpen}
52
+ docBrowserDockControls={docBrowserDockControls}
53
+ docBrowserRenderers={PANEL_APPS_DOC_BROWSER_RENDERERS}
54
+ topbarLeadingInset={
55
+ desktopHostPlatform === "darwin" ? "4.75rem" : undefined
56
+ }
57
+ >
46
58
  {children}
47
59
  </MobileAppShell>
48
60
  );
@@ -68,7 +80,9 @@ export function AppLayout({ children }: AppLayoutProps) {
68
80
 
69
81
  return (
70
82
  <DocBrowserProvider manager={presenter.docBrowserManager}>
71
- <AppLayoutInner sideDockManager={presenter.sideDockManager}>{children}</AppLayoutInner>
83
+ <AppLayoutInner sideDockManager={presenter.sideDockManager}>
84
+ {children}
85
+ </AppLayoutInner>
72
86
  </DocBrowserProvider>
73
87
  );
74
88
  }
@@ -1,10 +1,26 @@
1
- import type { ComponentType, ReactNode } from 'react';
2
- import { NavLink } from 'react-router-dom';
3
- import { Select, SelectContent, SelectTrigger } from '@/shared/components/ui/select';
4
- import { cn } from '@/shared/lib/utils';
1
+ import type { ComponentType, ReactNode } from "react";
2
+ import { Link, useLocation } from "react-router-dom";
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectTrigger,
7
+ } from "@/shared/components/ui/select";
8
+ import {
9
+ Tooltip,
10
+ TooltipContent,
11
+ TooltipProvider,
12
+ TooltipTrigger,
13
+ } from "@/shared/components/ui/tooltip";
14
+ import { cn } from "@/shared/lib/utils";
15
+ import {
16
+ SIDEBAR_RAIL_ACTIVE_SURFACE_CLASS,
17
+ SIDEBAR_RAIL_CONTROL_CLASS,
18
+ SIDEBAR_RAIL_ICON_CLASS,
19
+ SIDEBAR_RAIL_SURFACE_CLASS,
20
+ } from "@/app/components/layout/sidebar-rail.styles";
5
21
 
6
22
  type SidebarIcon = ComponentType<{ className?: string }>;
7
- type SidebarItemDensity = 'default' | 'compact';
23
+ type SidebarItemDensity = "default" | "compact";
8
24
 
9
25
  type SidebarItemTone = {
10
26
  row: string;
@@ -15,72 +31,117 @@ type SidebarItemTone = {
15
31
 
16
32
  const SIDEBAR_ITEM_TONES: Record<SidebarItemDensity, SidebarItemTone> = {
17
33
  default: {
18
- row: 'gap-3 px-3 py-2.5 text-[14px]',
19
- icon: 'h-[17px] w-[17px]',
20
- value: 'text-xs',
21
- gap: 'gap-3'
34
+ row: "gap-3 px-3 py-2.5 text-[14px]",
35
+ icon: "h-[17px] w-[17px]",
36
+ value: "text-xs",
37
+ gap: "gap-3",
22
38
  },
23
39
  compact: {
24
- row: 'gap-2.5 px-3 py-2 text-[13px]',
25
- icon: 'h-4 w-4',
26
- value: 'text-[11px]',
27
- gap: 'gap-2.5'
28
- }
40
+ row: "gap-2.5 px-3 py-2 text-[13px]",
41
+ icon: "h-4 w-4",
42
+ value: "text-[11px]",
43
+ gap: "gap-2.5",
44
+ },
29
45
  };
30
46
 
31
47
  function getSidebarItemTone(density: SidebarItemDensity): SidebarItemTone {
32
48
  return SIDEBAR_ITEM_TONES[density];
33
49
  }
34
50
 
51
+ function isSidebarRouteActive(pathname: string, target: string): boolean {
52
+ const normalizedPathname = pathname.toLowerCase();
53
+ const normalizedTarget = target.toLowerCase();
54
+ return (
55
+ normalizedPathname === normalizedTarget ||
56
+ normalizedPathname.startsWith(`${normalizedTarget}/`)
57
+ );
58
+ }
59
+
60
+ function SidebarItemTooltip({
61
+ children,
62
+ label,
63
+ side = "right",
64
+ }: {
65
+ children: ReactNode;
66
+ label: ReactNode;
67
+ side?: "right" | "bottom";
68
+ }) {
69
+ return (
70
+ <TooltipProvider delayDuration={250}>
71
+ <Tooltip>
72
+ <TooltipTrigger asChild>{children}</TooltipTrigger>
73
+ <TooltipContent side={side} className="text-xs">
74
+ {label}
75
+ </TooltipContent>
76
+ </Tooltip>
77
+ </TooltipProvider>
78
+ );
79
+ }
80
+
35
81
  type SidebarNavLinkItemProps = {
36
82
  to: string;
37
- label: ReactNode;
83
+ label: string;
38
84
  icon: SidebarIcon;
39
85
  density?: SidebarItemDensity;
40
86
  className?: string;
87
+ collapsed?: boolean;
41
88
  };
42
89
 
43
90
  export function SidebarNavLinkItem({
44
91
  to,
45
92
  label,
46
93
  icon: Icon,
47
- density = 'default',
48
- className
94
+ density = "default",
95
+ className,
96
+ collapsed = false,
49
97
  }: SidebarNavLinkItemProps) {
50
98
  const tone = getSidebarItemTone(density);
51
-
52
- return (
53
- <NavLink
99
+ const { pathname } = useLocation();
100
+ const isActive = isSidebarRouteActive(pathname, to);
101
+ const link = (
102
+ <Link
54
103
  to={to}
55
- className={({ isActive }) =>
56
- cn(
57
- 'group flex w-full items-center rounded-xl font-medium transition-colors duration-base',
58
- tone.row,
59
- isActive
60
- ? 'bg-gray-200 text-gray-900 shadow-sm'
61
- : 'text-gray-600 hover:bg-gray-200/60 hover:text-gray-900',
62
- className
63
- )
64
- }
65
- >
66
- {({ isActive }) => (
67
- <>
68
- <Icon
69
- className={cn(
70
- tone.icon,
71
- 'transition-colors',
72
- isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800'
73
- )}
74
- />
75
- <span className="min-w-0 flex-1 text-left">{label}</span>
76
- </>
104
+ aria-label={label}
105
+ aria-current={isActive ? "page" : undefined}
106
+ className={cn(
107
+ "group flex w-full items-center rounded-xl font-medium transition-colors duration-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35",
108
+ collapsed
109
+ ? cn(SIDEBAR_RAIL_CONTROL_CLASS, "justify-center px-0 py-0")
110
+ : tone.row,
111
+ isActive
112
+ ? collapsed
113
+ ? SIDEBAR_RAIL_ACTIVE_SURFACE_CLASS
114
+ : "bg-gray-200 text-gray-900 shadow-sm"
115
+ : collapsed
116
+ ? SIDEBAR_RAIL_SURFACE_CLASS
117
+ : "text-gray-600 hover:bg-gray-200/60 hover:text-gray-900",
118
+ className,
77
119
  )}
78
- </NavLink>
120
+ >
121
+ <Icon
122
+ className={cn(
123
+ collapsed ? SIDEBAR_RAIL_ICON_CLASS : tone.icon,
124
+ "shrink-0 transition-colors",
125
+ isActive
126
+ ? "text-gray-900"
127
+ : "text-gray-500 group-hover:text-gray-800",
128
+ )}
129
+ />
130
+ <span className={collapsed ? "sr-only" : "min-w-0 flex-1 text-left"}>
131
+ {label}
132
+ </span>
133
+ </Link>
134
+ );
135
+
136
+ return collapsed ? (
137
+ <SidebarItemTooltip label={label}>{link}</SidebarItemTooltip>
138
+ ) : (
139
+ link
79
140
  );
80
141
  }
81
142
 
82
143
  type SidebarActionItemProps = {
83
- label: ReactNode;
144
+ label: string;
84
145
  icon: SidebarIcon;
85
146
  onClick: () => void;
86
147
  density?: SidebarItemDensity;
@@ -90,38 +151,62 @@ type SidebarActionItemProps = {
90
151
  trailingClassName?: string;
91
152
  testId?: string;
92
153
  trailingTestId?: string;
154
+ collapsed?: boolean;
93
155
  };
94
156
 
95
157
  export function SidebarActionItem({
96
158
  label,
97
159
  icon: Icon,
98
160
  onClick,
99
- density = 'default',
161
+ density = "default",
100
162
  className,
101
163
  labelClassName,
102
164
  trailing,
103
165
  trailingClassName,
104
166
  testId,
105
- trailingTestId
167
+ trailingTestId,
168
+ collapsed = false,
106
169
  }: SidebarActionItemProps) {
107
170
  const tone = getSidebarItemTone(density);
108
-
109
- return (
171
+ const button = (
110
172
  <button
111
173
  type="button"
112
174
  onClick={onClick}
175
+ aria-label={label}
113
176
  className={cn(
114
- 'flex w-full items-center rounded-xl font-medium text-gray-600 transition-all duration-base hover:bg-gray-200/60 hover:text-gray-800',
115
- tone.row,
116
- className
177
+ "group flex w-full items-center rounded-xl font-medium text-gray-600 transition-all duration-base hover:bg-gray-200/60 hover:text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35",
178
+ collapsed
179
+ ? cn(
180
+ SIDEBAR_RAIL_CONTROL_CLASS,
181
+ "justify-center px-0 py-0",
182
+ SIDEBAR_RAIL_SURFACE_CLASS,
183
+ )
184
+ : tone.row,
185
+ className,
117
186
  )}
118
187
  data-testid={testId}
119
188
  >
120
- <Icon className={cn(tone.icon, 'shrink-0 text-gray-400')} />
121
- <span className={cn('min-w-0 flex-1 text-left', labelClassName)}>{label}</span>
122
- {trailing ? (
189
+ <Icon
190
+ className={cn(
191
+ collapsed ? SIDEBAR_RAIL_ICON_CLASS : tone.icon,
192
+ "shrink-0 text-gray-500 transition-colors group-hover:text-gray-800",
193
+ )}
194
+ />
195
+ <span
196
+ className={cn(
197
+ collapsed ? "sr-only" : "min-w-0 flex-1 text-left",
198
+ labelClassName,
199
+ )}
200
+ >
201
+ {label}
202
+ </span>
203
+ {!collapsed && trailing ? (
123
204
  <span
124
- className={cn('shrink-0 text-gray-500', tone.value, trailingClassName)}
205
+ className={cn(
206
+ "shrink-0 text-gray-500",
207
+ tone.value,
208
+ trailingClassName,
209
+ )}
125
210
  data-testid={trailingTestId}
126
211
  >
127
212
  {trailing}
@@ -129,16 +214,23 @@ export function SidebarActionItem({
129
214
  ) : null}
130
215
  </button>
131
216
  );
217
+
218
+ return collapsed ? (
219
+ <SidebarItemTooltip label={label}>{button}</SidebarItemTooltip>
220
+ ) : (
221
+ button
222
+ );
132
223
  }
133
224
 
134
225
  type SidebarSelectItemProps = {
135
- label: ReactNode;
226
+ label: string;
136
227
  icon: SidebarIcon;
137
228
  value: string;
138
- valueLabel: ReactNode;
229
+ valueLabel: string;
139
230
  onValueChange: (value: string) => void;
140
231
  density?: SidebarItemDensity;
141
232
  children: ReactNode;
233
+ collapsed?: boolean;
142
234
  };
143
235
 
144
236
  export function SidebarSelectItem({
@@ -147,25 +239,56 @@ export function SidebarSelectItem({
147
239
  value,
148
240
  valueLabel,
149
241
  onValueChange,
150
- density = 'default',
151
- children
242
+ density = "default",
243
+ children,
244
+ collapsed = false,
152
245
  }: SidebarSelectItemProps) {
153
246
  const tone = getSidebarItemTone(density);
154
-
155
- return (
156
- <Select value={value} onValueChange={onValueChange}>
157
- <SelectTrigger
247
+ const trigger = (
248
+ <SelectTrigger
249
+ aria-label={label}
250
+ className={cn(
251
+ "group h-auto w-full rounded-xl border-0 bg-transparent font-medium text-gray-600 shadow-none hover:bg-gray-200/60 focus:ring-0 focus-visible:ring-2 focus-visible:ring-primary/35",
252
+ collapsed
253
+ ? cn(
254
+ SIDEBAR_RAIL_CONTROL_CLASS,
255
+ "justify-center px-0 py-0",
256
+ SIDEBAR_RAIL_SURFACE_CLASS,
257
+ )
258
+ : tone.row,
259
+ )}
260
+ >
261
+ <div
158
262
  className={cn(
159
- 'h-auto w-full rounded-xl border-0 bg-transparent font-medium text-gray-600 shadow-none hover:bg-gray-200/60 focus:ring-0',
160
- tone.row
263
+ "flex min-w-0 items-center",
264
+ collapsed ? "justify-center" : tone.gap,
161
265
  )}
162
266
  >
163
- <div className={cn('flex min-w-0 items-center', tone.gap)}>
164
- <Icon className={cn(tone.icon, 'text-gray-400')} />
165
- <span className="text-left">{label}</span>
166
- </div>
167
- <span className={cn('ml-auto text-gray-500', tone.value)}>{valueLabel}</span>
168
- </SelectTrigger>
267
+ <Icon
268
+ className={cn(
269
+ collapsed ? SIDEBAR_RAIL_ICON_CLASS : tone.icon,
270
+ "shrink-0 text-gray-500 transition-colors group-hover:text-gray-800",
271
+ )}
272
+ />
273
+ <span className={collapsed ? "sr-only" : "text-left"}>{label}</span>
274
+ </div>
275
+ {!collapsed ? (
276
+ <span className={cn("ml-auto text-gray-500", tone.value)}>
277
+ {valueLabel}
278
+ </span>
279
+ ) : null}
280
+ </SelectTrigger>
281
+ );
282
+
283
+ return (
284
+ <Select value={value} onValueChange={onValueChange}>
285
+ {collapsed ? (
286
+ <SidebarItemTooltip label={`${label}: ${valueLabel}`}>
287
+ {trigger}
288
+ </SidebarItemTooltip>
289
+ ) : (
290
+ trigger
291
+ )}
169
292
  <SelectContent>{children}</SelectContent>
170
293
  </Select>
171
294
  );