@nextclaw/ui 0.23.0 → 0.24.0-beta.1

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 (123) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/assets/api-BqVIq0lF.js +13 -0
  3. package/dist/assets/{appearance-settings-page-C_AqxSF_.js → appearance-settings-page-CTWn1eAT.js} +1 -1
  4. package/dist/assets/{book-open-DYRapW_h.js → book-open-o2x7OLiO.js} +1 -1
  5. package/dist/assets/{channels-list-page-uR-M4Ls8.js → channels-list-page-Bzn-pzyJ.js} +2 -2
  6. package/dist/assets/{config-split-page-DJDp86Ne.js → config-split-page-CnRbXXlh.js} +1 -1
  7. package/dist/assets/{confirm-dialog-CHVBMpla.js → confirm-dialog-B_Hbcra2.js} +1 -1
  8. package/dist/assets/{createLucideIcon-CgM0rnsa.js → createLucideIcon-rwwpqyks.js} +1 -1
  9. package/dist/assets/{desktop-capabilities-page-phMuNq1B.js → desktop-capabilities-page-C2ayYQTr.js} +1 -1
  10. package/dist/assets/desktop-update-config-C13czpyL.js +1 -0
  11. package/dist/assets/{dist-N9zc5bkM.js → dist-BsF3hfOi.js} +1 -1
  12. package/dist/assets/{dist-BjYvUhPL.js → dist-aNN3iQxV.js} +1 -1
  13. package/dist/assets/{doc-browser-DwRLokCI.js → doc-browser-D8dIOo9E.js} +1 -1
  14. package/dist/assets/doc-browser-DcuOOtpP.js +1 -0
  15. package/dist/assets/{doc-browser-context-BDuypObS.js → doc-browser-context-Dhsit2Ma.js} +1 -1
  16. package/dist/assets/{extensions-CQKXTqTI.js → extensions-CD6xvKF_.js} +1 -1
  17. package/dist/assets/{form-actions-CzGmrPAv.js → form-actions-CC4Wz_lL.js} +1 -1
  18. package/dist/assets/index-BTsbdtv6.js +109 -0
  19. package/dist/assets/index-Bcfy2kTh.css +1 -0
  20. package/dist/assets/loader-circle-B6aFbB-8.js +1 -0
  21. package/dist/assets/mcp-marketplace-page-BtUAypA7.js +1 -0
  22. package/dist/assets/{mcp-marketplace-page-s9PTnSDT.js → mcp-marketplace-page-r2MmD9xO.js} +1 -1
  23. package/dist/assets/{model-config-page-CxnCUWRW.js → model-config-page-DboxoT-I.js} +1 -1
  24. package/dist/assets/{navigation-link-Ci4Z7Phi.js → navigation-link-C_6oj3zz.js} +1 -1
  25. package/dist/assets/plus-DRsq9Kvs.js +1 -0
  26. package/dist/assets/privacy-settings-page-C2sDBqhb.js +1 -0
  27. package/dist/assets/projects-BcPcx-uD.js +1 -0
  28. package/dist/assets/providers-config-page-CmUFl9RF.js +1 -0
  29. package/dist/assets/react-BjO9I4WZ.js +8 -0
  30. package/dist/assets/{refresh-cw-C-3_thgr.js → refresh-cw-DsT2kvXi.js} +1 -1
  31. package/dist/assets/remote-Ci6x91ES.js +1 -0
  32. package/dist/assets/{rotate-cw-DSFTq_yu.js → rotate-cw-BCAs6c3R.js} +1 -1
  33. package/dist/assets/runtime-config-page-cQKNeAjL.js +1 -0
  34. package/dist/assets/{save-CpPr78_S.js → save-CadRMTMQ.js} +1 -1
  35. package/dist/assets/{search-msqk2-rd.js → search-B6tB4fzB.js} +1 -1
  36. package/dist/assets/{search-config-page-D3_qNXbB.js → search-config-page-C8lOIkxn.js} +1 -1
  37. package/dist/assets/{secrets-config-page-Be4P1E8o.js → secrets-config-page-8clzAag2.js} +2 -2
  38. package/dist/assets/security-config-DPbM6IVT.js +1 -0
  39. package/dist/assets/{select-DPKCWajZ.js → select-CO9S_C2C.js} +1 -1
  40. package/dist/assets/skeleton-IHILNhIW.js +1 -0
  41. package/dist/assets/{tag-chip-Dc_NcCEe.js → tag-chip-BZKk0UkK.js} +1 -1
  42. package/dist/assets/x-BuvHIb1d.js +1 -0
  43. package/dist/index.html +21 -21
  44. package/package.json +6 -6
  45. package/src/app/configs/__tests__/app-navigation.config.test.ts +20 -0
  46. package/src/app/configs/app-navigation.config.ts +10 -0
  47. package/src/app/index.tsx +8 -1
  48. package/src/features/apps/components/__tests__/app-packages-panel.test.tsx +362 -241
  49. package/src/features/apps/components/app-document-access-section.tsx +187 -0
  50. package/src/features/apps/components/app-package-card.tsx +150 -275
  51. package/src/features/apps/hooks/use-app-packages.ts +91 -35
  52. package/src/features/chat/components/conversation/__tests__/chat-conversation-workspace-section.test.tsx +29 -0
  53. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +4 -2
  54. package/src/features/chat/components/layout/__tests__/chat-page-shell.test.tsx +21 -0
  55. package/src/features/chat/components/layout/chat-page-shell.tsx +19 -4
  56. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +1 -1
  57. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +0 -1
  58. package/src/features/chat/features/conversation/utils/__tests__/session-message-composer.utils.test.ts +11 -4
  59. package/src/features/chat/features/conversation/utils/__tests__/session-queued-input.utils.test.ts +0 -1
  60. package/src/features/chat/features/conversation/utils/session-message-composer.utils.ts +10 -11
  61. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +1 -0
  62. package/src/features/chat/features/message/hooks/use-chat-message-actions.tsx +0 -2
  63. package/src/features/chat/features/runtime/utils/chat-runtime.utils.ts +0 -14
  64. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +70 -22
  65. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +79 -32
  66. package/src/features/chat/features/session/utils/__tests__/chat-sidebar-session-groups.utils.test.ts +2 -0
  67. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +0 -6
  68. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +2 -0
  69. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +1 -0
  70. package/src/features/chat/index.ts +5 -1
  71. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +27 -0
  72. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +2 -14
  73. package/src/features/chat/types/chat-run.types.ts +0 -1
  74. package/src/features/projects/AGENTS.md +7 -0
  75. package/src/features/projects/components/project-agreement.tsx +92 -0
  76. package/src/features/projects/components/project-artifacts.test.tsx +77 -0
  77. package/src/features/projects/components/project-artifacts.tsx +188 -0
  78. package/src/features/projects/components/project-overview.tsx +81 -0
  79. package/src/features/projects/components/project-requests.tsx +57 -0
  80. package/src/features/projects/components/project-runs.tsx +60 -0
  81. package/src/features/projects/components/project-section.tsx +60 -0
  82. package/src/features/projects/components/project-skills.tsx +53 -0
  83. package/src/features/projects/components/work/project-work-item-drawer.test.tsx +65 -0
  84. package/src/features/projects/components/work/project-work-item-drawer.tsx +367 -0
  85. package/src/features/projects/components/work/project-work-items.test.tsx +98 -0
  86. package/src/features/projects/components/work/project-work-items.tsx +199 -0
  87. package/src/features/projects/components/work/project-work-state-settings.tsx +285 -0
  88. package/src/features/projects/hooks/use-project-observation.test.tsx +93 -0
  89. package/src/features/projects/hooks/use-project-observation.ts +115 -0
  90. package/src/features/projects/hooks/use-project-work.test.tsx +58 -0
  91. package/src/features/projects/hooks/use-project-work.ts +215 -0
  92. package/src/features/projects/index.ts +2 -0
  93. package/src/features/projects/pages/project-home-page.tsx +234 -0
  94. package/src/features/projects/pages/projects-page.test.tsx +137 -0
  95. package/src/features/projects/presenters/project-home.presenter.test.ts +74 -0
  96. package/src/features/projects/presenters/project-home.presenter.ts +55 -0
  97. package/src/features/projects/utils/project-artifact-view.utils.test.ts +63 -0
  98. package/src/features/projects/utils/project-artifact-view.utils.ts +62 -0
  99. package/src/features/projects/utils/project-request-response.utils.test.ts +44 -0
  100. package/src/features/projects/utils/project-request-response.utils.ts +39 -0
  101. package/src/shared/lib/i18n/index.ts +6 -2
  102. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +16 -1
  103. package/src/shared/lib/i18n/locales/en-US/projects.json +149 -0
  104. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +16 -1
  105. package/src/shared/lib/i18n/locales/zh-CN/projects.json +149 -0
  106. package/src/shared/lib/ui-document-title/__tests__/ui-document-title.test.ts +6 -0
  107. package/src/shared/lib/ui-document-title/index.ts +1 -0
  108. package/dist/assets/api-n96IfCKb.js +0 -13
  109. package/dist/assets/desktop-update-config-C77hkbor.js +0 -1
  110. package/dist/assets/doc-browser-JlXVLNRe.js +0 -1
  111. package/dist/assets/index-CLw9DuCJ.js +0 -109
  112. package/dist/assets/index-CwCTiMM9.css +0 -1
  113. package/dist/assets/loader-circle-D9etMT9f.js +0 -1
  114. package/dist/assets/mcp-marketplace-page-CbWOamyW.js +0 -1
  115. package/dist/assets/plus-DKGD04AO.js +0 -1
  116. package/dist/assets/privacy-settings-page-D8HZHHjL.js +0 -1
  117. package/dist/assets/providers-config-page-C0GIoGxm.js +0 -1
  118. package/dist/assets/react-CVFx225w.js +0 -8
  119. package/dist/assets/remote-DvTCSAxQ.js +0 -1
  120. package/dist/assets/runtime-config-page-VNJZui5f.js +0 -1
  121. package/dist/assets/security-config-BtcpKIhQ.js +0 -1
  122. package/dist/assets/skeleton-BsR5rwB7.js +0 -1
  123. package/dist/assets/x-Dp5GAFmP.js +0 -1
