Spaces:
Running
Running
Agent Manager commited on
Commit ·
2fc73bc
1
Parent(s): 7e49345
Harden screenshot input delivery and recovery
Browse files- docs/screenshot-input.md +44 -27
- server/package.json +2 -1
- server/screenshot-input.test.mjs +249 -0
- server/src/attachments.js +110 -56
- server/src/config.js +3 -1
- server/src/index.js +62 -8
- server/src/runner.js +25 -5
- server/terminal-ui.test.mjs +7 -1
- server/test/attachments.test.mjs +65 -8
- web/src/App.tsx +4 -1
- web/src/api.ts +13 -4
- web/src/components/ImageAttachments.tsx +16 -6
- web/src/components/Overview.tsx +26 -15
- web/src/components/Sidebar.tsx +39 -17
- web/src/components/TerminalPane.tsx +104 -13
- web/src/lib/imageAttachments.ts +32 -9
- web/src/styles.css +13 -2
docs/screenshot-input.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Screenshot input
|
| 2 |
|
| 3 |
-
Status:
|
| 4 |
|
| 5 |
Date: 2026-08-05
|
| 6 |
|
|
@@ -61,10 +61,10 @@ denominator:
|
|
| 61 |
| Harness | Observed path as of this design | First implementation |
|
| 62 |
|---|---|---|
|
| 63 |
| Claude Code | accepts an image path in a prompt; native terminals also support image paste/drop | explicit absolute path in the prompt |
|
| 64 |
-
| Codex | `-i/--image` supports initial images; in-session image paste reads the server clipboard |
|
| 65 |
| Gemini CLI | `@<path>` injects supported images as multimodal context | `@<absolute-path>` |
|
| 66 |
| opencode | TUI image drop and `opencode run -f/--file` are supported | explicit/drop-style absolute path |
|
| 67 |
-
| Hermes |
|
| 68 |
| OpenClaw | no stable image-attachment CLI contract was verified | explicit path, best effort |
|
| 69 |
|
| 70 |
Every adapter must retain the explicit-path fallback. CLI flags and TUI commands
|
|
@@ -226,6 +226,10 @@ session's own directory, and never join an arbitrary browser-supplied filename.
|
|
| 226 |
whose newest file is older than seven days. The grace period covers a crash
|
| 227 |
between session persistence and upload completion.
|
| 228 |
- Failed and aborted uploads delete their temporary file immediately.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
No separate database is required. The session-scoped directory, validated id,
|
| 231 |
extension, and `stat` provide the metadata needed in v1.
|
|
@@ -262,7 +266,8 @@ and abort with `413` above 25 MiB. Once complete:
|
|
| 262 |
|
| 263 |
1. Read the first small header from the temporary file.
|
| 264 |
2. Detect PNG, JPEG, GIF, or WebP by magic bytes.
|
| 265 |
-
3. Reject
|
|
|
|
| 266 |
4. Rename to the detected extension atomically.
|
| 267 |
5. Return `201`.
|
| 268 |
|
|
@@ -358,15 +363,12 @@ trace.
|
|
| 358 |
### 8.2 Adapter interface
|
| 359 |
|
| 360 |
Keep version-sensitive behavior in one module rather than scattered across React
|
| 361 |
-
components and `runner.js`
|
|
|
|
| 362 |
|
| 363 |
```ts
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
prelude?: Array<{ text: string; submit: boolean; settleMs?: number }>;
|
| 367 |
-
}
|
| 368 |
-
|
| 369 |
-
formatAttachmentDelivery(cli, text, paths): AttachmentDelivery
|
| 370 |
```
|
| 371 |
|
| 372 |
Initial adapters:
|
|
@@ -376,9 +378,10 @@ Initial adapters:
|
|
| 376 |
prompt. If the command is unavailable, use the universal prompt.
|
| 377 |
- all others: use the universal prompt.
|
| 378 |
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
|
|
|
| 382 |
|
| 383 |
### 8.3 First prompt without a boot race
|
| 384 |
|
|
@@ -391,8 +394,10 @@ two-step browser flow—create the session, then upload to its attachment scope
|
|
| 391 |
2. Upload all pending images to the returned session id.
|
| 392 |
3. `POST /api/sessions/:id/input` with text and attachment ids.
|
| 393 |
4. If the session has never started and its CLI has `withPrompt`, store the
|
| 394 |
-
fully formatted prompt as `pendingPrompt`
|
| 395 |
-
|
|
|
|
|
|
|
| 396 |
|
| 397 |
Only resumed or already-started sessions use the existing boot-then-type path.
|
| 398 |
If attachment upload fails after session creation, keep the stopped session and
|
|
@@ -401,19 +406,26 @@ the browser draft. Automatically deleting it would make recovery surprising.
|
|
| 401 |
### 8.4 Terminal insertion
|
| 402 |
|
| 403 |
Live terminal paste/drop does not call `/input`, because `/input` submits a turn.
|
| 404 |
-
After upload,
|
|
|
|
| 405 |
|
| 406 |
-
```
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
| 408 |
```
|
| 409 |
|
| 410 |
-
`
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
|
|
|
|
|
|
|
|
|
| 414 |
|
| 415 |
-
Hermes is the one useful native exception: the
|
| 416 |
-
and Return, wait for the command to settle, and then restore focus without
|
| 417 |
submitting the actual prompt. This logic should still live behind the same
|
| 418 |
adapter and fall back to `insertText`.
|
| 419 |
|
|
@@ -421,7 +433,7 @@ adapter and fall back to `insertText`.
|
|
| 421 |
|
| 422 |
Add a small module, for example `web/src/lib/imageAttachments.ts`, containing:
|
| 423 |
|
| 424 |
-
- accepted MIME
|
| 425 |
- `imageFilesFromTransfer(DataTransfer)`;
|
| 426 |
- `transferMayContainImage(DataTransfer)`;
|
| 427 |
- duplicate suppression across `items` and `files`;
|
|
@@ -545,6 +557,7 @@ may reuse already uploaded ids while uploading only failed files.
|
|
| 545 |
- The byte limit trips during streaming and leaves no partial file.
|
| 546 |
- Aborted requests leave no partial file.
|
| 547 |
- Concurrent uploads produce unique files.
|
|
|
|
| 548 |
- Attachment ids cannot cross sessions or traverse paths.
|
| 549 |
- Preview headers include CSP and `nosniff`.
|
| 550 |
- A structured prompt rejects if any referenced attachment is absent.
|
|
@@ -561,7 +574,11 @@ may reuse already uploaded ids while uploading only failed files.
|
|
| 561 |
- Plain-text paste is unchanged.
|
| 562 |
- Removing a chip revokes its preview and excludes it from upload.
|
| 563 |
- A multi-image submission waits for every upload before `/input`.
|
| 564 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
- Terminal image drop does not trigger pane movement.
|
| 566 |
- Pane movement data does not trigger the image drop UI.
|
| 567 |
- The mobile fallback textarea handles both image and text paste.
|
|
|
|
| 1 |
# Screenshot input
|
| 2 |
|
| 3 |
+
Status: implemented in draft PR
|
| 4 |
|
| 5 |
Date: 2026-08-05
|
| 6 |
|
|
|
|
| 61 |
| Harness | Observed path as of this design | First implementation |
|
| 62 |
|---|---|---|
|
| 63 |
| Claude Code | accepts an image path in a prompt; native terminals also support image paste/drop | explicit absolute path in the prompt |
|
| 64 |
+
| Codex | `-i/--image` supports initial images; in-session image paste reads the server clipboard | native `-i` on the first turn plus an explicit path fallback |
|
| 65 |
| Gemini CLI | `@<path>` injects supported images as multimodal context | `@<absolute-path>` |
|
| 66 |
| opencode | TUI image drop and `opencode run -f/--file` are supported | explicit/drop-style absolute path |
|
| 67 |
+
| Hermes | the current TUI supports `/image <path>` and detects a pasted standalone image path | `/image <absolute-path>` adapter, with explicit-path fallback |
|
| 68 |
| OpenClaw | no stable image-attachment CLI contract was verified | explicit path, best effort |
|
| 69 |
|
| 70 |
Every adapter must retain the explicit-path fallback. CLI flags and TUI commands
|
|
|
|
| 226 |
whose newest file is older than seven days. The grace period covers a crash
|
| 227 |
between session persistence and upload completion.
|
| 228 |
- Failed and aborted uploads delete their temporary file immediately.
|
| 229 |
+
- Each session is capped at 200 stored screenshots and 500 MiB. Uploads for one
|
| 230 |
+
session are serialized so concurrent requests cannot race the quota check.
|
| 231 |
+
- Pruning and session deletion use asynchronous filesystem operations so a
|
| 232 |
+
large attachment store cannot block terminal I/O.
|
| 233 |
|
| 234 |
No separate database is required. The session-scoped directory, validated id,
|
| 235 |
extension, and `stat` provide the metadata needed in v1.
|
|
|
|
| 266 |
|
| 267 |
1. Read the first small header from the temporary file.
|
| 268 |
2. Detect PNG, JPEG, GIF, or WebP by magic bytes.
|
| 269 |
+
3. Reject unsupported or malformed bytes with `415`. A missing, aliased, or
|
| 270 |
+
incorrect request `Content-Type` does not override byte detection.
|
| 271 |
4. Rename to the detected extension atomically.
|
| 272 |
5. Return `201`.
|
| 273 |
|
|
|
|
| 363 |
### 8.2 Adapter interface
|
| 364 |
|
| 365 |
Keep version-sensitive behavior in one module rather than scattered across React
|
| 366 |
+
components and `runner.js`. The implementation exposes the universal formatted
|
| 367 |
+
prompt and any native prelude commands separately:
|
| 368 |
|
| 369 |
```ts
|
| 370 |
+
formatAttachmentDelivery(cli, text, images): string
|
| 371 |
+
formatAttachmentPrelude(cli, images): string[]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
```
|
| 373 |
|
| 374 |
Initial adapters:
|
|
|
|
| 378 |
prompt. If the command is unavailable, use the universal prompt.
|
| 379 |
- all others: use the universal prompt.
|
| 380 |
|
| 381 |
+
opencode `--file` and other native launch flags are optional follow-ups. Codex
|
| 382 |
+
uses its installed `--image` support on a first turn; every adapter retains the
|
| 383 |
+
explicit path so a missing or changed native flag is not the only route to the
|
| 384 |
+
file.
|
| 385 |
|
| 386 |
### 8.3 First prompt without a boot race
|
| 387 |
|
|
|
|
| 394 |
2. Upload all pending images to the returned session id.
|
| 395 |
3. `POST /api/sessions/:id/input` with text and attachment ids.
|
| 396 |
4. If the session has never started and its CLI has `withPrompt`, store the
|
| 397 |
+
fully formatted prompt as `pendingPrompt`; for Codex, also retain the
|
| 398 |
+
validated paths as `pendingImagePaths`; then call `ensureRunning()`.
|
| 399 |
+
5. `commandFor()` consumes those fields on the first launch, adding one Codex
|
| 400 |
+
`-i` flag per image, and clears them once the PTY starts.
|
| 401 |
|
| 402 |
Only resumed or already-started sessions use the existing boot-then-type path.
|
| 403 |
If attachment upload fails after session creation, keep the stopped session and
|
|
|
|
| 406 |
### 8.4 Terminal insertion
|
| 407 |
|
| 408 |
Live terminal paste/drop does not call `/input`, because `/input` submits a turn.
|
| 409 |
+
After upload, it asks the server to insert the resolved attachments without a
|
| 410 |
+
Return key:
|
| 411 |
|
| 412 |
+
```http
|
| 413 |
+
POST /api/sessions/:id/attachments/insert
|
| 414 |
+
Content-Type: application/json
|
| 415 |
+
|
| 416 |
+
{"attachmentIds":["att_74e0f69dc9ed772cb685999e"]}
|
| 417 |
```
|
| 418 |
|
| 419 |
+
The server writes `insertText` to the running PTY and acknowledges only after
|
| 420 |
+
the write is accepted. It never sends Return. If the process stops after upload,
|
| 421 |
+
the browser says the image was saved but not inserted and retains its attachment
|
| 422 |
+
id behind a Retry action. This avoids treating a browser-local xterm paste as
|
| 423 |
+
proof that a disconnected or non-controlling pane reached the CLI. Completed
|
| 424 |
+
terminal insertions are idempotent by attachment id, so retrying after a lost
|
| 425 |
+
HTTP response cannot paste the same path twice.
|
| 426 |
|
| 427 |
+
Hermes is the one useful native exception: the server may send `/image <path>`
|
| 428 |
+
and Return, briefly wait for the command to settle, and then restore focus without
|
| 429 |
submitting the actual prompt. This logic should still live behind the same
|
| 430 |
adapter and fall back to `insertText`.
|
| 431 |
|
|
|
|
| 433 |
|
| 434 |
Add a small module, for example `web/src/lib/imageAttachments.ts`, containing:
|
| 435 |
|
| 436 |
+
- accepted MIME hints, byte/size checks, and client-side 25 MiB check;
|
| 437 |
- `imageFilesFromTransfer(DataTransfer)`;
|
| 438 |
- `transferMayContainImage(DataTransfer)`;
|
| 439 |
- duplicate suppression across `items` and `files`;
|
|
|
|
| 557 |
- The byte limit trips during streaming and leaves no partial file.
|
| 558 |
- Aborted requests leave no partial file.
|
| 559 |
- Concurrent uploads produce unique files.
|
| 560 |
+
- Concurrent uploads cannot race the per-session byte/count quota.
|
| 561 |
- Attachment ids cannot cross sessions or traverse paths.
|
| 562 |
- Preview headers include CSP and `nosniff`.
|
| 563 |
- A structured prompt rejects if any referenced attachment is absent.
|
|
|
|
| 574 |
- Plain-text paste is unchanged.
|
| 575 |
- Removing a chip revokes its preview and excludes it from upload.
|
| 576 |
- A multi-image submission waits for every upload before `/input`.
|
| 577 |
+
- Every chip mutation remains disabled for the complete multi-image send.
|
| 578 |
+
- Terminal paste receives a server acknowledgement and inserts without Return.
|
| 579 |
+
- A terminal stopped after upload reports saved-but-not-inserted, disables new
|
| 580 |
+
attachments, and can retry the stored attachment after restart.
|
| 581 |
+
- Remote composers show a visible explanation on touch layouts.
|
| 582 |
- Terminal image drop does not trigger pane movement.
|
| 583 |
- Pane movement data does not trigger the image drop UI.
|
| 584 |
- The mobile fallback textarea handles both image and text paste.
|
server/package.json
CHANGED
|
@@ -13,7 +13,8 @@
|
|
| 13 |
"scripts": {
|
| 14 |
"start": "node src/index.js",
|
| 15 |
"dev": "node --watch src/index.js",
|
| 16 |
-
"test:ui": "node terminal-ui.test.mjs",
|
|
|
|
| 17 |
"test": "node test/attachments.test.mjs && node test/repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node migration.test.mjs && node resize.test.mjs"
|
| 18 |
},
|
| 19 |
"engines": {
|
|
|
|
| 13 |
"scripts": {
|
| 14 |
"start": "node src/index.js",
|
| 15 |
"dev": "node --watch src/index.js",
|
| 16 |
+
"test:ui": "node terminal-ui.test.mjs && node screenshot-input.test.mjs",
|
| 17 |
+
"test:screenshots": "node screenshot-input.test.mjs",
|
| 18 |
"test": "node test/attachments.test.mjs && node test/repin.test.mjs && node test/opencode-resume.test.mjs && node test/terminal-modes.test.mjs && node migration.test.mjs && node resize.test.mjs"
|
| 19 |
},
|
| 20 |
"engines": {
|
server/screenshot-input.test.mjs
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
/**
|
| 3 |
+
* Screenshot-input integration checks in a real browser:
|
| 4 |
+
* - stored bytes, response MIME, and preview headers come from detection;
|
| 5 |
+
* - a stopped terminal never reports a false insertion success;
|
| 6 |
+
* - an uploaded-but-uninserted screenshot can be retried without reupload;
|
| 7 |
+
* - attachment chips cannot mutate an in-flight send;
|
| 8 |
+
* - remote limitations are visible rather than tooltip-only.
|
| 9 |
+
*
|
| 10 |
+
* Set SCREENSHOT_PUBLIC_DIR to a prebuilt web/dist to skip the build.
|
| 11 |
+
*/
|
| 12 |
+
import fs from 'node:fs';
|
| 13 |
+
import os from 'node:os';
|
| 14 |
+
import path from 'node:path';
|
| 15 |
+
import { spawn, spawnSync } from 'node:child_process';
|
| 16 |
+
import { fileURLToPath } from 'node:url';
|
| 17 |
+
import { chromium } from 'playwright';
|
| 18 |
+
|
| 19 |
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
| 20 |
+
const ROOT = path.dirname(HERE);
|
| 21 |
+
const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'am-screenshot-ui-'));
|
| 22 |
+
const PUBLIC_DIR = process.env.SCREENSHOT_PUBLIC_DIR || path.join(DATA_DIR, 'public');
|
| 23 |
+
const API = 'http://127.0.0.1:7896';
|
| 24 |
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
| 25 |
+
|
| 26 |
+
const png = Buffer.alloc(45);
|
| 27 |
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png);
|
| 28 |
+
png.writeUInt32BE(13, 8); Buffer.from('IHDR').copy(png, 12);
|
| 29 |
+
png.writeUInt32BE(1, 16); png.writeUInt32BE(1, 20);
|
| 30 |
+
Buffer.from('IEND').copy(png, 37);
|
| 31 |
+
|
| 32 |
+
let failures = 0;
|
| 33 |
+
const check = (name, ok, detail = '') => {
|
| 34 |
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` ${detail}` : ''}`);
|
| 35 |
+
if (!ok) failures += 1;
|
| 36 |
+
};
|
| 37 |
+
const waitFor = async (fn, timeout = 15_000) => {
|
| 38 |
+
const until = Date.now() + timeout;
|
| 39 |
+
while (Date.now() < until) {
|
| 40 |
+
try { if (await fn()) return true; } catch {}
|
| 41 |
+
await sleep(100);
|
| 42 |
+
}
|
| 43 |
+
return false;
|
| 44 |
+
};
|
| 45 |
+
const apiJson = async (url, init) => {
|
| 46 |
+
const response = await fetch(`${API}${url}`, init);
|
| 47 |
+
const body = await response.json().catch(() => ({}));
|
| 48 |
+
return { response, body };
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
if (!process.env.SCREENSHOT_PUBLIC_DIR) {
|
| 52 |
+
const build = spawnSync('npm', ['run', 'build', '--', '--outDir', PUBLIC_DIR], {
|
| 53 |
+
cwd: path.join(ROOT, 'web'), encoding: 'utf8',
|
| 54 |
+
});
|
| 55 |
+
if (build.status !== 0) throw new Error(`web build failed:\n${build.stdout}\n${build.stderr}`);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const backend = spawn('node', ['src/index.js'], {
|
| 59 |
+
cwd: HERE,
|
| 60 |
+
env: {
|
| 61 |
+
...process.env,
|
| 62 |
+
PORT: '7896', DATA_DIR, PUBLIC_DIR, AM_BASHRC: '/nonexistent', SPACE_HOST: '',
|
| 63 |
+
AM_TEST_REPAINT_CMD: 'bash --noprofile --norc',
|
| 64 |
+
},
|
| 65 |
+
stdio: ['ignore', 'pipe', 'pipe'],
|
| 66 |
+
});
|
| 67 |
+
let logs = '';
|
| 68 |
+
backend.stdout.on('data', (data) => { logs += data; });
|
| 69 |
+
backend.stderr.on('data', (data) => { logs += data; });
|
| 70 |
+
|
| 71 |
+
let browser;
|
| 72 |
+
try {
|
| 73 |
+
if (!await waitFor(() => fetch(`${API}/api/health`).then((response) => response.ok).catch(() => false), 60_000)) {
|
| 74 |
+
throw new Error(`server did not start:\n${logs.slice(-2000)}`);
|
| 75 |
+
}
|
| 76 |
+
await fetch(`${API}/api/welcome/seen`, { method: 'POST' });
|
| 77 |
+
|
| 78 |
+
const created = await apiJson('/api/sessions', {
|
| 79 |
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
| 80 |
+
body: JSON.stringify({ cli: 'test-repaint', name: 'screenshot-e2e', path: '.' }),
|
| 81 |
+
});
|
| 82 |
+
const id = created.body.id;
|
| 83 |
+
if (!id) throw new Error(`session creation failed: ${JSON.stringify(created.body)}`);
|
| 84 |
+
|
| 85 |
+
// The browser's declared type is deliberately non-canonical. The response
|
| 86 |
+
// and raw preview must reflect the PNG bytes, not that metadata.
|
| 87 |
+
const upload = await fetch(`${API}/api/sessions/${id}/attachments`, {
|
| 88 |
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: png,
|
| 89 |
+
});
|
| 90 |
+
const stored = await upload.json();
|
| 91 |
+
check('upload MIME is detected from bytes', upload.status === 201 && stored.mime === 'image/png',
|
| 92 |
+
JSON.stringify({ status: upload.status, mime: stored.mime }));
|
| 93 |
+
const preview = await fetch(`${API}${stored.previewUrl}`);
|
| 94 |
+
check('preview uses private, sandboxed response headers',
|
| 95 |
+
preview.headers.get('content-type') === 'image/png'
|
| 96 |
+
&& preview.headers.get('cache-control') === 'no-store'
|
| 97 |
+
&& preview.headers.get('x-content-type-options') === 'nosniff'
|
| 98 |
+
&& preview.headers.get('content-security-policy') === 'sandbox');
|
| 99 |
+
|
| 100 |
+
const missingId = `att_${'0'.repeat(24)}`;
|
| 101 |
+
const mixedSend = await apiJson(`/api/sessions/${id}/input`, {
|
| 102 |
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
| 103 |
+
body: JSON.stringify({ text: 'must not send partially', attachmentIds: [stored.id, missingId] }),
|
| 104 |
+
});
|
| 105 |
+
const sessionsAfterReject = await (await fetch(`${API}/api/sessions`)).json();
|
| 106 |
+
check('structured send rejects atomically before starting the agent',
|
| 107 |
+
mixedSend.response.status === 404
|
| 108 |
+
&& sessionsAfterReject.find((session) => session.id === id)?.running === false,
|
| 109 |
+
JSON.stringify({ status: mixedSend.response.status, error: mixedSend.body.error }));
|
| 110 |
+
|
| 111 |
+
const remoteCreated = await apiJson('/api/sessions', {
|
| 112 |
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
| 113 |
+
body: JSON.stringify({ cli: 'remote', name: 'remote-screenshot-e2e', path: '.' }),
|
| 114 |
+
});
|
| 115 |
+
const remoteUpload = await fetch(`${API}/api/sessions/${remoteCreated.body.id}/attachments`, {
|
| 116 |
+
method: 'POST', headers: { 'content-type': 'image/png' }, body: png,
|
| 117 |
+
});
|
| 118 |
+
const remoteUploadBody = await remoteUpload.json();
|
| 119 |
+
check('remote uploads are rejected with an actionable reason',
|
| 120 |
+
remoteUpload.status === 400 && remoteUploadBody.error.includes('cannot read files stored on this Space'));
|
| 121 |
+
|
| 122 |
+
const bakedChromium = '/opt/pw-browsers/chromium-1208/chrome-linux64/chrome';
|
| 123 |
+
const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
| 124 |
+
|| (fs.existsSync(bakedChromium) ? bakedChromium : undefined);
|
| 125 |
+
browser = await chromium.launch({
|
| 126 |
+
headless: true,
|
| 127 |
+
...(chromiumExecutable ? { executablePath: chromiumExecutable } : {}),
|
| 128 |
+
});
|
| 129 |
+
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
| 130 |
+
await page.goto(API, { waitUntil: 'domcontentloaded' });
|
| 131 |
+
await page.locator('.sidebar .row[title^="screenshot-e2e"]').first().click();
|
| 132 |
+
await page.locator('.tile-terminal:not(.tile-cached) .xterm-screen').waitFor({ state: 'visible' });
|
| 133 |
+
await page.locator('.pane-head .ph-image').waitFor({ state: 'visible' });
|
| 134 |
+
await waitFor(() => page.locator('.pane-head .ph-image').isEnabled());
|
| 135 |
+
|
| 136 |
+
// Let the upload finish, stop the process, and only then expose the response
|
| 137 |
+
// to the UI. The following insert request must fail authoritatively.
|
| 138 |
+
let disconnectedAttachmentId;
|
| 139 |
+
await page.route(`**/api/sessions/${id}/attachments`, async (route) => {
|
| 140 |
+
const response = await route.fetch();
|
| 141 |
+
disconnectedAttachmentId = (await response.json()).id;
|
| 142 |
+
await fetch(`${API}/api/sessions/${id}/stop`, { method: 'POST' });
|
| 143 |
+
await route.fulfill({ response });
|
| 144 |
+
}, { times: 1 });
|
| 145 |
+
await page.locator('.pane-head .image-file-input').setInputFiles({
|
| 146 |
+
name: 'disconnect.png', mimeType: 'image/png', buffer: png,
|
| 147 |
+
});
|
| 148 |
+
const retryStatus = page.locator('.term-image-status.has-action');
|
| 149 |
+
await retryStatus.waitFor({ state: 'visible' });
|
| 150 |
+
const failedText = await retryStatus.textContent();
|
| 151 |
+
check('a stopped terminal reports saved-but-not-inserted, never success',
|
| 152 |
+
!!failedText?.includes('saved but not inserted') && !failedText.includes('press Enter'),
|
| 153 |
+
JSON.stringify({ failedText }));
|
| 154 |
+
await page.locator('.term-exit').waitFor({ state: 'visible' });
|
| 155 |
+
check('terminal attachment picker is unavailable while stopped',
|
| 156 |
+
await page.locator('.pane-head .ph-image').isDisabled());
|
| 157 |
+
|
| 158 |
+
await page.locator('.term-exit .tx-btn').click();
|
| 159 |
+
await waitFor(() => page.locator('.pane-head .ph-image').isEnabled(), 20_000);
|
| 160 |
+
const retry = retryStatus.locator('button');
|
| 161 |
+
await retry.click();
|
| 162 |
+
await page.locator('.term-image-status.success').waitFor({ state: 'visible' });
|
| 163 |
+
const successText = await page.locator('.term-image-status.success').textContent();
|
| 164 |
+
check('retry inserts the already-uploaded screenshot after restart',
|
| 165 |
+
!!successText?.includes('inserted') && successText.includes('press Enter'),
|
| 166 |
+
JSON.stringify({ successText }));
|
| 167 |
+
const terminalTail = await (await fetch(`${API}/api/agents/${id}/tail?lines=80`)).json();
|
| 168 |
+
check('terminal retry reaches the PTY without submitting the prompt',
|
| 169 |
+
terminalTail.text.includes('Screenshot:') && !terminalTail.text.includes('command not found'));
|
| 170 |
+
const repeatedInsert = await apiJson(`/api/sessions/${id}/attachments/insert`, {
|
| 171 |
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
| 172 |
+
body: JSON.stringify({ attachmentIds: [disconnectedAttachmentId] }),
|
| 173 |
+
});
|
| 174 |
+
const tailAfterRepeat = await (await fetch(`${API}/api/agents/${id}/tail?lines=80`)).json();
|
| 175 |
+
const insertedCount = (value) => (value.match(/Screenshot:/g) || []).length;
|
| 176 |
+
check('retry is idempotent if the successful HTTP response was lost',
|
| 177 |
+
repeatedInsert.body.repeated === true
|
| 178 |
+
&& insertedCount(tailAfterRepeat.text) === insertedCount(terminalTail.text));
|
| 179 |
+
|
| 180 |
+
const watcher = await browser.newPage({ viewport: { width: 1000, height: 700 } });
|
| 181 |
+
await watcher.goto(API, { waitUntil: 'domcontentloaded' });
|
| 182 |
+
await watcher.locator('.sidebar .row[title^="screenshot-e2e"]').first().click();
|
| 183 |
+
await watcher.locator('.ph-role', { hasText: 'watching' }).waitFor({ state: 'visible' });
|
| 184 |
+
check('a shared-terminal watcher cannot inject into the controller composer',
|
| 185 |
+
await watcher.locator('.pane-head .ph-image').isDisabled()
|
| 186 |
+
&& (await watcher.locator('.pane-head .ph-image').getAttribute('title'))?.includes('take control'));
|
| 187 |
+
await watcher.close();
|
| 188 |
+
|
| 189 |
+
// Remote support is phase two, but its reason must be readable on touch
|
| 190 |
+
// devices where a disabled button's title can never be discovered.
|
| 191 |
+
await page.locator('.bolt-btn').click();
|
| 192 |
+
await page.locator('.quick-cli[title^="Remote agent"]').click();
|
| 193 |
+
const remoteReason = page.locator('.quick .image-attachments-note');
|
| 194 |
+
check('remote screenshot limitation is visibly explained',
|
| 195 |
+
await remoteReason.isVisible() && (await remoteReason.textContent())?.includes('cannot read files stored on this Space'));
|
| 196 |
+
|
| 197 |
+
// Switch back to the test harness and hold the second upload. Once the first
|
| 198 |
+
// chip says uploaded, every attachment mutation must remain disabled until
|
| 199 |
+
// the single logical send transaction finishes.
|
| 200 |
+
await page.locator('.quick-cli[title="Repaint fixture"]').click();
|
| 201 |
+
await page.locator('.quick-prompt').fill('inspect both screenshots');
|
| 202 |
+
await page.locator('.quick-prompt').evaluate((element, bytes) => {
|
| 203 |
+
const raw = atob(bytes);
|
| 204 |
+
const data = Uint8Array.from(raw, (character) => character.charCodeAt(0));
|
| 205 |
+
const transfer = new DataTransfer();
|
| 206 |
+
transfer.items.add(new File([data], 'first.jpg', { type: 'image/jpg' }));
|
| 207 |
+
element.dispatchEvent(new ClipboardEvent('paste', {
|
| 208 |
+
bubbles: true, cancelable: true, clipboardData: transfer,
|
| 209 |
+
}));
|
| 210 |
+
}, png.toString('base64'));
|
| 211 |
+
check('clipboard image paste creates one deduplicated chip and preserves prompt text',
|
| 212 |
+
await page.locator('.quick .image-chip').count() === 1
|
| 213 |
+
&& await page.locator('.quick-prompt').inputValue() === 'inspect both screenshots');
|
| 214 |
+
await page.locator('.quick .image-file-input').setInputFiles({
|
| 215 |
+
name: 'second.png', mimeType: 'image/png', buffer: png,
|
| 216 |
+
});
|
| 217 |
+
let releaseSecond;
|
| 218 |
+
let sawSecond;
|
| 219 |
+
const secondReached = new Promise((resolve) => { sawSecond = resolve; });
|
| 220 |
+
const secondHold = new Promise((resolve) => { releaseSecond = resolve; });
|
| 221 |
+
let uploadCount = 0;
|
| 222 |
+
await page.route('**/api/sessions/*/attachments', async (route) => {
|
| 223 |
+
uploadCount += 1;
|
| 224 |
+
if (uploadCount === 2) {
|
| 225 |
+
sawSecond();
|
| 226 |
+
await secondHold;
|
| 227 |
+
}
|
| 228 |
+
await route.continue();
|
| 229 |
+
});
|
| 230 |
+
await page.locator('.quick-prompt').press('Enter');
|
| 231 |
+
await Promise.race([
|
| 232 |
+
secondReached,
|
| 233 |
+
sleep(20_000).then(() => { throw new Error('second upload did not start'); }),
|
| 234 |
+
]);
|
| 235 |
+
const removeButtons = page.locator('.quick .image-chip > button');
|
| 236 |
+
check('all attachment mutations stay locked for the full send transaction',
|
| 237 |
+
await page.locator('.quick .image-pick').isDisabled()
|
| 238 |
+
&& await removeButtons.nth(0).isDisabled()
|
| 239 |
+
&& await removeButtons.nth(1).isDisabled());
|
| 240 |
+
releaseSecond();
|
| 241 |
+
await page.locator('.controls').waitFor({ state: 'hidden', timeout: 30_000 });
|
| 242 |
+
} finally {
|
| 243 |
+
try { await browser?.close(); } catch {}
|
| 244 |
+
backend.kill('SIGKILL');
|
| 245 |
+
fs.rmSync(DATA_DIR, { recursive: true, force: true });
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
console.log(failures ? `\n${failures} FAILURE(S)` : '\nall checks passed');
|
| 249 |
+
process.exit(failures ? 1 : 0);
|
server/src/attachments.js
CHANGED
|
@@ -6,6 +6,11 @@ import { pipeline } from 'node:stream/promises';
|
|
| 6 |
import { STATE_DIR } from './config.js';
|
| 7 |
|
| 8 |
export const ATTACHMENT_LIMIT = 25 * 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
export const ATTACHMENT_ID = /^att_[a-f0-9]{24}$/;
|
| 10 |
export const IMAGE_MIMES = Object.freeze([
|
| 11 |
'image/png',
|
|
@@ -22,6 +27,7 @@ const EXTENSIONS = Object.freeze({
|
|
| 22 |
'image/gif': 'gif',
|
| 23 |
});
|
| 24 |
const uploadWindows = new Map();
|
|
|
|
| 25 |
|
| 26 |
function httpError(statusCode, message) {
|
| 27 |
const error = new Error(message);
|
|
@@ -47,6 +53,39 @@ function checkUploadRate(sessionId) {
|
|
| 47 |
uploadWindows.set(sessionId, recent);
|
| 48 |
}
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
export function detectImageMime(bytes) {
|
| 51 |
if (bytes.length >= 8
|
| 52 |
&& bytes[0] === 0x89 && bytes.subarray(1, 4).toString('ascii') === 'PNG'
|
|
@@ -108,57 +147,63 @@ const responseShape = (sessionId, id, mime, bytes, filePath) => ({
|
|
| 108 |
|
| 109 |
/** Stream one browser image into the session-owned attachment store. */
|
| 110 |
export async function receiveImage(readable, sessionId, contentType) {
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
| 113 |
checkUploadRate(sessionId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
-
const dir = sessionDir(sessionId);
|
| 116 |
-
await fs.promises.mkdir(dir, { recursive: true });
|
| 117 |
-
const id = `att_${crypto.randomBytes(12).toString('hex')}`;
|
| 118 |
-
const temporary = path.join(dir, `.${id}.${crypto.randomBytes(4).toString('hex')}.part`);
|
| 119 |
-
let bytes = 0;
|
| 120 |
-
const limiter = new Transform({
|
| 121 |
-
transform(chunk, _encoding, callback) {
|
| 122 |
-
bytes += chunk.length;
|
| 123 |
-
if (bytes > ATTACHMENT_LIMIT) return callback(httpError(413, 'image is larger than 25 MB'));
|
| 124 |
-
callback(null, chunk);
|
| 125 |
-
},
|
| 126 |
-
});
|
| 127 |
-
|
| 128 |
-
try {
|
| 129 |
-
await pipeline(readable, limiter, fs.createWriteStream(temporary, { flags: 'wx' }));
|
| 130 |
-
if (bytes === 0) throw httpError(413, 'image is empty');
|
| 131 |
-
|
| 132 |
-
const handle = await fs.promises.open(temporary, 'r');
|
| 133 |
-
const header = Buffer.alloc(32);
|
| 134 |
-
const tail = Buffer.alloc(Math.min(16, bytes));
|
| 135 |
-
let bytesRead = 0;
|
| 136 |
-
let tailBytesRead = 0;
|
| 137 |
try {
|
| 138 |
-
|
| 139 |
-
(
|
| 140 |
-
} finally {
|
| 141 |
-
await handle.close();
|
| 142 |
-
}
|
| 143 |
-
const headerBytes = header.subarray(0, bytesRead);
|
| 144 |
-
const tailBytes = tail.subarray(0, tailBytesRead);
|
| 145 |
-
const detected = detectImageMime(headerBytes);
|
| 146 |
-
if (!detected || detected !== declared) {
|
| 147 |
-
throw httpError(415, detected
|
| 148 |
-
? `image bytes are ${detected}, not ${declared}`
|
| 149 |
-
: 'file is not a supported raster image');
|
| 150 |
-
}
|
| 151 |
-
if (!imageEnvelopeIsValid(detected, headerBytes, tailBytes, bytes)) {
|
| 152 |
-
throw httpError(415, 'image is truncated or malformed');
|
| 153 |
-
}
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
}
|
| 163 |
|
| 164 |
/** Resolve an untrusted attachment id within exactly one session. */
|
|
@@ -183,17 +228,17 @@ export function resolveImages(sessionId, attachmentIds) {
|
|
| 183 |
return attachmentIds.map((id) => resolveImage(sessionId, id));
|
| 184 |
}
|
| 185 |
|
| 186 |
-
export function removeSessionAttachments(sessionId) {
|
| 187 |
uploadWindows.delete(sessionId);
|
| 188 |
-
fs.
|
| 189 |
}
|
| 190 |
|
| 191 |
/** Remove only old orphan stores; recent crash leftovers keep a seven-day grace. */
|
| 192 |
-
export function pruneAttachmentDirs(sessionIds, now = Date.now()) {
|
| 193 |
const live = new Set(sessionIds);
|
| 194 |
const cutoff = now - 7 * 24 * 60 * 60 * 1000;
|
| 195 |
let entries = [];
|
| 196 |
-
try { entries = fs.
|
| 197 |
for (const entry of entries) {
|
| 198 |
if (!entry.isDirectory()) continue;
|
| 199 |
const dir = path.join(ATTACHMENTS_DIR, entry.name);
|
|
@@ -201,24 +246,33 @@ export function pruneAttachmentDirs(sessionIds, now = Date.now()) {
|
|
| 201 |
// Normal request failures remove these immediately; this is the crash
|
| 202 |
// backstop, with the same grace period as orphan session directories.
|
| 203 |
try {
|
| 204 |
-
for (const name of fs.
|
| 205 |
const part = path.join(dir, name);
|
| 206 |
-
|
| 207 |
-
|
|
|
|
| 208 |
}
|
| 209 |
}
|
| 210 |
} catch {}
|
| 211 |
if (live.has(entry.name)) continue;
|
| 212 |
let newest = 0;
|
| 213 |
try {
|
| 214 |
-
|
|
|
|
|
|
|
| 215 |
} catch { continue; }
|
| 216 |
-
if (newest < cutoff) fs.
|
| 217 |
}
|
| 218 |
}
|
| 219 |
|
| 220 |
const quotePath = (filePath) => JSON.stringify(filePath);
|
| 221 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
/** Keep CLI-version-specific formatting out of request handlers and React. */
|
| 223 |
export function formatAttachmentDelivery(cli, text, images) {
|
| 224 |
const prompt = String(text || '').trim()
|
|
|
|
| 6 |
import { STATE_DIR } from './config.js';
|
| 7 |
|
| 8 |
export const ATTACHMENT_LIMIT = 25 * 1024 * 1024;
|
| 9 |
+
// A single prompt is capped separately at five images. This lifetime cap keeps
|
| 10 |
+
// a forgotten session from growing without bound while still leaving room for
|
| 11 |
+
// many ordinary screenshot turns.
|
| 12 |
+
export const SESSION_ATTACHMENT_LIMIT = 500 * 1024 * 1024;
|
| 13 |
+
export const SESSION_ATTACHMENT_COUNT_LIMIT = 200;
|
| 14 |
export const ATTACHMENT_ID = /^att_[a-f0-9]{24}$/;
|
| 15 |
export const IMAGE_MIMES = Object.freeze([
|
| 16 |
'image/png',
|
|
|
|
| 27 |
'image/gif': 'gif',
|
| 28 |
});
|
| 29 |
const uploadWindows = new Map();
|
| 30 |
+
const uploadLocks = new Map();
|
| 31 |
|
| 32 |
function httpError(statusCode, message) {
|
| 33 |
const error = new Error(message);
|
|
|
|
| 53 |
uploadWindows.set(sessionId, recent);
|
| 54 |
}
|
| 55 |
|
| 56 |
+
// Serialize writes within one session so concurrent uploads cannot each pass a
|
| 57 |
+
// stale quota check. Different sessions still stream in parallel.
|
| 58 |
+
async function withUploadLock(sessionId, task) {
|
| 59 |
+
const previous = uploadLocks.get(sessionId) || Promise.resolve();
|
| 60 |
+
let release;
|
| 61 |
+
const hold = new Promise((resolve) => { release = resolve; });
|
| 62 |
+
const tail = previous.catch(() => {}).then(() => hold);
|
| 63 |
+
uploadLocks.set(sessionId, tail);
|
| 64 |
+
await previous.catch(() => {});
|
| 65 |
+
try {
|
| 66 |
+
return await task();
|
| 67 |
+
} finally {
|
| 68 |
+
release();
|
| 69 |
+
if (uploadLocks.get(sessionId) === tail) uploadLocks.delete(sessionId);
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
async function attachmentUsage(dir) {
|
| 74 |
+
let entries = [];
|
| 75 |
+
try { entries = await fs.promises.readdir(dir); } catch { return { bytes: 0, count: 0 }; }
|
| 76 |
+
let bytes = 0;
|
| 77 |
+
let count = 0;
|
| 78 |
+
for (const name of entries) {
|
| 79 |
+
try {
|
| 80 |
+
const stat = await fs.promises.lstat(path.join(dir, name));
|
| 81 |
+
if (!stat.isFile() || stat.isSymbolicLink()) continue;
|
| 82 |
+
bytes += stat.size;
|
| 83 |
+
if (ATTACHMENT_ID.test(path.parse(name).name)) count += 1;
|
| 84 |
+
} catch {}
|
| 85 |
+
}
|
| 86 |
+
return { bytes, count };
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
export function detectImageMime(bytes) {
|
| 90 |
if (bytes.length >= 8
|
| 91 |
&& bytes[0] === 0x89 && bytes.subarray(1, 4).toString('ascii') === 'PNG'
|
|
|
|
| 147 |
|
| 148 |
/** Stream one browser image into the session-owned attachment store. */
|
| 149 |
export async function receiveImage(readable, sessionId, contentType) {
|
| 150 |
+
// Browser MIME metadata is advisory. Clipboard implementations commonly
|
| 151 |
+
// omit it or use aliases such as image/jpg; the byte signature below is the
|
| 152 |
+
// security boundary and determines the stored extension and response type.
|
| 153 |
+
void contentType;
|
| 154 |
checkUploadRate(sessionId);
|
| 155 |
+
return withUploadLock(sessionId, async () => {
|
| 156 |
+
const dir = sessionDir(sessionId);
|
| 157 |
+
await fs.promises.mkdir(dir, { recursive: true });
|
| 158 |
+
const usage = await attachmentUsage(dir);
|
| 159 |
+
if (usage.count >= SESSION_ATTACHMENT_COUNT_LIMIT) {
|
| 160 |
+
throw httpError(413, `this session already has ${SESSION_ATTACHMENT_COUNT_LIMIT} screenshots`);
|
| 161 |
+
}
|
| 162 |
+
const id = `att_${crypto.randomBytes(12).toString('hex')}`;
|
| 163 |
+
const temporary = path.join(dir, `.${id}.${crypto.randomBytes(4).toString('hex')}.part`);
|
| 164 |
+
let bytes = 0;
|
| 165 |
+
const limiter = new Transform({
|
| 166 |
+
transform(chunk, _encoding, callback) {
|
| 167 |
+
bytes += chunk.length;
|
| 168 |
+
if (bytes > ATTACHMENT_LIMIT) return callback(httpError(413, 'image is larger than 25 MB'));
|
| 169 |
+
if (usage.bytes + bytes > SESSION_ATTACHMENT_LIMIT) {
|
| 170 |
+
return callback(httpError(413, 'this session has reached its 500 MB screenshot limit'));
|
| 171 |
+
}
|
| 172 |
+
callback(null, chunk);
|
| 173 |
+
},
|
| 174 |
+
});
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
try {
|
| 177 |
+
await pipeline(readable, limiter, fs.createWriteStream(temporary, { flags: 'wx' }));
|
| 178 |
+
if (bytes === 0) throw httpError(413, 'image is empty');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
+
const handle = await fs.promises.open(temporary, 'r');
|
| 181 |
+
const header = Buffer.alloc(32);
|
| 182 |
+
const tail = Buffer.alloc(Math.min(16, bytes));
|
| 183 |
+
let bytesRead = 0;
|
| 184 |
+
let tailBytesRead = 0;
|
| 185 |
+
try {
|
| 186 |
+
({ bytesRead } = await handle.read(header, 0, header.length, 0));
|
| 187 |
+
({ bytesRead: tailBytesRead } = await handle.read(tail, 0, tail.length, Math.max(0, bytes - tail.length)));
|
| 188 |
+
} finally {
|
| 189 |
+
await handle.close();
|
| 190 |
+
}
|
| 191 |
+
const headerBytes = header.subarray(0, bytesRead);
|
| 192 |
+
const tailBytes = tail.subarray(0, tailBytesRead);
|
| 193 |
+
const detected = detectImageMime(headerBytes);
|
| 194 |
+
if (!detected) throw httpError(415, 'file is not a supported raster image');
|
| 195 |
+
if (!imageEnvelopeIsValid(detected, headerBytes, tailBytes, bytes)) {
|
| 196 |
+
throw httpError(415, 'image is truncated or malformed');
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
const finalPath = path.join(dir, `${id}.${EXTENSIONS[detected]}`);
|
| 200 |
+
await fs.promises.rename(temporary, finalPath);
|
| 201 |
+
return responseShape(sessionId, id, detected, bytes, finalPath);
|
| 202 |
+
} catch (error) {
|
| 203 |
+
await fs.promises.unlink(temporary).catch(() => {});
|
| 204 |
+
throw error;
|
| 205 |
+
}
|
| 206 |
+
});
|
| 207 |
}
|
| 208 |
|
| 209 |
/** Resolve an untrusted attachment id within exactly one session. */
|
|
|
|
| 228 |
return attachmentIds.map((id) => resolveImage(sessionId, id));
|
| 229 |
}
|
| 230 |
|
| 231 |
+
export async function removeSessionAttachments(sessionId) {
|
| 232 |
uploadWindows.delete(sessionId);
|
| 233 |
+
await withUploadLock(sessionId, () => fs.promises.rm(sessionDir(sessionId), { recursive: true, force: true }));
|
| 234 |
}
|
| 235 |
|
| 236 |
/** Remove only old orphan stores; recent crash leftovers keep a seven-day grace. */
|
| 237 |
+
export async function pruneAttachmentDirs(sessionIds, now = Date.now()) {
|
| 238 |
const live = new Set(sessionIds);
|
| 239 |
const cutoff = now - 7 * 24 * 60 * 60 * 1000;
|
| 240 |
let entries = [];
|
| 241 |
+
try { entries = await fs.promises.readdir(ATTACHMENTS_DIR, { withFileTypes: true }); } catch { return; }
|
| 242 |
for (const entry of entries) {
|
| 243 |
if (!entry.isDirectory()) continue;
|
| 244 |
const dir = path.join(ATTACHMENTS_DIR, entry.name);
|
|
|
|
| 246 |
// Normal request failures remove these immediately; this is the crash
|
| 247 |
// backstop, with the same grace period as orphan session directories.
|
| 248 |
try {
|
| 249 |
+
for (const name of await fs.promises.readdir(dir)) {
|
| 250 |
const part = path.join(dir, name);
|
| 251 |
+
const stat = await fs.promises.lstat(part);
|
| 252 |
+
if (name.endsWith('.part') && stat.isFile() && !stat.isSymbolicLink() && stat.mtimeMs < cutoff) {
|
| 253 |
+
await fs.promises.unlink(part);
|
| 254 |
}
|
| 255 |
}
|
| 256 |
} catch {}
|
| 257 |
if (live.has(entry.name)) continue;
|
| 258 |
let newest = 0;
|
| 259 |
try {
|
| 260 |
+
const names = await fs.promises.readdir(dir);
|
| 261 |
+
const stats = await Promise.all(names.map((name) => fs.promises.lstat(path.join(dir, name))));
|
| 262 |
+
newest = Math.max((await fs.promises.stat(dir)).mtimeMs, ...stats.map((stat) => stat.mtimeMs));
|
| 263 |
} catch { continue; }
|
| 264 |
+
if (newest < cutoff) await fs.promises.rm(dir, { recursive: true, force: true });
|
| 265 |
}
|
| 266 |
}
|
| 267 |
|
| 268 |
const quotePath = (filePath) => JSON.stringify(filePath);
|
| 269 |
|
| 270 |
+
/** TUI commands that attach native image context before the textual prompt. */
|
| 271 |
+
export function formatAttachmentPrelude(cli, images) {
|
| 272 |
+
if (cli !== 'hermes') return [];
|
| 273 |
+
return images.map((image) => `/image ${quotePath(image.path)}`);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
/** Keep CLI-version-specific formatting out of request handlers and React. */
|
| 277 |
export function formatAttachmentDelivery(cli, text, images) {
|
| 278 |
const prompt = String(text || '').trim()
|
server/src/config.js
CHANGED
|
@@ -84,7 +84,9 @@ export const CLIS = [
|
|
| 84 |
withPrompt: (q) => `claude ${q}`,
|
| 85 |
setup: setupHint('ANTHROPIC_API_KEY') },
|
| 86 |
{ id: 'codex', label: 'Codex', bin: 'codex', color: '#5eb6a6', run: 'codex', cont: 'codex resume --last', resizeMode: 'repaint',
|
| 87 |
-
|
|
|
|
|
|
|
| 88 |
setup: setupHint('OPENAI_API_KEY') },
|
| 89 |
{ id: 'gemini', label: 'Gemini CLI', bin: 'gemini', color: '#4796e3', run: 'gemini', cont: null, resizeMode: 'repaint',
|
| 90 |
withPrompt: (q) => `gemini -i ${q}`, // -i = interactive session seeded with the prompt
|
|
|
|
| 84 |
withPrompt: (q) => `claude ${q}`,
|
| 85 |
setup: setupHint('ANTHROPIC_API_KEY') },
|
| 86 |
{ id: 'codex', label: 'Codex', bin: 'codex', color: '#5eb6a6', run: 'codex', cont: 'codex resume --last', resizeMode: 'repaint',
|
| 87 |
+
// `q` and image paths arrive shell-quoted from runner.commandFor(). Repeat
|
| 88 |
+
// -i because Codex's variadic flag would otherwise consume the prompt.
|
| 89 |
+
withPrompt: (q, images = []) => `codex${images.length ? ` ${images.map((image) => `-i ${image}`).join(' ')}` : ''} ${q}`,
|
| 90 |
setup: setupHint('OPENAI_API_KEY') },
|
| 91 |
{ id: 'gemini', label: 'Gemini CLI', bin: 'gemini', color: '#4796e3', run: 'gemini', cont: null, resizeMode: 'repaint',
|
| 92 |
withPrompt: (q) => `gemini -i ${q}`, // -i = interactive session seeded with the prompt
|
server/src/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import * as store from './sessions.js';
|
|
| 16 |
import * as groups from './groups.js';
|
| 17 |
import * as order from './order.js';
|
| 18 |
import * as demo from './demo.js';
|
| 19 |
-
import { attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, isRunning, capturePane, ghosttyReady, ghosttyError, installClaudeRepinHook } from './runner.js';
|
| 20 |
|
| 21 |
// Control frames ride the terminal socket behind a leading NUL pair, which real
|
| 22 |
// PTY output never begins with. Same sentinel the old copy-mode hint used, so the
|
|
@@ -32,14 +32,15 @@ import { shareSession, shareNamespace, findTrace, shareAccess, grantAccess, revo
|
|
| 32 |
importBundle, listBundles, SHAREABLE_CLIS } from './share.js';
|
| 33 |
import * as backup from './backup.js';
|
| 34 |
import {
|
| 35 |
-
formatAttachmentDelivery, pruneAttachmentDirs, receiveImage, removeSessionAttachments,
|
| 36 |
resolveImage, resolveImages,
|
| 37 |
} from './attachments.js';
|
| 38 |
|
| 39 |
ensureDirs();
|
| 40 |
refreshVersions();
|
| 41 |
store.init();
|
| 42 |
-
pruneAttachmentDirs(store.list().map((session) => session.id))
|
|
|
|
| 43 |
groups.init();
|
| 44 |
order.init();
|
| 45 |
demo.init();
|
|
@@ -155,7 +156,14 @@ process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e)
|
|
| 155 |
process.on('uncaughtException', (e) => console.error('[uncaughtException]', e));
|
| 156 |
|
| 157 |
const app = express();
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
// Safety lock: with no authentication, only serve the terminal backend when the
|
| 161 |
// Space is private. If it's public, block every working API (and /ws below) and
|
|
@@ -264,16 +272,24 @@ async function deliver(session, { text, attachments = [] }, from) {
|
|
| 264 |
return false;
|
| 265 |
}
|
| 266 |
const prompt = formatAttachmentDelivery(session.cli, text, attachments);
|
|
|
|
| 267 |
// A session created before its first prompt can still use the CLI's launch
|
| 268 |
// argument. This is especially important for quickstart attachments: upload
|
| 269 |
// needs a session id first, but typing into a half-booted TUI loses turns.
|
| 270 |
const cli = cliById(session.cli);
|
| 271 |
-
if (!session.everStarted && cli?.withPrompt) {
|
| 272 |
-
store.update(session.id, {
|
|
|
|
|
|
|
|
|
|
| 273 |
return ensureRunning(store.get(session.id) || session);
|
| 274 |
}
|
| 275 |
const started = ensureRunning(session);
|
| 276 |
if (started) await sleep(3500); // let the CLI boot before the keystrokes land
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
await sendInput(session.id, prompt);
|
| 278 |
return started;
|
| 279 |
}
|
|
@@ -299,6 +315,10 @@ app.post('/api/sessions/:id/input', async (req, res) => {
|
|
| 299 |
|
| 300 |
const canAttachImages = (session) => session.cli !== 'shell'
|
| 301 |
&& !PASSIVE_CLIS.includes(session.cli) && !isRemote(session.cli);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
|
| 303 |
// Managed screenshots live under STATE_DIR, never in the user's repository.
|
| 304 |
// The raw body is streamed and capped in attachments.js; express.json ignores
|
|
@@ -320,6 +340,39 @@ app.post('/api/sessions/:id/attachments', async (req, res) => {
|
|
| 320 |
}
|
| 321 |
});
|
| 322 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
app.get('/api/sessions/:id/attachments/:attachmentId/raw', (req, res) => {
|
| 324 |
const s = store.get(req.params.id);
|
| 325 |
if (!s) return res.status(404).json({ error: 'not found' });
|
|
@@ -2065,7 +2118,7 @@ app.put('/api/trace/:id/source', (req, res) => {
|
|
| 2065 |
res.json({ ok: true, traceSource: { kind, ref } });
|
| 2066 |
});
|
| 2067 |
|
| 2068 |
-
app.delete('/api/sessions/:id', (req, res) => {
|
| 2069 |
const s = store.get(req.params.id);
|
| 2070 |
if (!s) return res.status(404).json({ error: 'not found' });
|
| 2071 |
stop(s.id);
|
|
@@ -2076,7 +2129,8 @@ app.delete('/api/sessions/:id', (req, res) => {
|
|
| 2076 |
groups.detachSession(s.id);
|
| 2077 |
order.drop(`s:${s.id}`);
|
| 2078 |
store.remove(s.id);
|
| 2079 |
-
|
|
|
|
| 2080 |
res.json({ ok: true });
|
| 2081 |
});
|
| 2082 |
|
|
|
|
| 16 |
import * as groups from './groups.js';
|
| 17 |
import * as order from './order.js';
|
| 18 |
import * as demo from './demo.js';
|
| 19 |
+
import { attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, pasteInput, isRunning, capturePane, ghosttyReady, ghosttyError, installClaudeRepinHook } from './runner.js';
|
| 20 |
|
| 21 |
// Control frames ride the terminal socket behind a leading NUL pair, which real
|
| 22 |
// PTY output never begins with. Same sentinel the old copy-mode hint used, so the
|
|
|
|
| 32 |
importBundle, listBundles, SHAREABLE_CLIS } from './share.js';
|
| 33 |
import * as backup from './backup.js';
|
| 34 |
import {
|
| 35 |
+
formatAttachmentDelivery, formatAttachmentPrelude, pruneAttachmentDirs, receiveImage, removeSessionAttachments,
|
| 36 |
resolveImage, resolveImages,
|
| 37 |
} from './attachments.js';
|
| 38 |
|
| 39 |
ensureDirs();
|
| 40 |
refreshVersions();
|
| 41 |
store.init();
|
| 42 |
+
pruneAttachmentDirs(store.list().map((session) => session.id))
|
| 43 |
+
.catch((e) => console.error('[attachments.prune]', e && e.message));
|
| 44 |
groups.init();
|
| 45 |
order.init();
|
| 46 |
demo.init();
|
|
|
|
| 156 |
process.on('uncaughtException', (e) => console.error('[uncaughtException]', e));
|
| 157 |
|
| 158 |
const app = express();
|
| 159 |
+
const jsonBody = express.json();
|
| 160 |
+
app.use((req, res, next) => {
|
| 161 |
+
// The attachment route determines type from bytes. Skip JSON parsing even
|
| 162 |
+
// when untrusted browser metadata falsely claims application/json, or the
|
| 163 |
+
// global parser would reject valid PNG bytes before the streaming route.
|
| 164 |
+
if (req.method === 'POST' && /^\/api\/sessions\/[^/]+\/attachments$/.test(req.path)) return next();
|
| 165 |
+
return jsonBody(req, res, next);
|
| 166 |
+
});
|
| 167 |
|
| 168 |
// Safety lock: with no authentication, only serve the terminal backend when the
|
| 169 |
// Space is private. If it's public, block every working API (and /ws below) and
|
|
|
|
| 272 |
return false;
|
| 273 |
}
|
| 274 |
const prompt = formatAttachmentDelivery(session.cli, text, attachments);
|
| 275 |
+
const prelude = formatAttachmentPrelude(session.cli, attachments);
|
| 276 |
// A session created before its first prompt can still use the CLI's launch
|
| 277 |
// argument. This is especially important for quickstart attachments: upload
|
| 278 |
// needs a session id first, but typing into a half-booted TUI loses turns.
|
| 279 |
const cli = cliById(session.cli);
|
| 280 |
+
if (!session.everStarted && cli?.withPrompt && prelude.length === 0) {
|
| 281 |
+
store.update(session.id, {
|
| 282 |
+
pendingPrompt: prompt,
|
| 283 |
+
pendingImagePaths: session.cli === 'codex' ? attachments.map((image) => image.path) : undefined,
|
| 284 |
+
});
|
| 285 |
return ensureRunning(store.get(session.id) || session);
|
| 286 |
}
|
| 287 |
const started = ensureRunning(session);
|
| 288 |
if (started) await sleep(3500); // let the CLI boot before the keystrokes land
|
| 289 |
+
for (const command of prelude) {
|
| 290 |
+
await sendInput(session.id, command);
|
| 291 |
+
await sleep(500);
|
| 292 |
+
}
|
| 293 |
await sendInput(session.id, prompt);
|
| 294 |
return started;
|
| 295 |
}
|
|
|
|
| 315 |
|
| 316 |
const canAttachImages = (session) => session.cli !== 'shell'
|
| 317 |
&& !PASSIVE_CLIS.includes(session.cli) && !isRemote(session.cli);
|
| 318 |
+
// A lost HTTP response must not make the Retry action paste the same path a
|
| 319 |
+
// second time. Attachment ids are unique and the terminal UI never intentionally
|
| 320 |
+
// inserts one twice, so this bounded per-session set is a natural idempotency key.
|
| 321 |
+
const terminalAttachmentInsertions = new Map();
|
| 322 |
|
| 323 |
// Managed screenshots live under STATE_DIR, never in the user's repository.
|
| 324 |
// The raw body is streamed and capped in attachments.js; express.json ignores
|
|
|
|
| 340 |
}
|
| 341 |
});
|
| 342 |
|
| 343 |
+
// Insert terminal attachments without pressing Return on the operator's
|
| 344 |
+
// prompt. This server acknowledgement is the source of truth for the terminal
|
| 345 |
+
// overlay; a browser-local xterm paste can be dropped after a disconnect or
|
| 346 |
+
// when another viewer owns the input lease.
|
| 347 |
+
app.post('/api/sessions/:id/attachments/insert', async (req, res) => {
|
| 348 |
+
const s = store.get(req.params.id);
|
| 349 |
+
if (!s) return res.status(404).json({ error: 'not found' });
|
| 350 |
+
if (!canAttachImages(s)) return res.status(400).json({ error: `${s.cli} pane cannot accept screenshots` });
|
| 351 |
+
try {
|
| 352 |
+
const attachments = resolveImages(s.id, (req.body || {}).attachmentIds ?? []);
|
| 353 |
+
if (!attachments.length) return res.status(400).json({ error: 'no screenshots to insert' });
|
| 354 |
+
const inserted = terminalAttachmentInsertions.get(s.id) || new Set();
|
| 355 |
+
const pending = attachments.filter((image) => !inserted.has(image.id));
|
| 356 |
+
const mode = s.cli === 'hermes' ? 'attached' : 'inserted';
|
| 357 |
+
if (!pending.length) return res.json({ ok: true, mode, repeated: true });
|
| 358 |
+
terminalAttachmentInsertions.set(s.id, inserted);
|
| 359 |
+
const prelude = formatAttachmentPrelude(s.cli, pending);
|
| 360 |
+
if (prelude.length) {
|
| 361 |
+
for (let index = 0; index < prelude.length; index += 1) {
|
| 362 |
+
await sendInput(s.id, prelude[index]);
|
| 363 |
+
inserted.add(pending[index].id);
|
| 364 |
+
await sleep(500);
|
| 365 |
+
}
|
| 366 |
+
} else {
|
| 367 |
+
pasteInput(s.id, pending.map((image) => image.insertText).join(''));
|
| 368 |
+
for (const image of pending) inserted.add(image.id);
|
| 369 |
+
}
|
| 370 |
+
return res.json({ ok: true, mode });
|
| 371 |
+
} catch (e) {
|
| 372 |
+
return res.status(e.statusCode || 409).json({ error: String(e.message || e) });
|
| 373 |
+
}
|
| 374 |
+
});
|
| 375 |
+
|
| 376 |
app.get('/api/sessions/:id/attachments/:attachmentId/raw', (req, res) => {
|
| 377 |
const s = store.get(req.params.id);
|
| 378 |
if (!s) return res.status(404).json({ error: 'not found' });
|
|
|
|
| 2118 |
res.json({ ok: true, traceSource: { kind, ref } });
|
| 2119 |
});
|
| 2120 |
|
| 2121 |
+
app.delete('/api/sessions/:id', async (req, res) => {
|
| 2122 |
const s = store.get(req.params.id);
|
| 2123 |
if (!s) return res.status(404).json({ error: 'not found' });
|
| 2124 |
stop(s.id);
|
|
|
|
| 2129 |
groups.detachSession(s.id);
|
| 2130 |
order.drop(`s:${s.id}`);
|
| 2131 |
store.remove(s.id);
|
| 2132 |
+
terminalAttachmentInsertions.delete(s.id);
|
| 2133 |
+
try { await removeSessionAttachments(s.id); } catch (e) { console.error('[attachments.remove]', e && e.message); }
|
| 2134 |
res.json({ ok: true });
|
| 2135 |
});
|
| 2136 |
|
server/src/runner.js
CHANGED
|
@@ -104,6 +104,7 @@ const MAX_COLS = 1000;
|
|
| 104 |
const MAX_ROWS = 500;
|
| 105 |
|
| 106 |
const hosts = new Map(); // session id -> host
|
|
|
|
| 107 |
|
| 108 |
function djb2(s) {
|
| 109 |
let h = 5381;
|
|
@@ -112,7 +113,7 @@ function djb2(s) {
|
|
| 112 |
}
|
| 113 |
|
| 114 |
export function isRunning(id) {
|
| 115 |
-
return hosts.has(id);
|
| 116 |
}
|
| 117 |
|
| 118 |
export function ghosttyReady() {
|
|
@@ -1168,6 +1169,9 @@ export function commandFor(session) {
|
|
| 1168 |
// (claude 'p', codex 'p', gemini -i 'p', opencode --prompt 'p') — the CLI
|
| 1169 |
// starts already working on it, no typing race against a booting TUI.
|
| 1170 |
const q0 = !session.everStarted && session.pendingPrompt ? shq(session.pendingPrompt) : '';
|
|
|
|
|
|
|
|
|
|
| 1171 |
|
| 1172 |
// Claude keys conversations by working directory, so grouped sessions sharing
|
| 1173 |
// a folder would all `--continue` onto the SAME most-recent conversation. Pin
|
|
@@ -1238,7 +1242,7 @@ export function commandFor(session) {
|
|
| 1238 |
const shared = list().some((o) => o.id !== session.id && o.cli === 'opencode' && (o.path ?? o.id) === folder);
|
| 1239 |
const base = session.everStarted && cli.cont && !shared
|
| 1240 |
? `${cli.cont} || exec ${cli.run}`
|
| 1241 |
-
: `exec ${q0 && cli.withPrompt ?
|
| 1242 |
return `${guard}${base}`;
|
| 1243 |
}
|
| 1244 |
|
|
@@ -1256,7 +1260,7 @@ export function commandFor(session) {
|
|
| 1256 |
// the agent is the PTY's foreground process; when it exits the session ends —
|
| 1257 |
// a clear "done" signal — and the fallback preserves that.
|
| 1258 |
if (session.everStarted && cli.cont) return `${cli.cont} || exec ${cli.run}`;
|
| 1259 |
-
if (q0 && cli.withPrompt) return `exec ${
|
| 1260 |
return `exec ${cli.run}`;
|
| 1261 |
}
|
| 1262 |
|
|
@@ -1397,6 +1401,7 @@ export function ensureRunning(session, cols = 120, rows = 34) {
|
|
| 1397 |
|
| 1398 |
term.onExit(() => {
|
| 1399 |
hosts.delete(session.id);
|
|
|
|
| 1400 |
if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
|
| 1401 |
if (host.traceHistoryTimer) { clearTimeout(host.traceHistoryTimer); host.traceHistoryTimer = null; }
|
| 1402 |
if (host.resizeCapture) {
|
|
@@ -1411,8 +1416,11 @@ export function ensureRunning(session, cols = 120, rows = 34) {
|
|
| 1411 |
});
|
| 1412 |
|
| 1413 |
hosts.set(session.id, host);
|
|
|
|
| 1414 |
if (!persistedHistory && captureResize) hydrateTraceHistory(session, host);
|
| 1415 |
-
if (!session.everStarted) update(session.id, {
|
|
|
|
|
|
|
| 1416 |
if (session.cli === 'codex') scheduleCodexCapture(session, workdir);
|
| 1417 |
if (session.cli === 'opencode') scheduleOpencodeCapture(session, workdir);
|
| 1418 |
if (session.cli === 'claude') scheduleClaudeCapture(session, workdir);
|
|
@@ -1496,7 +1504,7 @@ export function attach(session, cols, rows) {
|
|
| 1496 |
/** Type a line into the session's terminal (works with no browser attached). */
|
| 1497 |
export async function sendInput(id, text) {
|
| 1498 |
const host = hosts.get(id);
|
| 1499 |
-
if (!host) throw new Error('session is not running');
|
| 1500 |
// Multi-line prompts go in as a bracketed paste so the CLI's composer treats
|
| 1501 |
// the inner newlines as soft line breaks instead of submitting early.
|
| 1502 |
const payload = text.includes('\n') ? `\x1b[200~${text}\x1b[201~` : text;
|
|
@@ -1508,6 +1516,16 @@ export async function sendInput(id, text) {
|
|
| 1508 |
host.pty.write('\r');
|
| 1509 |
}
|
| 1510 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1511 |
/**
|
| 1512 |
* The session's rendered screen plus `lines` of scrollback above it — what a
|
| 1513 |
* human would see in the pane. Used by the agent API so one agent can watch
|
|
@@ -1534,6 +1552,7 @@ export function capturePane(id, lines = 80) {
|
|
| 1534 |
export function stop(id) {
|
| 1535 |
const host = hosts.get(id);
|
| 1536 |
if (!host) return;
|
|
|
|
| 1537 |
try { host.pty.kill(); } catch {}
|
| 1538 |
}
|
| 1539 |
|
|
@@ -1543,6 +1562,7 @@ export function stop(id) {
|
|
| 1543 |
*/
|
| 1544 |
export function stopAll() {
|
| 1545 |
for (const host of hosts.values()) {
|
|
|
|
| 1546 |
try { host.pty.kill(); } catch {}
|
| 1547 |
}
|
| 1548 |
}
|
|
|
|
| 104 |
const MAX_ROWS = 500;
|
| 105 |
|
| 106 |
const hosts = new Map(); // session id -> host
|
| 107 |
+
const stopping = new Set();
|
| 108 |
|
| 109 |
function djb2(s) {
|
| 110 |
let h = 5381;
|
|
|
|
| 113 |
}
|
| 114 |
|
| 115 |
export function isRunning(id) {
|
| 116 |
+
return hosts.has(id) && !stopping.has(id);
|
| 117 |
}
|
| 118 |
|
| 119 |
export function ghosttyReady() {
|
|
|
|
| 1169 |
// (claude 'p', codex 'p', gemini -i 'p', opencode --prompt 'p') — the CLI
|
| 1170 |
// starts already working on it, no typing race against a booting TUI.
|
| 1171 |
const q0 = !session.everStarted && session.pendingPrompt ? shq(session.pendingPrompt) : '';
|
| 1172 |
+
const q0Images = !session.everStarted && Array.isArray(session.pendingImagePaths)
|
| 1173 |
+
? session.pendingImagePaths.map((image) => shq(String(image))) : [];
|
| 1174 |
+
const firstCommand = () => cli.withPrompt(q0, q0Images);
|
| 1175 |
|
| 1176 |
// Claude keys conversations by working directory, so grouped sessions sharing
|
| 1177 |
// a folder would all `--continue` onto the SAME most-recent conversation. Pin
|
|
|
|
| 1242 |
const shared = list().some((o) => o.id !== session.id && o.cli === 'opencode' && (o.path ?? o.id) === folder);
|
| 1243 |
const base = session.everStarted && cli.cont && !shared
|
| 1244 |
? `${cli.cont} || exec ${cli.run}`
|
| 1245 |
+
: `exec ${q0 && cli.withPrompt ? firstCommand() : cli.run}`;
|
| 1246 |
return `${guard}${base}`;
|
| 1247 |
}
|
| 1248 |
|
|
|
|
| 1260 |
// the agent is the PTY's foreground process; when it exits the session ends —
|
| 1261 |
// a clear "done" signal — and the fallback preserves that.
|
| 1262 |
if (session.everStarted && cli.cont) return `${cli.cont} || exec ${cli.run}`;
|
| 1263 |
+
if (q0 && cli.withPrompt) return `exec ${firstCommand()}`;
|
| 1264 |
return `exec ${cli.run}`;
|
| 1265 |
}
|
| 1266 |
|
|
|
|
| 1401 |
|
| 1402 |
term.onExit(() => {
|
| 1403 |
hosts.delete(session.id);
|
| 1404 |
+
stopping.delete(session.id);
|
| 1405 |
if (host.gridTimer) { clearTimeout(host.gridTimer); host.gridTimer = null; }
|
| 1406 |
if (host.traceHistoryTimer) { clearTimeout(host.traceHistoryTimer); host.traceHistoryTimer = null; }
|
| 1407 |
if (host.resizeCapture) {
|
|
|
|
| 1416 |
});
|
| 1417 |
|
| 1418 |
hosts.set(session.id, host);
|
| 1419 |
+
stopping.delete(session.id);
|
| 1420 |
if (!persistedHistory && captureResize) hydrateTraceHistory(session, host);
|
| 1421 |
+
if (!session.everStarted) update(session.id, {
|
| 1422 |
+
everStarted: true, pendingPrompt: undefined, pendingImagePaths: undefined,
|
| 1423 |
+
});
|
| 1424 |
if (session.cli === 'codex') scheduleCodexCapture(session, workdir);
|
| 1425 |
if (session.cli === 'opencode') scheduleOpencodeCapture(session, workdir);
|
| 1426 |
if (session.cli === 'claude') scheduleClaudeCapture(session, workdir);
|
|
|
|
| 1504 |
/** Type a line into the session's terminal (works with no browser attached). */
|
| 1505 |
export async function sendInput(id, text) {
|
| 1506 |
const host = hosts.get(id);
|
| 1507 |
+
if (!host || stopping.has(id)) throw new Error('session is not running');
|
| 1508 |
// Multi-line prompts go in as a bracketed paste so the CLI's composer treats
|
| 1509 |
// the inner newlines as soft line breaks instead of submitting early.
|
| 1510 |
const payload = text.includes('\n') ? `\x1b[200~${text}\x1b[201~` : text;
|
|
|
|
| 1516 |
host.pty.write('\r');
|
| 1517 |
}
|
| 1518 |
|
| 1519 |
+
/** Insert text into a running terminal's composer without submitting it. */
|
| 1520 |
+
export function pasteInput(id, text) {
|
| 1521 |
+
const host = hosts.get(id);
|
| 1522 |
+
if (!host || stopping.has(id)) throw new Error('session is not running');
|
| 1523 |
+
const value = String(text || '');
|
| 1524 |
+
if (!value) return;
|
| 1525 |
+
const payload = value.includes('\n') ? `\x1b[200~${value}\x1b[201~` : value;
|
| 1526 |
+
host.pty.write(payload);
|
| 1527 |
+
}
|
| 1528 |
+
|
| 1529 |
/**
|
| 1530 |
* The session's rendered screen plus `lines` of scrollback above it — what a
|
| 1531 |
* human would see in the pane. Used by the agent API so one agent can watch
|
|
|
|
| 1552 |
export function stop(id) {
|
| 1553 |
const host = hosts.get(id);
|
| 1554 |
if (!host) return;
|
| 1555 |
+
stopping.add(id);
|
| 1556 |
try { host.pty.kill(); } catch {}
|
| 1557 |
}
|
| 1558 |
|
|
|
|
| 1562 |
*/
|
| 1563 |
export function stopAll() {
|
| 1564 |
for (const host of hosts.values()) {
|
| 1565 |
+
stopping.add(host.id);
|
| 1566 |
try { host.pty.kill(); } catch {}
|
| 1567 |
}
|
| 1568 |
}
|
server/terminal-ui.test.mjs
CHANGED
|
@@ -102,7 +102,13 @@ try {
|
|
| 102 |
}));
|
| 103 |
await sleep(600);
|
| 104 |
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
const context = await browser.newContext({
|
| 107 |
viewport: { width: 1280, height: 800 },
|
| 108 |
permissions: ['clipboard-read', 'clipboard-write'],
|
|
|
|
| 102 |
}));
|
| 103 |
await sleep(600);
|
| 104 |
|
| 105 |
+
const bakedChromium = '/opt/pw-browsers/chromium-1208/chrome-linux64/chrome';
|
| 106 |
+
const chromiumExecutable = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
| 107 |
+
|| (fs.existsSync(bakedChromium) ? bakedChromium : undefined);
|
| 108 |
+
browser = await chromium.launch({
|
| 109 |
+
headless: true,
|
| 110 |
+
...(chromiumExecutable ? { executablePath: chromiumExecutable } : {}),
|
| 111 |
+
});
|
| 112 |
const context = await browser.newContext({
|
| 113 |
viewport: { width: 1280, height: 800 },
|
| 114 |
permissions: ['clipboard-read', 'clipboard-write'],
|
server/test/attachments.test.mjs
CHANGED
|
@@ -8,9 +8,12 @@ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'am-attachments-'));
|
|
| 8 |
process.env.DATA_DIR = root;
|
| 9 |
|
| 10 |
const {
|
| 11 |
-
|
| 12 |
-
|
|
|
|
| 13 |
} = await import('../src/attachments.js');
|
|
|
|
|
|
|
| 14 |
|
| 15 |
const png = Buffer.alloc(45);
|
| 16 |
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png);
|
|
@@ -43,10 +46,10 @@ try {
|
|
| 43 |
assert.throws(() => resolveImage('codex-123abc', '../sessions.json'), /not found/);
|
| 44 |
assert.throws(() => resolveImages('codex-123abc', Array(6).fill(stored.id)), /at most five/);
|
| 45 |
|
| 46 |
-
await
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
);
|
| 50 |
await assert.rejects(
|
| 51 |
receiveImage(Readable.from([png.subarray(0, 24)]), 'codex-123abc', 'image/png'),
|
| 52 |
(error) => error.statusCode === 415 && /malformed/.test(error.message),
|
|
@@ -55,12 +58,66 @@ try {
|
|
| 55 |
receiveImage(Readable.from([]), 'codex-123abc', 'image/png'),
|
| 56 |
(error) => error.statusCode === 413,
|
| 57 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
const formatted = formatAttachmentDelivery('codex', 'Compare this', [stored]);
|
| 60 |
assert.match(formatted, /Compare this/);
|
| 61 |
assert.match(formatted, /Attached screenshots:/);
|
| 62 |
assert.match(formatted, new RegExp(stored.id));
|
| 63 |
assert.match(formatAttachmentDelivery('gemini', '', [stored]), /Please inspect the attached screenshot\./);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
|
| 66 |
const oldPart = path.join(path.dirname(stored.path), '.crashed.part');
|
|
@@ -71,11 +128,11 @@ try {
|
|
| 71 |
fs.writeFileSync(path.join(orphan, 'old'), 'old');
|
| 72 |
fs.utimesSync(path.join(orphan, 'old'), old, old);
|
| 73 |
fs.utimesSync(orphan, old, old);
|
| 74 |
-
pruneAttachmentDirs(['codex-123abc']);
|
| 75 |
assert.equal(fs.existsSync(oldPart), false);
|
| 76 |
assert.equal(fs.existsSync(orphan), false);
|
| 77 |
|
| 78 |
-
removeSessionAttachments('codex-123abc');
|
| 79 |
assert.equal(fs.existsSync(path.dirname(stored.path)), false);
|
| 80 |
console.log('attachment tests passed');
|
| 81 |
} finally {
|
|
|
|
| 8 |
process.env.DATA_DIR = root;
|
| 9 |
|
| 10 |
const {
|
| 11 |
+
ATTACHMENT_LIMIT, SESSION_ATTACHMENT_LIMIT, detectImageMime, formatAttachmentDelivery,
|
| 12 |
+
formatAttachmentPrelude, pruneAttachmentDirs, receiveImage, removeSessionAttachments, resolveImage,
|
| 13 |
+
resolveImages,
|
| 14 |
} = await import('../src/attachments.js');
|
| 15 |
+
const { cliById } = await import('../src/config.js');
|
| 16 |
+
const { commandFor } = await import('../src/runner.js');
|
| 17 |
|
| 18 |
const png = Buffer.alloc(45);
|
| 19 |
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(png);
|
|
|
|
| 46 |
assert.throws(() => resolveImage('codex-123abc', '../sessions.json'), /not found/);
|
| 47 |
assert.throws(() => resolveImages('codex-123abc', Array(6).fill(stored.id)), /at most five/);
|
| 48 |
|
| 49 |
+
const mislabeled = await receiveImage(Readable.from([png]), 'codex-123abc', 'image/jpeg');
|
| 50 |
+
assert.equal(mislabeled.mime, 'image/png');
|
| 51 |
+
const untyped = await receiveImage(Readable.from([png]), 'codex-123abc', '');
|
| 52 |
+
assert.equal(untyped.mime, 'image/png');
|
| 53 |
await assert.rejects(
|
| 54 |
receiveImage(Readable.from([png.subarray(0, 24)]), 'codex-123abc', 'image/png'),
|
| 55 |
(error) => error.statusCode === 415 && /malformed/.test(error.message),
|
|
|
|
| 58 |
receiveImage(Readable.from([]), 'codex-123abc', 'image/png'),
|
| 59 |
(error) => error.statusCode === 413,
|
| 60 |
);
|
| 61 |
+
await assert.rejects(
|
| 62 |
+
receiveImage(Readable.from([Buffer.alloc(ATTACHMENT_LIMIT + 1)]), 'large-123abc', 'image/png'),
|
| 63 |
+
(error) => error.statusCode === 413 && /25 MB/.test(error.message),
|
| 64 |
+
);
|
| 65 |
+
assert.equal(fs.readdirSync(path.join(root, 'state', 'attachments', 'large-123abc')).some((name) => name.endsWith('.part')), false);
|
| 66 |
+
|
| 67 |
+
const aborted = new Readable({
|
| 68 |
+
read() {
|
| 69 |
+
this.push(png.subarray(0, 20));
|
| 70 |
+
this.destroy(new Error('request aborted'));
|
| 71 |
+
},
|
| 72 |
+
});
|
| 73 |
+
await assert.rejects(receiveImage(aborted, 'aborted-123abc', 'image/png'), /request aborted/);
|
| 74 |
+
assert.equal(fs.readdirSync(path.join(root, 'state', 'attachments', 'aborted-123abc')).some((name) => name.endsWith('.part')), false);
|
| 75 |
+
|
| 76 |
+
const quotaDir = path.join(root, 'state', 'attachments', 'quota-123abc');
|
| 77 |
+
fs.mkdirSync(quotaDir, { recursive: true });
|
| 78 |
+
const quotaFile = path.join(quotaDir, 'existing.bin');
|
| 79 |
+
fs.writeFileSync(quotaFile, '');
|
| 80 |
+
fs.truncateSync(quotaFile, SESSION_ATTACHMENT_LIMIT);
|
| 81 |
+
await assert.rejects(
|
| 82 |
+
receiveImage(Readable.from([png]), 'quota-123abc', 'image/png'),
|
| 83 |
+
(error) => error.statusCode === 413 && /500 MB/.test(error.message),
|
| 84 |
+
);
|
| 85 |
+
|
| 86 |
+
const raceQuotaDir = path.join(root, 'state', 'attachments', 'race-quota-123abc');
|
| 87 |
+
fs.mkdirSync(raceQuotaDir, { recursive: true });
|
| 88 |
+
const raceQuotaFile = path.join(raceQuotaDir, 'existing.bin');
|
| 89 |
+
fs.writeFileSync(raceQuotaFile, '');
|
| 90 |
+
fs.truncateSync(raceQuotaFile, SESSION_ATTACHMENT_LIMIT - png.length);
|
| 91 |
+
const raced = await Promise.allSettled([
|
| 92 |
+
receiveImage(Readable.from([png]), 'race-quota-123abc', 'image/png'),
|
| 93 |
+
receiveImage(Readable.from([png]), 'race-quota-123abc', 'image/png'),
|
| 94 |
+
]);
|
| 95 |
+
assert.equal(raced.filter((result) => result.status === 'fulfilled').length, 1);
|
| 96 |
+
assert.equal(raced.filter((result) => result.status === 'rejected'
|
| 97 |
+
&& result.reason.statusCode === 413).length, 1);
|
| 98 |
+
|
| 99 |
+
const concurrent = await Promise.all(Array.from({ length: 4 }, () =>
|
| 100 |
+
receiveImage(Readable.from([png]), 'parallel-123abc', 'application/octet-stream')));
|
| 101 |
+
assert.equal(new Set(concurrent.map((image) => image.id)).size, concurrent.length);
|
| 102 |
|
| 103 |
const formatted = formatAttachmentDelivery('codex', 'Compare this', [stored]);
|
| 104 |
assert.match(formatted, /Compare this/);
|
| 105 |
assert.match(formatted, /Attached screenshots:/);
|
| 106 |
assert.match(formatted, new RegExp(stored.id));
|
| 107 |
assert.match(formatAttachmentDelivery('gemini', '', [stored]), /Please inspect the attached screenshot\./);
|
| 108 |
+
assert.deepEqual(formatAttachmentPrelude('hermes', [stored]), [`/image ${JSON.stringify(stored.path)}`]);
|
| 109 |
+
assert.deepEqual(formatAttachmentPrelude('codex', [stored]), []);
|
| 110 |
+
assert.equal(
|
| 111 |
+
cliById('codex').withPrompt("'compare both'", ["'/tmp/first image.png'", "'/tmp/second.png'"]),
|
| 112 |
+
"codex -i '/tmp/first image.png' -i '/tmp/second.png' 'compare both'",
|
| 113 |
+
);
|
| 114 |
+
assert.equal(
|
| 115 |
+
commandFor({
|
| 116 |
+
id: 'codex-first-image', cli: 'codex', everStarted: false,
|
| 117 |
+
pendingPrompt: 'compare both', pendingImagePaths: ['/tmp/first image.png'],
|
| 118 |
+
}),
|
| 119 |
+
"exec codex -i '/tmp/first image.png' 'compare both'",
|
| 120 |
+
);
|
| 121 |
|
| 122 |
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
|
| 123 |
const oldPart = path.join(path.dirname(stored.path), '.crashed.part');
|
|
|
|
| 128 |
fs.writeFileSync(path.join(orphan, 'old'), 'old');
|
| 129 |
fs.utimesSync(path.join(orphan, 'old'), old, old);
|
| 130 |
fs.utimesSync(orphan, old, old);
|
| 131 |
+
await pruneAttachmentDirs(['codex-123abc']);
|
| 132 |
assert.equal(fs.existsSync(oldPart), false);
|
| 133 |
assert.equal(fs.existsSync(orphan), false);
|
| 134 |
|
| 135 |
+
await removeSessionAttachments('codex-123abc');
|
| 136 |
assert.equal(fs.existsSync(path.dirname(stored.path)), false);
|
| 137 |
console.log('attachment tests passed');
|
| 138 |
} finally {
|
web/src/App.tsx
CHANGED
|
@@ -520,7 +520,10 @@ export default function App() {
|
|
| 520 |
await refresh();
|
| 521 |
setActiveRef(`s:${sessionId}`);
|
| 522 |
} catch (e) {
|
| 523 |
-
|
|
|
|
|
|
|
|
|
|
| 524 |
throw e;
|
| 525 |
}
|
| 526 |
};
|
|
|
|
| 520 |
await refresh();
|
| 521 |
setActiveRef(`s:${sessionId}`);
|
| 522 |
} catch (e) {
|
| 523 |
+
// Quickstart owns a persistent inline recovery state (including the
|
| 524 |
+
// stopped session created before an upload), so a second generic toast
|
| 525 |
+
// obscures the useful error and makes one failure look like two.
|
| 526 |
+
console.error('Couldn’t quickstart the agent', e);
|
| 527 |
throw e;
|
| 528 |
}
|
| 529 |
};
|
web/src/api.ts
CHANGED
|
@@ -190,17 +190,26 @@ export interface ImageAttachment {
|
|
| 190 |
}
|
| 191 |
|
| 192 |
export const uploadImageAttachment = async (id: string, file: File): Promise<ImageAttachment> => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
const response = await fetch(`/api/sessions/${id}/attachments`, {
|
| 194 |
method: 'POST',
|
| 195 |
-
headers
|
| 196 |
-
'content-type': file.type,
|
| 197 |
-
'x-file-name': encodeURIComponent(file.name || 'Screenshot'),
|
| 198 |
-
},
|
| 199 |
body: file,
|
| 200 |
});
|
| 201 |
return jsonOrError(response);
|
| 202 |
};
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
export const sendInput = (id: string, text: string, attachmentIds: string[] = []): Promise<{ ok: boolean; started?: boolean }> =>
|
| 205 |
fetch(`/api/sessions/${id}/input`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ text, attachmentIds }) }).then(jsonOrError);
|
| 206 |
|
|
|
|
| 190 |
}
|
| 191 |
|
| 192 |
export const uploadImageAttachment = async (id: string, file: File): Promise<ImageAttachment> => {
|
| 193 |
+
const headers: Record<string, string> = {
|
| 194 |
+
'x-file-name': encodeURIComponent(file.name || 'Screenshot'),
|
| 195 |
+
};
|
| 196 |
+
if (file.type) headers['content-type'] = file.type;
|
| 197 |
const response = await fetch(`/api/sessions/${id}/attachments`, {
|
| 198 |
method: 'POST',
|
| 199 |
+
headers,
|
|
|
|
|
|
|
|
|
|
| 200 |
body: file,
|
| 201 |
});
|
| 202 |
return jsonOrError(response);
|
| 203 |
};
|
| 204 |
|
| 205 |
+
export const insertImageAttachments = (
|
| 206 |
+
id: string,
|
| 207 |
+
attachmentIds: string[],
|
| 208 |
+
): Promise<{ ok: boolean; mode: 'inserted' | 'attached'; repeated?: boolean }> =>
|
| 209 |
+
fetch(`/api/sessions/${id}/attachments/insert`, {
|
| 210 |
+
method: 'POST', headers: HEADERS, body: JSON.stringify({ attachmentIds }),
|
| 211 |
+
}).then(jsonOrError);
|
| 212 |
+
|
| 213 |
export const sendInput = (id: string, text: string, attachmentIds: string[] = []): Promise<{ ok: boolean; started?: boolean }> =>
|
| 214 |
fetch(`/api/sessions/${id}/input`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ text, attachmentIds }) }).then(jsonOrError);
|
| 215 |
|
web/src/components/ImageAttachments.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useRef } from 'react';
|
| 2 |
import type { PendingImage } from '../lib/imageAttachments';
|
| 3 |
import { IMAGE_ACCEPT } from '../lib/imageAttachments';
|
| 4 |
|
|
@@ -14,14 +14,17 @@ export default function ImageAttachments({ images, disabled, disabledReason, onF
|
|
| 14 |
onRemove: (key: string) => void;
|
| 15 |
}) {
|
| 16 |
const picker = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
| 17 |
return (
|
| 18 |
-
<div className={`image-attachments${images.length ? ' has-images' : ''}`}>
|
| 19 |
<button
|
| 20 |
type="button"
|
| 21 |
className="image-pick"
|
| 22 |
disabled={disabled}
|
| 23 |
title={disabledReason || 'Attach screenshots'}
|
| 24 |
-
aria-label=
|
|
|
|
| 25 |
onClick={() => picker.current?.click()}
|
| 26 |
>
|
| 27 |
<svg viewBox="0 0 18 18" aria-hidden="true">
|
|
@@ -42,17 +45,24 @@ export default function ImageAttachments({ images, disabled, disabledReason, onF
|
|
| 42 |
event.currentTarget.value = '';
|
| 43 |
}}
|
| 44 |
/>
|
|
|
|
| 45 |
{images.map((image) => (
|
| 46 |
<div key={image.key} className={`image-chip ${image.status}`}>
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
<span className="image-chip-copy">
|
| 49 |
<span className="image-chip-name">{image.file.name || 'Screenshot'}</span>
|
| 50 |
-
<span className="image-chip-meta">
|
| 51 |
{image.status === 'uploading' ? 'uploading…'
|
| 52 |
: image.error || (image.status === 'uploaded' ? 'uploaded' : fmtBytes(image.file.size))}
|
| 53 |
</span>
|
| 54 |
</span>
|
| 55 |
-
<button type="button" onClick={() => onRemove(image.key)} disabled={image.status === 'uploading'} aria-label={`Remove ${image.file.name || 'screenshot'}`}>×</button>
|
| 56 |
</div>
|
| 57 |
))}
|
| 58 |
</div>
|
|
|
|
| 1 |
+
import { useId, useRef } from 'react';
|
| 2 |
import type { PendingImage } from '../lib/imageAttachments';
|
| 3 |
import { IMAGE_ACCEPT } from '../lib/imageAttachments';
|
| 4 |
|
|
|
|
| 14 |
onRemove: (key: string) => void;
|
| 15 |
}) {
|
| 16 |
const picker = useRef<HTMLInputElement>(null);
|
| 17 |
+
const reasonId = useId();
|
| 18 |
+
const showReason = !!disabled && !!disabledReason;
|
| 19 |
return (
|
| 20 |
+
<div className={`image-attachments${images.length ? ' has-images' : ''}${showReason ? ' has-note' : ''}`}>
|
| 21 |
<button
|
| 22 |
type="button"
|
| 23 |
className="image-pick"
|
| 24 |
disabled={disabled}
|
| 25 |
title={disabledReason || 'Attach screenshots'}
|
| 26 |
+
aria-label="Attach screenshots"
|
| 27 |
+
aria-describedby={showReason ? reasonId : undefined}
|
| 28 |
onClick={() => picker.current?.click()}
|
| 29 |
>
|
| 30 |
<svg viewBox="0 0 18 18" aria-hidden="true">
|
|
|
|
| 45 |
event.currentTarget.value = '';
|
| 46 |
}}
|
| 47 |
/>
|
| 48 |
+
{showReason && <span id={reasonId} className="image-attachments-note">{disabledReason}</span>}
|
| 49 |
{images.map((image) => (
|
| 50 |
<div key={image.key} className={`image-chip ${image.status}`}>
|
| 51 |
+
{image.error === 'unsupported image type' ? (
|
| 52 |
+
<span className="image-chip-placeholder mono" aria-hidden="true">IMG</span>
|
| 53 |
+
) : (
|
| 54 |
+
<a className="image-chip-preview" href={image.previewUrl} target="_blank" rel="noreferrer" title={`Preview ${image.file.name || 'screenshot'}`}>
|
| 55 |
+
<img src={image.previewUrl} alt="" />
|
| 56 |
+
</a>
|
| 57 |
+
)}
|
| 58 |
<span className="image-chip-copy">
|
| 59 |
<span className="image-chip-name">{image.file.name || 'Screenshot'}</span>
|
| 60 |
+
<span className="image-chip-meta" aria-live="polite">
|
| 61 |
{image.status === 'uploading' ? 'uploading…'
|
| 62 |
: image.error || (image.status === 'uploaded' ? 'uploaded' : fmtBytes(image.file.size))}
|
| 63 |
</span>
|
| 64 |
</span>
|
| 65 |
+
<button type="button" onClick={() => onRemove(image.key)} disabled={disabled || image.status === 'uploading'} aria-label={`Remove ${image.file.name || 'screenshot'}`}>×</button>
|
| 66 |
</div>
|
| 67 |
))}
|
| 68 |
</div>
|
web/src/components/Overview.tsx
CHANGED
|
@@ -60,21 +60,30 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
|
|
| 60 |
useEffect(() => () => revokePendingImages(imagesRef.current), []);
|
| 61 |
|
| 62 |
const addImages = (files: File[]) => {
|
| 63 |
-
if (!allowImages || !files.length) return;
|
| 64 |
-
const next = pendingImagesFromFiles(files,
|
| 65 |
-
|
|
|
|
|
|
|
| 66 |
setImageError(next.error);
|
| 67 |
};
|
| 68 |
const removeImage = (key: string) => {
|
|
|
|
| 69 |
setImages((current) => {
|
| 70 |
const removed = current.find((image) => image.key === key);
|
| 71 |
if (removed) URL.revokeObjectURL(removed.previewUrl);
|
| 72 |
-
|
|
|
|
|
|
|
| 73 |
});
|
| 74 |
setImageError(null);
|
| 75 |
};
|
| 76 |
const updateImage = (key: string, patch: Partial<PendingImage>) => {
|
| 77 |
-
setImages((current) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
};
|
| 79 |
|
| 80 |
// After you send (or when the transcript shows a prompt newer than the last
|
|
@@ -92,15 +101,17 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
|
|
| 92 |
|
| 93 |
const send = async () => {
|
| 94 |
const text = draft.trim();
|
| 95 |
-
|
|
|
|
| 96 |
setSending(true);
|
| 97 |
setFailed(null);
|
| 98 |
try {
|
| 99 |
-
const attachments = await uploadPendingImages(s.id,
|
| 100 |
await api.sendInput(s.id, text, attachments.map((image) => image.id));
|
| 101 |
-
const optimisticText = text || defaultImagePrompt(
|
| 102 |
setDraft('');
|
| 103 |
-
revokePendingImages(
|
|
|
|
| 104 |
setImages([]);
|
| 105 |
setImageError(null);
|
| 106 |
setSent({ text: optimisticText, at: Date.now() });
|
|
@@ -186,11 +197,11 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
|
|
| 186 |
|
| 187 |
<div
|
| 188 |
className={`ov-composer${dropActive ? ' image-drop' : ''}`}
|
| 189 |
-
onDragEnter={(event) => { if (allowImages && transferMayContainImage(event.dataTransfer)) { event.preventDefault(); setDropActive(true); } }}
|
| 190 |
-
onDragOver={(event) => { if (allowImages && transferMayContainImage(event.dataTransfer)) { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; } }}
|
| 191 |
onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropActive(false); }}
|
| 192 |
onDrop={(event) => {
|
| 193 |
-
if (!allowImages || !transferMayContainImage(event.dataTransfer)) return;
|
| 194 |
event.preventDefault(); event.stopPropagation(); setDropActive(false);
|
| 195 |
addImages(imageFilesFromTransfer(event.dataTransfer));
|
| 196 |
}}
|
|
@@ -198,7 +209,7 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
|
|
| 198 |
<ImageAttachments
|
| 199 |
images={images}
|
| 200 |
disabled={sending || !allowImages}
|
| 201 |
-
disabledReason={!allowImages ? 'Screenshots are not available for remote agents yet' : undefined}
|
| 202 |
onFiles={addImages}
|
| 203 |
onRemove={removeImage}
|
| 204 |
/>
|
|
@@ -216,7 +227,7 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
|
|
| 216 |
spellCheck={false}
|
| 217 |
onPaste={(event) => {
|
| 218 |
const files = imageFilesFromTransfer(event.clipboardData);
|
| 219 |
-
if (!allowImages || !files.length) return;
|
| 220 |
event.preventDefault(); addImages(files);
|
| 221 |
}}
|
| 222 |
onChange={(e) => { setDraft(e.target.value); e.currentTarget.style.height = 'auto'; e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`; }}
|
|
@@ -234,7 +245,7 @@ function Card({ s, color, pending, isMobile, onOpen, onClose }: {
|
|
| 234 |
{(draft.trim() || images.length > 0) && !isMobile && <span className="ov-hint">↵ send · ⇧↵ newline</span>}
|
| 235 |
</div>
|
| 236 |
</div>
|
| 237 |
-
{(imageError || failed) && <div className="ov-note">{imageError || failed}</div>}
|
| 238 |
</div>
|
| 239 |
);
|
| 240 |
}
|
|
|
|
| 60 |
useEffect(() => () => revokePendingImages(imagesRef.current), []);
|
| 61 |
|
| 62 |
const addImages = (files: File[]) => {
|
| 63 |
+
if (!allowImages || sending || !files.length) return;
|
| 64 |
+
const next = pendingImagesFromFiles(files, imagesRef.current.length);
|
| 65 |
+
const merged = [...imagesRef.current, ...next.images];
|
| 66 |
+
imagesRef.current = merged;
|
| 67 |
+
setImages(merged);
|
| 68 |
setImageError(next.error);
|
| 69 |
};
|
| 70 |
const removeImage = (key: string) => {
|
| 71 |
+
if (sending) return;
|
| 72 |
setImages((current) => {
|
| 73 |
const removed = current.find((image) => image.key === key);
|
| 74 |
if (removed) URL.revokeObjectURL(removed.previewUrl);
|
| 75 |
+
const next = current.filter((image) => image.key !== key);
|
| 76 |
+
imagesRef.current = next;
|
| 77 |
+
return next;
|
| 78 |
});
|
| 79 |
setImageError(null);
|
| 80 |
};
|
| 81 |
const updateImage = (key: string, patch: Partial<PendingImage>) => {
|
| 82 |
+
setImages((current) => {
|
| 83 |
+
const next = current.map((image) => image.key === key ? { ...image, ...patch } : image);
|
| 84 |
+
imagesRef.current = next;
|
| 85 |
+
return next;
|
| 86 |
+
});
|
| 87 |
};
|
| 88 |
|
| 89 |
// After you send (or when the transcript shows a prompt newer than the last
|
|
|
|
| 101 |
|
| 102 |
const send = async () => {
|
| 103 |
const text = draft.trim();
|
| 104 |
+
const batch = imagesRef.current;
|
| 105 |
+
if ((!text && !batch.length) || sending) return;
|
| 106 |
setSending(true);
|
| 107 |
setFailed(null);
|
| 108 |
try {
|
| 109 |
+
const attachments = await uploadPendingImages(s.id, batch, updateImage);
|
| 110 |
await api.sendInput(s.id, text, attachments.map((image) => image.id));
|
| 111 |
+
const optimisticText = text || defaultImagePrompt(batch.length);
|
| 112 |
setDraft('');
|
| 113 |
+
revokePendingImages(batch);
|
| 114 |
+
imagesRef.current = [];
|
| 115 |
setImages([]);
|
| 116 |
setImageError(null);
|
| 117 |
setSent({ text: optimisticText, at: Date.now() });
|
|
|
|
| 197 |
|
| 198 |
<div
|
| 199 |
className={`ov-composer${dropActive ? ' image-drop' : ''}`}
|
| 200 |
+
onDragEnter={(event) => { if (allowImages && !sending && transferMayContainImage(event.dataTransfer)) { event.preventDefault(); setDropActive(true); } }}
|
| 201 |
+
onDragOver={(event) => { if (allowImages && !sending && transferMayContainImage(event.dataTransfer)) { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; } }}
|
| 202 |
onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropActive(false); }}
|
| 203 |
onDrop={(event) => {
|
| 204 |
+
if (!allowImages || sending || !transferMayContainImage(event.dataTransfer)) return;
|
| 205 |
event.preventDefault(); event.stopPropagation(); setDropActive(false);
|
| 206 |
addImages(imageFilesFromTransfer(event.dataTransfer));
|
| 207 |
}}
|
|
|
|
| 209 |
<ImageAttachments
|
| 210 |
images={images}
|
| 211 |
disabled={sending || !allowImages}
|
| 212 |
+
disabledReason={!allowImages ? 'Screenshots are not available for remote agents yet — that agent cannot read files stored on this Space.' : undefined}
|
| 213 |
onFiles={addImages}
|
| 214 |
onRemove={removeImage}
|
| 215 |
/>
|
|
|
|
| 227 |
spellCheck={false}
|
| 228 |
onPaste={(event) => {
|
| 229 |
const files = imageFilesFromTransfer(event.clipboardData);
|
| 230 |
+
if (!allowImages || sending || !files.length) return;
|
| 231 |
event.preventDefault(); addImages(files);
|
| 232 |
}}
|
| 233 |
onChange={(e) => { setDraft(e.target.value); e.currentTarget.style.height = 'auto'; e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`; }}
|
|
|
|
| 245 |
{(draft.trim() || images.length > 0) && !isMobile && <span className="ov-hint">↵ send · ⇧↵ newline</span>}
|
| 246 |
</div>
|
| 247 |
</div>
|
| 248 |
+
{(imageError || failed) && <div className="ov-note" role="alert">{imageError || failed}</div>}
|
| 249 |
</div>
|
| 250 |
);
|
| 251 |
}
|
web/src/components/Sidebar.tsx
CHANGED
|
@@ -108,18 +108,28 @@ export default function Sidebar({
|
|
| 108 |
useEffect(() => () => revokePendingImages(quickImagesRef.current), []);
|
| 109 |
|
| 110 |
const updateQuickImage = (key: string, patch: Partial<PendingImage>) => {
|
| 111 |
-
setQuickImages((current) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
};
|
| 113 |
const addQuickImages = (files: File[]) => {
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
| 116 |
setQuickError(next.error);
|
| 117 |
};
|
| 118 |
const removeQuickImage = (key: string) => {
|
|
|
|
| 119 |
setQuickImages((current) => {
|
| 120 |
const removed = current.find((image) => image.key === key);
|
| 121 |
if (removed) URL.revokeObjectURL(removed.previewUrl);
|
| 122 |
-
|
|
|
|
|
|
|
| 123 |
});
|
| 124 |
setQuickError(null);
|
| 125 |
};
|
|
@@ -127,9 +137,13 @@ export default function Sidebar({
|
|
| 127 |
// server-created session. Changing its identity starts a fresh target.
|
| 128 |
const resetQuickTarget = () => {
|
| 129 |
setQuickSessionId(null);
|
| 130 |
-
setQuickImages((current) =>
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
};
|
| 134 |
|
| 135 |
const clearDrag = () => { setDragRef(null); setDrop(null); onDragState?.(null); };
|
|
@@ -155,7 +169,8 @@ export default function Sidebar({
|
|
| 155 |
const bump = (id: string, d: number) => setCart((c) => ({ ...c, [id]: Math.max(0, (c[id] || 0) + d) }));
|
| 156 |
const closePanel = () => {
|
| 157 |
if (panel === 'quick') {
|
| 158 |
-
revokePendingImages(
|
|
|
|
| 159 |
setQuickImages([]);
|
| 160 |
setQuickSessionId(null);
|
| 161 |
setQuickSending(false);
|
|
@@ -410,18 +425,18 @@ export default function Sidebar({
|
|
| 410 |
<div
|
| 411 |
className={`widget quick${quickDrop ? ' image-drop' : ''}`}
|
| 412 |
onDragEnter={(event) => {
|
| 413 |
-
if (quickMode === 'agent' && quickCli && !isRemote(quickCli) && transferMayContainImage(event.dataTransfer)) {
|
| 414 |
event.preventDefault(); setQuickDrop(true);
|
| 415 |
}
|
| 416 |
}}
|
| 417 |
onDragOver={(event) => {
|
| 418 |
-
if (quickMode === 'agent' && quickCli && !isRemote(quickCli) && transferMayContainImage(event.dataTransfer)) {
|
| 419 |
event.preventDefault(); event.dataTransfer.dropEffect = 'copy';
|
| 420 |
}
|
| 421 |
}}
|
| 422 |
onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setQuickDrop(false); }}
|
| 423 |
onDrop={(event) => {
|
| 424 |
-
if (quickMode !== 'agent' || !quickCli || isRemote(quickCli) || !transferMayContainImage(event.dataTransfer)) return;
|
| 425 |
event.preventDefault(); event.stopPropagation(); setQuickDrop(false);
|
| 426 |
addQuickImages(imageFilesFromTransfer(event.dataTransfer));
|
| 427 |
}}
|
|
@@ -432,7 +447,7 @@ export default function Sidebar({
|
|
| 432 |
key={c.id}
|
| 433 |
className={`quick-cli${quickMode === 'agent' && quickCli === c.id ? ' on' : ''}${c.available ? '' : ' off'}`}
|
| 434 |
title={c.available ? c.label : `${c.label} (not installed)`}
|
| 435 |
-
disabled={!c.available}
|
| 436 |
style={quickMode === 'agent' && quickCli === c.id ? { borderColor: c.color } : undefined}
|
| 437 |
onClick={() => { setQuickMode('agent'); if (quickCli !== c.id) resetQuickTarget(); setQuickCli(c.id); }}
|
| 438 |
><Logo cli={c.id} size={14} /></button>
|
|
@@ -441,7 +456,8 @@ export default function Sidebar({
|
|
| 441 |
<button
|
| 442 |
className={`quick-cli quick-grp${quickMode === 'group' ? ' on' : ''}`}
|
| 443 |
title="New group"
|
| 444 |
-
|
|
|
|
| 445 |
>
|
| 446 |
<span className="grp-mini">
|
| 447 |
<Logo cli="claude" size={8} />
|
|
@@ -454,10 +470,11 @@ export default function Sidebar({
|
|
| 454 |
<button
|
| 455 |
className={`quick-cli${quickMode === 'agent' && quickCli === 'remote' ? ' on' : ''}`}
|
| 456 |
title="Remote agent — an agent on another machine"
|
|
|
|
| 457 |
style={quickMode === 'agent' && quickCli === 'remote' ? { borderColor: remoteCli.color } : undefined}
|
| 458 |
onClick={() => {
|
| 459 |
setQuickMode('agent'); setQuickCli('remote'); setQuickSessionId(null);
|
| 460 |
-
revokePendingImages(
|
| 461 |
}}
|
| 462 |
><Logo cli="remote" size={14} /></button>
|
| 463 |
)}
|
|
@@ -465,7 +482,12 @@ export default function Sidebar({
|
|
| 465 |
|
| 466 |
{quickMode === 'agent' ? (
|
| 467 |
<>
|
| 468 |
-
{quickError && <div className="open-trace-err">{quickError}</div>}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
<textarea
|
| 470 |
autoFocus
|
| 471 |
rows={1}
|
|
@@ -474,7 +496,7 @@ export default function Sidebar({
|
|
| 474 |
value={quickPrompt}
|
| 475 |
disabled={quickSending}
|
| 476 |
onPaste={(event) => {
|
| 477 |
-
if (!quickCli || isRemote(quickCli)) return;
|
| 478 |
const files = imageFilesFromTransfer(event.clipboardData);
|
| 479 |
if (!files.length) return;
|
| 480 |
event.preventDefault(); addQuickImages(files);
|
|
@@ -488,7 +510,7 @@ export default function Sidebar({
|
|
| 488 |
<ImageAttachments
|
| 489 |
images={quickImages}
|
| 490 |
disabled={quickSending || !quickCli || isRemote(quickCli)}
|
| 491 |
-
disabledReason={quickCli && isRemote(quickCli) ? 'Screenshots are not available for remote agents yet' : undefined}
|
| 492 |
onFiles={addQuickImages}
|
| 493 |
onRemove={removeQuickImage}
|
| 494 |
/>
|
|
|
|
| 108 |
useEffect(() => () => revokePendingImages(quickImagesRef.current), []);
|
| 109 |
|
| 110 |
const updateQuickImage = (key: string, patch: Partial<PendingImage>) => {
|
| 111 |
+
setQuickImages((current) => {
|
| 112 |
+
const next = current.map((image) => image.key === key ? { ...image, ...patch } : image);
|
| 113 |
+
quickImagesRef.current = next;
|
| 114 |
+
return next;
|
| 115 |
+
});
|
| 116 |
};
|
| 117 |
const addQuickImages = (files: File[]) => {
|
| 118 |
+
if (quickSending) return;
|
| 119 |
+
const next = pendingImagesFromFiles(files, quickImagesRef.current.length);
|
| 120 |
+
const merged = [...quickImagesRef.current, ...next.images];
|
| 121 |
+
quickImagesRef.current = merged;
|
| 122 |
+
setQuickImages(merged);
|
| 123 |
setQuickError(next.error);
|
| 124 |
};
|
| 125 |
const removeQuickImage = (key: string) => {
|
| 126 |
+
if (quickSending) return;
|
| 127 |
setQuickImages((current) => {
|
| 128 |
const removed = current.find((image) => image.key === key);
|
| 129 |
if (removed) URL.revokeObjectURL(removed.previewUrl);
|
| 130 |
+
const next = current.filter((image) => image.key !== key);
|
| 131 |
+
quickImagesRef.current = next;
|
| 132 |
+
return next;
|
| 133 |
});
|
| 134 |
setQuickError(null);
|
| 135 |
};
|
|
|
|
| 137 |
// server-created session. Changing its identity starts a fresh target.
|
| 138 |
const resetQuickTarget = () => {
|
| 139 |
setQuickSessionId(null);
|
| 140 |
+
setQuickImages((current) => {
|
| 141 |
+
const next = current.map((image) => image.attachment
|
| 142 |
+
? { ...image, attachment: undefined, status: 'pending' as const, error: undefined }
|
| 143 |
+
: image);
|
| 144 |
+
quickImagesRef.current = next;
|
| 145 |
+
return next;
|
| 146 |
+
});
|
| 147 |
};
|
| 148 |
|
| 149 |
const clearDrag = () => { setDragRef(null); setDrop(null); onDragState?.(null); };
|
|
|
|
| 169 |
const bump = (id: string, d: number) => setCart((c) => ({ ...c, [id]: Math.max(0, (c[id] || 0) + d) }));
|
| 170 |
const closePanel = () => {
|
| 171 |
if (panel === 'quick') {
|
| 172 |
+
revokePendingImages(quickImagesRef.current);
|
| 173 |
+
quickImagesRef.current = [];
|
| 174 |
setQuickImages([]);
|
| 175 |
setQuickSessionId(null);
|
| 176 |
setQuickSending(false);
|
|
|
|
| 425 |
<div
|
| 426 |
className={`widget quick${quickDrop ? ' image-drop' : ''}`}
|
| 427 |
onDragEnter={(event) => {
|
| 428 |
+
if (!quickSending && quickMode === 'agent' && quickCli && !isRemote(quickCli) && transferMayContainImage(event.dataTransfer)) {
|
| 429 |
event.preventDefault(); setQuickDrop(true);
|
| 430 |
}
|
| 431 |
}}
|
| 432 |
onDragOver={(event) => {
|
| 433 |
+
if (!quickSending && quickMode === 'agent' && quickCli && !isRemote(quickCli) && transferMayContainImage(event.dataTransfer)) {
|
| 434 |
event.preventDefault(); event.dataTransfer.dropEffect = 'copy';
|
| 435 |
}
|
| 436 |
}}
|
| 437 |
onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setQuickDrop(false); }}
|
| 438 |
onDrop={(event) => {
|
| 439 |
+
if (quickSending || quickMode !== 'agent' || !quickCli || isRemote(quickCli) || !transferMayContainImage(event.dataTransfer)) return;
|
| 440 |
event.preventDefault(); event.stopPropagation(); setQuickDrop(false);
|
| 441 |
addQuickImages(imageFilesFromTransfer(event.dataTransfer));
|
| 442 |
}}
|
|
|
|
| 447 |
key={c.id}
|
| 448 |
className={`quick-cli${quickMode === 'agent' && quickCli === c.id ? ' on' : ''}${c.available ? '' : ' off'}`}
|
| 449 |
title={c.available ? c.label : `${c.label} (not installed)`}
|
| 450 |
+
disabled={!c.available || quickSending}
|
| 451 |
style={quickMode === 'agent' && quickCli === c.id ? { borderColor: c.color } : undefined}
|
| 452 |
onClick={() => { setQuickMode('agent'); if (quickCli !== c.id) resetQuickTarget(); setQuickCli(c.id); }}
|
| 453 |
><Logo cli={c.id} size={14} /></button>
|
|
|
|
| 456 |
<button
|
| 457 |
className={`quick-cli quick-grp${quickMode === 'group' ? ' on' : ''}`}
|
| 458 |
title="New group"
|
| 459 |
+
disabled={quickSending}
|
| 460 |
+
onClick={() => setQuickMode('group')}
|
| 461 |
>
|
| 462 |
<span className="grp-mini">
|
| 463 |
<Logo cli="claude" size={8} />
|
|
|
|
| 470 |
<button
|
| 471 |
className={`quick-cli${quickMode === 'agent' && quickCli === 'remote' ? ' on' : ''}`}
|
| 472 |
title="Remote agent — an agent on another machine"
|
| 473 |
+
disabled={quickSending}
|
| 474 |
style={quickMode === 'agent' && quickCli === 'remote' ? { borderColor: remoteCli.color } : undefined}
|
| 475 |
onClick={() => {
|
| 476 |
setQuickMode('agent'); setQuickCli('remote'); setQuickSessionId(null);
|
| 477 |
+
revokePendingImages(quickImagesRef.current); quickImagesRef.current = []; setQuickImages([]); setQuickError(null);
|
| 478 |
}}
|
| 479 |
><Logo cli="remote" size={14} /></button>
|
| 480 |
)}
|
|
|
|
| 482 |
|
| 483 |
{quickMode === 'agent' ? (
|
| 484 |
<>
|
| 485 |
+
{quickError && <div className="open-trace-err" role="alert">{quickError}</div>}
|
| 486 |
+
{quickError && quickSessionId && (
|
| 487 |
+
<div className="quick-recovery mono">
|
| 488 |
+
The agent was created. Retry will reuse it; delete it from the agent row if you want to start over.
|
| 489 |
+
</div>
|
| 490 |
+
)}
|
| 491 |
<textarea
|
| 492 |
autoFocus
|
| 493 |
rows={1}
|
|
|
|
| 496 |
value={quickPrompt}
|
| 497 |
disabled={quickSending}
|
| 498 |
onPaste={(event) => {
|
| 499 |
+
if (quickSending || !quickCli || isRemote(quickCli)) return;
|
| 500 |
const files = imageFilesFromTransfer(event.clipboardData);
|
| 501 |
if (!files.length) return;
|
| 502 |
event.preventDefault(); addQuickImages(files);
|
|
|
|
| 510 |
<ImageAttachments
|
| 511 |
images={quickImages}
|
| 512 |
disabled={quickSending || !quickCli || isRemote(quickCli)}
|
| 513 |
+
disabledReason={quickCli && isRemote(quickCli) ? 'Screenshots are not available for remote agents yet — that agent cannot read files stored on this Space.' : undefined}
|
| 514 |
onFiles={addQuickImages}
|
| 515 |
onRemove={removeQuickImage}
|
| 516 |
/>
|
web/src/components/TerminalPane.tsx
CHANGED
|
@@ -9,8 +9,9 @@ import { STATE_LABEL } from '../types';
|
|
| 9 |
import Logo from './Logo';
|
| 10 |
import { CloseGlyph, RefreshGlyph } from './icons';
|
| 11 |
import * as api from '../api';
|
|
|
|
| 12 |
import {
|
| 13 |
-
IMAGE_ACCEPT,
|
| 14 |
transferMayContainImage,
|
| 15 |
} from '../lib/imageAttachments';
|
| 16 |
|
|
@@ -228,7 +229,12 @@ export default function TerminalPane({
|
|
| 228 |
const [pasteOpen, setPasteOpen] = useState(false);
|
| 229 |
const [imageDrop, setImageDrop] = useState(false);
|
| 230 |
const [imageStatus, setImageStatus] = useState<{ kind: 'uploading' | 'success' | 'error'; text: string } | null>(null);
|
|
|
|
|
|
|
| 231 |
const supportsImages = session.cli !== 'shell';
|
|
|
|
|
|
|
|
|
|
| 232 |
const commitName = () => {
|
| 233 |
const v = draft.trim();
|
| 234 |
if (v && v !== session.name) onRename?.(v);
|
|
@@ -251,30 +257,97 @@ export default function TerminalPane({
|
|
| 251 |
if (linger) imageStatusTimerRef.current = window.setTimeout(() => setImageStatus(null), linger);
|
| 252 |
};
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
uploadImagesRef.current = (files: File[]) => {
|
| 255 |
if (!supportsImages) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
if (imageUploadBusyRef.current) return;
|
| 257 |
-
const
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
| 260 |
if (!images.length) { showImageStatus({ kind: 'error', text: 'Use PNG, JPEG, GIF, or WebP' }, 4000); return; }
|
| 261 |
-
const invalid = images.
|
| 262 |
-
if (invalid) { showImageStatus({ kind: 'error', text: invalid
|
| 263 |
imageUploadBusyRef.current = true;
|
|
|
|
| 264 |
void (async () => {
|
|
|
|
| 265 |
try {
|
| 266 |
for (let index = 0; index < images.length; index += 1) {
|
| 267 |
showImageStatus({ kind: 'uploading', text: `uploading screenshot${images.length > 1 ? ` ${index + 1}/${images.length}` : ''}…` });
|
| 268 |
const attachment = await api.uploadImageAttachment(session.id, images[index]);
|
| 269 |
-
|
| 270 |
-
termRef.current?.paste(attachment.insertText);
|
| 271 |
}
|
| 272 |
-
|
| 273 |
-
|
| 274 |
} catch (error) {
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
} finally {
|
| 277 |
imageUploadBusyRef.current = false;
|
|
|
|
| 278 |
}
|
| 279 |
})();
|
| 280 |
};
|
|
@@ -927,7 +1000,13 @@ export default function TerminalPane({
|
|
| 927 |
<>
|
| 928 |
<button
|
| 929 |
className="mini-btn ph-image"
|
| 930 |
-
title=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 931 |
draggable={false}
|
| 932 |
onMouseDown={(event) => event.stopPropagation()}
|
| 933 |
onClick={(event) => { event.stopPropagation(); imagePickerRef.current?.click(); }}
|
|
@@ -944,6 +1023,7 @@ export default function TerminalPane({
|
|
| 944 |
type="file"
|
| 945 |
accept={IMAGE_ACCEPT}
|
| 946 |
multiple
|
|
|
|
| 947 |
onChange={(event) => {
|
| 948 |
uploadImagesRef.current(Array.from(event.currentTarget.files || []));
|
| 949 |
event.currentTarget.value = '';
|
|
@@ -957,7 +1037,18 @@ export default function TerminalPane({
|
|
| 957 |
<div className={`term-host${imageDrop ? ' image-drop' : ''}`} ref={frameRef}>
|
| 958 |
<div className="term-fill" ref={hostRef} />
|
| 959 |
</div>
|
| 960 |
-
{imageStatus &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 961 |
{isMobile && conn === 'connected' && (
|
| 962 |
// Control keys the phone keyboard lacks — needed for TUI menus (model
|
| 963 |
// pickers, etc.). preventDefault keeps terminal focus so the keyboard
|
|
|
|
| 9 |
import Logo from './Logo';
|
| 10 |
import { CloseGlyph, RefreshGlyph } from './icons';
|
| 11 |
import * as api from '../api';
|
| 12 |
+
import type { ImageAttachment } from '../api';
|
| 13 |
import {
|
| 14 |
+
IMAGE_ACCEPT, MAX_IMAGES, imageFileError, imageFilesFromTransfer, looksLikeImageFile,
|
| 15 |
transferMayContainImage,
|
| 16 |
} from '../lib/imageAttachments';
|
| 17 |
|
|
|
|
| 229 |
const [pasteOpen, setPasteOpen] = useState(false);
|
| 230 |
const [imageDrop, setImageDrop] = useState(false);
|
| 231 |
const [imageStatus, setImageStatus] = useState<{ kind: 'uploading' | 'success' | 'error'; text: string } | null>(null);
|
| 232 |
+
const [imageUploadBusy, setImageUploadBusy] = useState(false);
|
| 233 |
+
const [pendingInsert, setPendingInsert] = useState<ImageAttachment[]>([]);
|
| 234 |
const supportsImages = session.cli !== 'shell';
|
| 235 |
+
const hasInputControl = viewers <= 1 || controller;
|
| 236 |
+
const canAttachImages = supportsImages && conn === 'connected' && hasInputControl
|
| 237 |
+
&& !imageUploadBusy && pendingInsert.length === 0;
|
| 238 |
const commitName = () => {
|
| 239 |
const v = draft.trim();
|
| 240 |
if (v && v !== session.name) onRename?.(v);
|
|
|
|
| 257 |
if (linger) imageStatusTimerRef.current = window.setTimeout(() => setImageStatus(null), linger);
|
| 258 |
};
|
| 259 |
|
| 260 |
+
const insertTerminalImages = async (attachments: ImageAttachment[]) => {
|
| 261 |
+
try {
|
| 262 |
+
const result = await api.insertImageAttachments(session.id, attachments.map((image) => image.id));
|
| 263 |
+
setPendingInsert([]);
|
| 264 |
+
const count = attachments.length;
|
| 265 |
+
showImageStatus({
|
| 266 |
+
kind: 'success',
|
| 267 |
+
text: result.mode === 'attached'
|
| 268 |
+
? `${count === 1 ? 'Screenshot' : `${count} screenshots`} attached — continue typing`
|
| 269 |
+
: `${count === 1 ? 'Screenshot' : `${count} screenshots`} inserted — press Enter when ready`,
|
| 270 |
+
}, 3000);
|
| 271 |
+
termRef.current?.focus();
|
| 272 |
+
return true;
|
| 273 |
+
} catch {
|
| 274 |
+
setPendingInsert(attachments);
|
| 275 |
+
showImageStatus({
|
| 276 |
+
kind: 'error',
|
| 277 |
+
text: `${attachments.length === 1 ? 'Screenshot was' : 'Screenshots were'} saved but not inserted`,
|
| 278 |
+
});
|
| 279 |
+
return false;
|
| 280 |
+
}
|
| 281 |
+
};
|
| 282 |
+
|
| 283 |
+
const retryTerminalInsert = async () => {
|
| 284 |
+
if (!pendingInsert.length || imageUploadBusyRef.current) return;
|
| 285 |
+
if (conn !== 'connected') {
|
| 286 |
+
showImageStatus({ kind: 'error', text: 'Restart or reconnect the agent before retrying' });
|
| 287 |
+
return;
|
| 288 |
+
}
|
| 289 |
+
if (!hasInputControl) {
|
| 290 |
+
showImageStatus({ kind: 'error', text: 'Interact with the terminal to take control before retrying' });
|
| 291 |
+
return;
|
| 292 |
+
}
|
| 293 |
+
imageUploadBusyRef.current = true;
|
| 294 |
+
setImageUploadBusy(true);
|
| 295 |
+
showImageStatus({ kind: 'uploading', text: 'inserting saved screenshot…' });
|
| 296 |
+
try { await insertTerminalImages(pendingInsert); } finally {
|
| 297 |
+
imageUploadBusyRef.current = false;
|
| 298 |
+
setImageUploadBusy(false);
|
| 299 |
+
}
|
| 300 |
+
};
|
| 301 |
+
|
| 302 |
uploadImagesRef.current = (files: File[]) => {
|
| 303 |
if (!supportsImages) return;
|
| 304 |
+
if (conn !== 'connected') {
|
| 305 |
+
showImageStatus({ kind: 'error', text: 'Restart or reconnect the agent before attaching screenshots' }, 5000);
|
| 306 |
+
return;
|
| 307 |
+
}
|
| 308 |
+
if (!hasInputControl) {
|
| 309 |
+
showImageStatus({ kind: 'error', text: 'Interact with the terminal to take control before attaching screenshots' }, 5000);
|
| 310 |
+
return;
|
| 311 |
+
}
|
| 312 |
+
if (pendingInsert.length) {
|
| 313 |
+
showImageStatus({ kind: 'error', text: 'Retry the saved screenshot before attaching another' });
|
| 314 |
+
return;
|
| 315 |
+
}
|
| 316 |
if (imageUploadBusyRef.current) return;
|
| 317 |
+
const candidates = files.filter(looksLikeImageFile);
|
| 318 |
+
if (candidates.length > MAX_IMAGES) {
|
| 319 |
+
showImageStatus({ kind: 'error', text: `Attach at most ${MAX_IMAGES} screenshots at a time` }, 4000);
|
| 320 |
+
return;
|
| 321 |
+
}
|
| 322 |
+
const images = candidates.slice(0, MAX_IMAGES);
|
| 323 |
if (!images.length) { showImageStatus({ kind: 'error', text: 'Use PNG, JPEG, GIF, or WebP' }, 4000); return; }
|
| 324 |
+
const invalid = images.map((file) => imageFileError(file)).find(Boolean);
|
| 325 |
+
if (invalid) { showImageStatus({ kind: 'error', text: invalid }, 4000); return; }
|
| 326 |
imageUploadBusyRef.current = true;
|
| 327 |
+
setImageUploadBusy(true);
|
| 328 |
void (async () => {
|
| 329 |
+
const attachments: ImageAttachment[] = [];
|
| 330 |
try {
|
| 331 |
for (let index = 0; index < images.length; index += 1) {
|
| 332 |
showImageStatus({ kind: 'uploading', text: `uploading screenshot${images.length > 1 ? ` ${index + 1}/${images.length}` : ''}…` });
|
| 333 |
const attachment = await api.uploadImageAttachment(session.id, images[index]);
|
| 334 |
+
attachments.push(attachment);
|
|
|
|
| 335 |
}
|
| 336 |
+
showImageStatus({ kind: 'uploading', text: `inserting screenshot${images.length === 1 ? '' : 's'}…` });
|
| 337 |
+
await insertTerminalImages(attachments);
|
| 338 |
} catch (error) {
|
| 339 |
+
if (attachments.length) {
|
| 340 |
+
setPendingInsert(attachments);
|
| 341 |
+
showImageStatus({
|
| 342 |
+
kind: 'error',
|
| 343 |
+
text: `${attachments.length} screenshot${attachments.length === 1 ? '' : 's'} saved; another failed to upload`,
|
| 344 |
+
});
|
| 345 |
+
} else {
|
| 346 |
+
showImageStatus({ kind: 'error', text: error instanceof Error ? error.message : 'screenshot upload failed' }, 5000);
|
| 347 |
+
}
|
| 348 |
} finally {
|
| 349 |
imageUploadBusyRef.current = false;
|
| 350 |
+
setImageUploadBusy(false);
|
| 351 |
}
|
| 352 |
})();
|
| 353 |
};
|
|
|
|
| 1000 |
<>
|
| 1001 |
<button
|
| 1002 |
className="mini-btn ph-image"
|
| 1003 |
+
title={conn === 'connected'
|
| 1004 |
+
? (!hasInputControl
|
| 1005 |
+
? 'Interact with the terminal to take control before attaching screenshots'
|
| 1006 |
+
: (pendingInsert.length ? 'Retry the saved screenshot first' : 'Attach screenshot'))
|
| 1007 |
+
: 'Restart or reconnect the agent to attach screenshots'}
|
| 1008 |
+
aria-label="Attach screenshot"
|
| 1009 |
+
disabled={!canAttachImages}
|
| 1010 |
draggable={false}
|
| 1011 |
onMouseDown={(event) => event.stopPropagation()}
|
| 1012 |
onClick={(event) => { event.stopPropagation(); imagePickerRef.current?.click(); }}
|
|
|
|
| 1023 |
type="file"
|
| 1024 |
accept={IMAGE_ACCEPT}
|
| 1025 |
multiple
|
| 1026 |
+
disabled={!canAttachImages}
|
| 1027 |
onChange={(event) => {
|
| 1028 |
uploadImagesRef.current(Array.from(event.currentTarget.files || []));
|
| 1029 |
event.currentTarget.value = '';
|
|
|
|
| 1037 |
<div className={`term-host${imageDrop ? ' image-drop' : ''}`} ref={frameRef}>
|
| 1038 |
<div className="term-fill" ref={hostRef} />
|
| 1039 |
</div>
|
| 1040 |
+
{imageStatus && (
|
| 1041 |
+
<div
|
| 1042 |
+
className={`term-image-status ${imageStatus.kind}${pendingInsert.length ? ' has-action' : ''} mono`}
|
| 1043 |
+
role={imageStatus.kind === 'error' ? 'alert' : 'status'}
|
| 1044 |
+
aria-live={imageStatus.kind === 'error' ? 'assertive' : 'polite'}
|
| 1045 |
+
>
|
| 1046 |
+
<span>{imageStatus.text}</span>
|
| 1047 |
+
{pendingInsert.length > 0 && (
|
| 1048 |
+
<button type="button" onClick={retryTerminalInsert} disabled={imageUploadBusy || conn !== 'connected' || !hasInputControl}>retry</button>
|
| 1049 |
+
)}
|
| 1050 |
+
</div>
|
| 1051 |
+
)}
|
| 1052 |
{isMobile && conn === 'connected' && (
|
| 1053 |
// Control keys the phone keyboard lacks — needed for TUI menus (model
|
| 1054 |
// pickers, etc.). preventDefault keeps terminal focus so the keyboard
|
web/src/lib/imageAttachments.ts
CHANGED
|
@@ -18,10 +18,37 @@ export interface PendingImage {
|
|
| 18 |
}
|
| 19 |
|
| 20 |
const identity = (file: File) => `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
export function transferMayContainImage(transfer: DataTransfer) {
|
| 23 |
-
return Array.from(transfer.items || []).some((item) => item.kind === 'file'
|
| 24 |
-
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
/** DataTransfer exposes the same file through both items and files in Chromium. */
|
|
@@ -29,7 +56,7 @@ export function imageFilesFromTransfer(transfer: DataTransfer) {
|
|
| 29 |
const files: File[] = [];
|
| 30 |
const seen = new Set<string>();
|
| 31 |
const add = (file: File | null) => {
|
| 32 |
-
if (!file || !
|
| 33 |
const id = identity(file);
|
| 34 |
if (seen.has(id)) return;
|
| 35 |
seen.add(id);
|
|
@@ -45,10 +72,7 @@ export function imageFilesFromTransfer(transfer: DataTransfer) {
|
|
| 45 |
export function pendingImagesFromFiles(files: File[], currentCount = 0) {
|
| 46 |
const remaining = Math.max(0, MAX_IMAGES - currentCount);
|
| 47 |
const accepted = files.slice(0, remaining).map((file): PendingImage => {
|
| 48 |
-
|
| 49 |
-
if (!(IMAGE_MIMES as readonly string[]).includes(file.type)) error = 'unsupported image type';
|
| 50 |
-
else if (file.size > MAX_IMAGE_BYTES) error = 'too large (25 MB max)';
|
| 51 |
-
else if (file.size === 0) error = 'empty image';
|
| 52 |
return {
|
| 53 |
key: globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`,
|
| 54 |
file,
|
|
@@ -82,8 +106,7 @@ export async function uploadPendingImages(
|
|
| 82 |
attachments.push(image.attachment);
|
| 83 |
continue;
|
| 84 |
}
|
| 85 |
-
if (
|
| 86 |
-
|| image.file.size === 0 || image.file.size > MAX_IMAGE_BYTES) {
|
| 87 |
throw new Error(image.error || 'invalid image');
|
| 88 |
}
|
| 89 |
update(image.key, { status: 'uploading', error: undefined });
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
const identity = (file: File) => `${file.name}\u0000${file.type}\u0000${file.size}\u0000${file.lastModified}`;
|
| 21 |
+
const IMAGE_EXTENSION = /\.(png|jpe?g|gif|webp)$/i;
|
| 22 |
+
|
| 23 |
+
export function normalizedImageMime(type: string) {
|
| 24 |
+
const value = String(type || '').split(';', 1)[0].trim().toLowerCase();
|
| 25 |
+
return value === 'image/jpg' || value === 'image/pjpeg' ? 'image/jpeg' : value;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Clipboard and drag sources sometimes omit MIME metadata. Use it as a hint,
|
| 29 |
+
// not as proof; the server validates the actual bytes before storing anything.
|
| 30 |
+
export function looksLikeImageFile(file: Pick<File, 'name' | 'type'>) {
|
| 31 |
+
const type = normalizedImageMime(file.type);
|
| 32 |
+
return !type || type === 'application/octet-stream'
|
| 33 |
+
|| type.startsWith('image/') || IMAGE_EXTENSION.test(file.name || '');
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export function imageFileError(file: Pick<File, 'name' | 'type' | 'size'>) {
|
| 37 |
+
if (file.size > MAX_IMAGE_BYTES) return 'too large (25 MB max)';
|
| 38 |
+
if (file.size === 0) return 'empty image';
|
| 39 |
+
const type = normalizedImageMime(file.type);
|
| 40 |
+
if (type.startsWith('image/') && !(IMAGE_MIMES as readonly string[]).includes(type)) {
|
| 41 |
+
return 'unsupported image type';
|
| 42 |
+
}
|
| 43 |
+
if (type && type !== 'application/octet-stream' && !type.startsWith('image/')
|
| 44 |
+
&& !IMAGE_EXTENSION.test(file.name || '')) return 'unsupported image type';
|
| 45 |
+
return undefined;
|
| 46 |
+
}
|
| 47 |
|
| 48 |
export function transferMayContainImage(transfer: DataTransfer) {
|
| 49 |
+
return Array.from(transfer.items || []).some((item) => item.kind === 'file'
|
| 50 |
+
&& (item.type.startsWith('image/') || looksLikeImageFile(item.getAsFile() || { name: '', type: item.type })))
|
| 51 |
+
|| Array.from(transfer.files || []).some(looksLikeImageFile);
|
| 52 |
}
|
| 53 |
|
| 54 |
/** DataTransfer exposes the same file through both items and files in Chromium. */
|
|
|
|
| 56 |
const files: File[] = [];
|
| 57 |
const seen = new Set<string>();
|
| 58 |
const add = (file: File | null) => {
|
| 59 |
+
if (!file || !looksLikeImageFile(file)) return;
|
| 60 |
const id = identity(file);
|
| 61 |
if (seen.has(id)) return;
|
| 62 |
seen.add(id);
|
|
|
|
| 72 |
export function pendingImagesFromFiles(files: File[], currentCount = 0) {
|
| 73 |
const remaining = Math.max(0, MAX_IMAGES - currentCount);
|
| 74 |
const accepted = files.slice(0, remaining).map((file): PendingImage => {
|
| 75 |
+
const error = imageFileError(file);
|
|
|
|
|
|
|
|
|
|
| 76 |
return {
|
| 77 |
key: globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`,
|
| 78 |
file,
|
|
|
|
| 106 |
attachments.push(image.attachment);
|
| 107 |
continue;
|
| 108 |
}
|
| 109 |
+
if (imageFileError(image.file)) {
|
|
|
|
| 110 |
throw new Error(image.error || 'invalid image');
|
| 111 |
}
|
| 112 |
update(image.key, { status: 'uploading', error: undefined });
|
web/src/styles.css
CHANGED
|
@@ -164,6 +164,7 @@ body {
|
|
| 164 |
.quick-foot .quick-hint { margin-left: auto; white-space: nowrap; }
|
| 165 |
.quick-more { background: none; border: none; padding: 0; font: inherit; font-size: 10.5px; font-weight: 600; color: var(--accent); cursor: pointer; white-space: nowrap; }
|
| 166 |
.quick-more:hover { text-decoration: underline; }
|
|
|
|
| 167 |
.cart-row.off { opacity: 0.45; }
|
| 168 |
|
| 169 |
/* screenshot attachments: one compact operational row shared by composers */
|
|
@@ -173,10 +174,14 @@ body {
|
|
| 173 |
.image-pick:hover:not(:disabled) { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
|
| 174 |
.image-pick:disabled { opacity: 0.38; cursor: not-allowed; }
|
| 175 |
.image-pick svg { width: 15px; height: 15px; }
|
|
|
|
| 176 |
.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); }
|
| 177 |
.image-chip.error { border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); }
|
| 178 |
.image-chip.uploading { border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); }
|
| 179 |
-
.image-chip
|
|
|
|
|
|
|
|
|
|
| 180 |
.image-chip-copy { min-width: 0; display: flex; flex-direction: column; line-height: 1.15; }
|
| 181 |
.image-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10.5px; font-weight: 600; }
|
| 182 |
.image-chip-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; color: var(--muted); font: 9.5px var(--font-mono); }
|
|
@@ -363,7 +368,9 @@ body {
|
|
| 363 |
.ov-composer { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 7px 8px; border-top: 1px solid var(--border); padding-top: 7px; }
|
| 364 |
.ov-composer.image-drop { outline: 1px dashed var(--accent); outline-offset: 5px; background: var(--drop); }
|
| 365 |
.ov-composer .image-attachments.has-images { grid-column: 1 / -1; }
|
| 366 |
-
.ov-composer .image-attachments.has-
|
|
|
|
|
|
|
| 367 |
.ov-composer .ov-live { border-top: none; padding-top: 0; }
|
| 368 |
.ov-live { display: flex; gap: 8px; align-items: center; border-top: 1px solid var(--border); padding-top: 7px; font-size: 13px; }
|
| 369 |
.ov-live .ov-p { color: var(--accent); font-weight: 700; font-size: 13px; }
|
|
@@ -604,6 +611,10 @@ body {
|
|
| 604 |
.term-image-status.uploading { color: var(--accent); animation: breathe 1.2s ease-in-out infinite; }
|
| 605 |
.term-image-status.success { color: var(--go); }
|
| 606 |
.term-image-status.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
/* The grid is measured against this filler, so it must stay unpadded: FitAddon
|
| 608 |
reads the PARENT's computed height and does NOT subtract its padding, so a
|
| 609 |
padded parent made the grid count a row that doesn't fit and the bottom line
|
|
|
|
| 164 |
.quick-foot .quick-hint { margin-left: auto; white-space: nowrap; }
|
| 165 |
.quick-more { background: none; border: none; padding: 0; font: inherit; font-size: 10.5px; font-weight: 600; color: var(--accent); cursor: pointer; white-space: nowrap; }
|
| 166 |
.quick-more:hover { text-decoration: underline; }
|
| 167 |
+
.quick-recovery { margin-top: -3px; color: var(--muted); font-size: 9.5px; line-height: 1.35; }
|
| 168 |
.cart-row.off { opacity: 0.45; }
|
| 169 |
|
| 170 |
/* screenshot attachments: one compact operational row shared by composers */
|
|
|
|
| 174 |
.image-pick:hover:not(:disabled) { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
|
| 175 |
.image-pick:disabled { opacity: 0.38; cursor: not-allowed; }
|
| 176 |
.image-pick svg { width: 15px; height: 15px; }
|
| 177 |
+
.image-attachments-note { min-width: 0; color: var(--muted); font-size: 10px; line-height: 1.3; }
|
| 178 |
.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); }
|
| 179 |
.image-chip.error { border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); }
|
| 180 |
.image-chip.uploading { border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); }
|
| 181 |
+
.image-chip-preview { flex: none; width: 29px; height: 28px; border-radius: 3px; overflow: hidden; background: var(--panel-2); }
|
| 182 |
+
.image-chip-preview:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
| 183 |
+
.image-chip img { display: block; width: 29px; height: 28px; object-fit: cover; }
|
| 184 |
+
.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; }
|
| 185 |
.image-chip-copy { min-width: 0; display: flex; flex-direction: column; line-height: 1.15; }
|
| 186 |
.image-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10.5px; font-weight: 600; }
|
| 187 |
.image-chip-meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; color: var(--muted); font: 9.5px var(--font-mono); }
|
|
|
|
| 368 |
.ov-composer { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 7px 8px; border-top: 1px solid var(--border); padding-top: 7px; }
|
| 369 |
.ov-composer.image-drop { outline: 1px dashed var(--accent); outline-offset: 5px; background: var(--drop); }
|
| 370 |
.ov-composer .image-attachments.has-images { grid-column: 1 / -1; }
|
| 371 |
+
.ov-composer .image-attachments.has-note { grid-column: 1 / -1; }
|
| 372 |
+
.ov-composer .image-attachments.has-images + .ov-live,
|
| 373 |
+
.ov-composer .image-attachments.has-note + .ov-live { grid-column: 1 / -1; }
|
| 374 |
.ov-composer .ov-live { border-top: none; padding-top: 0; }
|
| 375 |
.ov-live { display: flex; gap: 8px; align-items: center; border-top: 1px solid var(--border); padding-top: 7px; font-size: 13px; }
|
| 376 |
.ov-live .ov-p { color: var(--accent); font-weight: 700; font-size: 13px; }
|
|
|
|
| 611 |
.term-image-status.uploading { color: var(--accent); animation: breathe 1.2s ease-in-out infinite; }
|
| 612 |
.term-image-status.success { color: var(--go); }
|
| 613 |
.term-image-status.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
|
| 614 |
+
.term-image-status.has-action { display: flex; align-items: center; gap: 8px; pointer-events: auto; }
|
| 615 |
+
.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; }
|
| 616 |
+
.term-image-status button:disabled { opacity: 0.45; cursor: default; }
|
| 617 |
+
.pane-head .ph-image:disabled { opacity: 0.35; cursor: default; }
|
| 618 |
/* The grid is measured against this filler, so it must stay unpadded: FitAddon
|
| 619 |
reads the PARENT's computed height and does NOT subtract its padding, so a
|
| 620 |
padded parent made the grid count a row that doesn't fit and the bottom line
|