@agentscope-ai/chat 1.1.70 → 1.1.71-beta.1782109675297

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 (45) hide show
  1. package/components/AgentScopeRuntimeWebUI/core/Chat/Input/index.tsx +75 -9
  2. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.tsx +289 -0
  3. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/__tests__/inputQueue.test.ts +235 -0
  4. package/components/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.ts +319 -0
  5. package/components/AgentScopeRuntimeWebUI/core/Chat/MessageList/index.tsx +4 -4
  6. package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.tsx +536 -6
  7. package/components/AgentScopeRuntimeWebUI/core/Chat/index.tsx +42 -3
  8. package/components/AgentScopeRuntimeWebUI/core/Chat/styles.tsx +320 -4
  9. package/components/AgentScopeRuntimeWebUI/core/ChatAnywhere/index.tsx +1 -1
  10. package/components/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.tsx +34 -8
  11. package/components/AgentScopeRuntimeWebUI/core/types/IChatAnywhere.ts +54 -1
  12. package/components/AgentScopeRuntimeWebUI/starter/index.tsx +103 -14
  13. package/lib/AgentScopeRuntimeWebUI/core/Chat/Input/index.d.ts +8 -0
  14. package/lib/AgentScopeRuntimeWebUI/core/Chat/Input/index.js +103 -16
  15. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.d.ts +15 -0
  16. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/Panel.js +275 -0
  17. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.d.ts +83 -0
  18. package/lib/AgentScopeRuntimeWebUI/core/Chat/InputQueue/index.js +202 -0
  19. package/lib/AgentScopeRuntimeWebUI/core/Chat/MessageList/index.js +4 -4
  20. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.d.ts +14 -0
  21. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.js +687 -86
  22. package/lib/AgentScopeRuntimeWebUI/core/Chat/index.js +33 -2
  23. package/lib/AgentScopeRuntimeWebUI/core/Chat/styles.js +155 -1
  24. package/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.d.ts +23 -1
  25. package/lib/AgentScopeRuntimeWebUI/core/Context/ChatAnywhereI18nContext.js +32 -8
  26. package/lib/AgentScopeRuntimeWebUI/core/types/IChatAnywhere.d.ts +65 -1
  27. package/lib/AgentScopeRuntimeWebUI/starter/index.js +148 -21
  28. package/package.json +2 -1
  29. package/bin/starter_webui/README.md +0 -75
  30. package/bin/starter_webui/eslint.config.js +0 -28
  31. package/bin/starter_webui/index.html +0 -12
  32. package/bin/starter_webui/package.json +0 -34
  33. package/bin/starter_webui/src/App.tsx +0 -20
  34. package/bin/starter_webui/src/components/Chat/OptionsPanel/FormItem.tsx +0 -37
  35. package/bin/starter_webui/src/components/Chat/OptionsPanel/OptionsEditor.tsx +0 -160
  36. package/bin/starter_webui/src/components/Chat/OptionsPanel/defaultConfig.ts +0 -41
  37. package/bin/starter_webui/src/components/Chat/OptionsPanel/index.tsx +0 -27
  38. package/bin/starter_webui/src/components/Chat/index.tsx +0 -45
  39. package/bin/starter_webui/src/components/Chat/sessionApi/index.ts +0 -53
  40. package/bin/starter_webui/src/main.tsx +0 -9
  41. package/bin/starter_webui/src/vite-env.d.ts +0 -4
  42. package/bin/starter_webui/tsconfig.app.json +0 -24
  43. package/bin/starter_webui/tsconfig.json +0 -7
  44. package/bin/starter_webui/tsconfig.node.json +0 -22
  45. package/bin/starter_webui/vite.config.ts +0 -11
@@ -1,14 +1,21 @@
1
- import { useCallback } from "react";
1
+ import { useCallback, type ReactNode } from "react";
2
2
  import { useProviderContext, ChatInput, Disclaimer } from '@agentscope-ai/chat';
3
3
  import { useChatAnywhereOptions } from "../../Context/ChatAnywhereOptionsContext";
4
4
  import { useGetState } from 'ahooks';
5
5
  import { useChatAnywhereInput } from "../../Context/ChatAnywhereInputContext";
6
6
  import useAttachments from "./useAttachments";