@@ -1,37 +1,51 @@
1
- import { useEffect } from 'react';
2
- import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
3
- import type { AppPackageOperationStatus, AppPackageOperationView } from '@nextclaw/client-sdk';
4
- import { nextclawClient } from '@/shared/lib/api';
5
- import type { AppPackageOperationSettlementManager } from '@/features/apps/managers/app-package-operation-settlement.manager';
6
- import { APP_DATA_QUERY_KEY } from '@/features/app-data';
1
+ import { useEffect } from "react";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import type {
4
+ AppPackageOperationStatus,
5
+ AppPackageOperationView,
6
+ } from "@nextclaw/client-sdk";
7
+ import { nextclawClient } from "@/shared/lib/api";
8
+ import type { AppPackageOperationSettlementManager } from "@/features/apps/managers/app-package-operation-settlement.manager";
9
+ import { APP_DATA_QUERY_KEY } from "@/features/app-data";
7
10
 
8
- const APP_PACKAGES_QUERY_KEY = ['app-packages'] as const;
9
- const PANEL_APPS_QUERY_KEY = ['panel-apps'] as const;
10
- const SERVICE_APPS_QUERY_KEY = ['service-apps'] as const;
11
- const SERVICE_ACTIONS_QUERY_KEY = ['service-actions'] as const;
12
- const APP_PACKAGE_OPERATIONS_QUERY_KEY = ['app-package-operations'] as const;
11
+ const APP_PACKAGES_QUERY_KEY = ["app-packages"] as const;
12
+ const PANEL_APPS_QUERY_KEY = ["panel-apps"] as const;
13
+ const SERVICE_APPS_QUERY_KEY = ["service-apps"] as const;
14
+ const SERVICE_ACTIONS_QUERY_KEY = ["service-actions"] as const;
15
+ const APP_PACKAGE_OPERATIONS_QUERY_KEY = ["app-package-operations"] as const;
13
16
 
