@nextclaw/ui 0.15.26 → 0.15.27

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 (125) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/assets/{api-CK3Fro7z.js → api-a_GaOFc7.js} +4 -4
  3. package/dist/assets/{appearance-settings-page-zqwWO8Dy.js → appearance-settings-page-DlGXLgff.js} +1 -1
  4. package/dist/assets/{channels-list-page-CVCZBlCL.js → channels-list-page-BEEk4Uc9.js} +2 -2
  5. package/dist/assets/{chat-page-D4wehRTz.js → chat-page-DUQXXqQm.js} +1 -1
  6. package/dist/assets/{config-split-page-CceNFvlY.js → config-split-page-ps1Aod3g.js} +1 -1
  7. package/dist/assets/{confirm-dialog-CWeNYWZY.js → confirm-dialog-DJSUNG33.js} +2 -2
  8. package/dist/assets/desktop-update-config--zagjHic.js +1 -0
  9. package/dist/assets/{dist-D9xtGgdL.js → dist-BkTYNzm_.js} +1 -1
  10. package/dist/assets/{dist-AYxkFx2B.js → dist-DkR7_Dpl.js} +1 -1
  11. package/dist/assets/doc-browser-DahzJEWj.js +1 -0
  12. package/dist/assets/doc-browser-Q67TOGVS.js +1 -0
  13. package/dist/assets/{doc-browser-context-Cb7Z0j90.js → doc-browser-context-BQim8SqD.js} +1 -1
  14. package/dist/assets/form-actions-B2-bpFie.js +1 -0
  15. package/dist/assets/index-Ba_o5fu9.js +109 -0
  16. package/dist/assets/index-DSLK7zgR.css +1 -0
  17. package/dist/assets/loader-circle-DyXZU92l.js +1 -0
  18. package/dist/assets/mcp-marketplace-page-B0dBE5CV.js +1 -0
  19. package/dist/assets/mcp-marketplace-page-w8panjZG.js +9 -0
  20. package/dist/assets/{model-config-page-DfOLyQqR.js → model-config-page-rrhFaque.js} +1 -1
  21. package/dist/assets/{navigation-link-MiiyoIjH.js → navigation-link-mpHFXwRr.js} +2 -2
  22. package/dist/assets/plus-Dhb4KuMH.js +1 -0
  23. package/dist/assets/{provider-scoped-model-input-DXzjr5l0.js → provider-scoped-model-input-CFsfWEt9.js} +1 -1
  24. package/dist/assets/{providers-config-page-CaO8WfZH.js → providers-config-page-D-TYFZsF.js} +1 -1
  25. package/dist/assets/react-BlXwvOLo.js +8 -0
  26. package/dist/assets/{refresh-cw-a2VVeeAF.js → refresh-cw-JFuyDj_P.js} +1 -1
  27. package/dist/assets/remote-BwVv_Qq-.js +1 -0
  28. package/dist/assets/rotate-cw-DjbbiIW_.js +1 -0
  29. package/dist/assets/{runtime-config-page-BuA4A02z.js → runtime-config-page-Br9j9Vxe.js} +1 -1
  30. package/dist/assets/{save-CGm23Yww.js → save-BL8P8CLX.js} +1 -1
  31. package/dist/assets/{search-CH2t--sH.js → search-bZL75Pgc.js} +1 -1
  32. package/dist/assets/{search-config-page-BAmYqoAz.js → search-config-page-B3fvzMd1.js} +1 -1
  33. package/dist/assets/{secrets-config-page-BlPOuql_.js → secrets-config-page-ClwTxtXd.js} +2 -2
  34. package/dist/assets/security-config-DmJw9QZA.js +1 -0
  35. package/dist/assets/{select-DnxxDpcV.js → select-rUZ8nIZu.js} +2 -2
  36. package/dist/assets/{settings-2-agnMYc8a.js → settings-2-BI4SCkxQ.js} +1 -1
  37. package/dist/assets/skeleton-CVmEPG43.js +1 -0
  38. package/dist/assets/{tag-chip-CPOc8b2N.js → tag-chip-BdI_yIOA.js} +1 -1
  39. package/dist/assets/x-BcEayhV6.js +1 -0
  40. package/dist/index.html +19 -19
  41. package/package.json +7 -7
  42. package/src/app/components/layout/app-layout.tsx +38 -2
  43. package/src/app/hooks/__tests__/use-app-event-consumers.test.ts +20 -0
  44. package/src/app/hooks/use-app-event-consumers.ts +21 -0
  45. package/src/app/presenters/app.presenter.ts +6 -0
  46. package/src/features/apps/components/__tests__/app-packages-panel.test.tsx +92 -14
  47. package/src/features/apps/components/__tests__/apps-panel.test.tsx +58 -0
  48. package/src/features/apps/components/app-artwork.tsx +49 -0
  49. package/src/features/apps/components/app-marketplace-catalog.tsx +332 -0
  50. package/src/features/apps/components/app-marketplace-cover.tsx +64 -0
  51. package/src/features/apps/components/app-marketplace-detail.tsx +161 -0
  52. package/src/features/apps/components/app-marketplace-dialog.tsx +107 -217
  53. package/src/features/apps/components/app-marketplace-operation.tsx +84 -0
  54. package/src/features/apps/components/app-package-card.tsx +201 -52
  55. package/src/features/apps/components/app-packages-panel.tsx +98 -26
  56. package/src/features/apps/components/apps-panel.tsx +28 -27
  57. package/src/features/apps/hooks/use-app-marketplace.test.ts +144 -0
  58. package/src/features/apps/hooks/use-app-marketplace.ts +55 -85
  59. package/src/features/apps/hooks/use-app-packages.ts +59 -4
  60. package/src/features/apps/index.ts +1 -0
  61. package/src/features/apps/managers/__tests__/app-package-operation-settlement.manager.test.ts +81 -0
  62. package/src/features/apps/managers/app-package-operation-settlement.manager.ts +80 -0
  63. package/src/features/apps/services/app-marketplace.service.ts +223 -0
  64. package/src/features/apps/types/app-marketplace.types.ts +75 -0
  65. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +64 -3
  66. package/src/features/chat/features/conversation/components/session-queued-input-rows.tsx +24 -14
  67. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +53 -0
  68. package/src/features/chat/features/conversation/hooks/__tests__/use-session-run-queue.test.tsx +18 -0
  69. package/src/features/chat/features/conversation/hooks/use-chat-composer-reference-intent.ts +4 -1
  70. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +126 -32
  71. package/src/features/chat/features/conversation/hooks/use-session-run-queue.ts +12 -2
  72. package/src/features/chat/features/input/utils/__tests__/chat-inline-token.utils.test.ts +31 -0
  73. package/src/features/chat/features/input/utils/chat-composer-token-protocol.utils.ts +5 -0
  74. package/src/features/chat/features/input/utils/chat-inline-token-composer.utils.ts +14 -0
  75. package/src/features/chat/features/input/utils/chat-inline-token.utils.ts +25 -0
  76. package/src/features/chat/features/message/hooks/use-chat-inline-token-actions.ts +8 -1
  77. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +77 -20
  78. package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +56 -50
  79. package/src/features/chat/features/workspace/components/project-files/workspace-directory-tree.tsx +33 -11
  80. package/src/features/chat/features/workspace/components/project-files/workspace-project-tree-view.tsx +93 -0
  81. package/src/features/chat/features/workspace/stores/__tests__/workspace-project-tree.store.test.ts +38 -0
  82. package/src/features/chat/features/workspace/stores/workspace-project-tree.store.ts +160 -0
  83. package/src/features/chat/index.ts +1 -0
  84. package/src/features/chat/managers/__tests__/chat-composer-intent.manager.test.ts +29 -0
  85. package/src/features/chat/managers/chat-composer-intent.manager.ts +29 -2
  86. package/src/features/chat/presenters/chat.presenter.ts +2 -2
  87. package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +1 -1
  88. package/src/features/panel-apps/components/panel-apps-list.tsx +1 -6
  89. package/src/features/right-panel-resources/utils/__tests__/right-panel-resource-uri.utils.test.ts +34 -0
  90. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +29 -0
  91. package/src/features/service-apps/components/service-apps-panel.tsx +3 -17
  92. package/src/platforms/desktop/components/desktop-app-shell.tsx +5 -0
  93. package/src/platforms/mobile/components/mobile-app-shell.tsx +9 -1
  94. package/src/shared/components/actions/__tests__/navigation-link.test.tsx +12 -0
  95. package/src/shared/components/actions/navigation-link.tsx +6 -3
  96. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +34 -0
  97. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +5 -0
  98. package/src/shared/components/doc-browser/doc-browser.tsx +11 -1
  99. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +13 -0
  100. package/src/shared/components/ui/tabs.tsx +24 -1
  101. package/src/shared/hooks/use-server-path-watch.ts +42 -0
  102. package/src/shared/lib/api/server-path/server-path.types.ts +10 -0
  103. package/src/shared/lib/api/types.ts +3 -0
  104. package/src/shared/lib/api/utils/server-path.utils.ts +10 -0
  105. package/src/shared/lib/i18n/locales/en-US/chat.json +1 -0
  106. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +49 -7
  107. package/src/shared/lib/i18n/locales/zh-CN/chat.json +1 -0
  108. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +49 -7
  109. package/vite.config.ts +32 -1
  110. package/dist/assets/desktop-update-config-Dirc3bpt.js +0 -1
  111. package/dist/assets/doc-browser-4Sud6dWw.js +0 -1
  112. package/dist/assets/doc-browser-D4x_yH5F.js +0 -1
  113. package/dist/assets/form-actions-B2q4jgjE.js +0 -1
  114. package/dist/assets/index-CdyTsSBu.css +0 -1
  115. package/dist/assets/index-Das_bw2h.js +0 -107
  116. package/dist/assets/loader-circle-CpmBdaon.js +0 -1
  117. package/dist/assets/mcp-marketplace-page-CdOY3Sop.js +0 -1
  118. package/dist/assets/mcp-marketplace-page-DvjrfqEt.js +0 -9
  119. package/dist/assets/plus-C8FkjpSZ.js +0 -1
  120. package/dist/assets/react-C8XFFvuz.js +0 -8
  121. package/dist/assets/remote-BJHXa4mB.js +0 -1
  122. package/dist/assets/rotate-cw-DJ7UF3F2.js +0 -1
  123. package/dist/assets/security-config-CbDiY2xr.js +0 -1
  124. package/dist/assets/skeleton-XpqeWL62.js +0 -1
  125. package/dist/assets/x-odTHAtLZ.js +0 -1