7
7
  import { IAgentScopeRuntimeWebUIInputData } from "@agentscope-ai/chat";
8
+ import type { QueueEnqueueResult, QueuedInputItem } from "../InputQueue";
8
9
 
9
10
  export interface InputProps {
10
11
  onCancel: () => void;
11
12
  onSubmit: (data: IAgentScopeRuntimeWebUIInputData) => void;
13
+ queue?: {
14
+ items: QueuedInputItem[];
15
+ isOwner: boolean;
16
+ panel?: ReactNode;
17
+ onEnqueue: (data: IAgentScopeRuntimeWebUIInputData) => QueueEnqueueResult | Promise<QueueEnqueueResult>;
18
+ };
12
19
  }
13
20
 
14
21
  export default function Input(props: InputProps) {
@@ -38,24 +45,83 @@ export default function Input(props: InputProps) {
38
45
  uploadFileListHeader
39
46
  } = useAttachments(attachments, { disabled: !!inputContext.disabled });
40
47
 
48
+ const getSubmittableData = useCallback(() => {
49
+ const fileList = (getFileList?.() || []).filter(i => i.response?.url);
50
+ const query = getContent();
51
+ if (!query.trim() && fileList.length === 0) return;
52
+
53
+ return {
54
+ query,
55
+ text: query,
56
+ fileList,
57
+ attachments: fileList,
58
+ };
59
+ }, [getContent, getFileList]);
60
+
61
+ const clearInput = useCallback(() => {
62
+ setContent('');
63
+ setFileList?.([]);
64
+ }, [setContent, setFileList]);
65
+
66
+ const handleEnqueue = useCallback(async () => {
67
+ const next = await beforeSubmit();
68
+ if (!next) return;
69
+
70
+ const data = getSubmittableData();
71
+ if (!data) return;
72
+ if (!props.queue) return;
73
+
74
+ const result = await props.queue.onEnqueue(data);
75
+ if (result.ok) {
76
+ clearInput();
77
+ }
78
+ }, [beforeSubmit, clearInput, getSubmittableData, props.queue]);
41
79
 
42
80
  const handleSubmit = useCallback(async () => {
43
81
  const next = await beforeSubmit();
44
82
  if (!next) return;
45
83
 
46
- const fileList = (getFileList?.() || []).filter(i => i.response?.url);
47
- props.onSubmit({ query: getContent(), fileList });
48
- setContent('');
49
- setFileList && setFileList([]);
50
- }, []);
84
+ const data = getSubmittableData();
85
+ if (!data) return;
86
+
87
+ if (props.queue && (
88
+ inputContext.loading ||
89
+ props.queue.items.length ||
90
+ props.queue.isOwner === false
91
+ )) {
92
+ const result = await props.queue.onEnqueue(data);
93
+ if (result.ok) {
94
+ clearInput();
95
+ }
96
+ } else {
97
+ props.onSubmit(data);
98
+ clearInput();
99
+ }
100
+ }, [beforeSubmit, clearInput, getSubmittableData, inputContext.loading, props.onSubmit, props.queue]);
101
+
102
+ const handleKeyDownCapture = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
103
+ if (event.key !== 'Enter' || event.shiftKey) return;
104
+ if (event.nativeEvent?.isComposing) return;
105
+ if (!props.queue) return;
106
+ const forceEnqueue = event.ctrlKey || event.metaKey;
107
+ if (!forceEnqueue && !inputContext.loading && !props.queue?.items.length) return;
108
+
109
+ const data = getSubmittableData();
110
+ if (!data) return;
111
+
112
+ event.preventDefault();
113
+ event.stopPropagation();
114
+ void (forceEnqueue ? handleEnqueue() : handleSubmit());
115
+ }, [getSubmittableData, handleEnqueue, handleSubmit, inputContext.loading, props.queue, props.queue?.items.length]);
51
116
 
52
117
  const handleCancel = useCallback(() => {
53
118
  props.onCancel();
54
- }, []);
119
+ }, [props.onCancel]);
55
120
 
56
- return <div className={prefixCls}>
121
+ return <div className={prefixCls} onKeyDownCapture={handleKeyDownCapture}>
57
122
  <div className={`${prefixCls}-wrapper`}>
58
123
  {beforeUI}
124
+ {props.queue?.panel}
59
125
  <ChatInput
