@nextclaw/ui 0.14.4 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/dist/assets/api-DLtsiBKm.js +13 -0
- package/dist/assets/appearance-settings-page-CtkeYXux.js +1 -0
- package/dist/assets/book-open-BQwWBDKb.js +1 -0
- package/dist/assets/channels-list-page-Cv8o9Hxg.js +8 -0
- package/dist/assets/chat-page-BtA7H1Ea.js +1 -0
- package/dist/assets/{config-split-page-CRhlZ_pH.js → config-split-page-EidRBpP2.js} +1 -1
- package/dist/assets/confirm-dialog-iIJFFp2T.js +5 -0
- package/dist/assets/desktop-update-config-C6n_TB1r.js +1 -0
- package/dist/assets/{dist-CikkhOlq.js → dist-CBxWOazz.js} +1 -1
- package/dist/assets/{dist-PVA_xL1D.js → dist-D2eZC3OR.js} +1 -1
- package/dist/assets/doc-browser-B76XOXmW.js +1 -0
- package/dist/assets/doc-browser-DBnj9UMB.js +1 -0
- package/dist/assets/{doc-browser-context-MTw4EvKd.js → doc-browser-context-Sv-044Cl.js} +1 -1
- package/dist/assets/{ellipsis-y6vDtq4M.js → ellipsis-2uzhypw5.js} +1 -1
- package/dist/assets/es2015-DhQo_oS6.js +41 -0
- package/dist/assets/{external-link-DVJIQf_G.js → external-link-CM5dnUI6.js} +1 -1
- package/dist/assets/host-capabilities-9bB_998Z.js +1 -0
- package/dist/assets/i18n-provider-di1waTGX.js +1 -0
- package/dist/assets/index-CBRP-7-a.css +1 -0
- package/dist/assets/index-Z1fYpbNd.js +104 -0
- package/dist/assets/loader-circle-BjmN5DeJ.js +1 -0
- package/dist/assets/mcp-marketplace-page-C_MVQUbT.js +9 -0
- package/dist/assets/mcp-marketplace-page-CoZIiD6G.js +1 -0
- package/dist/assets/model-config-page-Cdr9P6TQ.js +1 -0
- package/dist/assets/plus-3ztRZD_l.js +1 -0
- package/dist/assets/provider-scoped-model-input-g1WkGiJ8.js +1 -0
- package/dist/assets/providers-config-page-CWlO7J4A.js +1 -0
- package/dist/assets/{react-VNqT5Zp7.js → react-DjpzoZXB.js} +1 -1
- package/dist/assets/refresh-cw-DaTv3zxE.js +1 -0
- package/dist/assets/remote-CKWZfVVB.js +1 -0
- package/dist/assets/rotate-cw-CHcaRUDz.js +1 -0
- package/dist/assets/runtime-config-page-CfTqh1nv.js +1 -0
- package/dist/assets/{save-BOJ477H-.js → save-BZof1uie.js} +1 -1
- package/dist/assets/{search-BEti5beA.js → search-MxAarS_g.js} +1 -1
- package/dist/assets/search-config-page-Dm8daYny.js +1 -0
- package/dist/assets/{secrets-config-page--IzrIOid.js → secrets-config-page-D8AbcR05.js} +2 -2
- package/dist/assets/security-config-Ds0LsJmo.js +1 -0
- package/dist/assets/select-D7GcNP0z.js +1 -0
- package/dist/assets/setting-row-CuozlnJd.js +1 -0
- package/dist/assets/side-dock-BcLau5S_.js +3 -0
- package/dist/assets/skeleton-shfmXuYp.js +1 -0
- package/dist/assets/switch-C1BWMU5c.js +1 -0
- package/dist/assets/tag-chip-DCUrQUCP.js +1 -0
- package/dist/assets/tooltip-DfAHHA76.js +1 -0
- package/dist/assets/use-config-DAdbT5QJ.js +1 -0
- package/dist/assets/use-confirm-dialog-eWcJ6V-s.js +1 -0
- package/dist/assets/x-DqL1mYLC.js +1 -0
- package/dist/index.html +53 -17
- package/index.html +25 -2
- package/package.json +9 -9
- package/src/app/components/layout/__tests__/app-layout.test.tsx +27 -0
- package/src/app/components/layout/__tests__/settings-entry-page.test.tsx +9 -1
- package/src/app/components/layout/__tests__/sidebar.layout.test.tsx +6 -3
- package/src/app/components/layout/app-layout.tsx +10 -3
- package/src/app/components/layout/sidebar-items.tsx +8 -8
- package/src/app/components/layout/sidebar-rail.styles.ts +4 -2
- package/src/app/components/layout/sidebar.tsx +21 -28
- package/src/app/configs/app-navigation.config.ts +11 -5
- package/src/app/index.tsx +8 -0
- package/src/app/managers/__tests__/viewport-layout.manager.test.ts +59 -0
- package/src/app/managers/viewport-layout.manager.ts +39 -0
- package/src/app/presenters/app.presenter.ts +25 -2
- package/src/app/styles/design-system.css +91 -4
- package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +86 -0
- package/src/features/chat/components/conversation/chat-conversation-content.tsx +47 -17
- package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +7 -0
- package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +9 -5
- package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +5 -5
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +51 -2
- package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +170 -1
- package/src/features/chat/features/conversation/components/session-conversation-area.tsx +23 -1
- package/src/features/chat/features/conversation/components/session-conversation-input.tsx +24 -7
- package/src/features/chat/features/conversation/components/session-queued-input-rows.tsx +96 -0
- package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +181 -0
- package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +351 -89
- package/src/features/chat/features/input/hooks/__tests__/use-chat-input-surface-state.test.tsx +29 -4
- package/src/features/chat/features/input/hooks/use-chat-input-surface-state.ts +28 -3
- package/src/features/chat/features/input/input-surface-plugins/__tests__/slash-command-plugin.test.ts +108 -8
- package/src/features/chat/features/input/input-surface-plugins/panel-app-reference-plugin.utils.ts +50 -14
- package/src/features/chat/features/input/input-surface-plugins/slash-command-plugin.utils.ts +77 -6
- package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +21 -12
- package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +166 -0
- package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +19 -11
- package/src/features/chat/features/message/components/chat-message-list.container.tsx +38 -64
- package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +59 -0
- package/src/features/chat/features/message/utils/__tests__/chat-message-show-content-tool-card.utils.test.ts +56 -1
- package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +0 -2
- package/src/features/chat/features/message/utils/chat-message-process-summary.utils.ts +80 -0
- package/src/features/chat/features/message/utils/chat-message-show-content-tool-card.utils.ts +12 -3
- package/src/features/chat/features/message/utils/chat-message-texts.utils.ts +60 -0
- package/src/features/chat/features/message/utils/chat-message.utils.ts +3 -0
- package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +39 -0
- package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +2 -2
- package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +1 -1
- package/src/features/chat/features/session/components/session-header/__tests__/chat-session-header-actions.test.tsx +3 -1
- package/src/features/chat/features/session/components/session-header/chat-session-metadata-dialog.tsx +1 -1
- package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +270 -115
- package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumb-browser.tsx +121 -0
- package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +75 -20
- package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +70 -5
- package/src/features/chat/features/workspace/utils/chat-workspace-file-tab-persistence.utils.ts +83 -0
- package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +79 -1
- package/src/features/chat/managers/__tests__/chat-ui.manager.test.ts +2 -0
- package/src/features/chat/managers/chat-thread.manager.ts +9 -3
- package/src/features/chat/managers/chat-ui.manager.ts +5 -1
- package/src/features/chat/presenters/chat.presenter.ts +2 -1
- package/src/features/chat/stores/__tests__/chat-thread.store.test.ts +4 -0
- package/src/features/chat/stores/chat-thread.store.ts +6 -64
- package/src/features/chat/types/chat-message.types.ts +4 -0
- package/src/features/marketplace/components/__tests__/marketplace-page-detail.test.tsx +33 -26
- package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +1 -0
- package/src/features/marketplace/components/curated-shelves/__tests__/marketplace-curated-scene-route.test.tsx +1 -0
- package/src/features/marketplace/components/detail-doc/__tests__/marketplace-detail-doc.test.tsx +115 -0
- package/src/features/marketplace/components/marketplace-detail-doc.tsx +441 -0
- package/src/features/marketplace/components/mcp/__tests__/mcp-marketplace-page.test.tsx +3 -3
- package/src/features/marketplace/components/mcp/mcp-marketplace-doc.ts +0 -60
- package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +64 -29
- package/src/features/marketplace/hooks/use-marketplace-item-detail.ts +42 -31
- package/src/features/marketplace/index.ts +3 -0
- package/src/features/marketplace/stores/marketplace-detail-doc.store.ts +38 -0
- package/src/features/panel-apps/components/__tests__/panel-apps-list.test.tsx +32 -2
- package/src/features/pwa/managers/__tests__/pwa-shell-theme.manager.test.ts +11 -0
- package/src/features/pwa/managers/pwa-shell-theme.manager.ts +8 -3
- package/src/features/settings/pages/__tests__/appearance-settings-page.test.tsx +25 -0
- package/src/features/settings/pages/appearance-settings-page.tsx +40 -0
- package/src/features/side-dock/components/__tests__/side-dock.test.tsx +44 -2
- package/src/features/side-dock/components/side-dock.tsx +100 -13
- package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +10 -0
- package/src/features/side-dock/managers/__tests__/side-dock.manager.test.ts +32 -2
- package/src/features/side-dock/managers/side-dock.manager.ts +17 -7
- package/src/features/side-dock/stores/side-dock.store.ts +9 -0
- package/src/features/side-dock/types/side-dock.types.ts +10 -3
- package/src/features/side-dock/utils/side-dock-item.utils.ts +15 -2
- package/src/index.css +501 -27
- package/src/platforms/desktop/components/desktop-window-chrome.tsx +1 -1
- package/src/shared/components/doc-browser/__tests__/doc-browser-context.test.tsx +30 -1
- package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +206 -38
- package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +34 -11
- package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +1 -1
- package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +23 -5
- package/src/shared/components/doc-browser/doc-browser.tsx +128 -75
- package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +9 -1
- package/src/shared/components/doc-browser/utils/doc-browser-floating-panel.utils.ts +83 -0
- package/src/shared/components/ui/__tests__/dialog.test.tsx +27 -0
- package/src/shared/components/ui/actions/icon-action-button.tsx +14 -2
- package/src/shared/components/ui/dialog.tsx +2 -2
- package/src/shared/components/ui/popover.tsx +1 -1
- package/src/shared/components/ui/select.tsx +1 -1
- package/src/shared/components/ui/switch.tsx +4 -3
- package/src/shared/components/ui/tab-strip/__tests__/compact-tab-strip.test.tsx +54 -0
- package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +4 -5
- package/src/shared/components/ui/tooltip.tsx +1 -1
- package/src/shared/lib/api/utils/server-path.utils.ts +27 -1
- package/src/shared/lib/i18n/locales/en-US/chat.json +15 -6
- package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
- package/src/shared/lib/i18n/locales/en-US/doc-browser.json +11 -0
- package/src/shared/lib/i18n/locales/en-US/marketplace.json +5 -0
- package/src/shared/lib/i18n/locales/zh-CN/chat.json +15 -6
- package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
- package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +11 -0
- package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +5 -0
- package/src/shared/lib/session-project/__tests__/workspace-file-breadcrumb.test.ts +14 -0
- package/src/shared/lib/session-project/index.ts +1 -1
- package/src/shared/lib/session-project/{workspace-file-breadcrumb.ts → workspace-file-breadcrumb.utils.ts} +62 -8
- package/src/shared/lib/theme/index.ts +51 -13
- package/dist/assets/api-BP_8IaxB.js +0 -13
- package/dist/assets/channels-list-page-DEh5Jjqx.js +0 -8
- package/dist/assets/chat-page-BRB0N5WW.js +0 -105
- package/dist/assets/confirm-dialog-DsivFJ4r.js +0 -5
- package/dist/assets/desktop-update-config-B0w9p2ep.js +0 -1
- package/dist/assets/doc-browser-BoNJ7-tE.js +0 -1
- package/dist/assets/doc-browser-CdJ_d4ik.js +0 -1
- package/dist/assets/index-BiE-K8-q.css +0 -1
- package/dist/assets/index-D9BAE_L_.js +0 -105
- package/dist/assets/mcp-marketplace-page-B1SZNY5m.js +0 -40
- package/dist/assets/mcp-marketplace-page-Cq_Tw42T.js +0 -1
- package/dist/assets/model-config-page-DotaTUHb.js +0 -1
- package/dist/assets/plus-BbZqquJe.js +0 -1
- package/dist/assets/provider-scoped-model-input-DsFRuWJH.js +0 -1
- package/dist/assets/providers-config-page-DW4tQSLN.js +0 -1
- package/dist/assets/remote-B8TiUfwW.js +0 -1
- package/dist/assets/rotate-cw-DjHDTkCv.js +0 -1
- package/dist/assets/runtime-config-page-S6ErRHR8.js +0 -1
- package/dist/assets/search-config-page-DGj5am03.js +0 -1
- package/dist/assets/select-hoj8dK4K.js +0 -41
- package/dist/assets/tag-chip-T8-h-die.js +0 -1
- package/dist/assets/use-config-CjRV2hwL.js +0 -1
- package/dist/assets/x-D9PXs_xP.js +0 -1
- package/src/features/marketplace/components/detail-doc/__tests__/marketplace-detail-doc.test.ts +0 -40
- package/src/features/marketplace/components/detail-doc/marketplace-detail-doc-renderer.ts +0 -201
- package/src/features/marketplace/components/marketplace-detail-doc.ts +0 -142
- /package/dist/assets/{config-hints-BkYBc7z0.js → config-hints-fGnUjDe9.js} +0 -0
- /package/dist/assets/{middleware-CkFT3R4v.js → middleware-BILWbJE4.js} +0 -0
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
createChatComposerTextNode,
|
|
12
12
|
type ChatComposerNode,
|
|
13
13
|
} from '@nextclaw/agent-chat-ui';
|
|
14
|
+
import type { NcpAgentSendEnvelope, NcpRunHandle } from '@nextclaw/ncp';
|
|
14
15
|
|
|
15
16
|
import { I18nProvider } from '@/app/components/i18n-provider';
|
|
16
17
|
import { ChatPresenterProvider, type ChatPresenterLike } from '@/features/chat/components/providers/chat-presenter.provider';
|
|
@@ -18,11 +19,15 @@ import {
|
|
|
18
19
|
SessionConversationInput,
|
|
19
20
|
type SessionConversationInputController,
|
|
20
21
|
} from '@/features/chat/features/conversation/components/session-conversation-input';
|
|
22
|
+
import { useSessionConversationController } from '@/features/chat/features/conversation/hooks/use-session-conversation-controller';
|
|
21
23
|
import type {
|
|
22
24
|
SessionConversationInputActions,
|
|
23
25
|
SessionConversationInputPatch,
|
|
24
26
|
SessionConversationInputSnapshot,
|
|
25
27
|
} from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
|
|
28
|
+
import { useSessionConversationInputState } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-state';
|
|
29
|
+
|
|
30
|
+
const uploadNcpAssetsMock = vi.hoisted(() => vi.fn());
|
|
26
31
|
|
|
27
32
|
vi.mock('@/app/hooks/use-viewport-layout', () => ({
|
|
28
33
|
useViewportLayout: () => ({ isDesktop: true, isMobile: false }),
|
|
@@ -49,6 +54,7 @@ vi.mock('@/shared/lib/api', async (importOriginal) => {
|
|
|
49
54
|
return {
|
|
50
55
|
...(actual as object),
|
|
51
56
|
updateNcpSession: vi.fn(),
|
|
57
|
+
uploadNcpAssets: uploadNcpAssetsMock,
|
|
52
58
|
};
|
|
53
59
|
});
|
|
54
60
|
|
|
@@ -62,12 +68,16 @@ const presenter = {
|
|
|
62
68
|
},
|
|
63
69
|
chatUiManager: {
|
|
64
70
|
goToProviders: vi.fn(),
|
|
71
|
+
showContent: vi.fn(),
|
|
65
72
|
},
|
|
66
73
|
} as unknown as ChatPresenterLike;
|
|
67
74
|
|
|
68
75
|
const controller: SessionConversationInputController = {
|
|
69
76
|
canStopGeneration: true,
|
|
77
|
+
deleteQueuedInput: vi.fn(),
|
|
78
|
+
editQueuedInput: vi.fn(),
|
|
70
79
|
isSending: true,
|
|
80
|
+
queuedInputs: [],
|
|
71
81
|
send: vi.fn(),
|
|
72
82
|
sendDisabled: true,
|
|
73
83
|
stop: vi.fn(),
|
|
@@ -96,8 +106,10 @@ function createStreamingInputSnapshot(
|
|
|
96
106
|
}
|
|
97
107
|
|
|
98
108
|
function StreamingSessionConversationInputHarness({
|
|
109
|
+
controllerOverride = controller,
|
|
99
110
|
controlRef,
|
|
100
111
|
}: {
|
|
112
|
+
controllerOverride?: SessionConversationInputController;
|
|
101
113
|
controlRef: MutableRefObject<StreamingInputControl | null>;
|
|
102
114
|
}) {
|
|
103
115
|
const [nodes, setNodes] = useState<readonly ChatComposerNode[]>([
|
|
@@ -167,7 +179,7 @@ function StreamingSessionConversationInputHarness({
|
|
|
167
179
|
<div data-testid="stream-chunk">{streamChunk}</div>
|
|
168
180
|
<SessionConversationInput
|
|
169
181
|
contextWindow={null}
|
|
170
|
-
controller={
|
|
182
|
+
controller={controllerOverride}
|
|
171
183
|
inputActions={inputActions}
|
|
172
184
|
inputQuery={inputQuery}
|
|
173
185
|
inputSnapshot={inputSnapshot}
|
|
@@ -196,4 +208,161 @@ describe('SessionConversationInput streaming stability', () => {
|
|
|
196
208
|
|
|
197
209
|
await waitFor(() => expect(textbox.textContent).toBe('你'));
|
|
198
210
|
});
|
|
211
|
+
|
|
212
|
+
it('renders queued inputs above the composer with edit and delete actions', () => {
|
|
213
|
+
const controlRef: MutableRefObject<StreamingInputControl | null> = { current: null };
|
|
214
|
+
const deleteQueuedInput = vi.fn();
|
|
215
|
+
const editQueuedInput = vi.fn();
|
|
216
|
+
render(
|
|
217
|
+
<StreamingSessionConversationInputHarness
|
|
218
|
+
controlRef={controlRef}
|
|
219
|
+
controllerOverride={{
|
|
220
|
+
...controller,
|
|
221
|
+
deleteQueuedInput,
|
|
222
|
+
editQueuedInput,
|
|
223
|
+
queuedInputs: [
|
|
224
|
+
{ id: 'queued-1', preview: '先做 A' },
|
|
225
|
+
{ id: 'queued-2', preview: '再做 B' },
|
|
226
|
+
],
|
|
227
|
+
}}
|
|
228
|
+
/>,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(screen.getByText('先做 A')).toBeTruthy();
|
|
232
|
+
expect(screen.getByText('再做 B')).toBeTruthy();
|
|
233
|
+
const inputShell = document.querySelector('.nextclaw-chat-input-bar-shell');
|
|
234
|
+
expect(inputShell?.contains(screen.getByText('先做 A'))).toBe(true);
|
|
235
|
+
expect(inputShell?.contains(screen.getByText('再做 B'))).toBe(true);
|
|
236
|
+
|
|
237
|
+
fireEvent.click(screen.getAllByRole('button', { name: 'Edit queued input' })[0]);
|
|
238
|
+
fireEvent.click(screen.getAllByRole('button', { name: 'Delete queued input' })[1]);
|
|
239
|
+
|
|
240
|
+
expect(editQueuedInput).toHaveBeenCalledWith('queued-1');
|
|
241
|
+
expect(deleteQueuedInput).toHaveBeenCalledWith('queued-2');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
type AttachmentSubmitAgentSend = (envelope: NcpAgentSendEnvelope) => Promise<NcpRunHandle | null>;
|
|
246
|
+
|
|
247
|
+
function createAttachmentRunHandle(): NcpRunHandle {
|
|
248
|
+
return {
|
|
249
|
+
assistantMessageId: null,
|
|
250
|
+
correlationId: undefined,
|
|
251
|
+
runId: 'run-attachment',
|
|
252
|
+
sessionId: 'session-attachment',
|
|
253
|
+
userMessageId: 'user-message-attachment',
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function AttachmentSubmitHarness({ send }: { readonly send: AttachmentSubmitAgentSend }) {
|
|
258
|
+
const { inputActions, inputSnapshot } = useSessionConversationInputState();
|
|
259
|
+
const inputQuery = useMemo(() => ({
|
|
260
|
+
defaultModel: 'test-model',
|
|
261
|
+
defaultProjectRoot: null,
|
|
262
|
+
fallbackPreferredModel: undefined,
|
|
263
|
+
fallbackPreferredThinking: undefined,
|
|
264
|
+
isProviderStateResolved: true,
|
|
265
|
+
isSkillsLoading: false,
|
|
266
|
+
modelOptions: [
|
|
267
|
+
{
|
|
268
|
+
value: 'test-model',
|
|
269
|
+
modelLabel: 'Test Model',
|
|
270
|
+
providerLabel: 'Test',
|
|
271
|
+
thinkingCapability: null,
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
selectedSession: null,
|
|
275
|
+
selectedSessionKey: 'session-attachment',
|
|
276
|
+
sessionTypeState: {
|
|
277
|
+
canEditSessionType: true,
|
|
278
|
+
defaultSessionType: 'default',
|
|
279
|
+
selectedSessionType: 'default',
|
|
280
|
+
selectedSessionTypeOption: null,
|
|
281
|
+
sessionTypeOptions: [],
|
|
282
|
+
sessionTypeUnavailable: false,
|
|
283
|
+
sessionTypeUnavailableMessage: null,
|
|
284
|
+
},
|
|
285
|
+
skillRecords: [],
|
|
286
|
+
}), []);
|
|
287
|
+
const agent = useMemo(() => ({
|
|
288
|
+
abort: vi.fn(),
|
|
289
|
+
isHydrating: false,
|
|
290
|
+
isRunning: false,
|
|
291
|
+
isSending: false,
|
|
292
|
+
send,
|
|
293
|
+
snapshot: {
|
|
294
|
+
activeRun: null,
|
|
295
|
+
},
|
|
296
|
+
visibleMessages: [],
|
|
297
|
+
}), [send]);
|
|
298
|
+
const controller = useSessionConversationController({
|
|
299
|
+
agent,
|
|
300
|
+
inputSnapshot,
|
|
301
|
+
inputQuery,
|
|
302
|
+
isRuntimeBlocked: false,
|
|
303
|
+
selectedAgentId: 'main',
|
|
304
|
+
sessionKey: 'session-attachment',
|
|
305
|
+
resetComposer: inputActions.resetComposer,
|
|
306
|
+
restoreComposer: inputActions.restoreComposer,
|
|
307
|
+
setSendError: inputActions.setSendError,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<I18nProvider>
|
|
312
|
+
<ChatPresenterProvider presenter={presenter}>
|
|
313
|
+
<SessionConversationInput
|
|
314
|
+
contextWindow={null}
|
|
315
|
+
controller={controller}
|
|
316
|
+
inputActions={inputActions}
|
|
317
|
+
inputQuery={inputQuery}
|
|
318
|
+
inputSnapshot={inputSnapshot}
|
|
319
|
+
/>
|
|
320
|
+
</ChatPresenterProvider>
|
|
321
|
+
</I18nProvider>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
describe('SessionConversationInput attachment submit', () => {
|
|
326
|
+
it('keeps uploaded file attachments in the outgoing send envelope after token insertion', async () => {
|
|
327
|
+
uploadNcpAssetsMock.mockResolvedValueOnce([
|
|
328
|
+
{
|
|
329
|
+
id: 'uploaded-image',
|
|
330
|
+
name: 'sample.png',
|
|
331
|
+
mimeType: 'image/png',
|
|
332
|
+
sizeBytes: 11,
|
|
333
|
+
assetUri: 'asset://store/sample.png',
|
|
334
|
+
url: '/api/ncp/assets/content?uri=asset%3A%2F%2Fstore%2Fsample.png',
|
|
335
|
+
},
|
|
336
|
+
]);
|
|
337
|
+
const send = vi.fn<AttachmentSubmitAgentSend>(async () => createAttachmentRunHandle());
|
|
338
|
+
|
|
339
|
+
render(<AttachmentSubmitHarness send={send} />);
|
|
340
|
+
|
|
341
|
+
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement | null;
|
|
342
|
+
expect(fileInput).toBeTruthy();
|
|
343
|
+
await act(async () => {
|
|
344
|
+
fireEvent.change(fileInput!, {
|
|
345
|
+
target: {
|
|
346
|
+
files: [new File(['image-bytes'], 'sample.png', { type: 'image/png' })],
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
await Promise.resolve();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await waitFor(() => expect(screen.getByText('sample.png')).toBeTruthy());
|
|
353
|
+
|
|
354
|
+
fireEvent.click(screen.getByRole('button', { name: /Send|发送/ }));
|
|
355
|
+
|
|
356
|
+
await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
|
|
357
|
+
expect(send.mock.calls[0]?.[0].message.parts).toEqual([
|
|
358
|
+
{
|
|
359
|
+
type: 'file',
|
|
360
|
+
name: 'sample.png',
|
|
361
|
+
mimeType: 'image/png',
|
|
362
|
+
assetUri: 'asset://store/sample.png',
|
|
363
|
+
url: '/api/ncp/assets/content?uri=asset%3A%2F%2Fstore%2Fsample.png',
|
|
364
|
+
sizeBytes: 11,
|
|
365
|
+
},
|
|
366
|
+
]);
|
|
367
|
+
});
|
|
199
368
|
});
|
|
@@ -214,7 +214,7 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
|
|
|
214
214
|
const controllerAgent = useMemo(() => ({
|
|
215
215
|
...agent,
|
|
216
216
|
isRunning: currentSessionRunning,
|
|
217
|
-
isSending: agent.isSending
|
|
217
|
+
isSending: agent.isSending,
|
|
218
218
|
}), [agent, currentSessionRunning]);
|
|
219
219
|
const controller = useSessionConversationController({
|
|
220
220
|
agent: controllerAgent,
|
|
@@ -235,7 +235,10 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
|
|
|
235
235
|
}, [controller]);
|
|
236
236
|
const inputController = useMemo<SessionConversationInputController>(() => ({
|
|
237
237
|
canStopGeneration: controller.canStopGeneration,
|
|
238
|
+
deleteQueuedInput: (id: string) => controllerRef.current.deleteQueuedInput(id),
|
|
239
|
+
editQueuedInput: (id: string) => controllerRef.current.editQueuedInput(id),
|
|
238
240
|
isSending: controller.isSending,
|
|
241
|
+
queuedInputs: controller.queuedInputs,
|
|
239
242
|
send: () => controllerRef.current.send(),
|
|
240
243
|
sendDisabled: controller.sendDisabled,
|
|
241
244
|
stop: () => controllerRef.current.stop(),
|
|
@@ -243,6 +246,7 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
|
|
|
243
246
|
}), [
|
|
244
247
|
controller.canStopGeneration,
|
|
245
248
|
controller.isSending,
|
|
249
|
+
controller.queuedInputs,
|
|
246
250
|
controller.sendDisabled,
|
|
247
251
|
controller.stopDisabled,
|
|
248
252
|
]);
|
|
@@ -254,6 +258,23 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
|
|
|
254
258
|
...inputSnapshot,
|
|
255
259
|
sendError: lastSendError ?? inputSnapshot.sendError,
|
|
256
260
|
}), [inputSnapshot, lastSendError]);
|
|
261
|
+
const sessionFailurePreview = inputQuery.selectedSession?.activityPreview;
|
|
262
|
+
const sessionFailureMessage =
|
|
263
|
+
sessionFailurePreview?.state === 'failed'
|
|
264
|
+
? sessionFailurePreview.statusText?.trim() ||
|
|
265
|
+
sessionFailurePreview.replyText?.trim() ||
|
|
266
|
+
null
|
|
267
|
+
: null;
|
|
268
|
+
const sessionFailureSlot = sessionFailureMessage ? (
|
|
269
|
+
<div className="rounded-lg border border-red-200/80 bg-red-50/80 px-3 py-2.5 shadow-sm">
|
|
270
|
+
<div className="text-xs font-semibold text-red-800">
|
|
271
|
+
{t('chatSessionErrorTitle')}
|
|
272
|
+
</div>
|
|
273
|
+
<div className="mt-1 whitespace-pre-wrap break-words text-xs leading-5 text-red-700">
|
|
274
|
+
{sessionFailureMessage}
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
) : null;
|
|
257
278
|
useSessionConversationDraftIntent({
|
|
258
279
|
consumeDraftIntent,
|
|
259
280
|
applyPromptSuggestion: inputActions.applyPromptSuggestion,
|
|
@@ -287,6 +308,7 @@ export function SessionConversationArea(props: SessionConversationAreaProps) {
|
|
|
287
308
|
isAwaitingAssistantOutput={controller.isSending && currentSessionRunning}
|
|
288
309
|
isHistoryLoading={agent.isHydrating}
|
|
289
310
|
isSending={controller.isSending}
|
|
311
|
+
bottomSlot={sessionFailureSlot}
|
|
290
312
|
messages={agent.visibleMessages}
|
|
291
313
|
sessionKey={sessionKey}
|
|
292
314
|
showWelcome={showWelcome}
|
|
@@ -50,6 +50,9 @@ import {
|
|
|
50
50
|
|
|
51
51
|
import { useSessionConversationInputAttachments } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-attachments';
|
|
52
52
|
import type { useSessionConversationInputQuery } from '@/features/chat/features/conversation/hooks/use-session-conversation-input-query';
|
|
53
|
+
import type {
|
|
54
|
+
SessionConversationQueuedInput,
|
|
55
|
+
} from '@/features/chat/features/conversation/hooks/use-session-conversation-controller';
|
|
53
56
|
import type {
|
|
54
57
|
SessionConversationInputActions,
|
|
55
58
|
SessionConversationInputSnapshot,
|
|
@@ -59,12 +62,16 @@ import {
|
|
|
59
62
|
buildSessionConversationToolbarSelects,
|
|
60
63
|
resolveThinkingForConversationModel,
|
|
61
64
|
} from '@/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils';
|
|
65
|
+
import { SessionQueuedInputRows } from './session-queued-input-rows';
|
|
62
66
|
|
|
63
67
|
type SessionConversationInputQuery = ReturnType<typeof useSessionConversationInputQuery>;
|
|
64
68
|
|
|
65
69
|
export type SessionConversationInputController = {
|
|
66
70
|
readonly canStopGeneration: boolean;
|
|
71
|
+
readonly deleteQueuedInput: (id: string) => void;
|
|
72
|
+
readonly editQueuedInput: (id: string) => void;
|
|
67
73
|
readonly isSending: boolean;
|
|
74
|
+
readonly queuedInputs: readonly SessionConversationQueuedInput[];
|
|
68
75
|
readonly sendDisabled: boolean;
|
|
69
76
|
readonly stopDisabled: boolean;
|
|
70
77
|
readonly send: () => Promise<void> | void;
|
|
@@ -215,6 +222,13 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
|
|
|
215
222
|
language,
|
|
216
223
|
presenter.chatThreadManager,
|
|
217
224
|
]);
|
|
225
|
+
const handleSlashPanelAppSelect = useCallback(
|
|
226
|
+
(appId: string) =>
|
|
227
|
+
void presenter.chatUiManager.showContent({
|
|
228
|
+
target: { type: 'panel_app', payload: { appId } },
|
|
229
|
+
}),
|
|
230
|
+
[presenter.chatUiManager],
|
|
231
|
+
);
|
|
218
232
|
const { inputSurfaceState, setInputSurfaceTrigger } = useChatInputSurfaceState({
|
|
219
233
|
commands: slashCommands,
|
|
220
234
|
isSkillsLoading: inputQuery.isSkillsLoading,
|
|
@@ -222,6 +236,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
|
|
|
222
236
|
slashTexts: labels.slashTexts,
|
|
223
237
|
},
|
|
224
238
|
language,
|
|
239
|
+
onSelectPanelApp: handleSlashPanelAppSelect,
|
|
225
240
|
onSelectSkill: chatRecentSkillsManager.remember,
|
|
226
241
|
recentSkillValues,
|
|
227
242
|
skillRecords,
|
|
@@ -275,15 +290,14 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
|
|
|
275
290
|
}, [inputQuery.selectedSession, inputQuery.selectedSessionKey]);
|
|
276
291
|
const handleNodesChange = useCallback((nodes: SessionConversationInputSnapshot['nodes']) => {
|
|
277
292
|
const nextNodes = [...nodes];
|
|
278
|
-
|
|
279
|
-
inputActions.update({
|
|
293
|
+
inputActions.update((current) => ({
|
|
280
294
|
nodes: nextNodes,
|
|
281
|
-
attachments,
|
|
295
|
+
attachments: pruneComposerAttachments(nextNodes, current.attachments),
|
|
282
296
|
text: deriveChatComposerDraft(nextNodes),
|
|
283
297
|
selectedSkills: deriveSelectedSkillsFromComposer(nextNodes),
|
|
284
298
|
sendError: null,
|
|
285
|
-
});
|
|
286
|
-
}, [inputActions
|
|
299
|
+
}));
|
|
300
|
+
}, [inputActions]);
|
|
287
301
|
const handleModelChange = useCallback((value: string) => {
|
|
288
302
|
const nextThinkingLevel = resolveThinkingForConversationModel(
|
|
289
303
|
modelRecords.find((option) => option.value === value),
|
|
@@ -380,6 +394,9 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
|
|
|
380
394
|
<ChatInputBar
|
|
381
395
|
ref={inputBarRef}
|
|
382
396
|
surface={surface}
|
|
397
|
+
topSlot={controller.queuedInputs.length > 0
|
|
398
|
+
? <SessionQueuedInputRows controller={controller} />
|
|
399
|
+
: null}
|
|
383
400
|
composer={{
|
|
384
401
|
nodes: [...inputSnapshot.nodes],
|
|
385
402
|
placeholder: textareaPlaceholder,
|
|
@@ -407,7 +424,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
|
|
|
407
424
|
label: t('chatInputAttach'),
|
|
408
425
|
icon: 'paperclip' as const,
|
|
409
426
|
iconOnly: true,
|
|
410
|
-
disabled: !attachmentSupported || inputDisabled
|
|
427
|
+
disabled: !attachmentSupported || inputDisabled,
|
|
411
428
|
onClick: () => fileInputRef.current?.click(),
|
|
412
429
|
},
|
|
413
430
|
],
|
|
@@ -419,7 +436,7 @@ export const SessionConversationInput = memo(function SessionConversationInput(p
|
|
|
419
436
|
sendDisabled: controller.sendDisabled,
|
|
420
437
|
stopDisabled: controller.stopDisabled,
|
|
421
438
|
stopHint: t('chatStopUnavailable'),
|
|
422
|
-
sendButtonLabel: t('chatSend'),
|
|
439
|
+
sendButtonLabel: controller.isSending ? t('chatQueueSend') : t('chatSend'),
|
|
423
440
|
stopButtonLabel: t('chatStop'),
|
|
424
441
|
contextWindow,
|
|
425
442
|
onSend: controller.send,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { CornerDownRight, Pencil, Trash2 } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Tooltip,
|
|
6
|
+
TooltipContent,
|
|
7
|
+
TooltipProvider,
|
|
8
|
+
TooltipTrigger,
|
|
9
|
+
} from '@/shared/components/ui/tooltip';
|
|
10
|
+
import { t } from '@/shared/lib/i18n';
|
|
11
|
+
import type {
|
|
12
|
+
SessionConversationQueuedInput,
|
|
13
|
+
} from '@/features/chat/features/conversation/hooks/use-session-conversation-controller';
|
|
14
|
+
|
|
15
|
+
type SessionQueuedInputRowsController = {
|
|
16
|
+
readonly deleteQueuedInput: (id: string) => void;
|
|
17
|
+
readonly editQueuedInput: (id: string) => void;
|
|
18
|
+
readonly queuedInputs: readonly SessionConversationQueuedInput[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type SessionQueuedInputRowsProps = {
|
|
22
|
+
readonly controller: SessionQueuedInputRowsController;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type QueuedInputIconButtonProps = {
|
|
26
|
+
readonly children: ReactNode;
|
|
27
|
+
readonly disabled?: boolean;
|
|
28
|
+
readonly label: string;
|
|
29
|
+
readonly onClick: () => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function QueuedInputIconButton({
|
|
33
|
+
children,
|
|
34
|
+
disabled = false,
|
|
35
|
+
label,
|
|
36
|
+
onClick,
|
|
37
|
+
}: QueuedInputIconButtonProps) {
|
|
38
|
+
return (
|
|
39
|
+
<Tooltip>
|
|
40
|
+
<TooltipTrigger asChild>
|
|
41
|
+
<span className="inline-flex">
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
aria-label={label}
|
|
45
|
+
disabled={disabled}
|
|
46
|
+
onClick={onClick}
|
|
47
|
+
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:text-muted-foreground/50"
|
|
48
|
+
>
|
|
49
|
+
{children}
|
|
50
|
+
</button>
|
|
51
|
+
</span>
|
|
52
|
+
</TooltipTrigger>
|
|
53
|
+
<TooltipContent side="top">{label}</TooltipContent>
|
|
54
|
+
</Tooltip>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function SessionQueuedInputRows({
|
|
59
|
+
controller,
|
|
60
|
+
}: SessionQueuedInputRowsProps) {
|
|
61
|
+
if (controller.queuedInputs.length === 0) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<TooltipProvider delayDuration={120}>
|
|
67
|
+
<div className="flex flex-col gap-0.5">
|
|
68
|
+
{controller.queuedInputs.map((item) => (
|
|
69
|
+
<div
|
|
70
|
+
key={item.id}
|
|
71
|
+
className="flex min-h-8 min-w-0 items-center gap-2 text-sm"
|
|
72
|
+
>
|
|
73
|
+
<CornerDownRight className="h-4 w-4 shrink-0 text-muted-foreground/70" />
|
|
74
|
+
<span className="min-w-0 flex-1 truncate font-medium text-foreground/80">
|
|
75
|
+
{item.preview || t('chatQueuedBannerAttachmentFallback')}
|
|
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)}
|
|
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>
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
</TooltipProvider>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { NcpAgentSendEnvelope, NcpRunHandle } from '@nextclaw/ncp';
|
|
4
|
+
|
|
5
|
+
import { useSessionConversationController } from '@/features/chat/features/conversation/hooks/use-session-conversation-controller';
|
|
6
|
+
|
|
7
|
+
type TestAgentSendEnvelope = NcpAgentSendEnvelope;
|
|
8
|
+
type TestAgentSend = (envelope: TestAgentSendEnvelope) => Promise<NcpRunHandle | null>;
|
|
9
|
+
|
|
10
|
+
function createTextNode(text: string) {
|
|
11
|
+
return {
|
|
12
|
+
id: 'text-1',
|
|
13
|
+
type: 'text' as const,
|
|
14
|
+
text,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createRunHandle(overrides: Partial<NcpRunHandle> = {}): NcpRunHandle {
|
|
19
|
+
return {
|
|
20
|
+
assistantMessageId: null,
|
|
21
|
+
correlationId: undefined,
|
|
22
|
+
runId: 'run-1',
|
|
23
|
+
sessionId: 'session-1',
|
|
24
|
+
userMessageId: 'user-message-1',
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createSendMock() {
|
|
30
|
+
return vi.fn<TestAgentSend>(async () => createRunHandle());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createAgent(params: {
|
|
34
|
+
isRunning: boolean;
|
|
35
|
+
send: ReturnType<typeof createSendMock>;
|
|
36
|
+
}) {
|
|
37
|
+
return {
|
|
38
|
+
abort: vi.fn(),
|
|
39
|
+
isHydrating: false,
|
|
40
|
+
isRunning: params.isRunning,
|
|
41
|
+
isSending: false,
|
|
42
|
+
send: params.send,
|
|
43
|
+
snapshot: {
|
|
44
|
+
activeRun: params.isRunning ? { sessionId: 'session-1' } : null,
|
|
45
|
+
},
|
|
46
|
+
visibleMessages: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createControllerParams(params: {
|
|
51
|
+
isRunning: boolean;
|
|
52
|
+
send?: ReturnType<typeof createSendMock>;
|
|
53
|
+
}) {
|
|
54
|
+
const send = params.send ?? createSendMock();
|
|
55
|
+
return {
|
|
56
|
+
agent: createAgent({ isRunning: params.isRunning, send }),
|
|
57
|
+
inputQuery: {
|
|
58
|
+
defaultModel: 'test-model',
|
|
59
|
+
defaultProjectRoot: null,
|
|
60
|
+
fallbackPreferredModel: undefined,
|
|
61
|
+
fallbackPreferredThinking: undefined,
|
|
62
|
+
isProviderStateResolved: true,
|
|
63
|
+
isSkillsLoading: false,
|
|
64
|
+
modelOptions: [],
|
|
65
|
+
selectedSession: null,
|
|
66
|
+
selectedSessionKey: 'session-1',
|
|
67
|
+
sessionTypeState: {
|
|
68
|
+
canEditSessionType: true,
|
|
69
|
+
defaultSessionType: 'default',
|
|
70
|
+
selectedSessionType: 'default',
|
|
71
|
+
selectedSessionTypeOption: null,
|
|
72
|
+
sessionTypeOptions: [],
|
|
73
|
+
sessionTypeUnavailable: false,
|
|
74
|
+
sessionTypeUnavailableMessage: null,
|
|
75
|
+
},
|
|
76
|
+
skillRecords: [],
|
|
77
|
+
},
|
|
78
|
+
inputSnapshot: {
|
|
79
|
+
attachments: [],
|
|
80
|
+
composerFocusRequestId: 0,
|
|
81
|
+
nodes: [createTextNode('next task')],
|
|
82
|
+
pendingProjectRoot: null,
|
|
83
|
+
pendingSessionType: 'default',
|
|
84
|
+
selectedModel: undefined,
|
|
85
|
+
selectedSessionType: 'default',
|
|
86
|
+
selectedSkills: [],
|
|
87
|
+
selectedThinkingLevel: null,
|
|
88
|
+
sendError: null,
|
|
89
|
+
skillRecords: [],
|
|
90
|
+
text: 'next task',
|
|
91
|
+
},
|
|
92
|
+
isRuntimeBlocked: false,
|
|
93
|
+
selectedAgentId: 'main',
|
|
94
|
+
sessionKey: 'session-1',
|
|
95
|
+
resetComposer: vi.fn(),
|
|
96
|
+
restoreComposer: vi.fn(),
|
|
97
|
+
setSendError: vi.fn(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('useSessionConversationController queued input delivery', () => {
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
vi.useRealTimers();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('queues a draft while the agent is running and sends it when the run becomes idle', async () => {
|
|
107
|
+
vi.useFakeTimers({ toFake: ['Date'] });
|
|
108
|
+
vi.setSystemTime(new Date('2026-07-05T10:00:00.000Z'));
|
|
109
|
+
const send = createSendMock();
|
|
110
|
+
const { result, rerender } = renderHook(
|
|
111
|
+
({ isRunning }) => useSessionConversationController(createControllerParams({ isRunning, send })),
|
|
112
|
+
{ initialProps: { isRunning: true } },
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
await act(async () => {
|
|
116
|
+
await result.current.send();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(send).not.toHaveBeenCalled();
|
|
120
|
+
expect(result.current.queuedInputs).toEqual([{ id: expect.any(String), preview: 'next task' }]);
|
|
121
|
+
|
|
122
|
+
vi.setSystemTime(new Date('2026-07-05T10:01:00.000Z'));
|
|
123
|
+
rerender({ isRunning: false });
|
|
124
|
+
|
|
125
|
+
await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
|
|
126
|
+
expect(send.mock.calls[0]?.[0]).toMatchObject({
|
|
127
|
+
message: expect.objectContaining({
|
|
128
|
+
parts: [{ type: 'text', text: 'next task' }],
|
|
129
|
+
timestamp: '2026-07-05T10:01:00.000Z',
|
|
130
|
+
}),
|
|
131
|
+
sessionId: 'session-1',
|
|
132
|
+
});
|
|
133
|
+
expect(send.mock.calls[0]?.[0]).not.toHaveProperty('runDelivery');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns a queued draft to the composer for editing and cancels its queue entry', async () => {
|
|
137
|
+
const send = createSendMock();
|
|
138
|
+
const params = createControllerParams({ isRunning: true, send });
|
|
139
|
+
const { result } = renderHook(() => useSessionConversationController(params));
|
|
140
|
+
|
|
141
|
+
await act(async () => {
|
|
142
|
+
await result.current.send();
|
|
143
|
+
});
|
|
144
|
+
const queuedId = result.current.queuedInputs[0]?.id;
|
|
145
|
+
expect(queuedId).toBeTruthy();
|
|
146
|
+
|
|
147
|
+
act(() => {
|
|
148
|
+
result.current.editQueuedInput(queuedId ?? '');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(result.current.queuedInputs).toEqual([]);
|
|
152
|
+
expect(params.restoreComposer).toHaveBeenCalledWith({
|
|
153
|
+
attachments: [],
|
|
154
|
+
nodes: [createTextNode('next task')],
|
|
155
|
+
selectedSkills: [],
|
|
156
|
+
skillRecords: [],
|
|
157
|
+
text: 'next task',
|
|
158
|
+
});
|
|
159
|
+
expect(send).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('deletes a queued draft without restoring it to the composer', async () => {
|
|
163
|
+
const send = createSendMock();
|
|
164
|
+
const params = createControllerParams({ isRunning: true, send });
|
|
165
|
+
const { result } = renderHook(() => useSessionConversationController(params));
|
|
166
|
+
|
|
167
|
+
await act(async () => {
|
|
168
|
+
await result.current.send();
|
|
169
|
+
});
|
|
170
|
+
const queuedId = result.current.queuedInputs[0]?.id;
|
|
171
|
+
expect(queuedId).toBeTruthy();
|
|
172
|
+
|
|
173
|
+
act(() => {
|
|
174
|
+
result.current.deleteQueuedInput(queuedId ?? '');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(result.current.queuedInputs).toEqual([]);
|
|
178
|
+
expect(params.restoreComposer).not.toHaveBeenCalled();
|
|
179
|
+
expect(send).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
});
|