@@ -1,8 +1,11 @@
1
1
  import {
2
2
  CHAT_CONVERSATION_EXCERPT_TOKEN_KIND,
3
+ CHAT_UI_RESOURCE_TOKEN_KIND,
3
4
  CHAT_WORKSPACE_DIRECTORY_TOKEN_KIND,
4
5
  CHAT_WORKSPACE_EXCERPT_TOKEN_KIND,
5
6
  CHAT_WORKSPACE_FILE_TOKEN_KIND,
7
+ readChatUiResourceReference,
8
+ type ChatUiResourceReference,
6
9
  } from '@nextclaw/shared';
7
10
 
8
11
  type ChatComposerReferenceIntentBase = {
@@ -35,17 +38,24 @@ export type ChatComposerConversationExcerptIntent = ChatComposerReferenceIntentB
35
38
  excerpt: string;
36
39
  };
37
40
 
41
+ export type ChatComposerUiResourceReferenceIntent = ChatComposerReferenceIntentBase & {
42
+ kind: typeof CHAT_UI_RESOURCE_TOKEN_KIND;
43
+ reference: ChatUiResourceReference;
44
+ };
45
+
38
46
  export type ChatComposerReferenceIntent =
39
47
  | ChatComposerFileReferenceIntent
40
48
  | ChatComposerDirectoryReferenceIntent