60
126
  loading={inputContext.loading}
61
127
  disabled={inputContext.disabled}
@@ -80,4 +146,4 @@ export default function Input(props: InputProps) {
80
146
  disclaimer ? <Disclaimer desc={disclaimer} /> : <div className={`${prefixCls}-blank`}></div>
81
147
  }
82
148
  </div>;
83
- }
149
+ }
@@ -0,0 +1,289 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useProviderContext } from '@agentscope-ai/chat';
3
+ import { IconButton } from '@agentscope-ai/design';
4
+ import {
5
+ SparkClearLine,
6
+ SparkDeleteLine,
7
+ SparkDragDotLine,
8
+ SparkEditLine,
9
+ SparkPauseLine,
10
+ SparkPlayLine,
11
+ SparkRefreshLine,
12
+ SparkSendLine,
13
+ } from '@agentscope-ai/icons';
14
+ import { Tooltip } from 'antd';
15
+ import { useTranslation } from '../../Context/ChatAnywhereI18nContext';
16
+ import type { QueuedInputItem } from './index';
17
+
18
+ interface InputQueuePanelProps {
19
+ items: QueuedInputItem[];
20
+ paused: boolean;
21
+ isOwner: boolean;
22
+ onRemove: (id: string) => void;
23
+ onClear: () => void;
24
+ onRetry: (id: string) => void;
25
+ onTogglePaused: () => void;
26
+ onReorder: (sourceId: string, targetId: string) => void;
27
+ onUpdateQuery: (id: string, query: string) => void;
28
+ onSendNow: (id: string) => void;
29
+ }
30
+
31
+ export default function InputQueuePanel(props: InputQueuePanelProps) {
32
+ const {
33
+ items,
34
+ paused,
35
+ isOwner,
36
+ onRemove,
37
+ onClear,
38
+ onRetry,
39
+ onTogglePaused,
40
+ onReorder,
41
+ onUpdateQuery,
42
+ onSendNow,
43
+ } = props;
44
+ const [draggingId, setDraggingId] = useState<string>();
45
+ const [dragOverId, setDragOverId] = useState<string>();
46
+ const [editingId, setEditingId] = useState<string>();
47
+ const [draftQuery, setDraftQuery] = useState('');
48
+ const prefixCls = useProviderContext().getPrefixCls(
49
+ 'chat-anywhere-input-queue',
50
+ );
51
+ const { t } = useTranslation();
52
+ const tr = (key: Parameters<typeof t>[0]) => t?.(key) || key;
53
+
54
+ useEffect(() => {
55
+ if (editingId && !items.some(item => item.id === editingId)) {
56
+ setEditingId(undefined);
57
+ setDraftQuery('');
58
+ }
59
+ }, [editingId, items]);
60
+
61
+ const isNoDragTarget = (target: EventTarget | null) => {
62
+ return target instanceof HTMLElement && !!target.closest('[data-no-drag]');
63
+ };
64
+
65
+ const startEdit = (item: QueuedInputItem) => {
66
+ setEditingId(item.id);
67
+ setDraftQuery(item.data.query || '');
68
+ };
69
+
70
+ const commitEdit = () => {
71
+ if (!editingId) return;
72
+ onUpdateQuery(editingId, draftQuery);
73
+ setEditingId(undefined);
74
+ setDraftQuery('');
75
+ };
76
+
77
+ const cancelEdit = () => {
78
+ setEditingId(undefined);
79
+ setDraftQuery('');
80
+ };
81
+
82
+ const clearDragState = () => {
83
+ setDraggingId(undefined);
84
+ setDragOverId(undefined);
85
+ };
86
+
87
+ const getFileName = (file: NonNullable<QueuedInputItem['data']['fileList']>[number]) => {
88
+ return file.name || file.response?.name || file.response?.url || file.url || tr('queue.attachmentOnly');
89
+ };
90
+
91
+ const getFileUrl = (file: NonNullable<QueuedInputItem['data']['fileList']>[number]) => {
92
+ return file.thumbUrl || file.url || file.response?.url;
93
+ };
94
+
95
+ const isImageFile = (file: NonNullable<QueuedInputItem['data']['fileList']>[number]) => {
96
+ return file.type?.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(getFileName(file));
97
+ };
98
+
99
+ if (!items.length) return null;
100
+
101
+ return (
102
+ <div className={prefixCls}>
103
+ <div className={`${prefixCls}-header`}>
104
+ <div className={`${prefixCls}-title`}>
105
+ <span className={`${prefixCls}-pulse`} />
106
+ <span>{tr('queue.title')}</span>
107
+ {!isOwner ? (
108
+ <span className={`${prefixCls}-owner`}>{tr('queue.remoteOwner')}</span>
109
+ ) : null}
110
+ <span className={`${prefixCls}-count`}>{items.length}</span>
111
+ </div>
112
+ {isOwner ? <div className={`${prefixCls}-header-actions`}>
113
+ <Tooltip title={paused ? tr('queue.resume') : tr('queue.pause')}>
114
+ <IconButton
115
+ size="small"
116
+ bordered={false}
117
+ className={`${prefixCls}-clear`}
118
+ icon={paused ? <SparkPlayLine /> : <SparkPauseLine />}
119
+ onClick={onTogglePaused}
120
+ />
121
+ </Tooltip>
122
+ <Tooltip title={tr('queue.clear')}>
123
+ <IconButton
124
+ size="small"
125
+ bordered={false}
126
+ className={`${prefixCls}-clear`}
127
+ icon={<SparkClearLine />}
128
+ onClick={onClear}
129
+ />
130
+ </Tooltip>
131
+ </div> : null}
132
+ </div>
133
+ <div className={`${prefixCls}-list`}>
134
+ {items.map((item, index) => {
135
+ const failed = item.status === 'failed';
136
+ const files = item.data.attachments || item.data.fileList || [];
137
+ const statusText = failed
138
+ ? tr('queue.failed')
139
+ : index === 0
140
+ ? tr('queue.next')
141
+ : `${index + 1}`;
142
+ const queryText = item.data.query || tr('queue.attachmentOnly');
143
+ const itemClassName = [
144
+ `${prefixCls}-item`,
145
+ index === 0 ? `${prefixCls}-item-next` : '',
146
+ failed ? `${prefixCls}-item-failed` : '',
147
+ ]
148
+ .filter(Boolean)
149
+ .join(' ');
150
+
151
+ return (
152
+ <div
153
+ className={[
154
+ itemClassName,
155
+ draggingId === item.id ? `${prefixCls}-item-dragging` : '',
156
+ dragOverId === item.id && draggingId !== item.id
157
+ ? `${prefixCls}-item-drag-over`
158
+ : '',
159
+ ].filter(Boolean).join(' ')}
160
+ draggable
161
+ key={item.id}
162
+ onDragStart={(event) => {
163
+ if (isNoDragTarget(event.target)) {
164
+ event.preventDefault();
165
+ return;
166
+ }
167
+
168
+ setDraggingId(item.id);
169
+ event.dataTransfer.effectAllowed = 'move';
170
+ event.dataTransfer.setData('text/plain', item.id);
171
+ }}
172
+ onDragOver={(event) => {
173
+ if (!draggingId || draggingId === item.id) return;
174
+ event.preventDefault();
175
+ event.dataTransfer.dropEffect = 'move';
176
+ setDragOverId(item.id);
177
+ }}
178
+ onDragLeave={() => {
179
+ if (dragOverId === item.id) setDragOverId(undefined);
180
+ }}
181
+ onDrop={(event) => {
182
+ event.preventDefault();
183
+ const sourceId = draggingId || event.dataTransfer.getData('text/plain');
184
+ clearDragState();
185
+ if (sourceId && sourceId !== item.id) {
186
+ onReorder(sourceId, item.id);
187
+ }
188
+ }}
189
+ onDragEnd={clearDragState}
190
+ >
191
+ <span className={`${prefixCls}-drag-handle`}>
192
+ <SparkDragDotLine />
193
+ </span>
194
+ <span className={`${prefixCls}-index`}>{statusText}</span>
195
+ <div className={`${prefixCls}-content`}>
196
+ {editingId === item.id ? (
197
+ <input
198
+ autoFocus
199
+ className={`${prefixCls}-edit-input`}
200
+ data-no-drag
201
+ value={draftQuery}
202
+ onBlur={commitEdit}
203
+ onChange={(event) => setDraftQuery(event.target.value)}
204
+ onKeyDown={(event) => {
205
+ if (event.key === 'Enter') {
206
+ event.preventDefault();
207
+ commitEdit();
208
+ }
209
+ if (event.key === 'Escape') {
210
+ event.preventDefault();
211
+ cancelEdit();
212
+ }
213
+ }}
214
+ />
215
+ ) : (
216
+ <span className={`${prefixCls}-text`} title={queryText}>
217
+ {queryText}
218
+ </span>
219
+ )}
220
+ {files.length ? (
221
+ <span className={`${prefixCls}-files`}>
222
+ {files.slice(0, 2).map(file => {
223
+ const name = getFileName(file);
224
+ const url = getFileUrl(file);
225
+ return (
226
+ <span className={`${prefixCls}-file`} key={file.uid || name} title={name}>
227
+ {isImageFile(file) && url ? (
228
+ <img alt="" className={`${prefixCls}-file-thumb`} src={url} />
229
+ ) : (
230
+ <span className={`${prefixCls}-file-icon`}>#</span>
231
+ )}
232
+ <span className={`${prefixCls}-file-name`}>{name}</span>
233
+ </span>
234
+ );
235
+ })}
236
+ {files.length > 2 ? (
237
+ <span className={`${prefixCls}-file-more`}>+{files.length - 2}</span>
238
+ ) : null}
239
+ </span>
240
+ ) : null}
241
+ {failed ? (
242
+ <span className={`${prefixCls}-error`}>
243
+ {item.errorMessage || tr('queue.failed')}
244
+ </span>
245
+ ) : null}
246
+ </div>
247
+ <div className={`${prefixCls}-actions`} data-no-drag>
248
+ <Tooltip title={tr('common.edit')}>
249
+ <IconButton
250
+ size="small"
251
+ bordered={false}
252
+ icon={<SparkEditLine />}
253
+ onClick={() => startEdit(item)}
254
+ />
255
+ </Tooltip>
256
+ {isOwner ? <Tooltip title={tr('queue.sendNow')}>
257
+ <IconButton
258
+ size="small"
259
+ bordered={false}
260
+ icon={<SparkSendLine />}
261
+ onClick={() => onSendNow(item.id)}
262
+ />
263
+ </Tooltip> : null}
264
+ {isOwner && failed ? (
265
+ <Tooltip title={tr('queue.retry')}>
266
+ <IconButton
267
+ size="small"
268
+ bordered={false}
269
+ icon={<SparkRefreshLine />}
270
+ onClick={() => onRetry(item.id)}
271
+ />
272
+ </Tooltip>
273
+ ) : null}
274
+ {isOwner ? <Tooltip title={tr('common.delete')}>
275
+ <IconButton
276
+ size="small"
277
+ bordered={false}
278
+ icon={<SparkDeleteLine />}
279
+ onClick={() => onRemove(item.id)}
280
+ />
281
+ </Tooltip> : null}
282
+ </div>
283
+ </div>
284
+ );
285
+ })}
286
+ </div>
287
+ </div>
288
+ );
289
+ }
@@ -0,0 +1,235 @@
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ assignInputQueueOwner,
4
+ canSubmitDirectly,
5
+ createEmptyInputQueueState,
6
+ createQueuedInputItem,
7
+ createSendNowCommand,
8
+ dequeueNextQueuedInput,
9
+ enqueueInputQueueState,
10
+ enqueueQueuedInput,
11
+ INPUT_QUEUE_OWNER_TTL,
12
+ isInputQueueOwner,
13
+ isInputQueueStateEmpty,
14
+ reorderQueuedInput,
15
+ removeQueuedInput,
16
+ restoreFailedQueuedInput,
17
+ retryQueuedInput,
18
+ updateQueuedInputQuery,
19
+ } from '../index';
20
+
21
+ const input = (query: string) => ({ query });
22
+
23
+ function test(name: string, fn: () => void) {
24
+ try {
25
+ fn();
26
+ console.log(`ok - ${name}`);
27
+ } catch (error) {
28
+ console.error(`not ok - ${name}`);
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ test('enqueue appends inputs in FIFO order and dequeue consumes the head', () => {
34
+ let queue = enqueueQueuedInput([], input('first'), {
35
+ id: 'q1',
36
+ now: 1,
37
+ }).queue;
38
+ queue = enqueueQueuedInput(queue, input('second'), {
39
+ id: 'q2',
40
+ now: 2,
41
+ }).queue;
42
+
43
+ const first = dequeueNextQueuedInput(queue);
44
+ assert.equal(first.item?.data.query, 'first');
45
+ assert.deepEqual(first.queue.map(item => item.data.query), ['second']);
46
+
47
+ const second = dequeueNextQueuedInput(first.queue);
48
+ assert.equal(second.item?.data.query, 'second');
49
+ assert.equal(second.queue.length, 0);
50
+ });
51
+
52
+ test('direct submit is allowed only when idle, queue is empty and no drain is active', () => {
53
+ assert.equal(
54
+ canSubmitDirectly({ loading: false, queueLength: 0, draining: false }),
55
+ true,
56
+ );
57
+ assert.equal(
58
+ canSubmitDirectly({ loading: true, queueLength: 0, draining: false }),
59
+ false,
60
+ );
61
+ assert.equal(
62
+ canSubmitDirectly({ loading: false, queueLength: 1, draining: false }),
63
+ false,
64
+ );
65
+ assert.equal(
66
+ canSubmitDirectly({ loading: false, queueLength: 0, draining: true }),
67
+ false,
68
+ );
69
+ assert.equal(
70
+ canSubmitDirectly({
71
+ loading: false,
72
+ queueLength: 0,
73
+ draining: false,
74
+ paused: true,
75
+ }),
76
+ false,
77
+ );
78
+ assert.equal(
79
+ canSubmitDirectly({
80
+ loading: false,
81
+ queueLength: 0,
82
+ draining: false,
83
+ canExecute: false,
84
+ }),
85
+ false,
86
+ );
87
+ });
88
+
89
+ test('enqueue rejects new input when the queue reaches max size', () => {
90
+ const queue = enqueueQueuedInput([], input('first'), {
91
+ id: 'q1',
92
+ maxSize: 1,
93
+ }).queue;
94
+ const result = enqueueQueuedInput(queue, input('second'), {
95
+ id: 'q2',
96
+ maxSize: 1,
97
+ });
98
+
99
+ assert.equal(result.reason, 'full');
100
+ assert.deepEqual(result.queue.map(item => item.id), ['q1']);
101
+ });
102
+
103
+ test('full queue state is not rewritten when enqueue is rejected', () => {
104
+ const state = enqueueInputQueueState(createEmptyInputQueueState(1), input('first'), {
105
+ id: 'q1',
106
+ maxSize: 1,
107
+ now: 2,
108
+ }).state;
109
+ const result = enqueueInputQueueState(state, input('second'), {
110
+ id: 'q2',
111
+ maxSize: 1,
112
+ now: 3,
113
+ });
114
+
115
+ assert.equal(result.reason, 'full');
116
+ assert.equal(result.state, state);
117
+ });
118
+
119
+ test('queued input item preserves full message body aliases', () => {
120
+ const file = { uid: 'f1', name: 'shot.png', response: { url: '/shot.png' } };
121
+ const item = createQueuedInputItem({
122
+ query: 'with file',
123
+ fileList: [file as any],
124
+ });
125
+
126
+ assert.equal(item.data.text, 'with file');
127
+ assert.deepEqual(item.data.attachments, [file]);
128
+ });
129
+
130
+ test('failed send is restored at the queue head and blocks automatic dequeue', () => {
131
+ const original = enqueueQueuedInput([], input('send me'), {
132
+ id: 'q1',
133
+ }).queue[0];
134
+ const queue = restoreFailedQueuedInput([], original, new Error('network'));
135
+
136
+ assert.equal(queue[0].status, 'failed');
137
+ assert.equal(queue[0].retryCount, 1);
138
+ assert.equal(queue[0].errorMessage, 'network');
139
+
140
+ const next = dequeueNextQueuedInput(queue);
141
+ assert.equal(next.item, undefined);
142
+ assert.equal(next.queue, queue);
143
+ });
144
+
145
+ test('retry marks a failed item pending so it can be sent again', () => {
146
+ const original = enqueueQueuedInput([], input('retry me'), {
147
+ id: 'q1',
148
+ }).queue[0];
149
+ const failed = restoreFailedQueuedInput([], original, 'boom');
150
+ const retried = retryQueuedInput(failed, 'q1');
151
+ const next = dequeueNextQueuedInput(retried);
152
+
153
+ assert.equal(retried[0].status, 'pending');
154
+ assert.equal(retried[0].errorMessage, undefined);
155
+ assert.equal(next.item?.id, 'q1');
156
+ });
157
+
158
+ test('remove deletes only the selected queued input', () => {
159
+ let queue = enqueueQueuedInput([], input('first'), { id: 'q1' }).queue;
160
+ queue = enqueueQueuedInput(queue, input('second'), { id: 'q2' }).queue;
161
+
162
+ assert.deepEqual(removeQueuedInput(queue, 'q1').map(item => item.id), ['q2']);
163
+ assert.deepEqual(removeQueuedInput(queue, 'missing').map(item => item.id), [
164
+ 'q1',
165
+ 'q2',
166
+ ]);
167
+ });
168
+
169
+ test('queue state keeps owner and paused metadata separate from items', () => {
170
+ const empty = createEmptyInputQueueState(1);
171
+ const queued = enqueueInputQueueState(empty, input('first'), {
172
+ id: 'q1',
173
+ now: 2,
174
+ ownerTabId: 'tab-a',
175
+ }).state;
176
+
177
+ assert.equal(queued.ownerTabId, 'tab-a');
178
+ assert.equal(queued.ownerUpdatedAt, 2);
179
+ assert.equal(queued.paused, false);
180
+ assert.deepEqual(queued.items.map(item => item.id), ['q1']);
181
+ });
182
+
183
+ test('queue owner is isolated by tab and can be reclaimed when stale', () => {
184
+ const owned = assignInputQueueOwner(createEmptyInputQueueState(1), 'tab-a', 10);
185
+
186
+ assert.equal(isInputQueueOwner(owned, 'tab-a', 12), true);
187
+ assert.equal(isInputQueueOwner(owned, 'tab-b', 12), false);
188
+ assert.equal(isInputQueueOwner(owned, 'tab-b', 10 + INPUT_QUEUE_OWNER_TTL + 1), true);
189
+ });
190
+
191
+ test('drag reorder moves a queued input before the drop target', () => {
192
+ let queue = enqueueQueuedInput([], input('first'), { id: 'q1' }).queue;
193
+ queue = enqueueQueuedInput(queue, input('second'), { id: 'q2' }).queue;
194
+ queue = enqueueQueuedInput(queue, input('third'), { id: 'q3' }).queue;
195
+
196
+ assert.deepEqual(reorderQueuedInput(queue, 'q3', 'q1').map(item => item.id), [
197
+ 'q3',
198
+ 'q1',
199
+ 'q2',
200
+ ]);
201
+ assert.equal(reorderQueuedInput(queue, 'missing', 'q1'), queue);
202
+ assert.equal(reorderQueuedInput(queue, 'q2', 'q2'), queue);
203
+ });
204
+
205
+ test('queued input query can be edited before sending', () => {
206
+ const queue = enqueueQueuedInput([], input('old'), { id: 'q1' }).queue;
207
+ const edited = updateQueuedInputQuery(
208
+ restoreFailedQueuedInput([], queue[0], 'boom'),
209
+ 'q1',
210
+ 'new',
211
+ );
212
+
213
+ assert.equal(edited[0].data.query, 'new');
214
+ assert.equal(edited[0].data.text, 'new');
215
+ assert.equal(edited[0].status, 'pending');
216
+ assert.equal(edited[0].errorMessage, undefined);
217
+ });
218
+
219
+ test('empty queue state is removable from storage', () => {
220
+ assert.equal(isInputQueueStateEmpty(createEmptyInputQueueState()), true);
221
+
222
+ const queued = enqueueInputQueueState(createEmptyInputQueueState(), input('first'), {
223
+ id: 'q1',
224
+ }).state;
225
+ assert.equal(isInputQueueStateEmpty(queued), false);
226
+ });
227
+
228
+ test('send-now command carries selected task and source tab', () => {
229
+ const command = createSendNowCommand('q1', 'tab-a', 100);
230
+
231
+ assert.equal(command.type, 'send-now');
232
+ assert.equal(command.itemId, 'q1');
233
+ assert.equal(command.sourceTabId, 'tab-a');
234
+ assert.equal(command.createdAt, 100);
235
+ });