Agent Manager commited on
Commit
827cd2d
·
1 Parent(s): 90c04f1

Upload attachments immediately with progress

Browse files
docs/screenshot-input.md CHANGED
@@ -121,14 +121,17 @@ same paste/drop behavior:
121
  for several) as its text. Image-only server delivery retains the more specific
122
  `screenshot` wording.
123
  - At most five files may be attached to one prompt.
124
- - The send button is disabled while an upload is active.
125
- - A failed upload leaves the draft and pending files intact and names the
126
- failure next to the affected chip.
127
-
128
- Pending files remain browser `File` objects until the operator submits. This
129
- means abandoning or editing a draft does not create server-side orphan files.
130
- `URL.createObjectURL()` supplies local previews and is revoked when a chip is
131
- removed or the component unmounts.
 
 
 
132
 
133
  ### 5.2 Rendered terminal reader
134
 
@@ -149,7 +152,8 @@ xterm owns the visible composer, so Agent Manager cannot reliably place its own
149
  persistent attachment chips inside it. File paste/drop therefore behaves as a
150
  short transaction:
151
 
152
- 1. Show `uploading file` over the bottom of the pane.
 
153
  2. Upload the file without sending a prompt.
154
  3. On success, attach it through the harness adapter or paste a formatted path
155
  reference into the CLI composer.
@@ -235,9 +239,11 @@ session's own directory, and never join an arbitrary browser-supplied filename.
235
 
236
  ### 6.3 Lifecycle
237
 
238
- - Composer files are uploaded only on submit.
239
- - Terminal files are uploaded immediately because xterm needs a server path to
240
- insert.
 
 
241
  - Successful attachments remain while the session exists, including while it
242
  is stopped.
243
  - Deleting a session removes its managed attachment directory. It does not
@@ -415,13 +421,14 @@ file.
415
  ### 8.3 First prompt without a boot race
416
 
417
  The quickstart path currently places an initial prompt on the CLI launch command
418
- because typing into a booting TUI could lose it. Attachment quickstart needs a
419
- two-step browser flow—create the session, then upload to its attachment scope—so
420
- `deliver()` must preserve that property:
421
 
422
  1. `POST /api/sessions` without a prompt creates a stopped session.
423
- 2. Upload all pending files to the returned session id.
424
- 3. `POST /api/sessions/:id/input` with text and attachment ids.
 
425
  4. If the session has never started and its CLI has `withPrompt`, store the
426
  fully formatted prompt as `pendingPrompt`; for Codex, also retain the
427
  validated image paths as `pendingImagePaths`; then call `ensureRunning()`.
@@ -467,7 +474,7 @@ Add a small module, `web/src/lib/attachments.ts`, containing:
467
  - `transferMayContainFile(DataTransfer)`;
468
  - duplicate suppression across `items` and `files`;
469
  - local preview creation/revocation; and
470
- - sequential or bounded-concurrency upload helpers.
471
 
472
  Use sequential uploads initially. Five files is the maximum, bucket writes are
473
  the bottleneck, and simpler ordering makes chip status deterministic.
@@ -479,10 +486,10 @@ Expected file changes:
479
 
480
  | File | Change |
481
  |---|---|
482
- | `web/src/api.ts` | attachment types, upload, preview URL, `sendInput(..., attachmentIds)` |
483
  | `web/src/lib/attachments.ts` | clipboard/drop extraction and pending-file lifecycle |
484
  | `web/src/components/Attachments.tsx` | image previews, file badges, picker, progress/error states |
485
- | `web/src/components/Sidebar.tsx` | quickstart paste/drop and two-step submit |
486
  | `web/src/components/Overview.tsx` | reply attachments and file-only send |
487
  | `web/src/components/TerminalPane.tsx` | capture-phase file paste/drop and mobile file paste |
488
  | `web/src/components/conversation/ConversationView.tsx` | rendered-reader attachments and structured send |
@@ -568,7 +575,8 @@ Markdown logs, and needlessly injects binary material into traces.
568
  |---|---|---|
569
  | clipboard contains no file | ordinary text paste continues, or no-op | no |
570
  | file larger than limit | chip says `too large (100 MB max)` | no |
571
- | network/write failure | chip or terminal overlay says upload failed | no |
 
572
  | one of several uploads fails | successful files remain, all chips stay for retry | no |
573
  | attachment id missing at send | `attachment no longer exists`; retain draft | no |
574
  | terminal socket closes after upload | say file was saved but not inserted; offer retry | no |
@@ -576,8 +584,8 @@ Markdown logs, and needlessly injects binary material into traces.
576
  | CLI cannot inspect path | agent sees explicit path and can report the limitation | yes |
577
 
578
  For multi-file composer sends, atomicity applies to delivery, not storage: files
579
- may upload one by one, but `/input` runs only after all have succeeded. A retry
580
- may reuse already uploaded ids while uploading only failed files.
581
 
582
  ## 13. Testing
583
 
@@ -607,8 +615,12 @@ may reuse already uploaded ids while uploading only failed files.
607
  chip.
608
  - Plain-text paste is unchanged.
609
  - Removing a chip revokes its preview and excludes it from upload.
610
- - A multi-file submission waits for every upload before `/input`.
611
- - Every chip mutation remains disabled for the complete multi-file send.
 
 
 
 
612
  - Terminal paste receives a server acknowledgement and inserts without Return.
613
  - A terminal stopped after upload reports saved-but-not-inserted, disables new
614
  attachments, and can retry the stored attachment after restart.
 
121
  for several) as its text. Image-only server delivery retains the more specific
122
  `screenshot` wording.
123
  - At most five files may be attached to one prompt.
124
+ - Choosing, pasting, or dropping a file starts its upload immediately. Each
125
+ chip shows transferred bytes, percentage, and server-confirmed success.
126
+ - The send button is disabled until every upload has succeeded.
127
+ - A failed upload leaves the draft and pending files intact, names the exact
128
+ server/connection failure next to the affected chip, and offers retry when
129
+ retrying the same bytes can help.
130
+
131
+ Pending files remain browser `File` objects for retry even after the server has
132
+ stored them. `URL.createObjectURL()` supplies local previews and is revoked when
133
+ a chip is removed or the component unmounts. Successful files are session-owned
134
+ and follow that session's existing deletion/pruning lifecycle.
135
 
136
  ### 5.2 Rendered terminal reader
137
 
 
152
  persistent attachment chips inside it. File paste/drop therefore behaves as a
153
  short transaction:
154
 
155
+ 1. Show `uploading file · 0%` over the bottom of the pane and update it as bytes
156
+ transfer.
157
  2. Upload the file without sending a prompt.
158
  3. On success, attach it through the harness adapter or paste a formatted path
159
  reference into the CLI composer.
 
239
 
240
  ### 6.3 Lifecycle
241
 
242
+ - Composer and terminal files are uploaded immediately on attachment. Prompt
243
+ delivery only reuses server-confirmed attachment ids; it never starts an
244
+ upload.
245
+ - Quick creation first allocates a stopped session because attachment ids are
246
+ session-scoped, then immediately uploads into it without launching the CLI.
247
  - Successful attachments remain while the session exists, including while it
248
  is stopped.
249
  - Deleting a session removes its managed attachment directory. It does not
 
421
  ### 8.3 First prompt without a boot race
422
 
423
  The quickstart path currently places an initial prompt on the CLI launch command
424
+ because typing into a booting TUI could lose it. On the first attachment it uses
425
+ a two-step browser flow—create a stopped session, then upload to its attachment
426
+ scope—so `deliver()` must preserve that property:
427
 
428
  1. `POST /api/sessions` without a prompt creates a stopped session.
429
+ 2. Upload each file immediately to the returned session id, with byte progress.
430
+ 3. When the operator launches, `POST /api/sessions/:id/input` with text and the
431
+ already-uploaded attachment ids.
432
  4. If the session has never started and its CLI has `withPrompt`, store the
433
  fully formatted prompt as `pendingPrompt`; for Codex, also retain the
434
  validated image paths as `pendingImagePaths`; then call `ensureRunning()`.
 
474
  - `transferMayContainFile(DataTransfer)`;
475
  - duplicate suppression across `items` and `files`;
476
  - local preview creation/revocation; and
477
+ - sequential upload helpers that report progress and preserve per-file errors.
478
 
479
  Use sequential uploads initially. Five files is the maximum, bucket writes are
480
  the bottleneck, and simpler ordering makes chip status deterministic.
 
486
 
487
  | File | Change |
488
  |---|---|
489
+ | `web/src/api.ts` | attachment types, progress-aware XHR upload, preview URL, `sendInput(..., attachmentIds)` |
490
  | `web/src/lib/attachments.ts` | clipboard/drop extraction and pending-file lifecycle |
491
  | `web/src/components/Attachments.tsx` | image previews, file badges, picker, progress/error states |
492
+ | `web/src/components/Sidebar.tsx` | quickstart paste/drop, stopped-target creation, and immediate upload |
493
  | `web/src/components/Overview.tsx` | reply attachments and file-only send |
494
  | `web/src/components/TerminalPane.tsx` | capture-phase file paste/drop and mobile file paste |
495
  | `web/src/components/conversation/ConversationView.tsx` | rendered-reader attachments and structured send |
 
575
  |---|---|---|
576
  | clipboard contains no file | ordinary text paste continues, or no-op | no |
577
  | file larger than limit | chip says `too large (100 MB max)` | no |
578
+ | interrupted/offline connection | chip or terminal overlay names the connection failure and offers retry | no |
579
+ | non-JSON proxy rejection | chip classifies the HTTP status (including proxy `413`) | no |
580
  | one of several uploads fails | successful files remain, all chips stay for retry | no |
581
  | attachment id missing at send | `attachment no longer exists`; retain draft | no |
582
  | terminal socket closes after upload | say file was saved but not inserted; offer retry | no |
 
584
  | CLI cannot inspect path | agent sees explicit path and can report the limitation | yes |
585
 
586
  For multi-file composer sends, atomicity applies to delivery, not storage: files
587
+ start uploading when attached, but `/input` runs only after all have succeeded.
588
+ A retry reuses already uploaded ids and transfers only failed files.
589
 
590
  ## 13. Testing
591
 
 
615
  chip.
616
  - Plain-text paste is unchanged.
617
  - Removing a chip revokes its preview and excludes it from upload.
618
+ - Upload starts when a file is attached, before `/input` or launch.
619
+ - Progress exposes transferred bytes/percentage and waits for server confirmation
620
+ at 100%.
621
+ - Interrupted connections and non-JSON proxy errors remain actionable at the
622
+ affected chip, with retry where appropriate.
623
+ - A multi-file submission remains disabled until every upload succeeds.
624
  - Terminal paste receives a server acknowledgement and inserts without Return.
625
  - A terminal stopped after upload reports saved-but-not-inserted, disables new
626
  attachments, and can retry the stored attachment after restart.
server/screenshot-input.test.mjs CHANGED
@@ -7,6 +7,7 @@
7
  * - attachment chips cannot mutate an in-flight send;
8
  * - one clipboard image stays one chip across browser DataTransfer views;
9
  * - document files stay inert downloads and can be sent beside images;
 
10
  * - both rendered reader and Overview composers send structured attachments;
11
  * - the creation dialog has no redundant file picker.
12
  *