14
17
  const ACTIVE_OPERATION_STATUSES = new Set<AppPackageOperationStatus>([
15
- 'queued',
16
- 'resolving',
17
- 'downloading',
18
- 'verifying',
19
- 'installing',
20
- 'finalizing',
18
+ "queued",
19
+ "resolving",
20
+ "downloading",
21
+ "verifying",
22
+ "installing",
23
+ "finalizing",
21
24
  ]);
22
25
 
23
26
  export type AppPackageMutationInput =
24
- | { action: 'enable'; appId: string }
25
- | { action: 'disable'; appId: string }
26
- | { action: 'install'; source: string; registryUrl?: string }
27
- | { action: 'update'; appId: string }
28
- | { action: 'rollback'; appId: string; version: string }
29
- | { action: 'uninstall'; appId: string; purgeData?: boolean };
27
+ | { action: "enable"; appId: string }
28
+ | { action: "disable"; appId: string }
29
+ | { action: "install"; source: string; registryUrl?: string }
30
+ | { action: "update"; appId: string }
31
+ | { action: "rollback"; appId: string; version: string }
32
+ | { action: "uninstall"; appId: string; purgeData?: boolean };
33
+
34
+ export type AppDocumentAccessMutationInput =
35
+ | {
36
+ action: "grant";
37
+ appId: string;
38
+ scopeId: string;
39
+ directoryPath: string;
40
+ mode: "read" | "read-write";
41
+ }
42
+ | { action: "revoke"; appId: string; scopeId: string };
30
43
 
31
44
  export function useAppPackages() {
32
45
  return useQuery({
33
46
  queryKey: APP_PACKAGES_QUERY_KEY,
34
- queryFn: () => nextclawClient.appPackages.list({ includeStorageUsage: false }),
47
+ queryFn: () =>
48
+ nextclawClient.appPackages.list({ includeStorageUsage: false }),
35
49
  staleTime: 0,
36
50
  });
37
51
  }
@@ -42,13 +56,17 @@ export function useAppPackageOperations() {
42
56
  queryFn: () => nextclawClient.appPackages.listOperations(),
43
57
  refetchInterval: (query) => {
44
58
  const entries = query.state.data?.entries ?? [];
45
- return entries.some((entry) => isAppPackageOperationActive(entry.status)) ? 500 : false;
59
+ return entries.some((entry) => isAppPackageOperationActive(entry.status))
60
+ ? 500
61
+ : false;
46
62
  },
47
63
  staleTime: 0,
48
64
  });
49
65
  }
50
66
 
