@nextclaw/ui 0.22.5 → 0.24.0-beta.0

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 (111) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/assets/{api-aEu9cEI8.js → api-CoqPRKmF.js} +5 -5
  3. package/dist/assets/{appearance-settings-page-z4DaHc_E.js → appearance-settings-page-CPoFZLw8.js} +1 -1
  4. package/dist/assets/{book-open-DYRapW_h.js → book-open-jcUt0rr9.js} +1 -1
  5. package/dist/assets/{channels-list-page-x07sGqsT.js → channels-list-page-anMJAkfK.js} +2 -2
  6. package/dist/assets/{config-split-page-DJDp86Ne.js → config-split-page-AcuFnbIC.js} +1 -1
  7. package/dist/assets/{confirm-dialog-BALTnP33.js → confirm-dialog-B-atCfC5.js} +1 -1
  8. package/dist/assets/{createLucideIcon-CgM0rnsa.js → createLucideIcon-Blz0FzrV.js} +1 -1
  9. package/dist/assets/{desktop-capabilities-page-4-v3wKLV.js → desktop-capabilities-page-8lKF_Ob-.js} +1 -1
  10. package/dist/assets/desktop-update-config-DABXDpbj.js +1 -0
  11. package/dist/assets/{dist-BjYvUhPL.js → dist-D8kcEpTf.js} +1 -1
  12. package/dist/assets/{dist-N9zc5bkM.js → dist-eH-_j8yU.js} +1 -1
  13. package/dist/assets/doc-browser-DJEMr7Zk.js +1 -0
  14. package/dist/assets/{doc-browser-DwRLokCI.js → doc-browser-DzXRehlW.js} +1 -1
  15. package/dist/assets/{doc-browser-context-BDuypObS.js → doc-browser-context-BlbUcg2J.js} +1 -1
  16. package/dist/assets/{extensions-DFQXmSwG.js → extensions-mquwAJ2Z.js} +1 -1
  17. package/dist/assets/{form-actions-CzGmrPAv.js → form-actions-CaZDGEmB.js} +1 -1
  18. package/dist/assets/index-DyR5iR0O.css +1 -0
  19. package/dist/assets/{index-zuqzAvNx.js → index-XxXj8WDS.js} +10 -10
  20. package/dist/assets/loader-circle-0qPOOYQ5.js +1 -0
  21. package/dist/assets/{mcp-marketplace-page-COUopIOc.js → mcp-marketplace-page-gFtgyueL.js} +1 -1
  22. package/dist/assets/mcp-marketplace-page-uanamJ6L.js +1 -0
  23. package/dist/assets/{model-config-page-g9_-hZqZ.js → model-config-page-hFBpeRAO.js} +1 -1
  24. package/dist/assets/{navigation-link-Ci4Z7Phi.js → navigation-link-Hb5Z0UxZ.js} +1 -1
  25. package/dist/assets/plus-CYOvNvcV.js +1 -0
  26. package/dist/assets/privacy-settings-page-C18zo3ox.js +1 -0
  27. package/dist/assets/projects-C8Gm9RS8.js +1 -0
  28. package/dist/assets/providers-config-page-YU0-AOXJ.js +1 -0
  29. package/dist/assets/react-DF-kP7iN.js +8 -0
  30. package/dist/assets/{refresh-cw-C-3_thgr.js → refresh-cw-ZHKfE3yx.js} +1 -1
  31. package/dist/assets/remote-B_utkBra.js +1 -0
  32. package/dist/assets/{rotate-cw-DSFTq_yu.js → rotate-cw-CLhOXGwE.js} +1 -1
  33. package/dist/assets/runtime-config-page-Dmy1A_0Y.js +1 -0
  34. package/dist/assets/{save-CpPr78_S.js → save-CWuuZs77.js} +1 -1
  35. package/dist/assets/{search-msqk2-rd.js → search-DcmsKAn2.js} +1 -1
  36. package/dist/assets/{search-config-page-BqoDbKpM.js → search-config-page-CxBacx6W.js} +1 -1
  37. package/dist/assets/{secrets-config-page-GsahsEVw.js → secrets-config-page-B4py8-KR.js} +2 -2
  38. package/dist/assets/security-config-CTP4IQWw.js +1 -0
  39. package/dist/assets/{select-DPKCWajZ.js → select-BEr0Iy_f.js} +1 -1
  40. package/dist/assets/skeleton-B2NHluWf.js +1 -0
  41. package/dist/assets/{tag-chip-ClBWeQ4p.js → tag-chip-ByqDkn6g.js} +1 -1
  42. package/dist/assets/x-CENkFyK5.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/chat/components/conversation/__tests__/chat-conversation-workspace-section.test.tsx +29 -0
  49. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +4 -2
  50. package/src/features/chat/components/layout/__tests__/chat-page-shell.test.tsx +21 -0
  51. package/src/features/chat/components/layout/chat-page-shell.tsx +19 -4
  52. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +1 -1
  53. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +0 -1
  54. package/src/features/chat/features/conversation/utils/__tests__/session-message-composer.utils.test.ts +11 -4
  55. package/src/features/chat/features/conversation/utils/__tests__/session-queued-input.utils.test.ts +0 -1
  56. package/src/features/chat/features/conversation/utils/session-message-composer.utils.ts +10 -11
  57. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +1 -0
  58. package/src/features/chat/features/message/hooks/use-chat-message-actions.tsx +0 -2
  59. package/src/features/chat/features/runtime/utils/chat-runtime.utils.ts +0 -14
  60. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +70 -22
  61. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +79 -32
  62. package/src/features/chat/features/session/utils/__tests__/chat-sidebar-session-groups.utils.test.ts +2 -0
  63. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +0 -6
  64. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +2 -0
  65. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +1 -0
  66. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +27 -0
  67. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +2 -14
  68. package/src/features/chat/types/chat-run.types.ts +0 -1
  69. package/src/features/panel-apps/managers/__tests__/panel-app-bridge.manager.test.ts +51 -0
  70. package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +37 -2
  71. package/src/features/projects/AGENTS.md +6 -0
  72. package/src/features/projects/components/project-agreement.tsx +92 -0
  73. package/src/features/projects/components/project-artifacts.test.tsx +77 -0
  74. package/src/features/projects/components/project-artifacts.tsx +188 -0
  75. package/src/features/projects/components/project-overview.tsx +150 -0
  76. package/src/features/projects/components/project-requests.tsx +57 -0
  77. package/src/features/projects/components/project-runs.tsx +60 -0
  78. package/src/features/projects/components/project-section.tsx +60 -0
  79. package/src/features/projects/components/project-skills.tsx +53 -0
  80. package/src/features/projects/components/project-work-items.test.tsx +78 -0
  81. package/src/features/projects/components/project-work-items.tsx +272 -0
  82. package/src/features/projects/hooks/use-project-observation.test.tsx +93 -0
  83. package/src/features/projects/hooks/use-project-observation.ts +85 -0
  84. package/src/features/projects/index.ts +2 -0
  85. package/src/features/projects/pages/project-home-page.tsx +253 -0
  86. package/src/features/projects/pages/projects-page.test.tsx +366 -0
  87. package/src/features/projects/presenters/project-home.presenter.test.ts +74 -0
  88. package/src/features/projects/presenters/project-home.presenter.ts +55 -0
  89. package/src/features/projects/utils/project-artifact-view.utils.test.ts +63 -0
  90. package/src/features/projects/utils/project-artifact-view.utils.ts +62 -0
  91. package/src/features/projects/utils/project-request-response.utils.test.ts +44 -0
  92. package/src/features/projects/utils/project-request-response.utils.ts +39 -0
  93. package/src/shared/lib/i18n/index.ts +6 -2
  94. package/src/shared/lib/i18n/locales/en-US/projects.json +90 -0
  95. package/src/shared/lib/i18n/locales/zh-CN/projects.json +90 -0
  96. package/src/shared/lib/ui-document-title/__tests__/ui-document-title.test.ts +6 -0
  97. package/src/shared/lib/ui-document-title/index.ts +1 -0
  98. package/dist/assets/desktop-update-config-Cl-_XHqU.js +0 -1
  99. package/dist/assets/doc-browser-JlXVLNRe.js +0 -1
  100. package/dist/assets/index-CwCTiMM9.css +0 -1
  101. package/dist/assets/loader-circle-D9etMT9f.js +0 -1
  102. package/dist/assets/mcp-marketplace-page-DxPPmYqh.js +0 -1
  103. package/dist/assets/plus-DKGD04AO.js +0 -1
  104. package/dist/assets/privacy-settings-page-pNhQQT4r.js +0 -1
  105. package/dist/assets/providers-config-page-DftW_6KU.js +0 -1
  106. package/dist/assets/react-CVFx225w.js +0 -8
  107. package/dist/assets/remote-DS2TkMPm.js +0 -1
  108. package/dist/assets/runtime-config-page-Cd9sOYvn.js +0 -1
  109. package/dist/assets/security-config-BnHQyrtG.js +0 -1
  110. package/dist/assets/skeleton-BsR5rwB7.js +0 -1
  111. package/dist/assets/x-Dp5GAFmP.js +0 -1