41
49
  | ChatComposerExcerptReferenceIntent
42
- | ChatComposerConversationExcerptIntent;
50
+ | ChatComposerConversationExcerptIntent
51
+ | ChatComposerUiResourceReferenceIntent;
43
52
 
44
53
  type ChatComposerReferenceRequest =
45
54
  | Omit<ChatComposerFileReferenceIntent, 'id'>
46
55
  | Omit<ChatComposerDirectoryReferenceIntent, 'id'>
47
56
  | Omit<ChatComposerExcerptReferenceIntent, 'id'>
48
- | Omit<ChatComposerConversationExcerptIntent, 'id'>;
57
+ | Omit<ChatComposerConversationExcerptIntent, 'id'>
58
+ | Omit<ChatComposerUiResourceReferenceIntent, 'id'>;
49
59
 
50
60
  function createExcerptTokenKey(prefix: string, identity: string): string {
51
61
  let hash = 2166136261;
@@ -169,6 +179,23 @@ export class ChatComposerIntentManager {
169
179
  });
170
180
  };
171
181
 
182
+ requestUiResourceReference = (params: {
183
+ targetSessionKey: string | null;
184
+ reference: ChatUiResourceReference;
185
+ }) => {
186
+ const reference = readChatUiResourceReference(params.reference);
187
+ if (!reference) return;
188
+ const tokenKey = reference.uri.trim();
189
+ const label = reference.title.trim();
190
+ this.publish({
191
+ kind: CHAT_UI_RESOURCE_TOKEN_KIND,
192
+ targetSessionKey: params.targetSessionKey,
193
+ tokenKey,
194
+ label,
195
+ reference,
196
+ });
197
+ };
198
+
172
199
  consumePending = (targetSessionKey: string | null): ChatComposerReferenceIntent | null => {
173
200
  if (this.pendingIntent?.targetSessionKey !== targetSessionKey) {
174
201
  return null;
@@ -2,7 +2,7 @@ import { ChatSessionListManager } from '@/features/chat/managers/chat-session-li
2
2
  import { ChatUiManager } from '@/features/chat/managers/chat-ui.manager';
3
3
  import { ChatQueryManager } from '@/features/chat/managers/chat-query.manager';
4
4
  import { ChatThreadManager } from '@/features/chat/managers/chat-thread.manager';
5
- import { ChatComposerIntentManager } from '@/features/chat/managers/chat-composer-intent.manager';
5
+ import type { ChatComposerIntentManager } from '@/features/chat/managers/chat-composer-intent.manager';
6
6
  import type { AppPresenter } from '@/app/presenters/app.presenter';
7
7
 
8
8
  export class ChatPresenter {
@@ -16,7 +16,7 @@ export class ChatPresenter {
16
16
  this.chatUiManager = new ChatUiManager(appPresenter.docBrowserManager);
17
17
  this.chatSessionListManager = new ChatSessionListManager(this.chatUiManager);
18
18
  this.chatQueryManager = new ChatQueryManager();
19
- this.chatComposerIntentManager = new ChatComposerIntentManager();
19
+ this.chatComposerIntentManager = appPresenter.chatComposerIntentManager;
20
20
  this.chatThreadManager = new ChatThreadManager(
21
21
  this.chatUiManager,
22
22
  this.chatSessionListManager,
@@ -134,7 +134,7 @@ describe('PanelAppsList', () => {
134
134
 
135
135
  expect(screen.getByText('Demo Panel')).toBeTruthy();
136
136
 
137
- await user.click(screen.getByRole('button', { name: 'Favorites' }));
137
+ await user.click(screen.getByRole('tab', { name: 'Favorites' }));
138
138
 
139
139
  expect(screen.getByText('No panel apps in this view')).toBeTruthy();
140
140
  expect(screen.queryByText('Create your first panel app')).toBeNull();
@@ -14,10 +14,8 @@ import { t } from '@/shared/lib/i18n';
14
14
  const EMPTY_PANEL_APP_ENTRIES: PanelAppEntryView[] = [];
15
15
 
16
16
  export function PanelAppsList({
17
- headerContent,
18
17
  onOpenPanelApp,
19
18
  }: {
20
- headerContent?: ReactNode;
21
19
  onOpenPanelApp: (entry: PanelAppEntryView) => void;
22
20
  }) {
23
21
  const panelApps = usePanelApps();
@@ -95,11 +93,8 @@ export function PanelAppsList({
95
93
 
96
94
  return (
97
95
  <div className="flex h-full min-h-0 flex-col bg-card text-card-foreground">
98
- <div className="flex items-center justify-between gap-2 border-b border-border/70 px-4 py-3">
96
+ <div className="flex min-h-12 shrink-0 items-center justify-end gap-2 border-b border-border/70 px-4 py-2">
99
97
  <div className="flex min-w-0 items-center gap-1.5">
100
- {headerContent ?? (
101
- <div className="truncate text-sm font-semibold text-foreground">{t('panelAppsTitle')}</div>
102
- )}
103
98
  {panelApps.data?.panelsPath ? (
104
99
  <TooltipProvider delayDuration={250}>
105
100
  <Tooltip>
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { PanelAppEntryView } from '@/shared/lib/api';
3
3
  import {
4
+ createChatUiResourceReferenceFromTab,
4
5
  createPanelAppContentPath,
5
6
  createPanelAppResourceUri,
6
7
  createPanelAppRightPanelResourceTarget,
@@ -26,6 +27,39 @@ function createPanelAppEntry(overrides: Partial<PanelAppEntryView> = {}): PanelA
26
27
  }
27
28
 
28
29
  describe('right-panel-resource uri utils', () => {
30
+ it('creates a stable chat reference for an addressable panel tab', () => {
31
+ expect(createChatUiResourceReferenceFromTab({
32
+ id: 'panel-demo',
33
+ kind: 'panel-app',
34
+ title: 'Demo App',
35
+ currentUrl: '/api/panel-apps/demo/content',
36
+ resourceUri: 'nextclaw://panel-app/demo',
37
+ contentParams: { itemId: 'note-1' },
38
+ history: ['/api/panel-apps/demo/content'],
39
+ historyIndex: 0,
40
+ navVersion: 0,
41
+ })).toEqual({
42
+ uri: 'nextclaw://panel-app/demo',
43
+ resourceKind: 'panel-app',
44
+ title: 'Demo App',
45
+ currentUrl: '/api/panel-apps/demo/content',
46
+ contentParams: { itemId: 'note-1' },
47
+ });
48
+ });
49
+
50
+ it('does not create chat references for empty browser tabs', () => {
51
+ expect(createChatUiResourceReferenceFromTab({
52
+ id: 'home',
53
+ kind: 'home',
54
+ title: 'Start Page',
55
+ currentUrl: 'nextclaw://new-tab',
56
+ resourceUri: 'nextclaw://new-tab',
57
+ history: ['nextclaw://new-tab'],
58
+ historyIndex: 0,
59
+ navVersion: 0,
60
+ })).toBeNull();
61
+ });
62
+
29
63
  it('builds matching external panel app resource and content URLs', () => {
30
64
  expect(createPanelAppResourceUri('demo', '/tmp/demo.panel')).toBe(
31
65
  'nextclaw://panel-app/demo?path=%2Ftmp%2Fdemo.panel',
@@ -1,5 +1,10 @@
1
+ import {
2
+ readChatUiResourceReference,
3
+ type ChatUiResourceReference,
4
+ } from '@nextclaw/shared';
1
5
  import type { PanelAppEntryView } from '@/shared/lib/api';
2
6
  import type { DocBrowserDockIcon } from '@/shared/components/doc-browser';
7
+ import type { DocBrowserTab } from '@/shared/components/doc-browser/doc-browser-context';
3
8
  import type { RightPanelResourceTarget } from '@/features/right-panel-resources/types/right-panel-resource.types';
4
9
 
5
10
  export const RIGHT_PANEL_HOME_TAB_KIND = 'home';
@@ -10,6 +15,30 @@ export const RIGHT_PANEL_PANEL_APPS_URL = `${RIGHT_PANEL_APPS_URL}?tab=panel-app
10
15
  export const RIGHT_PANEL_SERVICE_APPS_URL = `${RIGHT_PANEL_APPS_URL}?tab=service-apps`;
11
16
  export const RIGHT_PANEL_PANEL_APP_TAB_KIND = 'panel-app';
12
17
 
18
+ const NON_REFERENCEABLE_RESOURCE_URIS = new Set([
19
+ RIGHT_PANEL_HOME_URL,
20
+ 'about:blank',
21
+ ]);
22
+
23
+ export function createChatUiResourceReferenceFromTab(
24
+ tab: DocBrowserTab,
25
+ ): ChatUiResourceReference | null {
26
+ const uri = tab.resourceUri?.trim() || tab.currentUrl.trim();
27
+ const currentUrl = tab.currentUrl.trim();
28
+ if (!uri || !currentUrl || NON_REFERENCEABLE_RESOURCE_URIS.has(uri)) {
29
+ return null;
30
+ }
31
+ return readChatUiResourceReference({
32
+ uri,
33
+ resourceKind: tab.kind,
34
+ title: tab.title.trim() || uri,
35
+ currentUrl,
36
+ ...(tab.contentParams
37
+ ? { contentParams: structuredClone(tab.contentParams) }
38
+ : {}),
39
+ });
40
+ }
41
+
13
42
  export type RightPanelAppsTab = 'apps' | 'panel-apps' | 'service-apps';
14
43
 
15
44
  const DEFAULT_RIGHT_PANEL_APPS_TAB: RightPanelAppsTab = 'apps';
@@ -1,4 +1,4 @@
1
- import { useState, type ReactNode } from "react";
1
+ import { useState } from "react";
2
2
  import type {
3
3
  ServiceActionGrantView,
4
4
  ServiceActionListView,
@@ -25,11 +25,7 @@ import { t } from "@/shared/lib/i18n";
25
25
 
26
26
  type ServiceActionView = ServiceActionListView["actions"][number];
27
27
 
28
- export function ServiceAppsPanel({
29
- headerContent,
30
- }: {
31
- headerContent?: ReactNode;
32
- }) {
28
+ export function ServiceAppsPanel() {
33
29
  const serviceApps = useServiceApps();
34
30
  const serviceActions = useServiceActions();
35
31
  const serviceActionGrants = useServiceActionGrants();
@@ -92,17 +88,7 @@ export function ServiceAppsPanel({
92
88
 
93
89
  return (
94
90
  <div className="flex h-full min-h-0 flex-col bg-card text-card-foreground">
95
- <div className="flex items-center justify-between gap-2 border-b border-border/60 px-4 py-3">
96
- <div className="flex min-w-0 items-center gap-2">
97
- {headerContent ?? (
98
- <>
99
- <Server className="h-4 w-4 text-primary" />
100
- <div className="truncate text-sm font-semibold text-foreground">
101
- {t("serviceAppsTitle")}
102
- </div>
103
- </>
104
- )}
105
- </div>
91
+ <div className="flex min-h-12 shrink-0 items-center justify-end border-b border-border/60 px-4 py-2">
106
92
  <TooltipProvider delayDuration={250}>
107
93
  <Tooltip>
108
94
  <TooltipTrigger asChild>
@@ -10,6 +10,7 @@ import { isWindowsDesktopHost } from "@/platforms/desktop/utils/desktop-host.uti
10
10
  import { MobileBottomNav } from "@/platforms/mobile";
11
11
  import type { DocBrowserCustomTabRenderers } from "@/shared/components/doc-browser/doc-browser-renderer.types";
12
12
  import type { DocBrowserDockControls } from "@/shared/components/doc-browser/doc-browser-context";
13
+ import type { DocBrowserTabMenuGroupsResolver } from "@/shared/components/doc-browser/doc-browser";
13
14
  import { cn } from "@/shared/lib/utils";
14
15
  import { useViewportLayoutStore } from "@/app/stores/viewport-layout.store";
15
16
  import { SIDEBAR_RAIL_WIDTH_PX } from "@/app/components/layout/sidebar-rail.styles";
@@ -26,6 +27,7 @@ type DesktopAppShellProps = {
26
27
  docBrowserMode: "floating" | "docked";
27
28
  docBrowserDockControls?: DocBrowserDockControls;
28
29
  docBrowserRenderers?: DocBrowserCustomTabRenderers;
30
+ docBrowserTabMenuGroups?: DocBrowserTabMenuGroupsResolver;
29
31
  sideDock?: React.ReactNode;
30
32
  children: React.ReactNode;
31
33
  };
@@ -37,6 +39,7 @@ export function DesktopAppShell({
37
39
  docBrowserMode,
38
40
  docBrowserDockControls,
39
41
  docBrowserRenderers = {},
42
+ docBrowserTabMenuGroups,
40
43
  sideDock,
41
44
  children,
42
45
  }: DesktopAppShellProps) {
@@ -91,6 +94,7 @@ export function DesktopAppShell({
91
94
  <DocBrowser
92
95
  customTabRenderers={docBrowserRenderers}
93
96
  dockControls={docBrowserDockControls}
97
+ getTabMenuGroups={docBrowserTabMenuGroups}
94
98
  />
95
99
  </Suspense>
96
100
  ) : null}
@@ -103,6 +107,7 @@ export function DesktopAppShell({
103
107
  <DocBrowser
104
108
  customTabRenderers={docBrowserRenderers}
105
109
  dockControls={docBrowserDockControls}
110
+ getTabMenuGroups={docBrowserTabMenuGroups}
106
111
  />
107
112
  </Suspense>
108
113
  ) : null}
@@ -7,6 +7,7 @@ import { MobileBottomNav } from "@/platforms/mobile/components/mobile-bottom-nav
7
7
  import { MobileTopbar } from "@/platforms/mobile/components/mobile-topbar";
8
8
  import type { DocBrowserCustomTabRenderers } from "@/shared/components/doc-browser/doc-browser-renderer.types";
9
9
  import type { DocBrowserDockControls } from "@/shared/components/doc-browser/doc-browser-context";
10
+ import type { DocBrowserTabMenuGroupsResolver } from "@/shared/components/doc-browser/doc-browser";
10
11
 
11
12
  const DocBrowser = lazy(async () => ({
12
13
  default: (await import("@/shared/components/doc-browser/doc-browser")).DocBrowser,
@@ -17,6 +18,7 @@ type MobileAppShellProps = {
17
18
  isDocBrowserOpen: boolean;
18
19
  docBrowserDockControls?: DocBrowserDockControls;
19
20
  docBrowserRenderers?: DocBrowserCustomTabRenderers;
21
+ docBrowserTabMenuGroups?: DocBrowserTabMenuGroupsResolver;
20
22
  topbarLeadingInset?: string;
21
23
  children: React.ReactNode;
22
24
  };
@@ -26,6 +28,7 @@ export function MobileAppShell({
26
28
  isDocBrowserOpen,
27
29
  docBrowserDockControls,
28
30
  docBrowserRenderers = {},
31
+ docBrowserTabMenuGroups,
29
32
  topbarLeadingInset,
30
33
  children,
31
34
  }: MobileAppShellProps) {
@@ -48,7 +51,12 @@ export function MobileAppShell({
48
51
  {showBottomNav ? <MobileBottomNav /> : null}
49
52
  {isDocBrowserOpen ? (
50
53
  <Suspense fallback={null}>
51
- <DocBrowser customTabRenderers={docBrowserRenderers} displayMode="fullscreen" dockControls={docBrowserDockControls} />
54
+ <DocBrowser
55
+ customTabRenderers={docBrowserRenderers}
56
+ displayMode="fullscreen"
57
+ dockControls={docBrowserDockControls}
58
+ getTabMenuGroups={docBrowserTabMenuGroups}
59
+ />
52
60
  </Suspense>
53
61
  ) : null}
54
62
  </div>
@@ -1,3 +1,4 @@
1
+ import { createRef } from "react";
1
2
  import { fireEvent, render, screen } from "@testing-library/react";
2
3
  import { MemoryRouter, Route, Routes } from "react-router-dom";
3
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -55,4 +56,15 @@ describe("NavigationLink", () => {
55
56
  expect(screen.getByText("Session route")).toBeTruthy();
56
57
  expect(mocks.openExternalUrl).not.toHaveBeenCalled();
57
58
  });
59
+
60
+ it("forwards refs to the rendered anchor for asChild consumers", () => {
61
+ const ref = createRef<HTMLAnchorElement>();
62
+ render(
63
+ <NavigationLink ref={ref} href="https://docs.nextclaw.io/" external>
64
+ View docs
65
+ </NavigationLink>,
66
+ );
67
+
68
+ expect(ref.current).toBe(screen.getByRole("link", { name: "View docs" }));
69
+ });
58
70
  });
@@ -3,6 +3,7 @@ import type {
3
3
  MouseEvent,
4
4
  ReactNode,
5
5
  } from "react";
6
+ import { forwardRef } from "react";
6
7
  import { ExternalLink as ExternalLinkIcon, type LucideIcon } from "lucide-react";
7
8
  import { Link } from "react-router-dom";
8
9
  import { hostCapabilityManager } from "@/shared/lib/host-capabilities";
@@ -62,7 +63,7 @@ function resolveExternalHttpUrl(href: string): string | null {
62
63
  * Internal links use client-side routing; external links delegate ordinary
63
64
  * clicks to the desktop host when one is available.
64
65
  */
65
- export function NavigationLink({
66
+ export const NavigationLink = forwardRef<HTMLAnchorElement, NavigationLinkProps>(function NavigationLink({
66
67
  children,
67
68
  className,
68
69
  external = false,
@@ -74,7 +75,7 @@ export function NavigationLink({
74
75
  size = "sm",
75
76
  target,
76
77
  ...props
77
- }: NavigationLinkProps) {
78
+ }, ref) {
78
79
  const Icon = icon === undefined && external ? ExternalLinkIcon : icon;
79
80
  const linkClassName = cn(
80
81
  "inline-flex items-center rounded-sm font-medium text-primary underline-offset-4 transition-colors hover:text-primary-hover hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/25",
@@ -98,6 +99,7 @@ export function NavigationLink({
98
99
  if (!external) {
99
100
  return (
100
101
  <Link
102
+ ref={ref}
101
103
  {...props}
102
104
  to={href}
103
105
  target={target}
@@ -112,6 +114,7 @@ export function NavigationLink({
112
114
 
113
115
  return (
114
116
  <a
117
+ ref={ref}
115
118
  {...props}
116
119
  href={href}
117
120
  target={target ?? "_blank"}
@@ -133,4 +136,4 @@ export function NavigationLink({
133
136
  {content}
134
137
  </a>
135
138
  );
136
- }
139
+ });
@@ -571,6 +571,40 @@ describe("DocBrowser floating interactions", () => {
571
571
  expect(onDragStart).toHaveBeenCalledTimes(1);
572
572
  });
573
573
 
574
+ it("opens the same tab action from right click and the more button", () => {
575
+ const onAddToChat = vi.fn();
576
+
577
+ render(
578
+ <DocBrowserTabStrip
579
+ tabs={docBrowserState.tabs}
580
+ activeTabId="docs"
581
+ canGoBack={false}
582
+ canGoForward={false}
583
+ isDocked={true}
584
+ isFullscreen={false}
585
+ onGoBack={vi.fn()}
586
+ onGoForward={vi.fn()}
587
+ onOpenNewTab={vi.fn()}
588
+ onSetActiveTab={vi.fn()}
589
+ onCloseTab={vi.fn()}
590
+ onClose={vi.fn()}
591
+ onDragStart={vi.fn()}
592
+ onToggleMode={vi.fn()}
593
+ getTabMenuGroups={() => [{
594
+ key: "chat",
595
+ items: [{ key: "add", label: "Add to Chat", onSelect: onAddToChat }],
596
+ }]}
597
+ />,
598
+ );
599
+
600
+ fireEvent.contextMenu(screen.getByRole("button", { name: "Docs" }));
601
+ fireEvent.click(screen.getByRole("menuitem", { name: "Add to Chat" }));
602
+ fireEvent.click(screen.getByRole("button", { name: "More tab actions" }));
603
+ fireEvent.click(screen.getByRole("menuitem", { name: "Add to Chat" }));
604
+
605
+ expect(onAddToChat).toHaveBeenCalledTimes(2);
606
+ });
607
+
574
608
  it("keeps the floating panel left edge stable when resizing from the right", () => {
575
609
  docBrowserState.mode = "floating";
576
610
 
@@ -10,6 +10,7 @@ import {
10
10
  X,
11
11
  } from "lucide-react";
12
12
  import type { DocBrowserDockState, DocBrowserTab } from "./doc-browser-context";
13
+ import type { ContextMenuGroup } from "@/shared/components/ui/context-menu/context-menu";
13
14
  import {
14
15
  CompactTabStrip,
15
16
  type CompactTabStripAction,
@@ -35,6 +36,7 @@ type DocBrowserTabStripProps = {
35
36
  onClose: () => void;
36
37
  onDragStart: (event: PointerEvent<HTMLElement>) => void;
37
38
  onToggleMode: () => void;
39
+ getTabMenuGroups?: (tab: DocBrowserTab) => readonly ContextMenuGroup[] | undefined;
38
40
  };
39
41
 
40
42
  function shouldBlockHeaderDrag(target: EventTarget | null): boolean {
@@ -60,6 +62,7 @@ export function DocBrowserTabStrip({
60
62
  onClose,
61
63
  onDragStart,
62
64
  onToggleMode,
65
+ getTabMenuGroups,
63
66
  }: DocBrowserTabStripProps) {
64
67
  const backLabel = t("docBrowserBack");
65
68
  const closeLabel = t("docBrowserClose");
@@ -81,6 +84,8 @@ export function DocBrowserTabStrip({
81
84
  closePlacement: "trailing",
82
85
  onSelect: () => onSetActiveTab(tab.id),
83
86
  onClose: () => onCloseTab(tab.id),
87
+ menuLabel: t("docBrowserTabMoreActions"),
88
+ menuGroups: getTabMenuGroups?.(tab),
84
89
  }));
85
90
  const actions: CompactTabStripAction[] = [
86
91
  { key: "back", disabled: !canGoBack, icon: <ArrowLeft className="h-3.5 w-3.5" />, label: backLabel, onClick: onGoBack },
@@ -28,6 +28,7 @@ import {
28
28
  import { ResizableRightPanel } from '@/shared/components/resizable-right-panel/resizable-right-panel';
29
29
  import { cn } from '@/shared/lib/utils';
30
30
  import { t } from '@/shared/lib/i18n';
31
+ import type { ContextMenuGroup } from '@/shared/components/ui/context-menu/context-menu';
31
32
  import { GripVertical } from 'lucide-react';
32
33
  import {
33
34
  DOC_BROWSER_DOCKED_MAX_WIDTH,
@@ -38,8 +39,11 @@ type DocBrowserProps = {
38
39
  customTabRenderers?: DocBrowserCustomTabRenderers;
39
40
  displayMode?: 'desktop' | 'fullscreen';
40
41
  dockControls?: DocBrowserDockControls;
42
+ getTabMenuGroups?: (tab: DocBrowserTab) => readonly ContextMenuGroup[] | undefined;
41
43
  };
42
44
 
45
+ export type DocBrowserTabMenuGroupsResolver = NonNullable<DocBrowserProps['getTabMenuGroups']>;
46
+
43
47
  type FloatingPanelInteraction = {
44
48
  startX: number;
45
49
  startY: number;
@@ -160,7 +164,12 @@ function useDocBrowserAddressBar({
160
164
  };
161
165
  }
162
166
 
163
- export function DocBrowser({ customTabRenderers = {}, displayMode = 'desktop', dockControls }: DocBrowserProps) {
167
+ export function DocBrowser({
168
+ customTabRenderers = {},
169
+ displayMode = 'desktop',
170
+ dockControls,
171
+ getTabMenuGroups,
172
+ }: DocBrowserProps) {
164
173
  const {
165
174
  isOpen,
166
175
  mode,
@@ -367,6 +376,7 @@ export function DocBrowser({ customTabRenderers = {}, displayMode = 'desktop', d
367
376
  onClose={close}
368
377
  onDragStart={startFloatDrag}
369
378
  onToggleMode={toggleMode}
379
+ getTabMenuGroups={getTabMenuGroups}
370
380
  />
371
381
 
372
382
  <DocBrowserAddressToolbar
@@ -267,6 +267,19 @@ export class DocBrowserManager {
267
267
  this.onRightPanelOpened?.();
268
268
  };
269
269
 
270
+ readonly reloadByDedupeKeys = (dedupeKeys: readonly string[]): void => {
271
+ const targets = new Set(dedupeKeys);
272
+ if (targets.size === 0) {
273
+ return;
274
+ }
275
+ this.setSnapshot((prev) => ({
276
+ ...prev,
277
+ tabs: prev.tabs.map((tab) => targets.has(tab.dedupeKey ?? "")
278
+ ? { ...tab, navVersion: tab.navVersion + 1 }
279
+ : tab),
280
+ }));
281
+ };
282
+
270
283
  readonly openNewTab = (): void => {
271
284
  this.open(undefined, { kind: DOC_BROWSER_HOME_TAB_KIND, newTab: true });
272
285
  };
@@ -39,8 +39,29 @@ export function Tabs({ defaultValue: _defaultValue, value, onValueChange, childr
39
39
  }
40
40
 
41
41
  export function TabsList({ children, className }: TabsListProps) {
42
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
43
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
44
+ const triggers = Array.from(
45
+ event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)'),
46
+ );
47
+ if (triggers.length === 0) return;
48
+ const currentIndex = triggers.indexOf(document.activeElement as HTMLButtonElement);
49
+ const nextIndex = event.key === 'Home'
50
+ ? 0
51
+ : event.key === 'End'
52
+ ? triggers.length - 1
53
+ : event.key === 'ArrowRight'
54
+ ? (Math.max(currentIndex, -1) + 1) % triggers.length
55
+ : (currentIndex <= 0 ? triggers.length : currentIndex) - 1;
56
+ event.preventDefault();
57
+ triggers[nextIndex]?.focus();
58
+ triggers[nextIndex]?.click();
59
+ };
60
+
42
61
  return (
43
62
  <div
63
+ role="tablist"
64
+ onKeyDown={handleKeyDown}
44
65
  className={cn(
45
66
  'inline-flex h-9 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground',
46
67
  className
@@ -61,7 +82,9 @@ export function TabsTrigger({ value, children, className }: TabsTriggerProps) {
61
82
  <button
62
83
  type="button"
63
84
  onClick={() => context.onValueChange(value)}
64
- aria-pressed={isActive}
85
+ role="tab"
86
+ aria-selected={isActive}
87
+ tabIndex={isActive ? 0 : -1}
65
88
  data-state={isActive ? 'active' : 'inactive'}
66
89
  className={cn(
67
90
  'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-[13px] font-medium transition-all duration-fast focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border disabled:pointer-events-none disabled:opacity-50',
@@ -0,0 +1,42 @@
1
+ import { useEffect } from 'react';
2
+ import { unwatchServerPaths, watchServerPaths } from '@/shared/lib/api';
3
+
4
+ const SERVER_PATH_WATCH_HEARTBEAT_MS = 2 * 60_000;
5
+
6
+ export function useServerPathWatch(directories: readonly string[]): void {
7
+ const signature = directories.join('\n');
8
+
9
+ useEffect(() => {
10
+ const watchedDirectories = signature.split('\n').filter(Boolean);
11
+ if (watchedDirectories.length === 0) return;
12
+ let disposed = false;
13
+ let subscriptionId: string | null = null;
14
+ let heartbeat: ReturnType<typeof setInterval> | null = null;
15
+
16
+ const subscribe = async () => {
17
+ try {
18
+ const result = await watchServerPaths({
19
+ directories: watchedDirectories,
20
+ subscriptionId,
21
+ });
22
+ if (disposed) {
23
+ await unwatchServerPaths(result.subscriptionId).catch(() => undefined);
24
+ return;
25
+ }
26
+ subscriptionId = result.subscriptionId;
27
+ } catch (error) {
28
+ console.warn('Project file auto-refresh is unavailable; manual refresh remains available.', error);
29
+ }
30
+ };
31
+
32
+ void subscribe().then(() => {
33
+ if (!disposed && subscriptionId) heartbeat = setInterval(() => void subscribe(), SERVER_PATH_WATCH_HEARTBEAT_MS);
34
+ });
35
+
36
+ return () => {
37
+ disposed = true;
38
+ if (heartbeat) clearInterval(heartbeat);
39
+ if (subscriptionId) void unwatchServerPaths(subscriptionId).catch(() => undefined);
40
+ };
41
+ }, [signature]);
42
+ }
@@ -21,6 +21,16 @@ export type ServerPathBrowseView = {
21
21
  locations: ServerPathLocationView[];
22
22
  };
23
23
 
24
+ export type ServerPathWatchRequest = {
25
+ subscriptionId?: string | null;
26
+ directories: string[];
27
+ };
28
+
29
+ export type ServerPathWatchView = {
30
+ subscriptionId: string;
31
+ watchedDirectories: string[];
32
+ };
33
+
24
34
  export type ServerPathSearchEntryView = {
25
35
  name: string;
26
36
  path: string;
@@ -449,6 +449,8 @@ export type {
449
449
  ServerPathReadView,
450
450
  ServerPathSearchEntryView,
451
451
  ServerPathSearchView,
452
+ ServerPathWatchRequest,
453
+ ServerPathWatchView,
452
454
  } from './server-path/server-path.types';
453
455
 
454
456
  export type PanelAppEntryView = {
@@ -826,6 +828,7 @@ export type ConfigActionExecuteResult = {
826
828
  // WebSocket events
827
829
  export type WsEvent =
828
830
  | { type: 'config.updated'; payload: { path: string } }
831
+ | { type: 'server-path.changed'; payload: { directoryPath: string } }
829
832
  | {
830
833
  type: 'channel.config.apply-status';
831
834
  payload: {
@@ -12,6 +12,8 @@ import type {
12
12
  ServerPathFilesUploadView,
13
13
  ServerPathReadView,
14
14
  ServerPathSearchView,
15
+ ServerPathWatchRequest,
16
+ ServerPathWatchView,
15
17
  } from '@/shared/lib/api/types';
16
18
 
17
19
  const SERVER_PATH_CONTENT_BASE_PATH = '/api/server-paths/content';
@@ -24,6 +26,14 @@ export async function fetchServerPathBrowse(params?: {
24
26
  return await nextclawClient.serverPaths.browse(params);
25
27
  }
26
28
 
29
+ export async function watchServerPaths(input: ServerPathWatchRequest): Promise<ServerPathWatchView> {
30
+ return await nextclawClient.serverPaths.watch(input);
31
+ }
32
+
33
+ export async function unwatchServerPaths(subscriptionId: string): Promise<void> {
34
+ await nextclawClient.serverPaths.unwatch(subscriptionId);
35
+ }
36
+
27
37
  export async function createServerPathDirectory(
28
38
  input: ServerPathDirectoryCreateRequest,
29
39
  ): Promise<ServerPathDirectoryCreateView> {