@nextclaw/ui 0.15.19 → 0.15.21

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 (128) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/assets/api-CHo2gekJ.js +13 -0
  3. package/dist/assets/app-presenter-provider-2_DmWYT9.js +108 -0
  4. package/dist/assets/appearance-settings-page-BEy2UE7R.js +1 -0
  5. package/dist/assets/{book-open-HtUSPwoS.js → book-open-BrZL4o9L.js} +1 -1
  6. package/dist/assets/card-B5rRLu_6.js +1 -0
  7. package/dist/assets/{channels-list-page-Bspr1Jp4.js → channels-list-page-C1dnm68I.js} +2 -2
  8. package/dist/assets/chat-page-Dz6tvZ91.js +1 -0
  9. package/dist/assets/config-split-page-6y6W6dz4.js +1 -0
  10. package/dist/assets/confirm-dialog-q1CpEmR8.js +5 -0
  11. package/dist/assets/desktop-update-config-DrdlwP4j.js +1 -0
  12. package/dist/assets/{dist-CZx9Q6jc.js → dist-C9EX46wM.js} +1 -1
  13. package/dist/assets/doc-browser-BfttDSti.js +1 -0
  14. package/dist/assets/{doc-browser-B4l0Xu7u.js → doc-browser-DH-3hDnI.js} +1 -1
  15. package/dist/assets/{doc-browser-context-Dy7EhLI8.js → doc-browser-context-TU2heon0.js} +1 -1
  16. package/dist/assets/{eye-DU7p1-7X.js → eye-B-3dyZNA.js} +1 -1
  17. package/dist/assets/form-actions-DvvxCpL4.js +1 -0
  18. package/dist/assets/index-CXDPLDFD.css +1 -0
  19. package/dist/assets/index-k_eWScvN.js +2 -0
  20. package/dist/assets/loader-circle-B35NlHTI.js +1 -0
  21. package/dist/assets/logo-badge-DB51c0cS.js +1 -0
  22. package/dist/assets/mcp-marketplace-page-CkPtwPBk.js +1 -0
  23. package/dist/assets/{mcp-marketplace-page-Dg03SZX1.js → mcp-marketplace-page-DWtsIqml.js} +3 -3
  24. package/dist/assets/{model-config-page-DOBqLZoQ.js → model-config-page-D6cGNnMM.js} +1 -1
  25. package/dist/assets/{navigation-link-Ct6xtnVM.js → navigation-link-BSJEUiwD.js} +1 -1
  26. package/dist/assets/plus-BRVe5jSJ.js +1 -0
  27. package/dist/assets/{popover-C2oIpGBF.js → popover-Bp9IpE_N.js} +1 -1
  28. package/dist/assets/provider-scoped-model-input-dk7fz9NL.js +1 -0
  29. package/dist/assets/{providers-config-page-BAVPJKpx.js → providers-config-page-DTP2YfxA.js} +1 -1
  30. package/dist/assets/react-D6ShNq95.js +8 -0
  31. package/dist/assets/{refresh-cw-BZ8giusN.js → refresh-cw-C2e94pmp.js} +1 -1
  32. package/dist/assets/remote-FllhXW3o.js +1 -0
  33. package/dist/assets/rotate-cw-CTC-Qszi.js +1 -0
  34. package/dist/assets/runtime-config-page-C57OLdPm.js +1 -0
  35. package/dist/assets/{save-CSzFGRse.js → save-CkwDOgvt.js} +1 -1
  36. package/dist/assets/{search-BmSWsiG2.js → search-CLXM2reO.js} +1 -1
  37. package/dist/assets/{search-config-page-CrL1pUzy.js → search-config-page-tnJXaaSr.js} +1 -1
  38. package/dist/assets/{secrets-config-page-BlgsD0-L.js → secrets-config-page-DQ_Curaj.js} +1 -1
  39. package/dist/assets/security-config-S00DjFfO.js +1 -0
  40. package/dist/assets/{select-CklWb6Rs.js → select-CNFBf00l.js} +1 -1
  41. package/dist/assets/{settings-2-BSZf8327.js → settings-2-B9IT2HGd.js} +1 -1
  42. package/dist/assets/skeleton-Dc06gatK.js +1 -0
  43. package/dist/assets/{tabs-Cah5klko.js → tabs-BuNr8LSO.js} +1 -1
  44. package/dist/assets/{tag-chip-DcrirDgN.js → tag-chip-CDkB5smU.js} +1 -1
  45. package/dist/assets/{trash-2-BLQOULWD.js → trash-2-GchaJuEM.js} +1 -1
  46. package/dist/assets/{use-config-CuFIsUVG.js → use-config-C69rR15i.js} +1 -1
  47. package/dist/assets/use-confirm-dialog-DF_EgPYx.js +1 -0
  48. package/dist/assets/{use-viewport-layout-DU29bUJS.js → use-viewport-layout-BACNZyMY.js} +1 -1
  49. package/dist/assets/x-BXFquY9e.js +1 -0
  50. package/dist/index.html +28 -28
  51. package/package.json +9 -9
  52. package/src/app/components/__tests__/app-notification-runtime.test.tsx +32 -0
  53. package/src/app/components/app-notification-runtime.tsx +15 -0
  54. package/src/app/components/layout/sidebar-items.tsx +23 -9
  55. package/src/app/configs/app-navigation.config.ts +21 -0
  56. package/src/app/index.tsx +22 -2
  57. package/src/app/presenters/app.presenter.ts +8 -0
  58. package/src/features/chat/components/layout/chat-page-shell.tsx +7 -2
  59. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +12 -0
  60. package/src/features/chat/features/conversation/hooks/__tests__/use-session-run-queue.test.tsx +28 -0
  61. package/src/features/chat/features/conversation/hooks/use-session-run-queue.ts +4 -5
  62. package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +13 -0
  63. package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +31 -8
  64. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +79 -9
  65. package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +16 -4
  66. package/src/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types.ts +10 -2
  67. package/src/features/chat/features/input/input-surface-plugins/context-reference-items.utils.ts +240 -0
  68. package/src/features/chat/features/input/input-surface-plugins/context-reference-plugin.utils.ts +62 -173
  69. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +29 -0
  70. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +5 -0
  71. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +26 -5
  72. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +12 -1
  73. package/src/features/chat/features/message/components/chat-message-list.container.tsx +9 -5
  74. package/src/features/chat/index.ts +1 -0
  75. package/src/features/chat/managers/__tests__/chat-completion-notification.manager.test.ts +177 -0
  76. package/src/features/chat/managers/chat-completion-notification.manager.ts +146 -0
  77. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +26 -0
  78. package/src/features/chat/pages/__tests__/ncp-chat-page.test.tsx +29 -2
  79. package/src/features/chat/pages/ncp-chat-page.tsx +9 -0
  80. package/src/features/inbox/components/inbox-delivery-content.test.tsx +57 -0
  81. package/src/features/inbox/components/inbox-delivery-content.tsx +118 -0
  82. package/src/features/inbox/components/inbox-reader-dialog.test.tsx +77 -0
  83. package/src/features/inbox/components/inbox-reader-dialog.tsx +224 -0
  84. package/src/features/inbox/components/inbox-runtime.tsx +14 -0
  85. package/src/features/inbox/hooks/use-inbox-deliveries.ts +14 -0
  86. package/src/features/inbox/index.ts +7 -0
  87. package/src/features/inbox/managers/inbox.manager.test.ts +86 -0
  88. package/src/features/inbox/managers/inbox.manager.ts +178 -0
  89. package/src/features/inbox/pages/inbox-page.test.ts +28 -0
  90. package/src/features/inbox/pages/inbox-page.tsx +385 -0
  91. package/src/features/inbox/stores/inbox.store.ts +23 -0
  92. package/src/features/notifications/components/__tests__/app-notification-toast.test.tsx +48 -0
  93. package/src/features/notifications/components/app-notification-toast.tsx +80 -0
  94. package/src/features/notifications/index.ts +8 -0
  95. package/src/features/notifications/managers/__tests__/app-notification.manager.test.ts +75 -0
  96. package/src/features/notifications/managers/app-notification.manager.ts +41 -0
  97. package/src/platforms/mobile/components/mobile-bottom-nav.tsx +14 -7
  98. package/src/shared/hooks/use-projects.ts +2 -1
  99. package/src/shared/lib/i18n/index.ts +4 -0
  100. package/src/shared/lib/i18n/locales/en-US/chat.json +16 -4
  101. package/src/shared/lib/i18n/locales/en-US/inbox.json +30 -0
  102. package/src/shared/lib/i18n/locales/zh-CN/chat.json +16 -4
  103. package/src/shared/lib/i18n/locales/zh-CN/inbox.json +30 -0
  104. package/dist/assets/api-O5yEgzBt.js +0 -13
  105. package/dist/assets/app-presenter-provider-DXTYtH78.js +0 -106
  106. package/dist/assets/appearance-settings-page-BuGxSvlJ.js +0 -1
  107. package/dist/assets/card-GkBc-UMw.js +0 -1
  108. package/dist/assets/chat-page-BKQ89f4s.js +0 -1
  109. package/dist/assets/config-split-page-CvLXSFSC.js +0 -1
  110. package/dist/assets/confirm-dialog-BVH7haN0.js +0 -5
  111. package/dist/assets/desktop-update-config-C9ZUI8UW.js +0 -1
  112. package/dist/assets/doc-browser-DsVF1DrS.js +0 -1
  113. package/dist/assets/form-actions-aM9_jSJC.js +0 -1
  114. package/dist/assets/index-6pHX0DZH.js +0 -2
  115. package/dist/assets/index-DN6KM7WD.css +0 -1
  116. package/dist/assets/loader-circle-Dd6jw700.js +0 -1
  117. package/dist/assets/logo-badge-2-iV1pkr.js +0 -1
  118. package/dist/assets/mcp-marketplace-page-C-d0RMfg.js +0 -1
  119. package/dist/assets/plus-CsgeRId8.js +0 -1
  120. package/dist/assets/provider-scoped-model-input-DDqCdnTU.js +0 -1
  121. package/dist/assets/react-CeMr9V52.js +0 -8
  122. package/dist/assets/remote-C-r0SHcz.js +0 -1
  123. package/dist/assets/rotate-cw-DVdRfMSO.js +0 -1
  124. package/dist/assets/runtime-config-page-D0244uxJ.js +0 -1
  125. package/dist/assets/security-config-cDrdpMJW.js +0 -1
  126. package/dist/assets/skeleton-DG9u1BmA.js +0 -1
  127. package/dist/assets/use-confirm-dialog-CcySmkWp.js +0 -1
  128. package/dist/assets/x-BVoeXny1.js +0 -1
