@nextclaw/ui 0.15.26 → 0.15.28

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 +23 -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-KEYKkxHu.js} +1 -1
  4. package/dist/assets/{channels-list-page-CVCZBlCL.js → channels-list-page-eXLXPgKT.js} +2 -2
  5. package/dist/assets/{chat-page-D4wehRTz.js → chat-page-jfImLdsq.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-C20U71k1.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-BP0Hd2jI.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-CJCnaKOh.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-Dqu-ZoWG.js} +1 -1
  24. package/dist/assets/{providers-config-page-CaO8WfZH.js → providers-config-page-C6fRN2rJ.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-Xyv2c_W9.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-CtypMgLd.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-DhSFjYj4.js} +1 -1
  33. package/dist/assets/{secrets-config-page-BlPOuql_.js → secrets-config-page-BLGcyb1D.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 +6 -6
  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 +116 -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 +120 -42
  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
@@ -0,0 +1,223 @@
1
+ import type {
2
+ AppMarketplaceCatalogParams,
3
+ AppMarketplaceCatalogView,
4
+ AppMarketplaceDetailView,
5
+ AppMarketplaceItemView,
6
+ } from '@/features/apps/types/app-marketplace.types';
7
+
8
+ const APP_MARKETPLACE_BASE_URL = 'https://apps-registry.nextclaw.io';
9
+ const UNSUPPORTED_CATALOG_STATUSES = new Set([404, 405, 501]);
10
+ const EXPLICIT_DEV_PREVIEW_COVER_BASE_URL = import.meta.env
11
+ .VITE_APP_MARKETPLACE_PREVIEW_COVER_BASE_URL?.replace(/\/$/, '');
12
+ const DEFAULT_DEV_PREVIEW_COVER_BASE_URL = import.meta.env.DEV && !import.meta.env.VITEST
13
+ ? '/__app-marketplace-preview'
14
+ : undefined;
15
+ const DEV_PREVIEW_COVERS = new Set([
16
+ 'hello-notes',
17
+ 'personal-organizer',
18
+ 'starter-card',
19
+ 'workspace-glance',
20
+ ]);
21
+
22
+ type AppMarketplaceRequestParams = AppMarketplaceCatalogParams & {
23
+ cursor?: string;
24
+ limit?: number;
25
+ };
26
+
27
+ export async function fetchAppMarketplace(
28
+ params: AppMarketplaceRequestParams = {},
29
+ signal?: AbortSignal,
30
+ ): Promise<AppMarketplaceCatalogView> {
31
+ const response = await fetchAppMarketplaceV2(params, signal);
32
+ if (UNSUPPORTED_CATALOG_STATUSES.has(response.status)) {
33
+ return await fetchLegacyAppMarketplace(params, signal);
34
+ }
35
+ return await readAppMarketplaceCatalogResponse(response);
36
+ }
37
+
38
+ export async function fetchAppMarketplaceDetail(
39
+ slug: string,
40
+ signal?: AbortSignal,
41
+ ): Promise<AppMarketplaceDetailView> {
42
+ const response = await fetch(
43
+ `${APP_MARKETPLACE_BASE_URL}/api/v1/apps/items/${encodeURIComponent(slug)}`,
44
+ { headers: { accept: 'application/json' }, signal },
45
+ );
46
+ if (!response.ok) {
47
+ throw new Error(`Marketplace detail request failed (${response.status})`);
48
+ }
49
+ const payload: unknown = await response.json();
50
+ if (!isRecord(payload) || payload.ok !== true || !isRecord(payload.data)) {
51
+ throw new Error('Marketplace returned an invalid app detail');
52
+ }
53
+ const { data: item } = payload;
54
+ const rawDetail = item as Record<string, unknown>;
55
+ if (!isAppMarketplaceItem(item) || !isRecord(rawDetail.manifest) || !isRecord(rawDetail.permissions)) {
56
+ throw new Error('Marketplace returned an invalid app detail');
57
+ }
58
+ const { manifest, permissions } = rawDetail;
59
+ const components = Array.isArray(manifest.components)
60
+ ? manifest.components.filter((component: unknown): component is { kind: 'panel' | 'service'; path: string } =>
61
+ isRecord(component)
62
+ && (component.kind === 'panel' || component.kind === 'service')
63
+ && typeof component.path === 'string')
64
+ : undefined;
65
+ return {
66
+ ...withDevPreviewCover(item),
67
+ description: typeof rawDetail.description === 'string' ? rawDetail.description : undefined,
68
+ descriptionI18n: isStringRecord(rawDetail.descriptionI18n) ? rawDetail.descriptionI18n : undefined,
69
+ manifest: {
70
+ schemaVersion: manifest.schemaVersion === 2 ? 2 : 1,
71
+ icon: typeof manifest.icon === 'string' ? manifest.icon : undefined,
72
+ engines: isRecord(manifest.engines) && typeof manifest.engines.nextclaw === 'string'
73
+ ? { nextclaw: manifest.engines.nextclaw }
74
+ : undefined,
75
+ components,
76
+ },
77
+ permissions: permissions as AppMarketplaceDetailView['permissions'],
78
+ };
79
+ }
80
+
81
+ export function withDevPreviewCover<T extends AppMarketplaceItemView>(item: T): T {
82
+ if (item.coverUrl) {
83
+ return item;
84
+ }
85
+ const previewCoverUrl = readDevPreviewCoverUrl(item.slug);
86
+ if (item.publisher.id !== 'nextclaw' || !previewCoverUrl) {
87
+ return item;
88
+ }
89
+ return {
90
+ ...item,
91
+ coverUrl: previewCoverUrl,
92
+ coverPreview: true,
93
+ };
94
+ }
95
+
96
+ async function fetchAppMarketplaceV2(
97
+ params: AppMarketplaceRequestParams,
98
+ signal?: AbortSignal,
99
+ ): Promise<Response> {
100
+ const { cursor, featured, limit, publisher, q, sort, tag, tags } = params;
101
+ const search = new URLSearchParams();
102
+ if (q) search.set('q', q);
103
+ if (tag) search.set('tag', tag);
104
+ if (tags?.length) search.set('tags', tags.join(','));
105
+ if (publisher) search.set('publisher', publisher);
106
+ if (featured !== undefined) search.set('featured', String(featured));
107
+ if (sort) search.set('sort', sort);
108
+ if (cursor) search.set('cursor', cursor);
109
+ search.set('limit', String(limit ?? 24));
110
+ return await fetch(`${APP_MARKETPLACE_BASE_URL}/api/v2/apps/items?${search}`, {
111
+ headers: { accept: 'application/json' },
112
+ signal,
113
+ });
114
+ }
115
+
116
+ async function readAppMarketplaceCatalogResponse(
117
+ response: Response,
118
+ ): Promise<AppMarketplaceCatalogView> {
119
+ if (!response.ok) {
120
+ throw new Error(`Marketplace request failed (${response.status})`);
121
+ }
122
+
123
+ const payload: unknown = await response.json();
124
+ if (!isRecord(payload) || payload.ok !== true || !isRecord(payload.data)) {
125
+ throw new Error('Marketplace returned an invalid response');
126
+ }
127
+
128
+ const items = Array.isArray(payload.data.items)
129
+ ? payload.data.items.filter(isAppMarketplaceItem)
130
+ : [];
131
+ return {
132
+ items: items.map(withDevPreviewCover),
133
+ nextCursor: typeof payload.data.nextCursor === 'string' ? payload.data.nextCursor : undefined,
134
+ hasMore: payload.data.hasMore === true,
135
+ query: typeof payload.data.query === 'string' ? payload.data.query : undefined,
136
+ tag: typeof payload.data.tag === 'string' ? payload.data.tag : undefined,
137
+ featured: typeof payload.data.featured === 'boolean' ? payload.data.featured : undefined,
138
+ sort: payload.data.sort === 'updated' || payload.data.sort === 'featured'
139
+ ? payload.data.sort
140
+ : 'relevance',
141
+ };
142
+ }
143
+
144
+ async function fetchLegacyAppMarketplace(
145
+ params: AppMarketplaceRequestParams,
146
+ signal?: AbortSignal,
147
+ ): Promise<AppMarketplaceCatalogView> {
148
+ const { featured, publisher, q, sort, tag, tags } = params;
149
+ const search = new URLSearchParams({ page: '1', pageSize: '100' });
150
+ if (q) search.set('q', q);
151
+ if (tag) search.set('tag', tag);
152
+ const response = await fetch(`${APP_MARKETPLACE_BASE_URL}/api/v1/apps/items?${search}`, {
153
+ headers: { accept: 'application/json' },
154
+ signal,
155
+ });
156
+ if (!response.ok) {
157
+ throw new Error(`Marketplace compatibility request failed (${response.status})`);
158
+ }
159
+ const payload: unknown = await response.json();
160
+ if (!isRecord(payload) || payload.ok !== true || !isRecord(payload.data)) {
161
+ throw new Error('Marketplace returned an invalid compatibility response');
162
+ }
163
+
164
+ const items = Array.isArray(payload.data.items)
165
+ ? payload.data.items.filter(isAppMarketplaceItem)
166
+ : [];
167
+ const requiredTags = new Set(tags ?? []);
168
+ return {
169
+ items: items
170
+ .filter((item) => !publisher || item.publisher.id === publisher)
171
+ .filter((item) => featured === undefined || item.featured === featured)
172
+ .filter((item) => requiredTags.size === 0 || item.tags.some((tag) => requiredTags.has(tag)))
173
+ .map(withDevPreviewCover),
174
+ hasMore: false,
175
+ query: q,
176
+ tag,
177
+ tags,
178
+ featured,
179
+ sort: sort ?? (q ? 'relevance' : 'featured'),
180
+ };
181
+ }
182
+
183
+ function readDevPreviewCoverUrl(slug: string): string | undefined {
184
+ const baseUrl = EXPLICIT_DEV_PREVIEW_COVER_BASE_URL ?? DEFAULT_DEV_PREVIEW_COVER_BASE_URL;
185
+ if (baseUrl && DEV_PREVIEW_COVERS.has(slug)) {
186
+ return `${baseUrl}/${encodeURIComponent(slug)}.webp`;
187
+ }
188
+ return undefined;
189
+ }
190
+
191
+ function isAppMarketplaceItem(value: unknown): value is AppMarketplaceItemView {
192
+ if (!isRecord(value) || !isRecord(value.install) || !isRecord(value.publisher)) {
193
+ return false;
194
+ }
195
+ return typeof value.id === 'string'
196
+ && typeof value.slug === 'string'
197
+ && typeof value.appId === 'string'
198
+ && typeof value.name === 'string'
199
+ && (value.iconUrl === undefined || typeof value.iconUrl === 'string')
200
+ && (value.coverUrl === undefined || typeof value.coverUrl === 'string')
201
+ && (value.coverPreview === undefined || typeof value.coverPreview === 'boolean')
202
+ && (value.accentColor === undefined || typeof value.accentColor === 'string')
203
+ && typeof value.summary === 'string'
204
+ && isStringRecord(value.summaryI18n)
205
+ && Array.isArray(value.tags)
206
+ && value.tags.every((tag) => typeof tag === 'string')
207
+ && typeof value.latestVersion === 'string'
208
+ && typeof value.featured === 'boolean'
209
+ && typeof value.publisher.id === 'string'
210
+ && typeof value.publisher.name === 'string'
211
+ && value.install.kind === 'registry'
212
+ && typeof value.install.spec === 'string'
213
+ && typeof value.install.registry === 'string'
214
+ && typeof value.webUrl === 'string';
215
+ }
216
+
217
+ function isStringRecord(value: unknown): value is Record<string, string> {
218
+ return isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string');
219
+ }
220
+
221
+ function isRecord(value: unknown): value is Record<string, unknown> {
222
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
223
+ }
@@ -0,0 +1,75 @@
1
+ export type AppMarketplaceItemView = {
2
+ id: string;
3
+ slug: string;
4
+ appId: string;
5
+ name: string;
6
+ iconUrl?: string;
7
+ coverUrl?: string;
8
+ coverPreview?: boolean;
9
+ accentColor?: string;
10
+ summary: string;
11
+ summaryI18n: Record<string, string>;
12
+ tags: string[];
13
+ latestVersion: string;
14
+ featured: boolean;
15
+ publisher: {
16
+ id: string;
17
+ name: string;
18
+ url?: string;
19
+ };
20
+ install: {
21
+ kind: 'registry';
22
+ spec: string;
23
+ registry: string;
24
+ };
25
+ webUrl: string;
26
+ };
27
+
28
+ export type AppMarketplaceCatalogView = {
29
+ items: AppMarketplaceItemView[];
30
+ nextCursor?: string;
31
+ hasMore: boolean;
32
+ query?: string;
33
+ tag?: string;
34
+ tags?: string[];
35
+ featured?: boolean;
36
+ sort: 'relevance' | 'featured' | 'updated';
37
+ };
38
+
39
+ export type AppMarketplaceListView = {
40
+ items: AppMarketplaceItemView[];
41
+ hasMore: boolean;
42
+ };
43
+
44
+ export type AppMarketplaceDetailView = AppMarketplaceItemView & {
45
+ description?: string;
46
+ descriptionI18n?: Record<string, string>;
47
+ manifest: {
48
+ schemaVersion: 1 | 2;
49
+ icon?: string;
50
+ engines?: { nextclaw?: string };
51
+ components?: Array<{
52
+ kind: 'panel' | 'service';
53
+ path: string;
54
+ }>;
55
+ };
56
+ permissions: {
57
+ documentAccess?: Array<{
58
+ id: string;
59
+ mode: string;
60
+ description?: string;
61
+ }>;
62
+ allowedDomains?: string[];
63
+ storage?: { namespace?: string };
64
+ capabilities?: { hostBridge?: boolean };
65
+ };
66
+ };
67
+
68
+ export type AppMarketplaceCatalogParams = {
69
+ q?: string;
70
+ tag?: string;
71
+ tags?: string[];
72
+ featured?: boolean;
73
+ publisher?: string;
74
+ sort?: 'relevance' | 'featured' | 'updated';
75
+ };
@@ -427,6 +427,26 @@ describe('SessionConversationInput streaming stability', () => {
427
427
  expect(deleteQueuedInput).toHaveBeenCalledWith('queued-2');
428
428
  });
429
429
 
430
+ it('renders a submitting queue row immediately without premature edit actions', () => {
431
+ const controlRef: MutableRefObject<StreamingInputControl | null> = { current: null };
432
+ renderInput(
433
+ <StreamingSessionConversationInputHarness
434
+ controlRef={controlRef}
435
+ controllerOverride={{
436
+ ...controller,
437
+ queuedInputs: [
438
+ { id: 'submitting-1', isSubmitting: true, preview: '正在提交的任务' },
439
+ ],
440
+ }}
441
+ />,
442
+ );
443
+
444
+ expect(screen.getByText('正在提交的任务')).toBeTruthy();
445
+ expect(screen.getByLabelText('Adding to queue…')).toBeTruthy();
446
+ expect(screen.queryByRole('button', { name: 'Edit queued input' })).toBeNull();
447
+ expect(screen.queryByRole('button', { name: 'Delete queued input' })).toBeNull();
448
+ });
449
+
430
450
  it('leaves send-error rendering to the conversation surface', () => {
431
451
  const controlRef: MutableRefObject<StreamingInputControl | null> = { current: null };
432
452
  const providerError = 'Chat Completions API failed (402): raw provider error';
@@ -456,9 +476,13 @@ function createAttachmentRunHandle(): NcpRunHandle {
456
476
 
457
477
  function AttachmentSubmitHarness({
458
478
  initialPrompt,
479
+ isRunning = false,
480
+ refreshQueuedInputs = async () => [],
459
481
  send,
460
482
  }: {
461
483
  readonly initialPrompt?: string;
484
+ readonly isRunning?: boolean;
485
+ readonly refreshQueuedInputs?: () => Promise<readonly never[]>;
462
486
  readonly send: AttachmentSubmitAgentSend;
463
487
  }) {
464
488
  const { inputActions, inputSnapshot } = useSessionConversationInputState(
@@ -503,14 +527,14 @@ function AttachmentSubmitHarness({
503
527
  continueRun: vi.fn(),
504
528
  editMessage: vi.fn(),
505
529
  isHydrating: false,
506
- isRunning: false,
530
+ isRunning,
507
531
  isSending: false,
508
532
  send,
509
533
  snapshot: {
510
- activeRun: null,
534
+ activeRun: isRunning ? { sessionId: 'session-attachment' } : null,
511
535
  },
512
536
  visibleMessages: [],
513
- }), [send]);
537
+ }), [isRunning, send]);
514
538
  const controller = useSessionConversationController({
515
539
  agent,
516
540
  inputSnapshot,
@@ -518,6 +542,7 @@ function AttachmentSubmitHarness({
518
542
  isRuntimeBlocked: false,
519
543
  runQueue: {
520
544
  inputs: [],
545
+ refreshQueuedInputs,
521
546
  removeQueuedInput: async () => null,
522
547
  },
523
548
  selectedAgentId: 'main',
@@ -543,6 +568,42 @@ function AttachmentSubmitHarness({
543
568
  }
544
569
 
545
570
  describe('SessionConversationInput attachment submit', () => {
571
+ it('shows immediate queue feedback from the real send button before backend acknowledgement', async () => {
572
+ let resolveSend!: (handle: NcpRunHandle) => void;
573
+ const send = vi.fn<AttachmentSubmitAgentSend>(() => new Promise((resolve) => {
574
+ resolveSend = resolve;
575
+ }));
576
+ const refreshQueuedInputs = vi.fn(async () => [] as const);
577
+
578
+ renderInput(
579
+ <AttachmentSubmitHarness
580
+ initialPrompt="稍后继续这个任务"
581
+ isRunning
582
+ refreshQueuedInputs={refreshQueuedInputs}
583
+ send={send}
584
+ />,
585
+ );
586
+
587
+ await act(async () => {
588
+ fireEvent.click(screen.getByRole('button', { name: /Queue|排队发送/ }));
589
+ await Promise.resolve();
590
+ });
591
+
592
+ expect(send).toHaveBeenCalledTimes(1);
593
+ const submittingStatus = screen.getByLabelText('Adding to queue…');
594
+ expect(submittingStatus.parentElement?.textContent).toContain('稍后继续这个任务');
595
+ await waitFor(() => expect(screen.getByRole('textbox').textContent).toBe(''));
596
+ expect(refreshQueuedInputs).not.toHaveBeenCalled();
597
+
598
+ await act(async () => {
599
+ resolveSend({ ...createAttachmentRunHandle(), runId: null });
600
+ await Promise.resolve();
601
+ });
602
+
603
+ await waitFor(() => expect(refreshQueuedInputs).toHaveBeenCalledTimes(1));
604
+ await waitFor(() => expect(screen.queryByLabelText('Adding to queue…')).toBeNull());
605
+ });
606
+
546
607
  it('preserves a project file reference in the outgoing user message and AI context', async () => {
547
608
  const send = vi.fn<AttachmentSubmitAgentSend>(async () => createAttachmentRunHandle());
548
609
 
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import { CornerDownRight, Pencil, Trash2 } from 'lucide-react';
2
+ import { CornerDownRight, LoaderCircle, Pencil, Trash2 } from 'lucide-react';
3
3
 
4
4
  import {
5
5
  Tooltip,
@@ -74,20 +74,30 @@ export function SessionQueuedInputRows({
74
74
  <span className="min-w-0 flex-1 truncate font-medium text-foreground/80">
75
75
  {item.preview || t('chatQueuedBannerAttachmentFallback')}
76
76
  </span>
77
- <div className="flex shrink-0 items-center gap-1.5">
78
- <QueuedInputIconButton
79
- label={t('chatQueuedEdit')}
80
- onClick={() => controller.editQueuedInput(item.id)}
77
+ {item.isSubmitting ? (
78
+ <span
79
+ aria-label={t('chatQueuedSubmitting')}
80
+ className="inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground"
81
81
  >
82
- <Pencil className="h-4 w-4" />
83
- </QueuedInputIconButton>
84
- <QueuedInputIconButton
85
- label={t('chatQueuedDelete')}
86
- onClick={() => controller.deleteQueuedInput(item.id)}
87
- >
88
- <Trash2 className="h-4 w-4" />
89
- </QueuedInputIconButton>
90
- </div>
82
+ <LoaderCircle className="h-3.5 w-3.5 animate-spin" />
83
+ {t('chatQueuedSubmitting')}
84
+ </span>
85
+ ) : (
86
+ <div className="flex shrink-0 items-center gap-1.5">
87
+ <QueuedInputIconButton
88
+ label={t('chatQueuedEdit')}
89
+ onClick={() => controller.editQueuedInput(item.id)}
90
+ >
91
+ <Pencil className="h-4 w-4" />
92
+ </QueuedInputIconButton>
93
+ <QueuedInputIconButton
94
+ label={t('chatQueuedDelete')}
95
+ onClick={() => controller.deleteQueuedInput(item.id)}
96
+ >
97
+ <Trash2 className="h-4 w-4" />
98
+ </QueuedInputIconButton>
99
+ </div>
100
+ )}
91
101
  </div>
92
102
  ))}
93
103
  </div>
@@ -59,6 +59,7 @@ function createControllerParams(params: {
59
59
  const removeQueuedInput = vi.fn(async (id: string) =>
60
60
  queuedInputs.find((item) => item.id === id) ?? null,
61
61
  );
62
+ const refreshQueuedInputs = vi.fn(async () => queuedInputs);
62
63
  return {
63
64
  agent: {
64
65
  abort: vi.fn(),
@@ -116,6 +117,7 @@ function createControllerParams(params: {
116
117
  isRuntimeBlocked: false,
117
118
  runQueue: {
118
119
  inputs: queuedInputs,
120
+ refreshQueuedInputs,
119
121
  removeQueuedInput,
120
122
  },
121
123
  selectedAgentId: 'main',
@@ -178,6 +180,57 @@ describe('useSessionConversationController backend run queue', () => {
178
180
  expect(params.resetComposer).toHaveBeenCalledTimes(1);
179
181
  });
180
182
 
183
+ it('projects a submitting queue row before the backend acknowledges the message', async () => {
184
+ let resolveSend!: (handle: NcpRunHandle) => void;
185
+ const send = vi.fn<TestAgentSend>(() => new Promise((resolve) => {
186
+ resolveSend = resolve;
187
+ }));
188
+ const params = createControllerParams({ isRunning: true, send });
189
+ const { result } = renderHook(() => useSessionConversationController(params));
190
+ let submission!: Promise<void>;
191
+
192
+ act(() => {
193
+ submission = result.current.send();
194
+ });
195
+
196
+ expect(params.resetComposer).toHaveBeenCalledTimes(1);
197
+ expect(result.current.queuedInputs).toEqual([
198
+ expect.objectContaining({
199
+ isSubmitting: true,
200
+ preview: 'next task',
201
+ }),
202
+ ]);
203
+ expect(params.runQueue.refreshQueuedInputs).not.toHaveBeenCalled();
204
+
205
+ await act(async () => {
206
+ resolveSend(createRunHandle({ runId: null }));
207
+ await submission;
208
+ });
209
+
210
+ expect(params.runQueue.refreshQueuedInputs).toHaveBeenCalledTimes(1);
211
+ expect(result.current.queuedInputs).toEqual([]);
212
+ });
213
+
214
+ it('removes the submitting queue row and restores the draft when submission fails', async () => {
215
+ const sendError = new Error('queue submission failed');
216
+ const send = vi.fn<TestAgentSend>(async () => {
217
+ throw sendError;
218
+ });
219
+ const params = createControllerParams({ isRunning: true, send });
220
+ const { result } = renderHook(() => useSessionConversationController(params));
221
+
222
+ await act(async () => {
223
+ await expect(result.current.send()).rejects.toThrow(sendError);
224
+ });
225
+
226
+ expect(result.current.queuedInputs).toEqual([]);
227
+ expect(params.restoreComposer).toHaveBeenCalledWith(expect.objectContaining({
228
+ text: 'next task',
229
+ }));
230
+ expect(params.setSendError).toHaveBeenLastCalledWith(sendError.message);
231
+ expect(params.runQueue.refreshQueuedInputs).not.toHaveBeenCalled();
232
+ });
233
+
181
234
  it('keeps an existing session bound to its agent instead of the global draft selection', async () => {
182
235
  const send = createSendMock();
183
236
  const params = createControllerParams({ isRunning: false, send });
@@ -83,6 +83,24 @@ describe('useSessionRunQueue', () => {
83
83
  unmount();
84
84
  });
85
85
 
86
+ it('refreshes the authoritative queue on demand after a submission is accepted', async () => {
87
+ const listQueuedInputs = vi.spyOn(nextclawClient.sessions, 'listQueuedInputs')
88
+ .mockResolvedValue(createQueue('session-1', 'queued'));
89
+ const { result } = renderHook(
90
+ () => useSessionRunQueue('session-1'),
91
+ { wrapper: createWrapper() },
92
+ );
93
+ await waitFor(() => expect(listQueuedInputs).toHaveBeenCalledTimes(1));
94
+
95
+ let refreshed: UiNcpSessionQueuedInputsView['inputs'] = [];
96
+ await act(async () => {
97
+ refreshed = [...await result.current.refreshQueuedInputs()];
98
+ });
99
+
100
+ expect(listQueuedInputs).toHaveBeenCalledTimes(2);
101
+ expect(refreshed[0]?.message.parts[0]).toMatchObject({ text: 'queued' });
102
+ });
103
+
86
104
  it('replaces an in-flight stale queue read after the queue changes', async () => {
87
105
  let resolveInitialRead!: (queue: UiNcpSessionQueuedInputsView) => void;
88
106
  const initialRead = new Promise<UiNcpSessionQueuedInputsView>((resolve) => {
@@ -2,6 +2,7 @@ import { useEffect, type RefObject } from 'react';
2
2
  import { type ChatInputBarHandle } from '@nextclaw/agent-chat-ui';
3
3
  import {
4
4
  CHAT_CONVERSATION_EXCERPT_TOKEN_KIND,
5
+ CHAT_UI_RESOURCE_TOKEN_KIND,
5
6
  CHAT_WORKSPACE_EXCERPT_TOKEN_KIND,
6
7
  } from '@nextclaw/shared';
7
8
 
@@ -41,7 +42,9 @@ export function useChatComposerReferenceIntent(params: {
41
42
  role: intent.role,
42
43
  excerpt: intent.excerpt,
43
44
  }
44
- : undefined,
45
+ : intent.kind === CHAT_UI_RESOURCE_TOKEN_KIND
46
+ ? { reference: intent.reference }
47
+ : undefined,
45
48
  });
46
49
  intentManager.markConsumed(intent.id);
47
50
  };