@@ -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();
@@ -1,4 +1,5 @@
1
1
  import { useMemo, useState, type ReactNode } from "react";
2
+ import { Link, useLocation } from "react-router-dom";
2
3
  import {
3
4
  AlarmClock,
4
5
  ChevronDown,
@@ -19,6 +20,8 @@ import type { ChatSidebarProjectGroup } from "@/features/chat/features/session/u
19
20
  import { useChatSessionListStore } from "@/features/chat/stores/chat-session-list.store";
20
21
  import { t } from "@/shared/lib/i18n";
21
22
  import { ChatSidebarContextCard } from "@/features/chat/features/session/components/chat-sidebar-context-card";
23
+ import { SessionRunBadge } from "@/features/chat/features/session/components/session-run-badge";
24
+ import { cn } from "@/shared/lib/utils";
22
25
 
23
26
  export type { ChatSidebarProjectGroup };
24
27
 
@@ -53,6 +56,7 @@ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
53
56
  projectCronJobCountByRoot,
54
57
  } = props;
55
58
  const presenter = usePresenter();
59
+ const location = useLocation();
56
60
  const collapsedProjectRoots = useChatSessionListStore(
57
61
  (state) => state.snapshot.collapsedProjectRoots,
58
62
  );
@@ -72,13 +76,23 @@ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
72
76
  {groups.map((group) => {
73
77
  const actionLabel = `${t("chatSidebarNewTask")} · ${group.projectName}`;
74
78
  const isCollapsed = collapsedProjectRoots.includes(group.projectRoot);
79
+ const hasRunningSession = isCollapsed && group.items.some(
80
+ (item) => item.runStatus === "running",
81
+ );
82
+ const isSelected = Boolean(group.projectId) &&
83
+ location.pathname.startsWith(`/projects/${group.projectId}/`);
75
84
  const pinLabel = t(
76
85
  group.isPinned ? "chatSidebarUnpinProject" : "chatSidebarPinProject",
77
86
  );
78
87
 
79
88
  return (
80
89
  <div key={group.projectRoot}>
81
- <div className="group/project relative h-8 rounded-lg px-2 text-muted-foreground transition-colors hover:bg-gray-200/60 hover:text-gray-900">
90
+ <div
91
+ className={cn(
92
+ "group/project relative h-8 rounded-lg px-1 text-muted-foreground transition-colors hover:bg-gray-200/60 hover:text-gray-900",
93
+ isSelected && "bg-gray-200/60 text-gray-900",
94
+ )}
95
+ >
82
96
  <ChatSidebarContextCard
83
97
  title={group.projectName}
84
98
  metrics={[
@@ -100,42 +114,75 @@ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
100
114
  },
101
115
  ]}
102
116
  >
103
- <button
104
- type="button"
105
- aria-expanded={!isCollapsed}
106
- aria-label={t(
107
- isCollapsed
108
- ? "chatSidebarExpandProject"
109
- : "chatSidebarCollapseProject",
117
+ <div
118
+ className={cn(
119
+ "flex h-full w-full min-w-0 items-center group-hover/project:pr-14 group-has-[[data-project-actions]:focus-within]/project:pr-14",
120
+ hasRunningSession && "pr-7",
110
121
  )}
111
- className="flex h-full w-full min-w-0 items-center gap-1.5 pr-14 text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
112
- onClick={() =>
113
- presenter.chatSessionListManager.toggleProjectCollapsed(
114
- group.projectRoot,
115
- )
116
- }
117
122
  >
118
- <Folder className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
119
- <span
120
- className="truncate text-[11px] font-medium uppercase tracking-wider"
121
- title={group.projectRoot}
122
- >
123
- {group.projectName}
124
- </span>
125
- {isCollapsed ? (
126
- <ChevronRight
127
- className="h-3.5 w-3.5 shrink-0 opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-within/project:opacity-100"
128
- aria-hidden="true"
129
- />
123
+ <IconActionButton
124
+ icon={
125
+ isCollapsed ? (
126
+ <ChevronRight className="h-3.5 w-3.5" />
127
+ ) : (
128
+ <ChevronDown className="h-3.5 w-3.5" />
129
+ )
130
+ }
131
+ label={t(
132
+ isCollapsed
133
+ ? "chatSidebarExpandProject"
134
+ : "chatSidebarCollapseProject",
135
+ )}
136
+ aria-expanded={!isCollapsed}
137
+ size="sm"
138
+ tone="surface"
139
+ tooltip={false}
140
+ onClick={() =>
141
+ presenter.chatSessionListManager.toggleProjectCollapsed(
142
+ group.projectRoot,
143
+ )
144
+ }
145
+ />
146
+ {group.projectId ? (
147
+ <Link
148
+ to={`/projects/${encodeURIComponent(group.projectId)}/overview`}
149
+ className="flex h-full min-w-0 flex-1 items-center gap-1.5 rounded-md px-1 text-left focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
150
+ aria-current={isSelected ? "page" : undefined}
151
+ title={group.projectRoot}
152
+ >
153
+ <Folder
154
+ className="h-3.5 w-3.5 shrink-0"
155
+ aria-hidden="true"
156
+ />
157
+ <span className="truncate text-[13px] font-medium">
158
+ {group.projectName}
159
+ </span>
160
+ </Link>
130
161
  ) : (
131
- <ChevronDown
132
- className="h-3.5 w-3.5 shrink-0 opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-within/project:opacity-100"
133
- aria-hidden="true"
134
- />
162
+ <span
163
+ className="flex h-full min-w-0 flex-1 items-center gap-1.5 rounded-md px-1 text-left"
164
+ title={group.projectRoot}
165
+ >
166
+ <Folder
167
+ className="h-3.5 w-3.5 shrink-0"
168
+ aria-hidden="true"
169
+ />
170
+ <span className="truncate text-[13px] font-medium">
171
+ {group.projectName}
172
+ </span>
173
+ </span>
135
174
  )}
136
- </button>
175
+ </div>
137
176
  </ChatSidebarContextCard>
138
- <div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center opacity-0 transition-opacity group-hover/project:pointer-events-auto group-hover/project:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100">
177
+ {hasRunningSession ? (
178
+ <span className="absolute right-2 top-1/2 inline-flex -translate-y-1/2 transition-opacity group-hover/project:opacity-0 group-has-[[data-project-actions]:focus-within]/project:opacity-0">
179
+ <SessionRunBadge status="running" />
180
+ </span>
181
+ ) : null}
182
+ <div
183
+ data-project-actions
184
+ className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center opacity-0 transition-opacity group-hover/project:pointer-events-auto group-hover/project:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100"
185
+ >
139
186
  {supportsSessionTypeChoice ? (
140
187
  <Popover
141
188
  open={openProjectRoot === group.projectRoot}
@@ -4,6 +4,7 @@ import { groupSessionsByProject } from "@/features/chat/features/session/utils/c
4
4
  describe("groupSessionsByProject", () => {
5
5
  it("uses registered projects as the project-first baseline", () => {
6
6
  const groups = groupSessionsByProject([], new Set(), new Set(), [{
7
+ id: "project-knowledge",
7
8
  name: "Knowledge",
8
9
  rootPath: "/tmp/knowledge",
9
10
  template: "knowledge-base",
@@ -12,6 +13,7 @@ describe("groupSessionsByProject", () => {
12
13
  }]);
13
14
 
14
15
  expect(groups).toEqual([{
16
+ projectId: "project-knowledge",
15
17
  projectRoot: "/tmp/knowledge",
16
18
  projectName: "Knowledge",
17
19
  items: [],
@@ -8,7 +8,6 @@ import {
8
8
  createInlineTokensMetadata,
9
9
  type ChatSkillReferenceSnapshot,
10
10
  } from "@/features/chat/features/input/utils/chat-inline-token.utils";
11
- import { normalizeRequestedSkills } from "@/features/chat/features/runtime/utils/chat-runtime.utils";
12
11
  import { normalizeSessionProjectRootValue } from "@/shared/lib/session-project";
13
12
 
14
13
  function createMetadataFields(
@@ -24,7 +23,6 @@ export function buildChatRunMetadata(payload: {
24
23
  thinkingLevel?: string;
25
24
  sessionType?: string;
26
25
  projectRoot?: string | null;
27
- requestedSkills?: string[];
28
26
  skillRecords?: readonly ChatSkillReferenceSnapshot[];
29
27
  composerNodes?: Parameters<typeof buildInlineTokensFromComposer>[0];
30
28
  sessionMaterialization?: AgentRunSessionMaterializationMetadata | null;
@@ -37,10 +35,6 @@ export function buildChatRunMetadata(payload: {
37
35
  ...createMetadataFields(payload.agentId?.trim(), ["agentId", "agent_id"]),
38
36
  ...createMetadataFields(projectRoot ?? undefined, ["projectRoot", "project_root"]),
39
37
  };
40
- const requestedSkills = normalizeRequestedSkills(payload.requestedSkills);
41
- if (requestedSkills.length > 0) {
42
- metadata.requested_skill_refs = requestedSkills;
43
- }
44
38
  const inlineTokens = payload.composerNodes
45
39
  ? buildInlineTokensFromComposer(payload.composerNodes, payload.skillRecords)
46
40
  : [];
@@ -9,6 +9,7 @@ export type ChatSidebarDateGroup = {
9
9
  };
10
10
 
11
11
  export type ChatSidebarProjectGroup = {
12
+ projectId?: string;
12
13
  projectRoot: string;
13
14
  projectName: string;
14
15
  items: NcpSessionListItemView[];
@@ -102,6 +103,7 @@ export function groupSessionsByProject(
102
103
 
103
104
  for (const project of projects) {
104
105
  grouped.set(project.rootPath, {
106
+ projectId: project.id,
105
107
  projectRoot: project.rootPath,
106
108
  projectName: project.name,
107
109
  items: [],
@@ -60,6 +60,7 @@ describe("buildChatWelcomeProjectOptions", () => {
60
60
  const options = buildChatWelcomeProjectOptions({
61
61
  defaultProjectRoot: "/Users/demo/.nextclaw/workspace",
62
62
  projects: [{
63
+ id: "project-knowledge",
63
64
  name: "Knowledge",
64
65
  rootPath: "/tmp/knowledge",
65
66
  template: "knowledge-base",
@@ -230,6 +230,33 @@ describe("ChatSessionListManager draft and selection flow", () => {
230
230
  });
231
231
  });
232
232
 
233
+ it("keeps a project-bound initial prompt as an editable unsent draft", () => {
234
+ const uiManager = {
235
+ goToChatRoot: vi.fn(),
236
+ navigateTo: vi.fn(),
237
+ goToSession: vi.fn(),
238
+ isAtChatRoot: vi.fn(() => true),
239
+ } as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
240
+
241
+ const manager = new ChatSessionListManager(uiManager);
242
+ manager.createSession({
243
+ projectRoot: "/tmp/project-alpha",
244
+ prompt: "Use the project observation capability.",
245
+ });
246
+
247
+ expect(uiManager.navigateTo).toHaveBeenCalledWith("/chat/draft", {
248
+ replace: true,
249
+ state: {
250
+ chatDraft: {
251
+ sessionType: "native",
252
+ projectRoot: "/tmp/project-alpha",
253
+ prompt: "Use the project observation capability.",
254
+ },
255
+ },
256
+ });
257
+ expect(useChatThreadStore.getState().snapshot.hasSubmittedDraftMessage).toBe(false);
258
+ });
259
+
233
260
  it("does not eagerly replace the old selected session before the route finishes switching", () => {
234
261
  const uiManager = {
235
262
  goToChatRoot: vi.fn(),
@@ -180,19 +180,8 @@ describe('buildChatRunMetadata', () => {
180
180
  ).not.toHaveProperty('project_root');
181
181
  });
182
182
 
183
- it('sends requested skill refs instead of legacy requested skill names', () => {
184
- expect(
185
- buildChatRunMetadata({
186
- requestedSkills: ['project:/tmp/project-alpha/.agents/skills/review'],
187
- }),
188
- ).toMatchObject({
189
- requested_skill_refs: ['project:/tmp/project-alpha/.agents/skills/review'],
190
- });
191
- });
192
-
193
- it('snapshots explicit skill references while keeping internal refs out of message text', () => {
183
+ it('snapshots skill token presentation without creating runtime selection metadata', () => {
194
184
  expect(buildChatRunMetadata({
195
- requestedSkills: ['workspace:/tmp/workspace/skills/review'],
196
185
  skillRecords: [{
197
186
  ref: 'workspace:/tmp/workspace/skills/review',
198
187
  name: 'review',
@@ -204,8 +193,7 @@ describe('buildChatRunMetadata', () => {
204
193
  tokenKey: 'workspace:/tmp/workspace/skills/review',
205
194
  label: 'review',
206
195
  })],
207
- })).toMatchObject({
208
- requested_skill_refs: ['workspace:/tmp/workspace/skills/review'],
196
+ })).toEqual({
209
197
  ui_inline_tokens: {
210
198
  schemaVersion: 2,
211
199
  items: [{
@@ -16,7 +16,6 @@ export type SendMessageParams = {
16
16
  model?: string;
17
17
  thinkingLevel?: ThinkingLevel;
18
18
  projectRoot?: string | null;
19
- requestedSkills?: string[];
20
19
  attachments?: NcpDraftAttachment[];
21
20
  parts?: NcpMessagePart[];
22
21
  composerNodes?: ChatComposerNode[];
@@ -11,6 +11,9 @@ const mocks = vi.hoisted(() => ({
11
11
  grantServiceActions: vi.fn(),
12
12
  invokeServiceAction: vi.fn(),
13
13
  listServiceActions: vi.fn(),
14
+ listVerificationRecords: vi.fn(),
15
+ getPortableRuntimeAcceptanceStatus: vi.fn(),
16
+ exportPortableRuntimeAcceptance: vi.fn(),
14
17
  sendAgentMessage: vi.fn(),
15
18
  requestAuthorization: vi.fn(),
16
19
  }));
@@ -27,6 +30,9 @@ vi.mock('@/shared/lib/api', () => ({
27
30
  grantServiceActions: mocks.grantServiceActions,
28
31
  invokeServiceAction: mocks.invokeServiceAction,
29
32
  listServiceActions: mocks.listServiceActions,
33
+ listVerificationRecords: mocks.listVerificationRecords,
34
+ getPortableRuntimeAcceptanceStatus: mocks.getPortableRuntimeAcceptanceStatus,
35
+ exportPortableRuntimeAcceptance: mocks.exportPortableRuntimeAcceptance,
30
36
  revokeServiceAction: vi.fn(),
31
37
  },
32
38
  },
@@ -231,4 +237,49 @@ describe('PanelAppBridgeManager', () => {
231
237
  bridgeSessionToken: 'token-3',
232
238
  });
233
239
  });
240
+
241
+ it('projects sandbox-safe verification and acceptance reads through the host bridge', async () => {
242
+ const manager = createManager();
243
+ const { postMessage, send } = createIframeHarness(manager);
244
+ mocks.listVerificationRecords.mockResolvedValue({ entries: [{ acceptanceId: 'PRT-ENTRY-001' }] });
245
+ mocks.getPortableRuntimeAcceptanceStatus.mockResolvedValue({ schemaVersion: 1, entries: [] });
246
+ mocks.exportPortableRuntimeAcceptance.mockResolvedValue({ schemaVersion: 1, entries: [] });
247
+
248
+ send({
249
+ method: 'verification.list',
250
+ payload: { appId: 'nextclaw.portable-runtime-lab', limit: 500 },
251
+ requestId: 'verification-1',
252
+ type: 'nextclaw:panel-app-service-actions:request',
253
+ });
254
+ send({
255
+ method: 'acceptance.status',
256
+ payload: { locale: 'zh-CN' },
257
+ requestId: 'acceptance-1',
258
+ type: 'nextclaw:panel-app-service-actions:request',
259
+ });
260
+ send({
261
+ method: 'acceptance.export',
262
+ payload: { locale: 'zh-CN' },
263
+ requestId: 'acceptance-2',
264
+ type: 'nextclaw:panel-app-service-actions:request',
265
+ });
266
+
267
+ await waitFor(() => expect(postMessage).toHaveBeenCalledTimes(3));
268
+ expect(mocks.listVerificationRecords).toHaveBeenCalledWith({
269
+ acceptanceId: undefined,
270
+ appId: 'nextclaw.portable-runtime-lab',
271
+ bridgeSessionToken: 'token-1',
272
+ limit: 500,
273
+ });
274
+ expect(mocks.getPortableRuntimeAcceptanceStatus).toHaveBeenCalledWith({
275
+ appId: undefined,
276
+ bridgeSessionToken: 'token-1',
277
+ locale: 'zh-CN',
278
+ });
279
+ expect(mocks.exportPortableRuntimeAcceptance).toHaveBeenCalledWith({
280
+ appId: undefined,
281
+ bridgeSessionToken: 'token-1',
282
+ locale: 'zh-CN',
283
+ });
284
+ });
234
285
  });