@@ -5,6 +5,7 @@ import {
5
5
  AlarmClock,
6
6
  Cpu,
7
7
  Download,
8
+ Inbox,
8
9
  KeyRound,
9
10
  MessageCircle,
10
11
  MessageSquare,
@@ -44,6 +45,8 @@ export function isMainWorkspaceRoute(pathname: string): boolean {
44
45
  return (
45
46
  normalized === "/chat" ||
46
47
  normalized.startsWith("/chat/") ||
48
+ normalized === "/inbox" ||
49
+ normalized.startsWith("/inbox/") ||
47
50
  normalized === "/skills" ||
48
51
  normalized.startsWith("/skills/") ||
49
52
  normalized === "/cron" ||
@@ -67,6 +70,11 @@ export function getMobileBottomNavItems(
67
70
  label: translate("chat"),
68
71
  icon: MessageCircle,
69
72
  },
73
+ {
74
+ target: "/inbox",
75
+ label: translate("inboxTitle"),
76
+ icon: Inbox,
77
+ },
70
78
  {
71
79
  target: "/skills",
72
80
  label: translate("marketplaceFilterSkills"),
@@ -94,6 +102,11 @@ export function getMainSidebarNavItems(
94
102
  label: translate("chat"),
95
103
  icon: MessageCircle,
96
104
  },
105
+ {
106
+ target: "/inbox",
107
+ label: translate("inboxTitle"),
108
+ icon: Inbox,
109
+ },
97
110
  {
98
111
  target: "/chat/cron",
99
112
  label: translate("cron"),
@@ -227,6 +240,14 @@ export function resolveMobileRouteMeta(
227
240
  };
228
241
  }
229
242
 
243
+ if (normalized === "/inbox" || normalized.startsWith("/inbox/")) {
244
+ return {
245
+ title: translate("inboxTitle"),
246
+ backTarget: normalized === "/inbox" ? null : "/inbox",
247
+ backLabel: translate("inboxTitle"),
248
+ };
249
+ }
250
+
230
251
  if (normalized === "/skills" || normalized.startsWith("/skills/")) {
231
252
  return {
232
253
  title: translate("marketplaceFilterSkills"),
package/src/app/index.tsx CHANGED
@@ -1,14 +1,17 @@
1
1
  import { lazy, Suspense, useEffect } from "react";
2
- import type { ReactElement } from "react";
2
+ import type { CSSProperties, ReactElement } from "react";
3
3
  import { QueryClientProvider } from "@tanstack/react-query";
4
4
  import { Navigate, Route, Routes } from "react-router-dom";
5
5
  import { Toaster } from "sonner";
6
6
  import { appQueryClient } from "@/app-query-client";
7
7
  import { AppPresenterProvider } from "@/app/components/app-presenter-provider";
8
+ import { AppNotificationRuntime } from "@/app/components/app-notification-runtime";
8
9
  import { AppLayout } from "@/app/components/layout/app-layout";
10
+ import { SIDEBAR_RAIL_WIDTH_PX } from "@/app/components/layout/sidebar-rail.styles";
9
11
  import { SettingsEntryPage } from "@/app/components/layout/settings-entry-page";
10
12
  import { LoginPage } from "@/components/auth/login-page";
11
13
  import { AccountPanel } from "@/features/account";
14
+ import { InboxRuntime } from "@/features/inbox";
12
15
  import { ServiceActionAuthorizationDialog } from "@/features/service-apps";
13
16
  import { runtimeUpdateManager, useSystemStatusSources } from "@/features/system-status";
14
17
  import {
@@ -21,6 +24,8 @@ import {
21
24
  } from "@/pwa/components/pwa-install-entry";
22
25
  import { startNextClawPwa } from "@/pwa/register-pwa";
23
26
 
27
+ const NOTIFICATION_TOASTER_STYLE = { "--width": "320px" } as CSSProperties;
28
+
24
29
  const ModelConfigPage = lazy(async () => ({
25
30
  default: (await import("@/features/settings/pages/model-config-page"))
26
31
  .ModelConfigPage,
@@ -117,6 +122,10 @@ const protectedRouteDefinitions: ProtectedRouteDefinition[] = [
117
122
  path: "/cron",
118
123
  element: createLazyElement(<ChatPage view="cron" />),
119
124
  },
125
+ {
126
+ path: "/inbox/:deliveryId?",
127
+ element: createLazyElement(<ChatPage view="inbox" />),
128
+ },
120
129
  {
121
130
  path: "/appearance",
122
131
  element: createLazyElement(<AppearanceSettingsPage />),
@@ -219,6 +228,8 @@ function ProtectedApp() {
219
228
 
220
229
  return (
221
230
  <AppPresenterProvider>
231
+ <AppNotificationRuntime />
232
+ <InboxRuntime />
222
233
  <AppLayout>
223
234
  <ProtectedRoutes />
224
235
  </AppLayout>
@@ -257,7 +268,16 @@ export default function AppContent() {
257
268
  <QueryClientProvider client={appQueryClient}>
258
269
  <AuthGate />
259
270
  <PwaInstallBanner />
260
- <Toaster position="top-right" richColors offset={window.nextclawDesktop?.platform === "win32" ? 56 : undefined} mobileOffset={window.nextclawDesktop?.platform === "win32" ? 56 : undefined} />
271
+ <Toaster
272
+ position="top-right"
273
+ richColors
274
+ offset={{
275
+ top: window.nextclawDesktop?.platform === "win32" ? 56 : 44,
276
+ right: SIDEBAR_RAIL_WIDTH_PX + 16,
277
+ }}
278
+ mobileOffset={window.nextclawDesktop?.platform === "win32" ? 56 : undefined}
279
+ style={NOTIFICATION_TOASTER_STYLE}
280
+ />
261
281
  </QueryClientProvider>
262
282
  );
263
283
  }
@@ -1,10 +1,13 @@
1
1
  import { AccountManager } from '@/features/account';
2
2
  import { viewportLayoutManager } from '@/app/managers/viewport-layout.manager';
3
3
  import {
4
+ ChatCompletionNotificationManager,
4
5
  ChatDraftIntentManager,
5
6
  useChatThreadStore,
6
7
  } from '@/features/chat';
8
+ import { AppNotificationManager } from '@/features/notifications';
7
9
  import { PanelAppBridgeManager } from '@/features/panel-apps';
10
+ import { InboxManager } from '@/features/inbox';
8
11
  import { RightPanelResourceRouteResolver } from '@/features/right-panel-resources';
9
12
  import { RemoteAccessManager } from '@/features/remote';
10
13
  import { ServiceActionAuthorizationManager } from '@/features/service-apps';
@@ -19,6 +22,11 @@ function isChatWorkspacePanelOpen(snapshot: ChatThreadSnapshot): boolean {
19
22
  }
20
23
 
21
24
  export class AppPresenter {
25
+ notificationManager = new AppNotificationManager();
26
+ inboxManager = new InboxManager();
27
+ chatCompletionNotificationManager = new ChatCompletionNotificationManager(
28
+ this.notificationManager,
29
+ );
22
30
  accountManager = new AccountManager();
23
31
  rightPanelResourceRouteResolver = new RightPanelResourceRouteResolver();
24
32
  notifyRightPanelOpened = () => {
@@ -6,7 +6,8 @@ import { CronConfig } from "@/features/cron";
6
6
  import { MarketplacePage } from "@/features/marketplace";
7
7
  import { useViewportLayout } from "@/app/hooks/use-viewport-layout";
8
8
  import { ChatMobileShell } from "@/platforms/mobile";
9
- export type MainPanelView = "chat" | "cron" | "skills" | "agents";
9
+ import { InboxPage } from "@/features/inbox";
10
+ export type MainPanelView = "chat" | "cron" | "skills" | "agents" | "inbox";
10
11
  export type ChatPageProps = {
11
12
  view: MainPanelView;
12
13
  };
@@ -51,7 +52,11 @@ export function ChatPageLayout({ view, confirmDialog }: ChatPageLayoutProps) {
51
52
  isMobile ? <ChatMobileShell /> : <ChatConversationPanel />
52
53
  ) : (
53
54
  <section className="flex-1 min-h-0 overflow-hidden bg-background">
54
- {view === "cron" ? (
55
+ {view === "inbox" ? (
56
+ <div className="mx-auto flex h-full min-h-0 w-full max-w-[min(1180px,100%)] flex-col py-4 sm:px-6 sm:py-5">
57
+ <InboxPage />
58
+ </div>
59
+ ) : view === "cron" ? (
55
60
  <div className="h-full overflow-auto custom-scrollbar">
56
61
  <div className="mx-auto w-full max-w-[min(1120px,100%)] px-4 py-4 sm:px-6 sm:py-5">
57
62
  <CronConfig />
@@ -16,10 +16,12 @@ import {
16
16
  AlarmClock,
17
17
  Bot,
18
18
  BrainCircuit,
19
+ Inbox,
19
20
  PanelLeftClose,
20
21
  PanelLeftOpen,
21
22
  Plus,
22
23
  } from "lucide-react";
24
+ import { useInboxUnreadCount } from "@/features/inbox";
23
25
  import { ChatSidebarSessionList } from "@/features/chat/features/session/components/chat-sidebar-session-list";
24
26
  import { ChatSidebarUtilityMenu } from "@/features/chat/components/layout/chat-sidebar-utility-menu";
25
27
  import { isWindowsDesktopHost } from "@/platforms/desktop";
@@ -34,6 +36,11 @@ import {
34
36
  } from "@/app/components/layout/sidebar-rail.styles";
35
37
 
36
38
  const navItems = [
39
+ {
40
+ target: "/inbox",
41
+ label: () => t("inboxTitle"),
42
+ icon: Inbox,
43
+ },
37
44
  {
38
45
  target: "/cron",
39
46
  label: () => t("chatSidebarScheduledTasks"),
@@ -99,6 +106,7 @@ export function ChatSidebarDesktopNav({
99
106
  }: {
100
107
  isCollapsed: boolean;
101
108
  }) {
109
+ const unreadCount = useInboxUnreadCount();
102
110
  return (
103
111
  <>
104
112
  <div className={cn("pb-2", isCollapsed ? "px-0" : "px-3")}>
@@ -114,6 +122,10 @@ export function ChatSidebarDesktopNav({
114
122
  icon={item.icon}
115
123
  density="compact"
116
124
  collapsed={isCollapsed}
125
+ indicator={item.target === "/inbox" && unreadCount > 0}
126
+ trailing={item.target === "/inbox" && unreadCount > 0
127
+ ? unreadCount > 99 ? "99+" : unreadCount
128
+ : undefined}
117
129
  />
118
130
  </li>
119
131
  ))}
@@ -82,4 +82,32 @@ describe('useSessionRunQueue', () => {
82
82
  await waitFor(() => expect(listQueuedInputs).toHaveBeenCalledTimes(2));
83
83
  unmount();
84
84
  });
85
+
86
+ it('replaces an in-flight stale queue read after the queue changes', async () => {
87
+ let resolveInitialRead!: (queue: UiNcpSessionQueuedInputsView) => void;
88
+ const initialRead = new Promise<UiNcpSessionQueuedInputsView>((resolve) => {
89
+ resolveInitialRead = resolve;
90
+ });
91
+ const listQueuedInputs = vi.spyOn(nextclawClient.sessions, 'listQueuedInputs')
92
+ .mockImplementationOnce(async () => await initialRead)
93
+ .mockResolvedValue({ sessionId: 'session-1', inputs: [] });
94
+ const { result } = renderHook(
95
+ () => useSessionRunQueue('session-1'),
96
+ { wrapper: createWrapper() },
97
+ );
98
+ await waitFor(() => expect(listQueuedInputs).toHaveBeenCalledTimes(1));
99
+
100
+ act(() => {
101
+ nextclawClient.eventBus.emit(eventKeys.sessionRunQueueUpdated, {
102
+ sessionKey: 'session-1',
103
+ });
104
+ });
105
+ await waitFor(() => expect(listQueuedInputs).toHaveBeenCalledTimes(2));
106
+
107
+ await act(async () => {
108
+ resolveInitialRead(createQueue('session-1', 'stale queued input'));
109
+ await initialRead;
110
+ });
111
+ await waitFor(() => expect(result.current.inputs).toEqual([]));
112
+ });
85
113
  });
@@ -21,14 +21,13 @@ export function useSessionRunQueue(sessionKey: string | null) {
21
21
  if (!normalizedSessionKey) {
22
22
  return undefined;
23
23
  }
24
- return nextclawClient.eventBus.on(eventKeys.sessionRunQueueUpdated, ({ sessionKey: updatedSessionKey }) => {
24
+ return nextclawClient.eventBus.on(eventKeys.sessionRunQueueUpdated, async ({ sessionKey: updatedSessionKey }) => {
25
25
  if (updatedSessionKey !== normalizedSessionKey) {
26
26
  return;
27
27
  }
28
- void queryClient.invalidateQueries({
29
- queryKey: [SESSION_RUN_QUEUE_QUERY_KEY, normalizedSessionKey],
30
- exact: true,
31
- });
28
+ const queryKey = [SESSION_RUN_QUEUE_QUERY_KEY, normalizedSessionKey];
29
+ await queryClient.cancelQueries({ queryKey, exact: true });
30
+ await queryClient.invalidateQueries({ queryKey, exact: true });
32
31
  });
33
32
  }, [normalizedSessionKey, queryClient]);
34
33
 
@@ -9,6 +9,14 @@ const usePanelAppsMock = vi.hoisted(() =>
9
9
  isLoading: false,
10
10
  })),
11
11
  );
12
+ const useProjectsMock = vi.hoisted(() =>
13
+ vi.fn(() => ({
14
+ data: { projects: [] },
15
+ error: null,
16
+ isFetching: false,
17
+ isLoading: false,
18
+ })),
19
+ );
12
20
  const useServerPathSearchMock = vi.hoisted(() =>
13
21
  vi.fn(() => ({
14
22
  data: { entries: [] },
@@ -38,6 +46,9 @@ const useServerPathBrowseMock = vi.hoisted(() =>
38
46
  vi.mock('@/features/panel-apps', () => ({
39
47
  usePanelApps: usePanelAppsMock,
40
48
  }));
49
+ vi.mock('@/shared/hooks/use-projects', () => ({
50
+ useProjects: useProjectsMock,
51
+ }));
41
52
  vi.mock('@/shared/hooks/use-server-path-search', () => ({
42
53
  useServerPathSearch: useServerPathSearchMock,
43
54
  }));
@@ -68,6 +79,7 @@ function createHookParams() {
68
79
 
69
80
  beforeEach(() => {
70
81
  usePanelAppsMock.mockClear();
82
+ useProjectsMock.mockClear();
71
83
  useServerPathBrowseMock.mockClear();
72
84
  useServerPathSearchMock.mockClear();
73
85
  });
@@ -84,6 +96,7 @@ it('browses nested folders while reserving search for project-wide queries', ()
84
96
  start: 0,
85
97
  }),
86
98
  );
99
+ expect(useProjectsMock).toHaveBeenLastCalledWith({ enabled: true });
87
100
  const filesItem = result.current.inputSurfaceState.panel?.items.find(
88
101
  (item) => item.selectionBehavior === 'navigate',
89
102
  );
@@ -10,11 +10,13 @@ import {
10
10
  CONTEXT_REFERENCE_TRIGGER_SPEC,
11
11
  createContextReferenceInputSurfacePlugin,
12
12
  } from '@/features/chat/features/input/input-surface-plugins/context-reference-plugin.utils';
13
+ import type { ContextReferenceLocation } from '@/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types';
13
14
  import {
14
15
  createSlashCommandInputSurfacePlugin,
15
16
  type ChatSlashCommandDescriptor,
16
17
  } from '@/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils';
17
18
  import type { ChatInputBarAdapterTexts, ChatSkillRecord } from '@/features/chat/types/chat-input-bar.types';
19
+ import { useProjects } from '@/shared/hooks/use-projects';
18
20
  import { useServerPathBrowse } from '@/shared/hooks/use-server-path-browse';
19
21
  import { useServerPathSearch } from '@/shared/hooks/use-server-path-search';
20
22
  import type { ServerPathEntryView, ServerPathSearchEntryView } from '@/shared/lib/api';
@@ -39,7 +41,7 @@ type UseChatInputSurfaceStateParams = {
39
41
  function buildChatInputSurfacePlugins(params: {
40
42
  commands: readonly ChatSlashCommandDescriptor[];
41
43
  language: I18nLanguage;
42
- onNavigate: (path: string | null) => void;
44
+ onNavigate: (location: ContextReferenceLocation) => void;
43
45
  onSelectPanelApp: (appId: string) => void;
44
46
  onSelectSkill: (skillRef: string) => void;
45
47
  slashTexts: ChatInputSurfaceSlashTexts;
@@ -105,6 +107,15 @@ function buildChatInputSurfacePlugins(params: {
105
107
  parentLabel: t('chatContextReferenceParent', language),
106
108
  parentDescription: t('chatContextReferenceParentDescription', language),
107
109
  parentHintLabel: t('chatContextReferenceParentHint', language),
110
+ projectDescription: t('chatContextReferenceProjectDescription', language),
111
+ projectPathLabel: t('chatContextReferenceProjectPath', language),
112
+ projectSectionLabel: t('chatContextReferenceProjectSection', language),
113
+ projectSubtitle: t('chatContextReferenceProjectType', language),
114
+ projectsDescription: t('chatContextReferenceProjectsDescription', language),
115
+ projectsHintLabel: t('chatContextReferenceProjectsHint', language),
116
+ projectsLabel: t('chatContextReferenceProjectsLabel', language),
117
+ projectsLoadFailedLabel: t('chatContextReferenceProjectsLoadFailed', language),
118
+ projectsSubtitle: t('chatContextReferenceProjectsType', language),
108
119
  projectRootLabel: t('chatContextReferenceProjectRoot', language),
109
120
  searchFailedLabel: t('chatContextReferenceSearchFailed', language),
110
121
  workspaceSectionLabel: t('chatContextReferenceWorkspaceSection', language),
@@ -150,7 +161,9 @@ export function useChatInputSurfaceState(params: UseChatInputSurfaceStateParams)
150
161
  skillRecords,
151
162
  commands,
152
163
  } = params;
153
- const [referencePath, setReferencePath] = useState<string | null>(null);
164
+ const [referenceLocation, setReferenceLocation] = useState<ContextReferenceLocation>({
165
+ view: 'root',
166
+ });
154
167
  const [inputSurfaceTrigger, setInputSurfaceTriggerState] = useState<ChatInputSurfaceTrigger | null>(null);
155
168
  const inputSurfaceTriggerSignatureRef = useRef('null');
156
169
  const setInputSurfaceTrigger = useCallback((nextTrigger: ChatInputSurfaceTrigger | null): void => {
@@ -158,23 +171,25 @@ export function useChatInputSurfaceState(params: UseChatInputSurfaceStateParams)
158
171
  if (inputSurfaceTriggerSignatureRef.current === nextSignature) return;
159
172
  inputSurfaceTriggerSignatureRef.current = nextSignature;
160
173
  if (!nextTrigger) {
161
- setReferencePath(null);
174
+ setReferenceLocation({ view: 'root' });
162
175
  }
163
176
  setInputSurfaceTriggerState(nextTrigger);
164
177
  }, []);
165
178
  const isContextReferenceTrigger = inputSurfaceTrigger?.key === CONTEXT_REFERENCE_TRIGGER_SPEC.key;
179
+ const referencePath = referenceLocation.view === 'files' ? referenceLocation.path : '';
166
180
  const deferredReferenceQuery = useDeferredValue(
167
181
  isContextReferenceTrigger ? inputSurfaceTrigger.query : '',
168
182
  );
169
183
  const isBrowsingServerPaths = Boolean(
170
184
  isContextReferenceTrigger &&
171
185
  projectRoot &&
172
- referencePath !== null &&
186
+ referenceLocation.view === 'files' &&
173
187
  !deferredReferenceQuery.trim(),
174
188
  );
175
189
  const shouldSearchServerPaths = Boolean(
176
190
  isContextReferenceTrigger &&
177
191
  projectRoot &&
192
+ referenceLocation.view !== 'projects' &&
178
193
  deferredReferenceQuery.trim(),
179
194
  );
180
195
  const serverPathBrowse = useServerPathBrowse({
@@ -193,7 +208,7 @@ export function useChatInputSurfaceState(params: UseChatInputSurfaceStateParams)
193
208
  () => isBrowsingServerPaths
194
209
  ? buildBrowsedReferenceEntries({
195
210
  entries: serverPathBrowse.data?.entries ?? [],
196
- parentRelativePath: referencePath ?? '',
211
+ parentRelativePath: referencePath,
197
212
  })
198
213
  : serverPathSearch.data?.entries ?? [],
199
214
  [
@@ -207,12 +222,13 @@ export function useChatInputSurfaceState(params: UseChatInputSurfaceStateParams)
207
222
  isContextReferenceTrigger ||
208
223
  inputSurfaceTrigger?.key === CHAT_INPUT_SURFACE_SLASH_TRIGGER_SPEC.key;
209
224
  const panelApps = usePanelApps({ enabled: shouldLoadPanelApps });
225
+ const projects = useProjects({ enabled: isContextReferenceTrigger });
210
226
 
211
227
  const inputSurfacePlugins = useMemo(
212
228
  () => buildChatInputSurfacePlugins({
213
229
  commands,
214
230
  language,
215
- onNavigate: setReferencePath,
231
+ onNavigate: setReferenceLocation,
216
232
  onSelectPanelApp,
217
233
  onSelectSkill,
218
234
  slashTexts: itemTexts.slashTexts,
@@ -229,12 +245,15 @@ export function useChatInputSurfaceState(params: UseChatInputSurfaceStateParams)
229
245
  () => resolveChatInputSurfaceState({
230
246
  data: {
231
247
  isPanelAppsLoading: panelApps.isLoading || panelApps.isFetching,
248
+ isProjectsLoading: projects.isLoading || projects.isFetching,
232
249
  isServerPathSearchLoading: activeServerPathQuery.isLoading || activeServerPathQuery.isFetching,
233
250
  isSkillsLoading,
234
251
  panelApps: panelApps.data?.entries ?? [],
235
252
  projectRoot,
253
+ projects: projects.data?.projects ?? [],
254
+ projectsError: projects.error instanceof Error ? projects.error.message : null,
236
255
  recentSkillValues,
237
- referencePath,
256
+ referenceLocation,
238
257
  serverPathEntries,
239
258
  serverPathSearchError: activeServerPathQuery.error instanceof Error
240
259
  ? activeServerPathQuery.error.message
@@ -251,10 +270,14 @@ export function useChatInputSurfaceState(params: UseChatInputSurfaceStateParams)
251
270
  panelApps.isFetching,
252
271
  panelApps.isLoading,
253
272
  projectRoot,
273
+ projects.data?.projects,
274
+ projects.error,
275
+ projects.isFetching,
276
+ projects.isLoading,
254
277
  activeServerPathQuery.error,
255
278
  activeServerPathQuery.isFetching,
256
279
  activeServerPathQuery.isLoading,
257
- referencePath,
280
+ referenceLocation,
258
281
  serverPathEntries,
259
282
  isSkillsLoading,
260
283
  recentSkillValues,
@@ -4,7 +4,8 @@ import {
4
4
  CONTEXT_REFERENCE_TRIGGER_SPEC,
5
5
  createContextReferenceInputSurfacePlugin,
6
6
  } from '@/features/chat/features/input/input-surface-plugins/context-reference-plugin.utils';
7
- import type { PanelAppEntryView } from '@/shared/lib/api';
7
+ import type { ContextReferenceLocation } from '@/features/chat/features/input/input-surface-plugins/chat-input-product-plugin-adapters.types';
8
+ import type { PanelAppEntryView, ProjectView } from '@/shared/lib/api';
8
9
 
9
10
  function createPanelApp(): PanelAppEntryView {
10
11
  return {
@@ -25,6 +26,15 @@ function createPanelApp(): PanelAppEntryView {
25
26
  };
26
27
  }
27
28
 
29
+ function createProject(): ProjectView {
30
+ return {
31
+ name: 'NextClaw',
32
+ rootPath: '/tmp/nextclaw',
33
+ createdAt: '2026-07-28T00:00:00.000Z',
34
+ updatedAt: '2026-07-28T00:00:00.000Z',
35
+ };
36
+ }
37
+
28
38
  function createPlugin(onNavigate = vi.fn()) {
29
39
  return createContextReferenceInputSurfacePlugin({
30
40
  itemTexts: {
@@ -43,6 +53,15 @@ function createPlugin(onNavigate = vi.fn()) {
43
53
  parentLabel: 'Up one folder',
44
54
  parentDescription: 'Browse parent',
45
55
  parentHintLabel: 'Enter to browse parent',
56
+ projectDescription: 'Project context',
57
+ projectPathLabel: 'Project path',
58
+ projectSectionLabel: 'Projects',
59
+ projectSubtitle: 'Project',
60
+ projectsDescription: 'Browse registered projects',
61
+ projectsHintLabel: 'Enter to browse projects',
62
+ projectsLabel: 'Projects',
63
+ projectsLoadFailedLabel: 'Projects failed',
64
+ projectsSubtitle: 'Project Context',
46
65
  projectRootLabel: 'Project directory',
47
66
  searchFailedLabel: 'Search failed',
48
67
  workspaceSectionLabel: 'Files & Folders',
@@ -65,15 +84,18 @@ function createPlugin(onNavigate = vi.fn()) {
65
84
  });
66
85
  }
67
86
 
68
- function createData(referencePath: string | null = null) {
87
+ function createData(referenceLocation: ContextReferenceLocation = { view: 'root' }) {
69
88
  return {
70
89
  isPanelAppsLoading: false,
90
+ isProjectsLoading: false,
71
91
  isServerPathSearchLoading: false,
72
92
  isSkillsLoading: false,
73
93
  panelApps: [createPanelApp()],
74
94
  projectRoot: '/tmp/project',
95
+ projects: [createProject()],
96
+ projectsError: null,
75
97
  recentSkillValues: [],
76
- referencePath,
98
+ referenceLocation,
77
99
  serverPathEntries: [
78
100
  {
79
101
  name: 'server-path.ts',
@@ -98,7 +120,7 @@ function createData(referencePath: string | null = null) {
98
120
  }
99
121
 
100
122
  describe('context reference input surface plugin', () => {
101
- it('shows Files & Folders and panel apps in the root @ menu', () => {
123
+ it('shows Files & Folders, Projects, and panel apps in the root @ menu', () => {
102
124
  const state = resolveChatInputSurfaceState({
103
125
  plugins: [createPlugin()],
104
126
  trigger: {
@@ -116,6 +138,11 @@ describe('context reference input surface plugin', () => {
116
138
  icon: 'files',
117
139
  selectionBehavior: 'navigate',
118
140
  }),
141
+ expect.objectContaining({
142
+ title: 'Projects',
143
+ icon: 'project',
144
+ selectionBehavior: 'navigate',
145
+ }),
119
146
  expect.objectContaining({
120
147
  key: 'panel-app:task-board',
121
148
  tokenKind: 'panel_app',
@@ -133,7 +160,7 @@ describe('context reference input surface plugin', () => {
133
160
  start: 0,
134
161
  end: 7,
135
162
  },
136
- data: createData(''),
163
+ data: createData({ view: 'files', path: '' }),
137
164
  });
138
165
 
139
166
  expect(state.panel?.items.slice(1)).toEqual([
@@ -173,7 +200,7 @@ describe('context reference input surface plugin', () => {
173
200
 
174
201
  state.panel?.onSelectItem?.(item!);
175
202
 
176
- expect(onNavigate).toHaveBeenCalledWith('');
203
+ expect(onNavigate).toHaveBeenCalledWith({ view: 'files', path: '' });
177
204
  expect(item).not.toHaveProperty('tokenKind');
178
205
  });
179
206
 
@@ -187,7 +214,7 @@ describe('context reference input surface plugin', () => {
187
214
  start: 0,
188
215
  end: 1,
189
216
  },
190
- data: createData(''),
217
+ data: createData({ view: 'files', path: '' }),
191
218
  });
192
219
  const directoryItem = rootState.panel?.items.find((item) => item.title === 'docs');
193
220
 
@@ -197,7 +224,7 @@ describe('context reference input surface plugin', () => {
197
224
  });
198
225
  expect(directoryItem?.tokenKind).toBeUndefined();
199
226
  rootState.panel?.onSelectItem?.(directoryItem!);
200
- expect(onNavigate).toHaveBeenCalledWith('docs');
227
+ expect(onNavigate).toHaveBeenCalledWith({ view: 'files', path: 'docs' });
201
228
 
202
229
  const nestedState = resolveChatInputSurfaceState({
203
230
  plugins: [createPlugin(onNavigate)],
@@ -207,7 +234,7 @@ describe('context reference input surface plugin', () => {
207
234
  start: 0,
208
235
  end: 1,
209
236
  },
210
- data: createData('docs'),
237
+ data: createData({ view: 'files', path: 'docs' }),
211
238
  });
212
239
  expect(nestedState.panel?.items).toEqual(expect.arrayContaining([
213
240
  expect.objectContaining({
@@ -222,4 +249,47 @@ describe('context reference input surface plugin', () => {
222
249
  }),
223
250
  ]));
224
251
  });
252
+
253
+ it('opens the project view and builds semantic project tokens', () => {
254
+ const onNavigate = vi.fn();
255
+ const rootState = resolveChatInputSurfaceState({
256
+ plugins: [createPlugin(onNavigate)],
257
+ trigger: {
258
+ ...CONTEXT_REFERENCE_TRIGGER_SPEC,
259
+ query: '',
260
+ start: 0,
261
+ end: 1,
262
+ },
263
+ data: createData(),
264
+ });
265
+ const projectsItem = rootState.panel?.items.find((item) => item.title === 'Projects');
266
+
267
+ rootState.panel?.onSelectItem?.(projectsItem!);
268
+ expect(onNavigate).toHaveBeenCalledWith({ view: 'projects' });
269
+
270
+ const projectsState = resolveChatInputSurfaceState({
271
+ plugins: [createPlugin(onNavigate)],
272
+ trigger: {
273
+ ...CONTEXT_REFERENCE_TRIGGER_SPEC,
274
+ query: 'next',
275
+ start: 0,
276
+ end: 5,
277
+ },
278
+ data: createData({ view: 'projects' }),
279
+ });
280
+
281
+ expect(projectsState.panel?.items).toEqual([
282
+ expect.objectContaining({
283
+ title: 'Back',
284
+ selectionBehavior: 'navigate',
285
+ }),
286
+ expect.objectContaining({
287
+ title: 'NextClaw',
288
+ icon: 'project',
289
+ tokenKind: 'project',
290
+ tokenKey: '/tmp/nextclaw',
291
+ detailLines: ['Project path: /tmp/nextclaw'],
292
+ }),
293
+ ]);
294
+ });
225
295
  });