@@ -24,7 +25,8 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
24
  const ROOT = path.dirname(HERE);
25
  const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-screenshot-ui-'));
26
  const PUBLIC_DIR = process.env.SCREENSHOT_PUBLIC_DIR || path.join(DATA_DIR, 'public');
27
- const API = 'http://127.0.0.1:7896';
 
28
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
29
 
30
  const png = Buffer.alloc(45);
@@ -32,6 +34,9 @@ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png);
32
  png.writeUInt32BE(13, 8); Buffer.from('IHDR').copy(png, 12);
33
  png.writeUInt32BE(1, 16); png.writeUInt32BE(1, 20);
34
  Buffer.from('IEND').copy(png, 37);
 
 
 
35
  const pdf = Buffer.from('%PDF-1.7\nopaque pdf data');
36
  const docx = Buffer.from('PK\x03\x04opaque office data');
37
 
@@ -73,7 +78,7 @@ const backend = spawn('node', ['src/index.js'], {
73
  cwd: HERE,
74
  env: {
75
  ...BASE_ENV,
76
- PORT: '7896', DATA_DIR, PUBLIC_DIR, AM_BASHRC: '/nonexistent', SPACE_HOST: '',
77
  AM_ALLOW_MISSING_ORIGIN: '1',
78
  AM_TEST_REPAINT_CMD: 'bash --noprofile --norc',
79
  },
@@ -226,9 +231,11 @@ try {
226
  await watcher.locator('.sidebar .row[title^="screenshot-e2e"]').first().click();
227
  await watcher.locator('.pane-head .ph-image').waitFor({ state: 'visible' });
228
  await waitFor(() => watcher.locator('.pane-head .ph-image').isDisabled());
 
 
229
  check('a shared-terminal watcher cannot inject into the controller composer',
230
- await watcher.locator('.pane-head .ph-image').isDisabled()
231
- && (await watcher.locator('.pane-head .ph-image').getAttribute('title'))?.includes('take control'));
232
  await watcher.close();
233
 
234
  // Reader mode is an overlay above the mounted terminal. Its own composer must
@@ -267,8 +274,80 @@ try {
267
  await page.locator('.pane-head .ph-image').isVisible()
268
  && await page.locator('.pane-reader .image-pick').count() === 0
269
  && await page.locator('.pane-reader .image-file-input').count() === 1);
270
- await readerPicker.setInputFiles({ name: 'reader.png', mimeType: 'image/png', buffer: png });
271
- await page.locator('.pane-reader .image-chip').waitFor({ state: 'visible' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  let readerInput;
273
  let sawReaderInput;
274
  const readerInputReached = new Promise((resolve) => { sawReaderInput = resolve; });
@@ -291,11 +370,13 @@ try {
291
  await page.locator('.sidebar .ov-row').click();
292
  await page.locator('.ovt-tile').filter({
293
  has: page.locator('.ovt-name', { hasText: /^screenshot-e2e$/ }),
294
- }).click();
295
  const overviewPicker = page.locator('.ovw-win .image-file-input');
296
  await overviewPicker.waitFor({ state: 'attached' });
297
  await overviewPicker.setInputFiles({ name: 'overview.png', mimeType: 'image/png', buffer: png });
298
- await page.locator('.ovw-win .image-chip').waitFor({ state: 'visible' });
 
 
299
  let overviewInput;
300
  let sawOverviewInput;
301
  const overviewInputReached = new Promise((resolve) => { sawOverviewInput = resolve; });
@@ -322,9 +403,22 @@ try {
322
  && await page.locator('.quick .image-file-input').count() === 0);
323
  await page.locator('.quick-cli[title="Repaint fixture"]').click();
324
 
325
- // Hold the second upload. Once the first
326
- // chip says uploaded, every attachment mutation must remain disabled until
327
- // the single logical send transaction finishes.
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  await page.locator('.quick-prompt').fill('inspect both files');
329
  await page.locator('.quick-prompt').evaluate((element, bytes) => {
330
  const raw = atob(bytes);
@@ -358,30 +452,22 @@ try {
358
  check('DOCX paste creates a generic file chip beside the image',
359
  await page.locator('.quick .image-chip').count() === 2
360
  && await page.locator('.quick .image-chip-placeholder', { hasText: 'DOCX' }).count() === 1);
361
- let releaseSecond;
362
- let sawSecond;
363
- const secondReached = new Promise((resolve) => { sawSecond = resolve; });
364
- const secondHold = new Promise((resolve) => { releaseSecond = resolve; });
365
- let uploadCount = 0;
366
- await page.route('**/api/sessions/*/attachments', async (route) => {
367
- uploadCount += 1;
368
- if (uploadCount === 2) {
369
- sawSecond();
370
- await secondHold;
371
- }
372
- await route.continue();
373
- });
374
- await page.locator('.quick-prompt').press('Enter');
375
  await Promise.race([
376
  secondReached,
377
- sleep(20_000).then(() => { throw new Error('second upload did not start'); }),
378
  ]);
379
- const removeButtons = page.locator('.quick .image-chip > button');
380
- check('attachment removal stays locked for the full send transaction',
381
- await removeButtons.nth(0).isDisabled()
382
- && await removeButtons.nth(1).isDisabled());
 
 
383
  releaseSecond();
 
 
 
384
  await page.locator('.controls').waitFor({ state: 'hidden', timeout: 30_000 });
 
385
  } finally {
386
  try { await browser?.close(); } catch {}
387
  backend.kill('SIGKILL');
 
7
  * - attachment chips cannot mutate an in-flight send;
8
  * - one clipboard image stays one chip across browser DataTransfer views;
9
  * - document files stay inert downloads and can be sent beside images;
10
+ * - attachment-time uploads expose progress and actionable transport errors;
11
  * - both rendered reader and Overview composers send structured attachments;
12
  * - the creation dialog has no redundant file picker.
13
  *
 
25
  const ROOT = path.dirname(HERE);
26
  const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-screenshot-ui-'));
27
  const PUBLIC_DIR = process.env.SCREENSHOT_PUBLIC_DIR || path.join(DATA_DIR, 'public');
28
+ const PORT = process.env.SCREENSHOT_PORT || '7896';
29
+ const API = `http://127.0.0.1:${PORT}`;
30
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
31
 
32
  const png = Buffer.alloc(45);
 
34
  png.writeUInt32BE(13, 8); Buffer.from('IHDR').copy(png, 12);
35
  png.writeUInt32BE(1, 16); png.writeUInt32BE(1, 20);
36
  Buffer.from('IEND').copy(png, 37);
37
+ const progressPng = Buffer.alloc(2 * 1024 * 1024);
38
+ png.subarray(0, png.length - 12).copy(progressPng);
39
+ png.subarray(png.length - 12).copy(progressPng, progressPng.length - 12);
40
  const pdf = Buffer.from('%PDF-1.7\nopaque pdf data');
41
  const docx = Buffer.from('PK\x03\x04opaque office data');
42
 
 
78
  cwd: HERE,
79
  env: {
80
  ...BASE_ENV,
81
+ PORT, DATA_DIR, PUBLIC_DIR, AM_BASHRC: '/nonexistent', SPACE_HOST: '',
82
  AM_ALLOW_MISSING_ORIGIN: '1',
83
  AM_TEST_REPAINT_CMD: 'bash --noprofile --norc',
84
  },
 
231
  await watcher.locator('.sidebar .row[title^="screenshot-e2e"]').first().click();
232
  await watcher.locator('.pane-head .ph-image').waitFor({ state: 'visible' });
233
  await waitFor(() => watcher.locator('.pane-head .ph-image').isDisabled());
234
+ const watcherPickerDisabled = await watcher.locator('.pane-head .ph-image').isDisabled();
235
+ const watcherPickerTitle = await watcher.locator('.pane-head .ph-image').getAttribute('title');
236
  check('a shared-terminal watcher cannot inject into the controller composer',
237
+ watcherPickerDisabled && !!watcherPickerTitle?.includes('take control'),
238
+ JSON.stringify({ watcherPickerDisabled, watcherPickerTitle }));
239
  await watcher.close();
240
 
241
  // Reader mode is an overlay above the mounted terminal. Its own composer must
 
274
  await page.locator('.pane-head .ph-image').isVisible()
275
  && await page.locator('.pane-reader .image-pick').count() === 0
276
  && await page.locator('.pane-reader .image-file-input').count() === 1);
277
+
278
+ // The failure that motivated immediate uploads: once the HTTP connection is
279
+ // cut, fetch used to surface only "Failed to fetch" after Send. The XHR
280
+ // transport must classify it at attachment time and keep a retry beside it.
281
+ await page.route(`**/api/sessions/${id}/attachments`, (route) => route.abort('connectionreset'), { times: 1 });
282
+ await readerPicker.setInputFiles({
283
+ name: 'interrupted.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(256 * 1024),
284
+ });
285
+ const interruptedChip = page.locator('.pane-reader .image-chip', { hasText: 'interrupted.bin' });
286
+ await interruptedChip.filter({ has: page.locator('.image-chip-retry') }).waitFor({ state: 'visible' });
287
+ const interruptedText = await interruptedChip.locator('.image-chip-meta').textContent();
288
+ check('an interrupted upload fails immediately with a reason and retry',
289
+ !!interruptedText?.includes('connection was interrupted')
290
+ && await interruptedChip.locator('.image-chip-retry').isVisible(),
291
+ JSON.stringify({ interruptedText }));
292
+ await interruptedChip.locator('.image-chip-retry').click();
293
+ await interruptedChip.filter({ has: page.locator('.image-chip-meta', { hasText: 'uploaded' }) }).waitFor();
294
+ await interruptedChip.getByRole('button', { name: 'Remove interrupted.bin' }).click();
295
+
296
+ // Hugging Face's ingress may answer before Express with an HTML error page.
297
+ // Preserve the status as a useful diagnosis instead of reducing it to "413".
298
+ await page.route(`**/api/sessions/${id}/attachments`, (route) => route.fulfill({
299
+ status: 413, contentType: 'text/html', body: '<h1>Payload Too Large</h1>',
300
+ }), { times: 1 });
301
+ await readerPicker.setInputFiles({
302
+ name: 'proxy-limit.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(1024),
303
+ });
304
+ const proxyChip = page.locator('.pane-reader .image-chip', { hasText: 'proxy-limit.bin' });
305
+ await proxyChip.filter({ has: page.locator('.image-chip-retry') }).waitFor();
306
+ const proxyText = await proxyChip.locator('.image-chip-meta').textContent();
307
+ check('a non-JSON proxy rejection keeps an actionable HTTP reason',
308
+ !!proxyText?.includes('proxy rejected this file as too large') && proxyText.includes('HTTP 413'),
309
+ JSON.stringify({ proxyText }));
310
+ await proxyChip.getByRole('button', { name: 'Remove proxy-limit.bin' }).click();
311
+
312
+ // Client-side size rejection happens at attachment time and never offers a
313
+ // futile retry. The server separately exercises its streaming 100 MiB cap.
314
+ const tooLargePath = path.join(DATA_DIR, 'too-large.bin');
315
+ fs.writeFileSync(tooLargePath, '');
316
+ fs.truncateSync(tooLargePath, 101 * 1024 * 1024);
317
+ await readerPicker.setInputFiles(tooLargePath);
318
+ const tooLargeChip = page.locator('.pane-reader .image-chip', { hasText: 'too-large.bin' });
319
+ await tooLargeChip.filter({ has: page.locator('.image-chip-meta', { hasText: 'too large' }) }).waitFor();
320
+ check('a large file is rejected before upload with its 100 MB limit',
321
+ (await tooLargeChip.locator('.image-chip-meta').textContent())?.includes('100 MB max')
322
+ && await tooLargeChip.locator('.image-chip-retry').count() === 0);
323
+ await tooLargeChip.getByRole('button', { name: 'Remove too-large.bin' }).click();
324
+
325
+ // Hold the intercepted request lifecycle so the progress surface can be
326
+ // inspected before the response changes the chip to server-confirmed success.
327
+ let releaseReaderUpload;
328
+ let sawReaderUpload;
329
+ const readerUploadReached = new Promise((resolve) => { sawReaderUpload = resolve; });
330
+ const readerUploadHold = new Promise((resolve) => { releaseReaderUpload = resolve; });
331
+ await page.route(`**/api/sessions/${id}/attachments`, async (route) => {
332
+ const response = await route.fetch();
333
+ sawReaderUpload();
334
+ await readerUploadHold;
335
+ await route.fulfill({ response });
336
+ }, { times: 1 });
337
+ await readerPicker.setInputFiles({ name: 'reader.png', mimeType: 'image/png', buffer: progressPng });
338
+ await Promise.race([
339
+ readerUploadReached,
340
+ sleep(10_000).then(() => { throw new Error('reader upload did not start on attachment'); }),
341
+ ]);
342
+ const readerChip = page.locator('.pane-reader .image-chip', { hasText: 'reader.png' });
343
+ await readerChip.locator('.image-chip-progress').waitFor({ state: 'visible' });
344
+ const readerProgress = await readerChip.locator('.image-chip-progress').getAttribute('aria-valuenow');
345
+ const readerProgressText = await readerChip.locator('.image-chip-meta').textContent();
346
+ check('reader upload starts before Send and reports byte progress',
347
+ readerProgress != null && !!readerProgressText?.includes('%') && readerProgressText.includes('/'),
348
+ JSON.stringify({ readerProgress, readerProgressText }));
349
+ releaseReaderUpload();
350
+ await readerChip.filter({ has: page.locator('.image-chip-meta', { hasText: 'uploaded' }) }).waitFor();
351
  let readerInput;
352
  let sawReaderInput;
353
  const readerInputReached = new Promise((resolve) => { sawReaderInput = resolve; });
 
370
  await page.locator('.sidebar .ov-row').click();
371
  await page.locator('.ovt-tile').filter({
372
  has: page.locator('.ovt-name', { hasText: /^screenshot-e2e$/ }),
373
+ }).last().click();
374
  const overviewPicker = page.locator('.ovw-win .image-file-input');
375
  await overviewPicker.waitFor({ state: 'attached' });
376
  await overviewPicker.setInputFiles({ name: 'overview.png', mimeType: 'image/png', buffer: png });
377
+ const overviewChip = page.locator('.ovw-win .image-chip', { hasText: 'overview.png' });
378
+ await overviewChip.filter({ has: page.locator('.image-chip-meta', { hasText: 'uploaded' }) }).waitFor();
379
+ check('Overview uploads a file when attached, before Send', await overviewChip.isVisible());
380
  let overviewInput;
381
  let sawOverviewInput;
382
  const overviewInputReached = new Promise((resolve) => { sawOverviewInput = resolve; });
 
403
  && await page.locator('.quick .image-file-input').count() === 0);
404
  await page.locator('.quick-cli[title="Repaint fixture"]').click();
405
 
406
+ // Quick creation has to allocate its stopped session first, but attachment
407
+ // selection still starts both uploads before the operator launches it. Hold
408
+ // the second request to make that ordering and the progress lock observable.
409
+ let releaseSecond;
410
+ let sawSecond;
411
+ const secondReached = new Promise((resolve) => { sawSecond = resolve; });
412
+ const secondHold = new Promise((resolve) => { releaseSecond = resolve; });
413
+ let uploadCount = 0;
414
+ await page.route('**/api/sessions/*/attachments', async (route) => {
415
+ uploadCount += 1;
416
+ if (uploadCount === 2) {
417
+ sawSecond();
418
+ await secondHold;
419
+ }
420
+ await route.continue();
421
+ });
422
  await page.locator('.quick-prompt').fill('inspect both files');
423
  await page.locator('.quick-prompt').evaluate((element, bytes) => {
424
  const raw = atob(bytes);
 
452
  check('DOCX paste creates a generic file chip beside the image',
453
  await page.locator('.quick .image-chip').count() === 2
454
  && await page.locator('.quick .image-chip-placeholder', { hasText: 'DOCX' }).count() === 1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  await Promise.race([
456
  secondReached,
457
+ sleep(20_000).then(() => { throw new Error('second upload did not start on attachment'); }),
458
  ]);
459
+ const quickChips = page.locator('.quick .image-chip');
460
+ await quickChips.nth(0).filter({ has: page.locator('.image-chip-meta', { hasText: 'uploaded' }) }).waitFor();
461
+ check('quick creation uploads before launch and shows progress while pending',
462
+ uploadCount === 2
463
+ && await quickChips.nth(1).locator('.image-chip-progress').isVisible()
464
+ && await quickChips.nth(1).getByRole('button', { name: 'Remove requirements.docx' }).isDisabled());
465
  releaseSecond();
466
+ await quickChips.nth(1).filter({ has: page.locator('.image-chip-meta', { hasText: 'uploaded' }) }).waitFor();
467
+ const uploadsBeforeLaunch = uploadCount;
468
+ await page.locator('.quick-prompt').press('Enter');
469
  await page.locator('.controls').waitFor({ state: 'hidden', timeout: 30_000 });
470
+ check('launch reuses already-uploaded attachment ids', uploadCount === uploadsBeforeLaunch);
471
  } finally {
472
  try { await browser?.close(); } catch {}
473
  backend.kill('SIGKILL');
web/src/App.tsx CHANGED
@@ -22,7 +22,6 @@ import { useReaderBatch } from './lib/readerBatch';
22
  import { paneOwnsBack } from './lib/mobileBack';
23
  import { isPassive, isRemote, isShareable } from './types';
24
  import { EyeGlyph, EyeOffGlyph, GridGlyph, ListGlyph, SortGlyph } from './components/icons';
25
- import { uploadPendingAttachments } from './lib/attachments';
26
 
27
  // `?vvdebug=1` — a phone has no devtools, and the keyboard layout is a guess
28
  // when the app is embedded cross-origin. Read once: it never changes mid-run,
@@ -628,28 +627,32 @@ export default function App() {
628
  };
629
  // Quickstart: server boots the agent and types the prompt; we jump straight
630
  // to the new pane so you watch it happen.
 
 
 
 
 
 
631
  const quickStart = async (cli: string, prompt: string, name = '', path = '.', attachmentOptions?: QuickStartAttachmentOptions) => {
632
  try {
633
  let sessionId: string;
634
  let sessionPath: string | null = path;
635
- if (attachmentOptions?.attachments.length) {
636
  if (attachmentOptions.sessionId) {
637
  sessionId = attachmentOptions.sessionId;
638
  } else {
639
- // Attachments are session-scoped, so create the stopped session first,
640
- // then upload. If an upload fails the session remains visible and the
641
- // sidebar retains its id for a retry.
642
- const created = await api.createSession(name, cli, undefined, path);
643
  sessionId = created.id;
644
  sessionPath = created.path;
645
  attachmentOptions.onSessionCreated(created.id);
646
- rememberPath(created.path);
647
- await refresh();
648
  }
649
- const attachments = await uploadPendingAttachments(
650
- sessionId, attachmentOptions.attachments, attachmentOptions.onAttachmentUpdate,
651
- );
652
- await api.sendInput(sessionId, prompt, attachments.map((image) => image.id));
 
653
  } else {
654
  const created = await api.quickStart(cli, prompt, name, path);
655
  sessionId = created.id;
@@ -1066,6 +1069,7 @@ export default function App() {
1066
  theme={theme}
1067
  onToggleTheme={toggleTheme}
1068
  onQuickStart={quickStart}
 
1069
  archived={archivedIds}
1070
  showArchived={showArchived}
1071
  onToggleArchived={() => setShowArchived((v) => !v)}
 
22
  import { paneOwnsBack } from './lib/mobileBack';
23
  import { isPassive, isRemote, isShareable } from './types';
24
  import { EyeGlyph, EyeOffGlyph, GridGlyph, ListGlyph, SortGlyph } from './components/icons';
 
25
 
26
  // `?vvdebug=1` — a phone has no devtools, and the keyboard layout is a guess
27
  // when the app is embedded cross-origin. Read once: it never changes mid-run,
 
627
  };
628
  // Quickstart: server boots the agent and types the prompt; we jump straight
629
  // to the new pane so you watch it happen.
630
+ const prepareQuickStart = async (cli: string, name = '', path = '.') => {
631
+ const created = await api.createSession(name, cli, undefined, path);
632
+ rememberPath(created.path);
633
+ await refresh();
634
+ return created;
635
+ };
636
  const quickStart = async (cli: string, prompt: string, name = '', path = '.', attachmentOptions?: QuickStartAttachmentOptions) => {
637
  try {
638
  let sessionId: string;
639
  let sessionPath: string | null = path;
640
+ if (attachmentOptions && (attachmentOptions.sessionId || attachmentOptions.attachments.length)) {
641
  if (attachmentOptions.sessionId) {
642
  sessionId = attachmentOptions.sessionId;
643
  } else {
644
+ // Defensive fallback for a submit racing the target-creation render.
645
+ // Normal attachment uploads create this stopped target immediately.
646
+ const created = await prepareQuickStart(cli, name, path);
 
647
  sessionId = created.id;
648
  sessionPath = created.path;
649
  attachmentOptions.onSessionCreated(created.id);
 
 
650
  }
651
+ if (attachmentOptions.attachments.some((attachment) => !attachment.attachment)) {
652
+ throw new Error('Wait for every file to finish uploading, or retry/remove the failed file.');
653
+ }
654
+ await api.sendInput(sessionId, prompt,
655
+ attachmentOptions.attachments.map((attachment) => attachment.attachment!.id));
656
  } else {
657
  const created = await api.quickStart(cli, prompt, name, path);
658
  sessionId = created.id;
 
1069
  theme={theme}
1070
  onToggleTheme={toggleTheme}
1071
  onQuickStart={quickStart}
1072
+ onPrepareQuickStart={prepareQuickStart}
1073
  archived={archivedIds}
1074
  showArchived={showArchived}
1075
  onToggleArchived={() => setShowArchived((v) => !v)}
web/src/api.ts CHANGED
@@ -207,19 +207,63 @@ export interface Attachment {
207
  insertText: string;
208
  }
209
 
210
- export const uploadAttachment = async (id: string, file: File): Promise<Attachment> => {
211
- const headers: Record<string, string> = {
212
- 'x-file-name': encodeURIComponent(file.name || 'Attachment'),
213
- };
214
- if (file.type) headers['content-type'] = file.type;
215
- const response = await fetch(`/api/sessions/${id}/attachments`, {
216
- method: 'POST',
217
- headers,
218
- body: file,
219
- });
220
- return jsonOrError(response);
 
 
 
221
  };
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  export const insertAttachments = (
224
  id: string,
225
  attachmentIds: string[],
 
207
  insertText: string;
208
  }
209
 
210
+ export interface AttachmentUploadProgress { loaded: number; total: number }
211
+
212
+ const attachmentUploadError = (request: XMLHttpRequest) => {
213
+ let detail = '';
214
+ try {
215
+ const body = JSON.parse(request.responseText || '{}');
216
+ if (typeof body?.error === 'string') detail = body.error;
217
+ } catch { /* An ingress/proxy error can be HTML. Classify it by status below. */ }
218
+ if (detail) return detail;
219
+ if (request.status === 413) return 'The server or its proxy rejected this file as too large (HTTP 413). Try a smaller file.';
220
+ if (request.status === 408 || request.status === 504) return `The upload timed out (HTTP ${request.status}). Check the connection and retry.`;
221
+ if (request.status === 429) return 'Too many uploads at once (HTTP 429). Wait a minute, then retry.';
222
+ if (request.status >= 500) return `The upload server failed (HTTP ${request.status}). Retry in a moment.`;
223
+ return `Upload failed (HTTP ${request.status}${request.statusText ? ` ${request.statusText}` : ''}).`;
224
  };
225
 
226
+ /** XMLHttpRequest is intentional: fetch has no browser upload-progress API. */
227
+ export const uploadAttachment = (
228
+ id: string,
229
+ file: File,
230
+ onProgress?: (progress: AttachmentUploadProgress) => void,
231
+ ): Promise<Attachment> => new Promise((resolve, reject) => {
232
+ const request = new XMLHttpRequest();
233
+ request.open('POST', `/api/sessions/${encodeURIComponent(id)}/attachments`);
234
+ request.setRequestHeader('x-am-origin', 'operator');
235
+ request.setRequestHeader('x-file-name', encodeURIComponent(file.name || 'Attachment'));
236
+ if (file.type) request.setRequestHeader('content-type', file.type);
237
+ request.upload.onprogress = (event) => onProgress?.({
238
+ loaded: event.loaded,
239
+ total: event.lengthComputable && event.total ? event.total : file.size,
240
+ });
241
+ // Some browsers coalesce every progress event for a fast/small body. The
242
+ // upload-side load event still fires before the response, so 100% means
243
+ // "bytes sent, awaiting server confirmation", not prematurely "stored".
244
+ request.upload.onload = () => onProgress?.({ loaded: file.size, total: file.size });
245
+ request.onload = () => {
246
+ if (request.status < 200 || request.status >= 300) {
247
+ reject(new Error(attachmentUploadError(request)));
248
+ return;
249
+ }
250
+ try {
251
+ resolve(JSON.parse(request.responseText) as Attachment);
252
+ } catch {
253
+ reject(new Error('The upload completed, but the server returned an unreadable response. Retry the file.'));
254
+ }
255
+ };
256
+ request.onerror = () => reject(new Error(
257
+ typeof navigator !== 'undefined' && navigator.onLine === false
258
+ ? 'Upload stopped because this device is offline. Reconnect and retry.'
259
+ : 'Upload connection was interrupted before the server confirmed the file. Check the connection and retry.',
260
+ ));
261
+ request.onabort = () => reject(new Error('Upload was canceled before it completed. Retry the file.'));
262
+ request.ontimeout = () => reject(new Error('Upload timed out before it completed. Check the connection and retry.'));
263
+ onProgress?.({ loaded: 0, total: file.size });
264
+ request.send(file);
265
+ });
266
+
267
  export const insertAttachments = (
268
  id: string,
269
  attachmentIds: string[],
web/src/components/Attachments.tsx CHANGED
@@ -1,8 +1,9 @@
1
  import { useId, useRef } from 'react';
 
2
  import type { PendingAttachment } from '../lib/attachments';
3
 
4
  const fmtBytes = (bytes: number) => bytes < 1024 * 1024
5
- ? `${Math.max(1, Math.round(bytes / 1024))} KB`
6
  : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
7
 
8
  const fileLabel = (name: string) => {
@@ -10,13 +11,14 @@ const fileLabel = (name: string) => {
10
  return extension && extension !== name ? extension.slice(0, 4).toUpperCase() : 'FILE';
11
  };
12
 
13
- export default function Attachments({ attachments, disabled, disabledReason, showPicker = true, onFiles, onRemove }: {
14
  attachments: PendingAttachment[];
15
  disabled?: boolean;
16
  disabledReason?: string;
17
  showPicker?: boolean;
18
  onFiles: (files: File[]) => void;
19
  onRemove: (key: string) => void;
 
20
  }) {
21
  const picker = useRef<HTMLInputElement>(null);
22
  const reasonId = useId();
@@ -53,25 +55,45 @@ export default function Attachments({ attachments, disabled, disabledReason, sho
53
  </>
54
  )}
55
  {showReason && <span id={reasonId} className="image-attachments-note">{disabledReason}</span>}
56
- {attachments.map((attachment) => (
57
- <div key={attachment.key} className={`image-chip ${attachment.status}`}>
58
- {attachment.previewUrl ? (
59
- <a className="image-chip-preview" href={attachment.previewUrl} target="_blank" rel="noreferrer" title={`Preview ${attachment.file.name || 'image'}`}>
60
- <img src={attachment.previewUrl} alt="" />
61
- </a>
62
- ) : (
63
- <span className="image-chip-placeholder mono" aria-hidden="true">{fileLabel(attachment.file.name || '')}</span>
64
- )}
65
- <span className="image-chip-copy">
66
- <span className="image-chip-name">{attachment.file.name || 'Attachment'}</span>
67
- <span className="image-chip-meta" aria-live="polite">
68
- {attachment.status === 'uploading' ? 'uploading…'
69
- : attachment.error || (attachment.status === 'uploaded' ? 'uploaded' : fmtBytes(attachment.file.size))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  </span>
71
- </span>
72
- <button type="button" onClick={() => onRemove(attachment.key)} disabled={disabled || attachment.status === 'uploading'} aria-label={`Remove ${attachment.file.name || 'file'}`}>×</button>
73
- </div>
74
- ))}
 
 
 
75
  </div>
76
  );
77
  }
 
1
  import { useId, useRef } from 'react';
2
+ import { attachmentFileError } from '../lib/attachments';
3
  import type { PendingAttachment } from '../lib/attachments';
4
 
5
  const fmtBytes = (bytes: number) => bytes < 1024 * 1024
6
+ ? `${bytes ? Math.max(1, Math.round(bytes / 1024)) : 0} KB`
7
  : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
8
 
9
  const fileLabel = (name: string) => {
 
11
  return extension && extension !== name ? extension.slice(0, 4).toUpperCase() : 'FILE';
12
  };
13
 
14
+ export default function Attachments({ attachments, disabled, disabledReason, showPicker = true, onFiles, onRemove, onRetry }: {
15
  attachments: PendingAttachment[];
16
  disabled?: boolean;
17
  disabledReason?: string;
18
  showPicker?: boolean;
19
  onFiles: (files: File[]) => void;
20
  onRemove: (key: string) => void;
21
+ onRetry?: (key: string) => void;
22
  }) {
23
  const picker = useRef<HTMLInputElement>(null);
24
  const reasonId = useId();
 
55
  </>
56
  )}
57
  {showReason && <span id={reasonId} className="image-attachments-note">{disabledReason}</span>}
58
+ {attachments.map((attachment) => {
59
+ const loaded = Math.min(attachment.file.size, attachment.uploadedBytes || 0);
60
+ const progress = attachment.file.size ? Math.round((loaded / attachment.file.size) * 100) : 0;
61
+ const retryable = attachment.status === 'error' && !attachmentFileError(attachment.file) && !!onRetry;
62
+ return (
63
+ <div key={attachment.key} className={`image-chip ${attachment.status}`}>
64
+ {attachment.previewUrl ? (
65
+ <a className="image-chip-preview" href={attachment.previewUrl} target="_blank" rel="noreferrer" title={`Preview ${attachment.file.name || 'image'}`}>
66
+ <img src={attachment.previewUrl} alt="" />
67
+ </a>
68
+ ) : (
69
+ <span className="image-chip-placeholder mono" aria-hidden="true">{fileLabel(attachment.file.name || '')}</span>
70
+ )}
71
+ <span className="image-chip-copy">
72
+ <span className="image-chip-name">{attachment.file.name || 'Attachment'}</span>
73
+ <span className="image-chip-meta" aria-live="polite" title={attachment.error}>
74
+ {attachment.status === 'uploading' ? `${progress}% · ${fmtBytes(loaded)} / ${fmtBytes(attachment.file.size)}`
75
+ : attachment.error || (attachment.status === 'uploaded' ? 'uploaded' : fmtBytes(attachment.file.size))}
76
+ </span>
77
+ {attachment.status === 'uploading' && (
78
+ <span
79
+ className="image-chip-progress"
80
+ role="progressbar"
81
+ aria-label={`Uploading ${attachment.file.name || 'file'}`}
82
+ aria-valuemin={0}
83
+ aria-valuemax={100}
84
+ aria-valuenow={progress}
85
+ >
86
+ <span style={{ width: `${progress}%` }} />
87
+ </span>
88
+ )}
89
  </span>
90
+ {retryable && (
91
+ <button type="button" className="image-chip-retry" onClick={() => onRetry(attachment.key)} disabled={disabled} aria-label={`Retry ${attachment.file.name || 'file'}`}>retry</button>
92
+ )}
93
+ <button type="button" onClick={() => onRemove(attachment.key)} disabled={disabled || attachment.status === 'uploading'} aria-label={`Remove ${attachment.file.name || 'file'}`}>×</button>
94
+ </div>
95
+ );
96
+ })}
97
  </div>
98
  );
99
  }
web/src/components/FolderPicker.tsx CHANGED
@@ -83,20 +83,21 @@ function Level({ path, depth, value, onPick }: {
83
  * workspace root. Picking a not-yet-existing folder is fine: the server
84
  * creates it when the agent is created.
85
  */
86
- export default function FolderPicker({ value, onChange }: {
87
  value: string;
88
  onChange: (p: string) => void;
 
89
  }) {
90
  const [open, setOpen] = useState(false);
91
  const root = isRoot(value);
92
  return (
93
  <div className="fp">
94
- <button className="fp-current" onClick={() => setOpen((o) => !o)} title="Choose where this agent runs">
95
  <FolderGlyph className="fp-ico" open={open || root} />
96
  <span className="fp-path">{root ? 'workspaces' : value}</span>
97
  <span className="fp-toggle">{open ? '▾' : '▸'}</span>
98
  </button>
99
- {open && (
100
  <div className="fp-tree">
101
  <button className={`fp-row fp-root${root ? ' picked' : ''}`} onClick={() => onChange(ROOT)}>
102
  <FolderGlyph className="fp-ico" open />
 
83
  * workspace root. Picking a not-yet-existing folder is fine: the server
84
  * creates it when the agent is created.
85
  */
86
+ export default function FolderPicker({ value, onChange, disabled = false }: {
87
  value: string;
88
  onChange: (p: string) => void;
89
+ disabled?: boolean;
90
  }) {
91
  const [open, setOpen] = useState(false);
92
  const root = isRoot(value);
93
  return (
94
  <div className="fp">
95
+ <button className="fp-current" disabled={disabled} onClick={() => setOpen((o) => !o)} title="Choose where this agent runs">
96
  <FolderGlyph className="fp-ico" open={open || root} />
97
  <span className="fp-path">{root ? 'workspaces' : value}</span>
98
  <span className="fp-toggle">{open ? '▾' : '▸'}</span>
99
  </button>
100
+ {open && !disabled && (
101
  <div className="fp-tree">
102
  <button className={`fp-row fp-root${root ? ' picked' : ''}`} onClick={() => onChange(ROOT)}>
103
  <FolderGlyph className="fp-ico" open />
web/src/components/Overview.tsx CHANGED
@@ -171,6 +171,9 @@ export function Card({ s, color, group, pending, isMobile, onOpen, onClose }: {
171
  imagesRef.current = merged;
172
  setImages(merged);
173
  setImageError(next.error);
 
 
 
174
  };
175
  const removeImage = (key: string) => {
176
  if (sending) return;
@@ -190,6 +193,11 @@ export function Card({ s, color, group, pending, isMobile, onOpen, onClose }: {
190
  return next;
191
  });
192
  };
 
 
 
 
 
193
 
194
  // After you send (or when the transcript shows a prompt newer than the last
195
  // answer), the old answer is stale — a spinner takes its place.
@@ -219,6 +227,7 @@ export function Card({ s, color, group, pending, isMobile, onOpen, onClose }: {
219
  const text = draft.trim();
220
  const batch = imagesRef.current;
221
  if ((!text && !batch.length) || sending) return;
 
222
  const optimisticText = text || defaultAttachmentPrompt(batch.length);
223
  setSending(true);
224
  setFailed(null);
@@ -227,8 +236,7 @@ export function Card({ s, color, group, pending, isMobile, onOpen, onClose }: {
227
  setHistIdx(0);
228
  if (inputRef.current) { inputRef.current.style.height = 'auto'; inputRef.current.blur(); }
229
  try {
230
- const attachments = await uploadPendingAttachments(s.id, batch, updateImage);
231
- await api.sendInput(s.id, text, attachments.map((image) => image.id));
232
  revokePendingAttachments(batch);
233
  imagesRef.current = [];
234
  setImages([]);
@@ -371,13 +379,15 @@ export function Card({ s, color, group, pending, isMobile, onOpen, onClose }: {
371
  sending={sending}
372
  isMobile={isMobile}
373
  inputRef={inputRef}
374
- canSend={!!draft.trim() || images.length > 0}
 
375
  above={<Attachments
376
  attachments={images}
377
  disabled={sending || !allowAttachments}
378
  disabledReason={!allowAttachments ? 'Files are not available for remote agents yet — that agent cannot read files stored on this Space.' : undefined}
379
  onFiles={addImages}
380
  onRemove={removeImage}
 
381
  />}
382
  onChange={setDraft}
383
  onSend={send}
 
171
  imagesRef.current = merged;
172
  setImages(merged);
173
  setImageError(next.error);
174
+ void uploadPendingAttachments(s.id, next.attachments, updateImage).catch(() => {
175
+ // The affected chip owns the persistent, actionable error and retry.
176
+ });
177
  };
178
  const removeImage = (key: string) => {
179
  if (sending) return;
 
193
  return next;
194
  });
195
  };
196
+ const retryImage = (key: string) => {
197
+ const image = imagesRef.current.find((item) => item.key === key);
198
+ if (!image || sending) return;
199
+ void uploadPendingAttachments(s.id, [image], updateImage).catch(() => {});
200
+ };
201
 
202
  // After you send (or when the transcript shows a prompt newer than the last
203
  // answer), the old answer is stale — a spinner takes its place.
 
227
  const text = draft.trim();
228
  const batch = imagesRef.current;
229
  if ((!text && !batch.length) || sending) return;
230
+ if (batch.some((image) => !image.attachment)) return;
231
  const optimisticText = text || defaultAttachmentPrompt(batch.length);
232
  setSending(true);
233
  setFailed(null);
 
236
  setHistIdx(0);
237
  if (inputRef.current) { inputRef.current.style.height = 'auto'; inputRef.current.blur(); }
238
  try {
239
+ await api.sendInput(s.id, text, batch.map((image) => image.attachment!.id));
 
240
  revokePendingAttachments(batch);
241
  imagesRef.current = [];
242
  setImages([]);
 
379
  sending={sending}
380
  isMobile={isMobile}
381
  inputRef={inputRef}
382
+ canSend={(!!draft.trim() || images.length > 0)
383
+ && images.every((image) => !!image.attachment)}
384
  above={<Attachments
385
  attachments={images}
386
  disabled={sending || !allowAttachments}
387
  disabledReason={!allowAttachments ? 'Files are not available for remote agents yet — that agent cannot read files stored on this Space.' : undefined}
388
  onFiles={addImages}
389
  onRemove={removeImage}
390
+ onRetry={retryImage}
391
  />}
392
  onChange={setDraft}
393
  onSend={send}
web/src/components/Sidebar.tsx CHANGED
@@ -1,13 +1,13 @@
1
  import { useEffect, useMemo, useRef, useState } from 'react';
2
  import type { Cli, MoveTarget, Group, Session, Tree } from '../types';
3
  import { STATE_LABEL, REMOTE_STATE_LABEL, isPassive, isRemote, isShareable } from '../types';
4
- import { hiddenSessionIds } from '../lib/overviewHidden';
5
  import Logo from './Logo';
6
  import NewSession from './NewSession';
7
  import FolderPicker from './FolderPicker';
8
  import Attachments from './Attachments';
9
  import {
10
- filesFromTransfer, pendingAttachmentsFromFiles, revokePendingAttachments, transferMayContainFile,
 
11
  } from '../lib/attachments';
12
  import type { PendingAttachment } from '../lib/attachments';
13
  import { SlidersGlyph, SunGlyph, MoonGlyph, CloseGlyph, PencilGlyph, StopGlyph, PlayGlyph, GridGlyph, PlusGlyph, AmMark, ShareGlyph, HandoverGlyph, ListGlyph, EyeGlyph, EyeOffGlyph } from './icons';
@@ -19,7 +19,6 @@ export interface QuickStartAttachmentOptions {
19
  sessionId: string | null;
20
  attachments: PendingAttachment[];
21
  onSessionCreated: (id: string) => void;
22
- onAttachmentUpdate: (key: string, patch: Partial<PendingAttachment>) => void;
23
  }
24
 
25
  // Folded groups, remembered across reloads.
@@ -38,6 +37,7 @@ export default function Sidebar({
38
  clis, tree, activeRef, focusedId, defaultPath, ages,
39
  onActivate, onOpenSession, onNewSession, onNewGroup, onRenameGroup, onRenameSession, onDeleteGroup,
40
  onStopSession, onSetRemotePaused, onDeleteSession, onShareSession, onShareTrace, onTraceHandover, onOpenTrace, onMove, onDragState, onOpenSettings, theme, onToggleTheme, onQuickStart,
 
41
  archived, showArchived, onToggleArchived,
42
  overviewHidden, onToggleOverviewHidden,
43
  }: {
@@ -67,6 +67,7 @@ export default function Sidebar({
67
  theme: 'light' | 'dark';
68
  onToggleTheme: () => void;
69
  onQuickStart: (cli: string, prompt: string, name?: string, path?: string, attachmentOptions?: QuickStartAttachmentOptions) => Promise<void>;
 
70
  archived: Set<string>;
71
  showArchived: boolean;
72
  onToggleArchived: () => void;
@@ -87,6 +88,9 @@ export default function Sidebar({
87
  const [quickImages, setQuickImages] = useState<PendingAttachment[]>([]);
88
  const quickImagesRef = useRef<PendingAttachment[]>([]);
89
  const [quickSessionId, setQuickSessionId] = useState<string | null>(null);
 
 
 
90
  const [quickSending, setQuickSending] = useState(false);
91
  const [quickDrop, setQuickDrop] = useState(false);
92
  // When creation was launched from a group's + the new agent lands there.
@@ -114,9 +118,16 @@ export default function Sidebar({
114
  const sessById = useMemo(() => Object.fromEntries(tree.sessions.map((s) => [s.id, s])), [tree.sessions]);
115
  const groupById = useMemo(() => Object.fromEntries(tree.groups.map((g) => [g.id, g])), [tree.groups]);
116
  const colorOf = useMemo(() => Object.fromEntries(clis.map((c) => [c.id, c.color])), [clis]);
 
 
117
  useEffect(() => { quickImagesRef.current = quickImages; }, [quickImages]);
118
  useEffect(() => () => revokePendingAttachments(quickImagesRef.current), []);
119
 
 
 
 
 
 
120
  const updateQuickImage = (key: string, patch: Partial<PendingAttachment>) => {
121
  setQuickImages((current) => {
122
  const next = current.map((image) => image.key === key ? { ...image, ...patch } : image);
@@ -124,6 +135,46 @@ export default function Sidebar({
124
  return next;
125
  });
126
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  const addQuickImages = (files: File[]) => {
128
  if (quickSending) return;
129
  const next = pendingAttachmentsFromFiles(files, quickImagesRef.current.length);
@@ -131,6 +182,7 @@ export default function Sidebar({
131
  quickImagesRef.current = merged;
132
  setQuickImages(merged);
133
  setQuickError(next.error);
 
134
  };
135
  const removeQuickImage = (key: string) => {
136
  if (quickSending) return;
@@ -143,10 +195,17 @@ export default function Sidebar({
143
  });
144
  setQuickError(null);
145
  };
 
 
 
 
 
146
  // A retry may reuse uploaded ids only while it still targets the same
147
  // server-created session. Changing its identity starts a fresh target.
148
  const resetQuickTarget = () => {
149
- setQuickSessionId(null);
 
 
150
  setQuickImages((current) => {
151
  const next = current.map((image) => image.attachment
152
  ? { ...image, attachment: undefined, status: 'pending' as const, error: undefined }
@@ -162,10 +221,12 @@ export default function Sidebar({
162
  const bump = (id: string, d: number) => setCart((c) => ({ ...c, [id]: Math.max(0, (c[id] || 0) + d) }));
163
  const closePanel = () => {
164
  if (panel === 'quick') {
 
165
  revokePendingAttachments(quickImagesRef.current);
166
  quickImagesRef.current = [];
167
  setQuickImages([]);
168
- setQuickSessionId(null);
 
169
  setQuickSending(false);
170
  setQuickDrop(false);
171
  }
@@ -181,9 +242,11 @@ export default function Sidebar({
181
  const quickable = clis.filter((c) => c.id !== 'shell' && !isPassive(c.id) && !isRemote(c.id));
182
  const remoteCli = clis.find((c) => isRemote(c.id)) || null;
183
  const openQuick = () => {
 
184
  setQuickError(null);
185
  setQuickName('');
186
- setQuickSessionId(null);
 
187
  setQuickCli((q) => q ?? (quickable.find((c) => c.available && c.ready)?.id || quickable.find((c) => c.available)?.id || null));
188
  setQuickMode('agent');
189
  setQuickLoc(defaultPath || '.');
@@ -212,6 +275,7 @@ export default function Sidebar({
212
  const submitQuick = async () => {
213
  const p = quickPrompt.trim();
214
  if (!quickCli || quickSending) return;
 
215
  // A remote agent names itself like any other agent when unnamed
216
  // (remote-agent-1, -2, …); its "location" is always its own message folder,
217
  // never the picker's.
@@ -231,11 +295,10 @@ export default function Sidebar({
231
  try {
232
  await onQuickStart(
233
  quickCli, p, quickMore ? quickName.trim() : '', quickMore ? quickLoc : '.',
234
- quickImages.length ? {
235
  sessionId: quickSessionId,
236
  attachments: quickImages,
237
- onSessionCreated: setQuickSessionId,
238
- onAttachmentUpdate: updateQuickImage,
239
  } : undefined,
240
  );
241
  setQuickPrompt('');
@@ -500,7 +563,7 @@ export default function Sidebar({
500
  key={c.id}
501
  className={`quick-cli${quickMode === 'agent' && quickCli === c.id ? ' on' : ''}${c.available ? '' : ' off'}`}
502
  title={c.available ? c.label : `${c.label} (not installed)`}
503
- disabled={!c.available || quickSending}
504
  style={quickMode === 'agent' && quickCli === c.id ? { borderColor: c.color } : undefined}
505
  onClick={() => { setQuickMode('agent'); if (quickCli !== c.id) resetQuickTarget(); setQuickCli(c.id); }}
506
  ><Logo cli={c.id} size={14} /></button>
@@ -509,7 +572,7 @@ export default function Sidebar({
509
  <button
510
  className={`quick-cli quick-grp${quickMode === 'group' ? ' on' : ''}`}
511
  title="New group"
512
- disabled={quickSending}
513
  onClick={() => setQuickMode('group')}
514
  >
515
  <span className="grp-mini">
@@ -523,10 +586,11 @@ export default function Sidebar({
523
  <button
524
  className={`quick-cli${quickMode === 'agent' && quickCli === 'remote' ? ' on' : ''}`}
525
  title="Remote agent — an agent on another machine"
526
- disabled={quickSending}
527
  style={quickMode === 'agent' && quickCli === 'remote' ? { borderColor: remoteCli.color } : undefined}
528
  onClick={() => {
529
- setQuickMode('agent'); setQuickCli('remote'); setQuickSessionId(null);
 
530
  revokePendingAttachments(quickImagesRef.current); quickImagesRef.current = []; setQuickImages([]); setQuickError(null);
531
  }}
532
  ><Logo cli="remote" size={14} /></button>
@@ -541,6 +605,15 @@ export default function Sidebar({
541
  The agent was created. Retry will reuse it; delete it from the agent row if you want to start over.
542
  </div>
543
  )}
 
 
 
 
 
 
 
 
 
544
  <textarea
545
  autoFocus
546
  rows={1}
@@ -566,25 +639,26 @@ export default function Sidebar({
566
  showPicker={false}
567
  onFiles={addQuickImages}
568
  onRemove={removeQuickImage}
 
569
  />
570
  {quickMore && (
571
  <>
572
  <input
573
  placeholder="Name (optional)"
574
  value={quickName}
575
- disabled={quickSending}
576
  onChange={(e) => { resetQuickTarget(); setQuickName(e.target.value); }}
577
  onKeyDown={(e) => { if (e.key === 'Enter') submitQuick(); if (e.key === 'Escape') closePanel(); }}
578
  />
579
- <FolderPicker value={quickLoc} onChange={(value) => { resetQuickTarget(); setQuickLoc(value); }} />
580
  <div className="widget-actions">
581
- <button className="btn-primary" onClick={submitQuick} disabled={quickSending}>{quickSending ? 'Uploading…' : `Create${quickPrompt.trim() || quickImages.length ? ' & send' : ''}`}</button>
582
  <button className="btn-ghost" onClick={closePanel} disabled={quickSending}>Cancel</button>
583
  </div>
584
  </>
585
  )}
586
  <div className="quick-foot">
587
- <button className="quick-more" onClick={() => setQuickMore((v) => !v)} disabled={quickSending}>{quickMore ? '▴ less' : '▾ more options'}</button>
588
  <span className="quick-hint mono">↵ launch · ⇧↵ newline</span>
589
  </div>
590
  </>
 
1
  import { useEffect, useMemo, useRef, useState } from 'react';
2
  import type { Cli, MoveTarget, Group, Session, Tree } from '../types';
3
  import { STATE_LABEL, REMOTE_STATE_LABEL, isPassive, isRemote, isShareable } from '../types';
 
4
  import Logo from './Logo';
5
  import NewSession from './NewSession';
6
  import FolderPicker from './FolderPicker';
7
  import Attachments from './Attachments';
8
  import {
9
+ attachmentFileError, filesFromTransfer, pendingAttachmentsFromFiles, revokePendingAttachments,
10
+ transferMayContainFile, uploadPendingAttachments,
11
  } from '../lib/attachments';
12
  import type { PendingAttachment } from '../lib/attachments';
13
  import { SlidersGlyph, SunGlyph, MoonGlyph, CloseGlyph, PencilGlyph, StopGlyph, PlayGlyph, GridGlyph, PlusGlyph, AmMark, ShareGlyph, HandoverGlyph, ListGlyph, EyeGlyph, EyeOffGlyph } from './icons';
 
19
  sessionId: string | null;
20
  attachments: PendingAttachment[];
21
  onSessionCreated: (id: string) => void;
 
22
  }
23
 
24
  // Folded groups, remembered across reloads.
 
37
  clis, tree, activeRef, focusedId, defaultPath, ages,
38
  onActivate, onOpenSession, onNewSession, onNewGroup, onRenameGroup, onRenameSession, onDeleteGroup,
39
  onStopSession, onSetRemotePaused, onDeleteSession, onShareSession, onShareTrace, onTraceHandover, onOpenTrace, onMove, onDragState, onOpenSettings, theme, onToggleTheme, onQuickStart,
40
+ onPrepareQuickStart,
41
  archived, showArchived, onToggleArchived,
42
  overviewHidden, onToggleOverviewHidden,
43
  }: {
 
67
  theme: 'light' | 'dark';
68
  onToggleTheme: () => void;
69
  onQuickStart: (cli: string, prompt: string, name?: string, path?: string, attachmentOptions?: QuickStartAttachmentOptions) => Promise<void>;
70
+ onPrepareQuickStart: (cli: string, name?: string, path?: string) => Promise<Session>;
71
  archived: Set<string>;
72
  showArchived: boolean;
73
  onToggleArchived: () => void;
 
88
  const [quickImages, setQuickImages] = useState<PendingAttachment[]>([]);
89
  const quickImagesRef = useRef<PendingAttachment[]>([]);
90
  const [quickSessionId, setQuickSessionId] = useState<string | null>(null);
91
+ const quickSessionIdRef = useRef<string | null>(null);
92
+ const quickPrepareRef = useRef<Promise<Session> | null>(null);
93
+ const quickGenerationRef = useRef(0);
94
  const [quickSending, setQuickSending] = useState(false);
95
  const [quickDrop, setQuickDrop] = useState(false);
96
  // When creation was launched from a group's + the new agent lands there.
 
118
  const sessById = useMemo(() => Object.fromEntries(tree.sessions.map((s) => [s.id, s])), [tree.sessions]);
119
  const groupById = useMemo(() => Object.fromEntries(tree.groups.map((g) => [g.id, g])), [tree.groups]);
120
  const colorOf = useMemo(() => Object.fromEntries(clis.map((c) => [c.id, c.color])), [clis]);
121
+ const quickFilesBlocked = quickImages.some((image) => !image.attachment);
122
+
123
  useEffect(() => { quickImagesRef.current = quickImages; }, [quickImages]);
124
  useEffect(() => () => revokePendingAttachments(quickImagesRef.current), []);
125
 
126
+ const rememberQuickSession = (id: string | null) => {
127
+ quickSessionIdRef.current = id;
128
+ setQuickSessionId(id);
129
+ };
130
+
131
  const updateQuickImage = (key: string, patch: Partial<PendingAttachment>) => {
132
  setQuickImages((current) => {
133
  const next = current.map((image) => image.key === key ? { ...image, ...patch } : image);
 
135
  return next;
136
  });
137
  };
138
+ const prepareQuickTarget = async (generation: number) => {
139
+ if (quickSessionIdRef.current) return quickSessionIdRef.current;
140
+ if (!quickCli || isRemote(quickCli)) throw new Error('Choose a local agent before attaching files.');
141
+ if (!quickPrepareRef.current) {
142
+ quickPrepareRef.current = onPrepareQuickStart(
143
+ quickCli, quickMore ? quickName.trim() : '', quickMore ? quickLoc : '.',
144
+ );
145
+ }
146
+ const preparing = quickPrepareRef.current;
147
+ try {
148
+ const created = await preparing;
149
+ if (generation === quickGenerationRef.current) rememberQuickSession(created.id);
150
+ return created.id;
151
+ } catch (error) {
152
+ if (quickPrepareRef.current === preparing) quickPrepareRef.current = null;
153
+ throw error;
154
+ }
155
+ };
156
+ const startQuickUploads = (attachments: PendingAttachment[]) => {
157
+ const uploadable = attachments.filter((attachment) => !attachmentFileError(attachment.file));
158
+ if (!uploadable.length) return;
159
+ const generation = quickGenerationRef.current;
160
+ void (async () => {
161
+ let sessionId: string;
162
+ try {
163
+ sessionId = await prepareQuickTarget(generation);
164
+ } catch (error) {
165
+ if (generation !== quickGenerationRef.current) return;
166
+ const message = error instanceof Error ? error.message : 'Could not prepare an agent for this upload.';
167
+ for (const attachment of uploadable) updateQuickImage(attachment.key, { status: 'error', error: message });
168
+ setQuickError(message);
169
+ return;
170
+ }
171
+ if (generation !== quickGenerationRef.current) return;
172
+ setQuickError(null);
173
+ await uploadPendingAttachments(sessionId, uploadable, updateQuickImage).catch(() => {
174
+ // Each failed chip keeps the exact server or connection error and retry action.
175
+ });
176
+ })();
177
+ };
178
  const addQuickImages = (files: File[]) => {
179
  if (quickSending) return;
180
  const next = pendingAttachmentsFromFiles(files, quickImagesRef.current.length);
 
182
  quickImagesRef.current = merged;
183
  setQuickImages(merged);
184
  setQuickError(next.error);
185
+ startQuickUploads(next.attachments);
186
  };
187
  const removeQuickImage = (key: string) => {
188
  if (quickSending) return;
 
195
  });
196
  setQuickError(null);
197
  };
198
+ const retryQuickImage = (key: string) => {
199
+ const image = quickImagesRef.current.find((item) => item.key === key);
200
+ if (!image || quickSending) return;
201
+ startQuickUploads([image]);
202
+ };
203
  // A retry may reuse uploaded ids only while it still targets the same
204
  // server-created session. Changing its identity starts a fresh target.
205
  const resetQuickTarget = () => {
206
+ quickGenerationRef.current += 1;
207
+ quickPrepareRef.current = null;
208
+ rememberQuickSession(null);
209
  setQuickImages((current) => {
210
  const next = current.map((image) => image.attachment
211
  ? { ...image, attachment: undefined, status: 'pending' as const, error: undefined }
 
221
  const bump = (id: string, d: number) => setCart((c) => ({ ...c, [id]: Math.max(0, (c[id] || 0) + d) }));
222
  const closePanel = () => {
223
  if (panel === 'quick') {
224
+ quickGenerationRef.current += 1;
225
  revokePendingAttachments(quickImagesRef.current);
226
  quickImagesRef.current = [];
227
  setQuickImages([]);
228
+ quickPrepareRef.current = null;
229
+ rememberQuickSession(null);
230
  setQuickSending(false);
231
  setQuickDrop(false);
232
  }
 
242
  const quickable = clis.filter((c) => c.id !== 'shell' && !isPassive(c.id) && !isRemote(c.id));
243
  const remoteCli = clis.find((c) => isRemote(c.id)) || null;
244
  const openQuick = () => {
245
+ quickGenerationRef.current += 1;
246
  setQuickError(null);
247
  setQuickName('');
248
+ quickPrepareRef.current = null;
249
+ rememberQuickSession(null);
250
  setQuickCli((q) => q ?? (quickable.find((c) => c.available && c.ready)?.id || quickable.find((c) => c.available)?.id || null));
251
  setQuickMode('agent');
252
  setQuickLoc(defaultPath || '.');
 
275
  const submitQuick = async () => {
276
  const p = quickPrompt.trim();
277
  if (!quickCli || quickSending) return;
278
+ if (quickImagesRef.current.some((image) => !image.attachment)) return;
279
  // A remote agent names itself like any other agent when unnamed
280
  // (remote-agent-1, -2, …); its "location" is always its own message folder,
281
  // never the picker's.
 
295
  try {
296
  await onQuickStart(
297
  quickCli, p, quickMore ? quickName.trim() : '', quickMore ? quickLoc : '.',
298
+ quickSessionId || quickImages.length ? {
299
  sessionId: quickSessionId,
300
  attachments: quickImages,
301
+ onSessionCreated: rememberQuickSession,
 
302
  } : undefined,
303
  );
304
  setQuickPrompt('');
 
563
  key={c.id}
564
  className={`quick-cli${quickMode === 'agent' && quickCli === c.id ? ' on' : ''}${c.available ? '' : ' off'}`}
565
  title={c.available ? c.label : `${c.label} (not installed)`}
566
+ disabled={!c.available || quickSending || quickImages.length > 0}
567
  style={quickMode === 'agent' && quickCli === c.id ? { borderColor: c.color } : undefined}
568
  onClick={() => { setQuickMode('agent'); if (quickCli !== c.id) resetQuickTarget(); setQuickCli(c.id); }}
569
  ><Logo cli={c.id} size={14} /></button>
 
572
  <button
573
  className={`quick-cli quick-grp${quickMode === 'group' ? ' on' : ''}`}
574
  title="New group"
575
+ disabled={quickSending || quickImages.length > 0}
576
  onClick={() => setQuickMode('group')}
577
  >
578
  <span className="grp-mini">
 
586
  <button
587
  className={`quick-cli${quickMode === 'agent' && quickCli === 'remote' ? ' on' : ''}`}
588
  title="Remote agent — an agent on another machine"
589
+ disabled={quickSending || quickImages.length > 0}
590
  style={quickMode === 'agent' && quickCli === 'remote' ? { borderColor: remoteCli.color } : undefined}
591
  onClick={() => {
592
+ setQuickMode('agent'); setQuickCli('remote'); quickGenerationRef.current += 1;
593
+ quickPrepareRef.current = null; rememberQuickSession(null);
594
  revokePendingAttachments(quickImagesRef.current); quickImagesRef.current = []; setQuickImages([]); setQuickError(null);
595
  }}
596
  ><Logo cli="remote" size={14} /></button>
 
605
  The agent was created. Retry will reuse it; delete it from the agent row if you want to start over.
606
  </div>
607
  )}
608
+ {!quickError && quickSessionId && quickImages.length > 0 && (
609
+ <div className="quick-recovery mono">
610
+ {quickImages.every((image) => !!image.attachment)
611
+ ? 'Files are uploaded to this stopped agent; launch will reuse them.'
612
+ : quickImages.some((image) => image.status === 'error')
613
+ ? 'A file needs attention before this stopped agent can launch.'
614
+ : 'Files are uploading to this stopped agent now; launch waits until they are ready.'}
615
+ </div>
616
+ )}
617
  <textarea
618
  autoFocus
619
  rows={1}
 
639
  showPicker={false}
640
  onFiles={addQuickImages}
641
  onRemove={removeQuickImage}
642
+ onRetry={retryQuickImage}
643
  />
644
  {quickMore && (
645
  <>
646
  <input
647
  placeholder="Name (optional)"
648
  value={quickName}
649
+ disabled={quickSending || quickImages.length > 0}
650
  onChange={(e) => { resetQuickTarget(); setQuickName(e.target.value); }}
651
  onKeyDown={(e) => { if (e.key === 'Enter') submitQuick(); if (e.key === 'Escape') closePanel(); }}
652
  />
653
+ <FolderPicker disabled={quickSending || quickImages.length > 0} value={quickLoc} onChange={(value) => { resetQuickTarget(); setQuickLoc(value); }} />
654
  <div className="widget-actions">
655
+ <button className="btn-primary" onClick={submitQuick} disabled={quickSending || quickFilesBlocked}>{quickSending ? 'Starting…' : `Create${quickPrompt.trim() || quickImages.length ? ' & send' : ''}`}</button>
656
  <button className="btn-ghost" onClick={closePanel} disabled={quickSending}>Cancel</button>
657
  </div>
658
  </>
659
  )}
660
  <div className="quick-foot">
661
+ <button className="quick-more" onClick={() => setQuickMore((v) => !v)} disabled={quickSending || quickImages.length > 0}>{quickMore ? '▴ less' : '▾ more options'}</button>
662
  <span className="quick-hint mono">↵ launch · ⇧↵ newline</span>
663
  </div>
664
  </>
web/src/components/TerminalPane.tsx CHANGED
@@ -385,18 +385,23 @@ export default function TerminalPane({
385
  const attachments: Attachment[] = [];
386
  try {
387
  for (let index = 0; index < images.length; index += 1) {
388
- showImageStatus({ kind: 'uploading', text: `uploading file${images.length > 1 ? ` ${index + 1}/${images.length}` : ''}` });
389
- const attachment = await api.uploadAttachment(session.id, images[index]);
 
 
 
 
390
  attachments.push(attachment);
391
  }
392
  showImageStatus({ kind: 'uploading', text: `inserting file${images.length === 1 ? '' : 's'}…` });
393
  await insertTerminalAttachments(attachments);
394
  } catch (error) {
395
  if (attachments.length) {
 
396
  setPendingInsert(attachments);
397
  showImageStatus({
398
  kind: 'error',
399
- text: `${attachments.length} file${attachments.length === 1 ? '' : 's'} saved; another failed to upload`,
400
  });
401
  } else {
402
  showImageStatus({ kind: 'error', text: error instanceof Error ? error.message : 'file upload failed' }, 5000);
 
385
  const attachments: Attachment[] = [];
386
  try {
387
  for (let index = 0; index < images.length; index += 1) {
388
+ const fileLabel = `file${images.length > 1 ? ` ${index + 1}/${images.length}` : ''}`;
389
+ showImageStatus({ kind: 'uploading', text: `uploading ${fileLabel} · 0%` });
390
+ const attachment = await api.uploadAttachment(session.id, images[index], ({ loaded, total }) => {
391
+ const progress = total ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
392
+ showImageStatus({ kind: 'uploading', text: `uploading ${fileLabel} · ${progress}%` });
393
+ });
394
  attachments.push(attachment);
395
  }
396
  showImageStatus({ kind: 'uploading', text: `inserting file${images.length === 1 ? '' : 's'}…` });
397
  await insertTerminalAttachments(attachments);
398
  } catch (error) {
399
  if (attachments.length) {
400
+ const reason = error instanceof Error ? error.message : 'upload failed';
401
  setPendingInsert(attachments);
402
  showImageStatus({
403
  kind: 'error',
404
+ text: `${attachments.length} file${attachments.length === 1 ? '' : 's'} saved; another failed: ${reason}`,
405
  });
406
  } else {
407
  showImageStatus({ kind: 'error', text: error instanceof Error ? error.message : 'file upload failed' }, 5000);
web/src/components/conversation/ConversationView.tsx CHANGED
@@ -106,6 +106,9 @@ export default function ConversationView({
106
  attachmentsRef.current = merged;
107
  setAttachments(merged);
108
  setAttachmentError(next.error);
 
 
 
109
  };
110
  const removeAttachment = (key: string) => {
111
  if (sending) return;
@@ -125,6 +128,11 @@ export default function ConversationView({
125
  return next;
126
  });
127
  };
 
 
 
 
 
128
 
129
  // Closing the search CLEARS it. A query filters the reader to matching turns,
130
  // so a hidden box with a live filter is a reader that looks broken — a
@@ -196,14 +204,14 @@ export default function ConversationView({
196
  const text = draft.trim();
197
  const batch = attachmentsRef.current;
198
  if ((!text && !batch.length) || sending) return;
 
199
  const optimisticText = text || defaultAttachmentPrompt(batch.length);
200
  setSending(true); setFailed(null);
201
  setDraft(''); setSent({ text: optimisticText, at: Date.now() });
202
  if (inputRef.current) { inputRef.current.style.height = 'auto'; inputRef.current.blur(); }
203
  stick.current = true;
204
  try {
205
- const uploaded = await uploadPendingAttachments(session.id, batch, updateAttachment);
206
- await api.sendInput(session.id, text, uploaded.map((attachment) => attachment.id));
207
  revokePendingAttachments(batch);
208
  attachmentsRef.current = [];
209
  setAttachments([]);
@@ -562,7 +570,8 @@ export default function ConversationView({
562
  sending={sending}
563
  isMobile={isMobile}
564
  inputRef={inputRef}
565
- canSend={!!draft.trim() || attachments.length > 0}
 
566
  above={<Attachments
567
  showPicker={false}
568
  attachments={attachments}
@@ -570,6 +579,7 @@ export default function ConversationView({
570
  disabledReason={!allowAttachments ? 'Files are not available for remote agents yet — that agent cannot read files stored on this Space.' : undefined}
571
  onFiles={addAttachments}
572
  onRemove={removeAttachment}
 
573
  />}
574
  onChange={setDraft}
575
  onSend={send}
 
106
  attachmentsRef.current = merged;
107
  setAttachments(merged);
108
  setAttachmentError(next.error);
109
+ void uploadPendingAttachments(session.id, next.attachments, updateAttachment).catch(() => {
110
+ // The affected chip owns the persistent, actionable error and retry.
111
+ });
112
  };
113
  const removeAttachment = (key: string) => {
114
  if (sending) return;
 
128
  return next;
129
  });
130
  };
131
+ const retryAttachment = (key: string) => {
132
+ const attachment = attachmentsRef.current.find((item) => item.key === key);
133
+ if (!attachment || sending) return;
134
+ void uploadPendingAttachments(session.id, [attachment], updateAttachment).catch(() => {});
135
+ };
136
 
137
  // Closing the search CLEARS it. A query filters the reader to matching turns,
138
  // so a hidden box with a live filter is a reader that looks broken — a
 
204
  const text = draft.trim();
205
  const batch = attachmentsRef.current;
206
  if ((!text && !batch.length) || sending) return;
207
+ if (batch.some((attachment) => !attachment.attachment)) return;
208
  const optimisticText = text || defaultAttachmentPrompt(batch.length);
209
  setSending(true); setFailed(null);
210
  setDraft(''); setSent({ text: optimisticText, at: Date.now() });
211
  if (inputRef.current) { inputRef.current.style.height = 'auto'; inputRef.current.blur(); }
212
  stick.current = true;
213
  try {
214
+ await api.sendInput(session.id, text, batch.map((attachment) => attachment.attachment!.id));
 
215
  revokePendingAttachments(batch);
216
  attachmentsRef.current = [];
217
  setAttachments([]);
 
570
  sending={sending}
571
  isMobile={isMobile}
572
  inputRef={inputRef}
573
+ canSend={(!!draft.trim() || attachments.length > 0)
574
+ && attachments.every((attachment) => !!attachment.attachment)}
575
  above={<Attachments
576
  showPicker={false}
577
  attachments={attachments}
 
579
  disabledReason={!allowAttachments ? 'Files are not available for remote agents yet — that agent cannot read files stored on this Space.' : undefined}
580
  onFiles={addAttachments}
581
  onRemove={removeAttachment}
582
+ onRetry={retryAttachment}
583
  />}
584
  onChange={setDraft}
585
  onSend={send}
web/src/lib/attachments.ts CHANGED
@@ -12,6 +12,7 @@ export interface PendingAttachment {
12
  file: File;
13
  previewUrl?: string;
14
  status: PendingAttachmentStatus;
 
15
  error?: string;
16
  attachment?: Attachment;
17
  }
@@ -136,24 +137,33 @@ export async function uploadPendingAttachments(
136
  update: (key: string, patch: Partial<PendingAttachment>) => void,
137
  ) {
138
  const uploaded: Attachment[] = [];
 
139
  for (const attachment of attachments) {
140
  if (attachment.attachment) {
141
  uploaded.push(attachment.attachment);
142
  continue;
143
  }
144
- if (attachmentFileError(attachment.file)) {
145
- throw new Error(attachment.error || 'invalid file');
 
 
 
146
  }
147
- update(attachment.key, { status: 'uploading', error: undefined });
148
  try {
149
- const stored = await api.uploadAttachment(sessionId, attachment.file);
150
- update(attachment.key, { status: 'uploaded', attachment: stored });
 
 
 
 
151
  uploaded.push(stored);
152
  } catch (error) {
153
  const message = error instanceof Error ? error.message : 'upload failed';
154
  update(attachment.key, { status: 'error', error: message });
155
- throw error;
156
  }
157
  }
 
158
  return uploaded;
159
  }
 
12
  file: File;
13
  previewUrl?: string;
14
  status: PendingAttachmentStatus;
15
+ uploadedBytes?: number;
16
  error?: string;
17
  attachment?: Attachment;
18
  }
 
137
  update: (key: string, patch: Partial<PendingAttachment>) => void,
138
  ) {
139
  const uploaded: Attachment[] = [];
140
+ let firstFailure: Error | null = null;
141
  for (const attachment of attachments) {
142
  if (attachment.attachment) {
143
  uploaded.push(attachment.attachment);
144
  continue;
145
  }
146
+ const invalid = attachmentFileError(attachment.file);
147
+ if (invalid) {
148
+ update(attachment.key, { status: 'error', error: invalid });
149
+ firstFailure ??= new Error(invalid);
150
+ continue;
151
  }
152
+ update(attachment.key, { status: 'uploading', uploadedBytes: 0, error: undefined });
153
  try {
154
+ const stored = await api.uploadAttachment(sessionId, attachment.file, ({ loaded }) => {
155
+ update(attachment.key, { uploadedBytes: loaded });
156
+ });
157
+ update(attachment.key, {
158
+ status: 'uploaded', uploadedBytes: attachment.file.size, attachment: stored,
159
+ });
160
  uploaded.push(stored);
161
  } catch (error) {
162
  const message = error instanceof Error ? error.message : 'upload failed';
163
  update(attachment.key, { status: 'error', error: message });
164
+ firstFailure ??= error instanceof Error ? error : new Error(message);
165
  }
166
  }
167
+ if (firstFailure) throw firstFailure;
168
  return uploaded;
169
  }
web/src/styles.css CHANGED
@@ -195,19 +195,22 @@ body {
195
  .image-pick:disabled { opacity: 0.38; cursor: not-allowed; }
196
  .image-pick svg { width: 15px; height: 15px; }
197
  .image-attachments-note { min-width: 0; color: var(--muted); font-size: 10px; line-height: 1.3; }
198
- .image-chip { min-width: 0; max-width: 205px; height: 36px; display: flex; align-items: center; gap: 6px; padding: 3px 5px 3px 3px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel); }
199
- .image-chip.error { border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); }
200
  .image-chip.uploading { border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); }
201
  .image-chip-preview { flex: none; width: 29px; height: 28px; border-radius: 3px; overflow: hidden; background: var(--panel-2); }
202
  .image-chip-preview:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
203
  .image-chip img { display: block; width: 29px; height: 28px; object-fit: cover; }
204
  .image-chip-placeholder { flex: none; width: 29px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border-radius: 3px; background: var(--panel-2); color: var(--muted); font-size: 8px; }
205
- .image-chip-copy { min-width: 0; display: flex; flex-direction: column; line-height: 1.15; }
206
  .image-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10.5px; font-weight: 600; }
207
  .image-chip-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; color: var(--muted); font: 9.5px var(--font-mono); }
208
- .image-chip.error .image-chip-meta { color: var(--danger); }
209
- .image-chip.uploading .image-chip-meta { color: var(--accent); animation: breathe 1.2s ease-in-out infinite; }
 
 
210
  .image-chip > button { flex: none; padding: 1px; border: none; background: transparent; color: var(--muted); font: 13px/1 var(--font-sans); cursor: pointer; }
 
211
  .image-chip > button:hover:not(:disabled) { color: var(--text); }
212
  .image-chip > button:disabled { opacity: 0.35; cursor: default; }
213
  .quick .image-attachments { align-items: flex-start; }
@@ -240,6 +243,7 @@ body {
240
  .fp { display: flex; flex-direction: column; gap: 4px; }
241
  .fp-current { display: flex; align-items: center; gap: 7px; width: 100%; padding: 7px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-md); color: var(--text); font: inherit; font-size: 12.5px; cursor: pointer; text-align: left; }
242
  .fp-current:hover { border-color: var(--border-strong); }
 
243
  .fp-ico { flex: none; width: 14px; height: 14px; color: var(--muted); }
244
  .fp-row .fp-ico { width: 13px; height: 13px; margin-right: 4px; }
245
  .fp-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
@@ -892,7 +896,7 @@ body {
892
  .term-image-status { position: absolute; z-index: 8; left: 50%; bottom: 10px; transform: translateX(-50%); max-width: calc(100% - 24px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 5px 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: color-mix(in srgb, var(--term-bg) 92%, transparent); color: var(--muted); box-shadow: 0 4px 14px rgb(0 0 0 / 0.12); font-size: 10.5px; pointer-events: none; }
893
  .term-image-status.uploading { color: var(--accent); animation: breathe 1.2s ease-in-out infinite; }
894
  .term-image-status.success { color: var(--go); }
895
- .term-image-status.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
896
  .term-image-status.has-action { display: flex; align-items: center; gap: 8px; pointer-events: auto; }
897
  .term-image-status button { padding: 0; border: 0; background: none; color: inherit; font: inherit; font-weight: 700; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
898
  .term-image-status button:disabled { opacity: 0.45; cursor: default; }
 
195
  .image-pick:disabled { opacity: 0.38; cursor: not-allowed; }
196
  .image-pick svg { width: 15px; height: 15px; }
197
  .image-attachments-note { min-width: 0; color: var(--muted); font-size: 10px; line-height: 1.3; }
198
+ .image-chip { min-width: 0; max-width: 250px; height: 38px; display: flex; align-items: center; gap: 6px; padding: 3px 5px 3px 3px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel); }
199
+ .image-chip.error { max-width: min(100%, 360px); height: auto; min-height: 38px; border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); }
200
  .image-chip.uploading { border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); }
201
  .image-chip-preview { flex: none; width: 29px; height: 28px; border-radius: 3px; overflow: hidden; background: var(--panel-2); }
202
  .image-chip-preview:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
203
  .image-chip img { display: block; width: 29px; height: 28px; object-fit: cover; }
204
  .image-chip-placeholder { flex: none; width: 29px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border-radius: 3px; background: var(--panel-2); color: var(--muted); font-size: 8px; }
205
+ .image-chip-copy { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.15; }
206
  .image-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10.5px; font-weight: 600; }
207
  .image-chip-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; color: var(--muted); font: 9.5px var(--font-mono); }
208
+ .image-chip.error .image-chip-meta { overflow: visible; text-overflow: clip; white-space: normal; color: var(--danger); line-height: 1.25; }
209
+ .image-chip.uploading .image-chip-meta { color: var(--accent); }
210
+ .image-chip-progress { display: block; width: 100%; height: 2px; margin-top: 3px; overflow: hidden; border-radius: 1px; background: color-mix(in srgb, var(--accent) 16%, var(--panel-2)); }
211
+ .image-chip-progress > span { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width 120ms linear; }
212
  .image-chip > button { flex: none; padding: 1px; border: none; background: transparent; color: var(--muted); font: 13px/1 var(--font-sans); cursor: pointer; }
213
+ .image-chip > .image-chip-retry { color: var(--danger); font-size: 9px; font-weight: 650; }
214
  .image-chip > button:hover:not(:disabled) { color: var(--text); }
215
  .image-chip > button:disabled { opacity: 0.35; cursor: default; }
216
  .quick .image-attachments { align-items: flex-start; }
 
243
  .fp { display: flex; flex-direction: column; gap: 4px; }
244
  .fp-current { display: flex; align-items: center; gap: 7px; width: 100%; padding: 7px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-md); color: var(--text); font: inherit; font-size: 12.5px; cursor: pointer; text-align: left; }
245
  .fp-current:hover { border-color: var(--border-strong); }
246
+ .fp-current:disabled { opacity: 0.5; cursor: not-allowed; }
247
  .fp-ico { flex: none; width: 14px; height: 14px; color: var(--muted); }
248
  .fp-row .fp-ico { width: 13px; height: 13px; margin-right: 4px; }
249
  .fp-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
 
896
  .term-image-status { position: absolute; z-index: 8; left: 50%; bottom: 10px; transform: translateX(-50%); max-width: calc(100% - 24px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 5px 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: color-mix(in srgb, var(--term-bg) 92%, transparent); color: var(--muted); box-shadow: 0 4px 14px rgb(0 0 0 / 0.12); font-size: 10.5px; pointer-events: none; }
897
  .term-image-status.uploading { color: var(--accent); animation: breathe 1.2s ease-in-out infinite; }
898
  .term-image-status.success { color: var(--go); }
899
+ .term-image-status.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); white-space: normal; text-overflow: clip; }
900
  .term-image-status.has-action { display: flex; align-items: center; gap: 8px; pointer-events: auto; }
901
  .term-image-status button { padding: 0; border: 0; background: none; color: inherit; font: inherit; font-weight: 700; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
902
  .term-image-status button:disabled { opacity: 0.45; cursor: default; }