51
- export function isAppPackageOperationActive(status: AppPackageOperationStatus): boolean {
67
+ export function isAppPackageOperationActive(
68
+ status: AppPackageOperationStatus,
69
+ ): boolean {
52
70
  return ACTIVE_OPERATION_STATUSES.has(status);
53
71
  }
54
72
 
@@ -77,7 +95,14 @@ export function useAppPackageOperationSettlement({
77
95
  refetchPanels,
78
96
  refetchAppData,
79
97
  });
80
- }, [isLoaded, operations, refetchPackages, refetchPanels, refetchAppData, settlementManager]);
98
+ }, [
99
+ isLoaded,
100
+ operations,
101
+ refetchPackages,
102
+ refetchPanels,
103
+ refetchAppData,
104
+ settlementManager,
105
+ ]);
81
106
  }
82
107
 
83
108
  export function useAppPackageMutation() {
@@ -85,20 +110,23 @@ export function useAppPackageMutation() {
85
110
  return useMutation({
86
111
  mutationFn: async (input: AppPackageMutationInput) => {
87
112
  switch (input.action) {
88
- case 'enable':
113
+ case "enable":
89
114
  return await nextclawClient.appPackages.enable(input.appId);
90
- case 'disable':
115
+ case "disable":
91
116
  return await nextclawClient.appPackages.disable(input.appId);
92
- case 'install':
117
+ case "install":
93
118
  return await nextclawClient.appPackages.startInstall({
94
119
  source: input.source,
95
120
  registryUrl: input.registryUrl,
96
121
  });
97
- case 'update':
122
+ case "update":
98
123
  return await nextclawClient.appPackages.startUpdate(input.appId);
99
- case 'rollback':
100
- return await nextclawClient.appPackages.startRollback(input.appId, input.version);
101
- case 'uninstall':
124
+ case "rollback":
125
+ return await nextclawClient.appPackages.startRollback(
126
+ input.appId,
127
+ input.version,
128
+ );
129
+ case "uninstall":
102
130
  return await nextclawClient.appPackages.startUninstall(
103
131
  input.appId,
104
132
  input.purgeData ?? false,
@@ -111,9 +139,37 @@ export function useAppPackageMutation() {
111
139
  queryClient.invalidateQueries({ queryKey: PANEL_APPS_QUERY_KEY }),
112
140
  queryClient.invalidateQueries({ queryKey: SERVICE_APPS_QUERY_KEY }),
113
141
  queryClient.invalidateQueries({ queryKey: SERVICE_ACTIONS_QUERY_KEY }),
114
- queryClient.invalidateQueries({ queryKey: APP_PACKAGE_OPERATIONS_QUERY_KEY }),
142
+ queryClient.invalidateQueries({
143
+ queryKey: APP_PACKAGE_OPERATIONS_QUERY_KEY,
144
+ }),
115
145
  queryClient.invalidateQueries({ queryKey: APP_DATA_QUERY_KEY }),
116
146
  ]);
117
147
  },
118
148
  });
119
149
  }
150
+
151
+ export function useAppDocumentAccessMutation() {
152
+ const queryClient = useQueryClient();
153
+ return useMutation({
154
+ mutationFn: async (input: AppDocumentAccessMutationInput) => {
155
+ if (input.action === "revoke") {
156
+ return await nextclawClient.appPackages.revokeDocumentAccess(
157
+ input.appId,
158
+ input.scopeId,
159
+ );
160
+ }
161
+ return await nextclawClient.appPackages.grantDocumentAccess(input.appId, {
162
+ scopeId: input.scopeId,
163
+ directoryPath: input.directoryPath,
164
+ mode: input.mode,
165
+ });
166
+ },
167
+ onSuccess: async () => {
168
+ await Promise.all([
169
+ queryClient.invalidateQueries({ queryKey: APP_PACKAGES_QUERY_KEY }),
170
+ queryClient.invalidateQueries({ queryKey: SERVICE_APPS_QUERY_KEY }),
171
+ queryClient.invalidateQueries({ queryKey: SERVICE_ACTIONS_QUERY_KEY }),
172
+ ]);
173
+ },
174
+ });
175
+ }
@@ -183,6 +183,35 @@ describe("ChatConversationWorkspaceSection", () => {
183
183
  );
184
184
  });
185
185
 
186
+ it("uses an explicit project root for a project-owned file preview", () => {
187
+ useChatThreadStore.getState().setSnapshot({
188
+ workspacePanelParentKey: null,
189
+ activeWorkspacePanelKind: "file",
190
+ workspaceFileTabs: [{
191
+ key: "project-skill",
192
+ parentSessionKey: null,
193
+ path: "/Users/peiwang/Projects/nextbot/.agents/skills/project/SKILL.md",
194
+ viewMode: "preview",
195
+ }],
196
+ activeWorkspaceFileKey: "project-skill",
197
+ });
198
+
199
+ render(
200
+ <ChatConversationWorkspaceSection
201
+ layoutMode="desktop"
202
+ sessionKey={null}
203
+ projectRoot="/Users/peiwang/Projects/nextbot"
204
+ />,
205
+ );
206
+
207
+ expect(mocks.panelProps).toHaveBeenCalledWith(
208
+ expect.objectContaining({
209
+ sessionProjectRoot: "/Users/peiwang/Projects/nextbot",
210
+ sessionWorkingDir: "/Users/peiwang/Projects/nextbot",
211
+ }),
212
+ );
213
+ });
214
+
186
215
  it("keeps the workspace panel mounted while a root draft materializes", () => {
187
216
  useChatSessionListStore.getState().setSnapshot({ selectedSessionKey: null });
188
217
  useChatThreadStore.getState().setSnapshot({
@@ -8,11 +8,13 @@ import { normalizeSessionProjectRootValue } from "@/shared/lib/session-project";
8
8
  type ChatConversationWorkspaceSectionProps = {
9
9
  layoutMode: "desktop" | "mobile";
10
10
  sessionKey: string | null;
11
+ projectRoot?: string | null;
11
12
  };
12
13
 
13
14
  export function ChatConversationWorkspaceSection({
14
15
  layoutMode,
15
16
  sessionKey,
17
+ projectRoot,
16
18
  }: ChatConversationWorkspaceSectionProps) {
17
19
  const snapshot = useChatThreadStore((state) => state.snapshot);
18
20
  const defaultWorkspacePath = useChatQueryStore((state) =>
@@ -21,9 +23,9 @@ export function ChatConversationWorkspaceSection({
21
23
  ),
22
24
  );
23
25
  const selectedSession = useNcpChatSelectedSession(sessionKey);
24
- const draftProjectRoot = sessionKey
26
+ const draftProjectRoot = projectRoot ?? (sessionKey
25
27
  ? null
26
- : snapshot.draftProjectRoot ?? defaultWorkspacePath;
28
+ : snapshot.draftProjectRoot ?? defaultWorkspacePath);
27
29
  const {
28
30
  childSessionTabs,
29
31
  activeSideChatDraft,
@@ -38,6 +38,10 @@ vi.mock("@/features/marketplace", () => ({
38
38
  MarketplacePage: () => <div>Marketplace</div>,
39
39
  }));
40
40
 
41
+ vi.mock("@/features/projects", () => ({
42
+ ProjectsPage: () => <div>Project Home</div>,
43
+ }));
44
+
41
45
  describe("ChatPageLayout", () => {
42
46
  it("uses the same canvas width for management pages", () => {
43
47
  const agentsView = render(
@@ -81,4 +85,21 @@ describe("ChatPageLayout", () => {
81
85
  expect(screen.queryByTestId("chat-conversation-panel")).toBeNull();
82
86
  expect(screen.getByTestId("confirm-dialog")).toBeTruthy();
83
87
  });
88
+
89
+ it("keeps the chat sidebar while rendering the project home in the workspace", async () => {
90
+ useViewportLayoutMock.mockReturnValue({
91
+ mode: "desktop",
92
+ isMobile: false,
93
+ isDesktop: true,
94
+ });
95
+
96
+ render(<ChatPageLayout view="projects" confirmDialog={<div />} />);
97
+
98
+ expect(screen.getByTestId("desktop-chat-sidebar")).toBeTruthy();
99
+ const projectHome = await screen.findByText("Project Home");
100
+ expect(projectHome).toBeTruthy();
101
+ expect(projectHome.parentElement?.className).toContain("flex");
102
+ expect(projectHome.parentElement?.className).toContain("overflow-hidden");
103
+ expect(screen.queryByTestId("chat-conversation-panel")).toBeNull();
104
+ });
84
105
  });
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect } from "react";
1
+ import { lazy, Suspense, useLayoutEffect } from "react";
2
2
  import { ChatSidebar } from "@/features/chat/components/layout/chat-sidebar";
3
3
  import { ChatConversationPanel } from "@/features/chat/components/conversation/chat-conversation-panel";
4
4
  import { AgentsPage } from "@/features/agents";
@@ -9,11 +9,22 @@ import { ChatMobileShell } from "@/platforms/mobile";
9
9
  import { InboxPage } from "@/features/inbox";
10
10
  import { PanelAppMainPage } from "@/features/panel-apps";
11
11
  import { useScrollRestoration } from "@/shared/hooks/use-scroll-restoration";
12
- export type MainPanelView = "chat" | "cron" | "skills" | "agents" | "inbox" | "panel-app";
12
+ const ProjectsPage = lazy(async () => ({
13
+ default: (await import("@/features/projects")).ProjectsPage,
14
+ }));
15
+
16
+ export type MainPanelView = "chat" | "cron" | "skills" | "agents" | "inbox" | "panel-app" | "projects";
13
17
  export type ChatPageProps = {
14
18
  view: MainPanelView;
15
19
  };
16
20
  const MANAGEMENT_PAGE_CANVAS_WIDTH_CLASS = "max-w-[min(1180px,100%)]";
21
+
22
+ function resolveManagementWorkspaceClass(view: MainPanelView): string {
23
+ return view === "projects"
24
+ ? "relative flex flex-1 min-h-0 overflow-hidden bg-background"
25
+ : "flex-1 min-h-0 overflow-hidden bg-background";
26
+ }
27
+
17
28
  type UseChatSessionSyncParams = {
18
29
  routeSessionKey: string | null;
19
30
  syncRouteSessionSelection: (routeSessionKey: string | null) => void;
@@ -55,9 +66,13 @@ export function ChatPageLayout({ view, confirmDialog }: ChatPageLayoutProps) {
55
66
  ) : (
56
67
  <section
57
68
  data-theme-surface="workspace"
58
- className="flex-1 min-h-0 overflow-hidden bg-background"
69
+ className={resolveManagementWorkspaceClass(view)}
59
70
  >
60
- {view === "inbox" ? (
71
+ {view === "projects" ? (
72
+ <Suspense fallback={<div className="h-full animate-pulse bg-card/30" />}>
73
+ <ProjectsPage />
74
+ </Suspense>
75
+ ) : view === "inbox" ? (
61
76
  <div className={`mx-auto flex h-full min-h-0 w-full flex-col py-4 sm:px-6 sm:py-5 ${MANAGEMENT_PAGE_CANVAS_WIDTH_CLASS}`}>
62
77
  <InboxPage />
63
78
  </div>
@@ -240,7 +240,7 @@ describe('useSessionConversationController backend run queue', () => {
240
240
  parts: [{ type: 'text', text: 'Update this session title' }],
241
241
  }),
242
242
  });
243
- expect(envelope?.metadata).not.toHaveProperty('requested_skill_refs');
243
+ expect(envelope?.metadata).not.toHaveProperty('ui_inline_tokens');
244
244
  expect(params.resetComposer).not.toHaveBeenCalled();
245
245
  expect(params.restoreComposer).not.toHaveBeenCalled();
246
246
  });
@@ -175,7 +175,6 @@ function buildSubmissionDraft(params: BuildSubmissionDraftParams): SubmissionDra
175
175
  : sessionKey
176
176
  ? inputQuery.selectedSession?.projectRoot ?? null
177
177
  : inputSnapshot.pendingProjectRoot,
178
- requestedSkills: [...composerSnapshot.selectedSkills],
179
178
  skillRecords: inputQuery.skillRecords.filter((record) =>
180
179
  composerSnapshot.selectedSkills.includes(record.ref)
181
180
  ),
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
3
3
  import { buildSessionMessageComposerSnapshot } from '@/features/chat/features/conversation/utils/session-message-composer.utils';
4
4
 
5
5
  describe('buildSessionMessageComposerSnapshot', () => {
6
- it('round-trips text, attachment and requested skill state into the shared composer', () => {
6
+ it('round-trips text, attachment and skill-token state into the shared composer', () => {
7
7
  const snapshot = buildSessionMessageComposerSnapshot({
8
8
  attachmentIdPrefix: 'edit-user-1',
9
9
  availableSkills: [{ ref: 'skill://review', name: 'Review' }],
@@ -13,15 +13,22 @@ describe('buildSessionMessageComposerSnapshot', () => {
13
13
  role: 'user',
14
14
  status: 'final',
15
15
  timestamp: '2026-08-08T10:00:00.000Z',
16
- metadata: { requested_skill_refs: ['skill://review'] },
16
+ metadata: {
17
+ ui_inline_tokens: [{
18
+ kind: 'skill',
19
+ key: 'skill://review',
20
+ label: 'Review',
21
+ rawText: '$Review',
22
+ }],
23
+ },
17
24
  parts: [
18
- { type: 'text', text: 'check this' },
25
+ { type: 'text', text: 'check $Review this' },
19
26
  { type: 'file', name: 'notes.txt', mimeType: 'text/plain', sizeBytes: 12 },
20
27
  ],
21
28
  },
22
29
  });
23
30
 
24
- expect(snapshot.text).toContain('check this');
31
+ expect(snapshot.text).toBe('check this');
25
32
  expect(snapshot.selectedSkills).toEqual(['skill://review']);
26
33
  expect(snapshot.skillRecords).toEqual([{ ref: 'skill://review', name: 'Review' }]);
27
34
  expect(snapshot.attachments).toEqual([
@@ -13,7 +13,6 @@ describe('session queued input utils', () => {
13
13
  sessionId: 'session-1',
14
14
  enqueuedAt: '2026-07-22T10:00:00.000Z',
15
15
  metadata: {
16
- requested_skill_refs: ['project:review'],
17
16
  ui_inline_tokens: [{
18
17
  kind: 'skill',
19
18
  key: 'project:review',
@@ -14,7 +14,6 @@ import {
14
14
  resolveInlineTokensForText,
15
15
  type ChatInlineTokenSource,
16
16
  } from '@/features/chat/features/input/utils/chat-inline-token.utils';
17
- import { normalizeRequestedSkills } from '@/features/chat/features/runtime/utils/chat-runtime.utils';
18
17
  import type { SessionConversationComposerState } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
19
18
 
20
19
  export type SessionMessageComposerSnapshot = SessionConversationComposerState & {
@@ -55,15 +54,6 @@ function buildTextNodes(
55
54
  return nodes;
56
55
  }
57
56
 
58
- function readRequestedSkills(metadata: Record<string, unknown>): string[] {
59
- const raw = metadata.requested_skill_refs;
60
- return normalizeRequestedSkills(
61
- Array.isArray(raw)
62
- ? raw.filter((value): value is string => typeof value === 'string')
63
- : undefined,
64
- );
65
- }
66
-
67
57
  export function buildSessionMessageComposerSnapshot(params: {
68
58
  readonly attachmentIdPrefix: string;
69
59
  readonly availableSkills: readonly { ref: string; name: string }[];
@@ -107,8 +97,17 @@ export function buildSessionMessageComposerSnapshot(params: {
107
97
  });
108
98
 
109
99
  const normalizedNodes = normalizeChatComposerNodes(nodes);
110
- const selectedSkills = readRequestedSkills(metadata);
100
+ const skillTokens = metadataTokens.filter(
101
+ (token): token is Extract<ChatInlineTokenSource, { kind: 'skill' }> =>
102
+ token.kind === 'skill',
103
+ );
104
+ const selectedSkills = [...new Set(skillTokens.map((token) => token.ref))];
111
105
  const skillNameByRef = new Map(availableSkills.map(({ ref, name }) => [ref, name]));
106
+ for (const token of skillTokens) {
107
+ if (!skillNameByRef.has(token.ref)) {
108
+ skillNameByRef.set(token.ref, token.name);
109
+ }
110
+ }
112
111
  return {
113
112
  text: deriveChatComposerDraft(normalizedNodes),
114
113
  nodes: normalizedNodes,
@@ -32,6 +32,7 @@ function createPanelApp(): PanelAppEntryView {
32
32
 
33
33
  function createProject(): ProjectView {
34
34
  return {
35
+ id: 'project-nextclaw',
35
36
  name: 'NextClaw',
36
37
  rootPath: '/tmp/nextclaw',
37
38
  createdAt: '2026-07-28T00:00:00.000Z',
@@ -41,7 +41,6 @@ function buildEditedMessageMetadata(
41
41
  snapshot: SessionMessageComposerSnapshot,
42
42
  ): Record<string, unknown> | undefined {
43
43
  const metadata = { ...message.metadata };
44
- delete metadata.requested_skill_refs;
45
44
  delete metadata[CHAT_INLINE_TOKENS_METADATA_KEY];
46
45
  const skillRecords = readInlineTokensFromMetadata(message.metadata).flatMap(
47
46
  (token) =>
@@ -59,7 +58,6 @@ function buildEditedMessageMetadata(
59
58
  );
60
59
  Object.assign(metadata, buildChatRunMetadata({
61
60
  composerNodes: [...snapshot.nodes],
62
- requestedSkills: [...snapshot.selectedSkills],
63
61
  skillRecords,
64
62
  }));
65
63
  return Object.keys(metadata).length > 0 ? metadata : undefined;
@@ -216,20 +216,6 @@ class HistoryMessageBuilder {
216
216
  };
217
217
  }
218
218
 
219
- export function normalizeRequestedSkills(value: string[] | undefined): string[] {
220
- if (!Array.isArray(value)) {
221
- return [];
222
- }
223
- const deduped = new Set<string>();
224
- for (const item of value) {
225
- const trimmed = item.trim();
226
- if (trimmed) {
227
- deduped.add(trimmed);
228
- }
229
- }
230
- return [...deduped];
231
- }
232
-
233
219
  export function buildUiMessagesFromHistoryMessages(messages: SessionMessageView[]): UIMessage[] {
234
220
  return new HistoryMessageBuilder().build(messages);
235
221
  }
@@ -1,7 +1,11 @@
1
1
  import { fireEvent, render, screen, within } from "@testing-library/react";
2
2
  import userEvent from "@testing-library/user-event";
3
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
- import { ChatSidebarProjectGroups } from "@/features/chat/features/session/components/chat-sidebar-project-groups";
4
+ import { MemoryRouter, useLocation } from "react-router-dom";
5
+ import {
6
+ ChatSidebarProjectGroups,
7
+ type ChatSidebarProjectGroup,
8
+ } from "@/features/chat/features/session/components/chat-sidebar-project-groups";
5
9
  import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
6
10
 
7
11
  const mocks = vi.hoisted(() => ({
@@ -16,7 +20,8 @@ vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
16
20
  }),
17
21
  }));
18
22
 
19
- const projectGroup = {
23
+ const projectGroup: ChatSidebarProjectGroup = {
24
+ projectId: "project-analysis",
20
25
  projectRoot: "/tmp/analysis-project",
21
26
  projectName: "analysis-project",
22
27
  items: [
@@ -35,17 +40,30 @@ const projectGroup = {
35
40
  isPinned: false,
36
41
  };
37
42
 
38
- function renderProjectGroups(isPinned = false) {
43
+ function LocationProbe() {
44
+ const location = useLocation();
45
+ return (
46
+ <output data-testid="location">{`${location.pathname}${location.search}`}</output>
47
+ );
48
+ }
49
+
50
+ function renderProjectGroups(
51
+ isPinned = false,
52
+ group: ChatSidebarProjectGroup = projectGroup,
53
+ ) {
39
54
  return render(
40
- <ChatSidebarProjectGroups
41
- groups={[{ ...projectGroup, isPinned }]}
42
- projectCronJobCountByRoot={new Map([[projectGroup.projectRoot, 2]])}
43
- defaultSessionType="native"
44
- sessionTypeOptions={[
45
- { value: "native", label: "Native", icon: null, ready: true },
46
- ]}
47
- renderSessionItem={() => <div>Project session</div>}
48
- />,
55
+ <MemoryRouter initialEntries={["/chat"]}>
56
+ <ChatSidebarProjectGroups
57
+ groups={[{ ...group, isPinned }]}
58
+ projectCronJobCountByRoot={new Map([[projectGroup.projectRoot, 2]])}
59
+ defaultSessionType="native"
60
+ sessionTypeOptions={[
61
+ { value: "native", label: "Native", icon: null, ready: true },
62
+ ]}
63
+ renderSessionItem={() => <div>Project session</div>}
64
+ />
65
+ <LocationProbe />
66
+ </MemoryRouter>,
49
67
  );
50
68
  }
51
69
 
@@ -72,18 +90,23 @@ describe("ChatSidebarProjectGroups", () => {
72
90
  const { container } = renderProjectGroups();
73
91
 
74
92
  const header = screen.getByLabelText("Collapse project");
93
+ const projectLink = screen.getByRole("link", { name: "analysis-project" });
94
+ const projectRow = projectLink.parentElement?.parentElement;
75
95
  expect(container.firstElementChild?.className).toBe("space-y-0.5");
76
- expect(header.parentElement?.className).toContain("h-8");
77
- expect(header.parentElement?.className).toContain("hover:bg-gray-200/60");
78
- expect(header.parentElement?.className).not.toContain("focus-within:bg");
79
- expect(
80
- screen.getByText("analysis-project").nextElementSibling?.tagName,
81
- ).toBe("svg");
96
+ expect(projectRow?.className).toContain("h-8");
97
+ expect(projectRow?.className).toContain("hover:bg-gray-200/60");
98
+ const projectContent = projectLink.parentElement;
99
+ expect(projectContent?.classList.contains("pr-14")).toBe(false);
100
+ expect(projectContent?.className).toContain("group-hover/project:pr-14");
101
+ expect(projectContent?.className).toContain(
102
+ "group-has-[[data-project-actions]:focus-within]/project:pr-14",
103
+ );
104
+ expect(projectLink.querySelector("span")?.className).not.toContain("uppercase");
105
+ expect(projectLink.querySelector("span")?.className).toContain("text-[13px]");
82
106
  expect(
83
- screen
84
- .getByText("analysis-project")
85
- .nextElementSibling?.getAttribute("class"),
86
- ).toContain("opacity-0");
107
+ header.compareDocumentPosition(projectLink) &
108
+ Node.DOCUMENT_POSITION_FOLLOWING,
109
+ ).toBeTruthy();
87
110
  expect(screen.queryByText("Sessions")).toBeNull();
88
111
 
89
112
  fireEvent.click(header);
@@ -97,6 +120,31 @@ describe("ChatSidebarProjectGroups", () => {
97
120
  expect(screen.queryByText("Project session")).toBeNull();
98
121
  });
99
122
 
123
+ it("opens the project home from the project name without changing expansion", () => {
124
+ renderProjectGroups();
125
+
126
+ fireEvent.click(screen.getByRole("link", { name: "analysis-project" }));
127
+
128
+ expect(mocks.toggleProjectCollapsed).not.toHaveBeenCalled();
129
+ expect(screen.getByTestId("location").textContent).toBe(
130
+ "/projects/project-analysis/overview",
131
+ );
132
+ expect(screen.getByText("Project session")).toBeTruthy();
133
+ });
134
+
135
+ it("surfaces a running child session while its project is collapsed", () => {
136
+ renderProjectGroups(false, {
137
+ ...projectGroup,
138
+ items: [{ ...projectGroup.items[0], runStatus: "running" as const }],
139
+ });
140
+
141
+ expect(screen.queryByLabelText("Running")).toBeNull();
142
+
143
+ fireEvent.click(screen.getByLabelText("Collapse project"));
144
+
145
+ expect(screen.getByLabelText("Running")).toBeTruthy();
146
+ });
147
+
100
148
  it("shows project context from the row on hover", async () => {
101
149
  const user = userEvent.setup();
102
150
  renderProjectGroups();