pilotswarm 0.5.17 → 0.5.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/dist/src/tools/capabilities.d.ts +3 -0
- package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
- package/mcp/dist/src/tools/capabilities.js +4 -0
- package/mcp/dist/src/tools/capabilities.js.map +1 -1
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
- package/mcp/dist/src/tools/sessions.js +46 -6
- package/mcp/dist/src/tools/sessions.js.map +1 -1
- package/package.json +2 -2
- package/tui/src/app.js +10 -0
- package/tui/src/node-sdk-transport.js +103 -0
- package/ui/core/src/controller.js +205 -4
- package/ui/core/src/history.js +6 -0
- package/ui/core/src/reducer.js +28 -7
- package/ui/core/src/selectors.js +31 -2
- package/ui/react/src/web-app.js +278 -12
- package/web/api/router.js +7 -0
- package/web/dist/assets/{index-CFbG5y06.css → index-CxkLM8_a.css} +1 -1
- package/web/dist/assets/{index-Cae-9EH8.js → index-DdPfXk16.js} +2 -2
- package/web/dist/assets/pilotswarm-DgoJR1qM.js +90 -0
- package/web/dist/index.html +3 -3
- package/web/server.js +5 -0
- package/web/dist/assets/pilotswarm-Drj2bs6t.js +0 -90
|
@@ -765,9 +765,20 @@ function stripPromptAttachmentTokens(prompt, attachments = []) {
|
|
|
765
765
|
.trim();
|
|
766
766
|
}
|
|
767
767
|
|
|
768
|
+
// Raster types + caps for image prompt attachments. Mirrors the SDK's
|
|
769
|
+
// IMAGE_ATTACHMENT_CONTENT_TYPES / ATTACHMENT_MAX_BYTES — the server is the
|
|
770
|
+
// enforcement point; these exist so the composer rejects early with a clear
|
|
771
|
+
// message instead of a failed send.
|
|
772
|
+
export const IMAGE_PROMPT_ATTACHMENT_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
773
|
+
export const IMAGE_PROMPT_ATTACHMENT_MAX_BYTES = 4 * 1024 * 1024;
|
|
774
|
+
export const IMAGE_PROMPT_ATTACHMENT_MAX_COUNT = 4;
|
|
775
|
+
|
|
768
776
|
function expandPromptAttachments(prompt, attachments = []) {
|
|
769
777
|
const validAttachments = Array.isArray(attachments)
|
|
770
|
-
|
|
778
|
+
// Image attachments are model-visible (sent as blob attachments by the
|
|
779
|
+
// worker) — they inject NO text ref. Only legacy artifact references
|
|
780
|
+
// still expand into artifact:// pointers.
|
|
781
|
+
? attachments.filter((attachment) => attachment?.sessionId && attachment?.filename && attachment?.kind !== "image")
|
|
771
782
|
: [];
|
|
772
783
|
if (validAttachments.length === 0) return String(prompt || "");
|
|
773
784
|
|
|
@@ -1324,12 +1335,157 @@ export class PilotSwarmUiController {
|
|
|
1324
1335
|
return await this.cancelOutboxItem(sessionId, target.id);
|
|
1325
1336
|
}
|
|
1326
1337
|
|
|
1327
|
-
queuePromptInOutbox(sessionId, prompt) {
|
|
1338
|
+
queuePromptInOutbox(sessionId, prompt, extras = null) {
|
|
1328
1339
|
const item = this.buildOutboxItem(prompt, "pending");
|
|
1340
|
+
if (Array.isArray(extras?.attachments) && extras.attachments.length > 0) {
|
|
1341
|
+
item.attachments = extras.attachments;
|
|
1342
|
+
}
|
|
1329
1343
|
this.setSessionOutboxItems(sessionId, [...this.getSessionOutbox(sessionId), item]);
|
|
1330
1344
|
return item;
|
|
1331
1345
|
}
|
|
1332
1346
|
|
|
1347
|
+
/**
|
|
1348
|
+
* Stage image files (paste / drop / picker) as pending prompt attachments.
|
|
1349
|
+
* Client-side pre-validation only — the API edge re-validates on send.
|
|
1350
|
+
* Returns { accepted, rejected: [{name, reason}] } for UI feedback.
|
|
1351
|
+
*/
|
|
1352
|
+
addPendingImageFiles(files) {
|
|
1353
|
+
const incoming = Array.from(files || []).filter(Boolean);
|
|
1354
|
+
const current = this.getPromptAttachments();
|
|
1355
|
+
const existingImages = current.filter((a) => a?.kind === "image");
|
|
1356
|
+
const accepted = [];
|
|
1357
|
+
const rejected = [];
|
|
1358
|
+
for (const file of incoming) {
|
|
1359
|
+
const contentType = String(file.type || "").toLowerCase();
|
|
1360
|
+
if (!IMAGE_PROMPT_ATTACHMENT_TYPES.has(contentType)) {
|
|
1361
|
+
rejected.push({ name: file.name || "image", reason: "unsupported type" });
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
if ((Number(file.size) || 0) > IMAGE_PROMPT_ATTACHMENT_MAX_BYTES) {
|
|
1365
|
+
rejected.push({ name: file.name || "image", reason: "over 4 MB" });
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
if (existingImages.length + accepted.length >= IMAGE_PROMPT_ATTACHMENT_MAX_COUNT) {
|
|
1369
|
+
rejected.push({ name: file.name || "image", reason: `max ${IMAGE_PROMPT_ATTACHMENT_MAX_COUNT} images` });
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
accepted.push({
|
|
1373
|
+
kind: "image",
|
|
1374
|
+
file,
|
|
1375
|
+
filename: String(file.name || "image"),
|
|
1376
|
+
contentType,
|
|
1377
|
+
sizeBytes: Number(file.size) || 0,
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
if (accepted.length > 0) {
|
|
1381
|
+
this.setPromptAttachments([...current, ...accepted]);
|
|
1382
|
+
}
|
|
1383
|
+
if (rejected.length > 0) {
|
|
1384
|
+
this.dispatch({
|
|
1385
|
+
type: "ui/status",
|
|
1386
|
+
text: `Skipped ${rejected.map((r) => `${r.name} (${r.reason})`).join(", ")}`,
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
return { accepted: accepted.length, rejected };
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Stage an already-uploaded raster artifact as a prompt image attachment
|
|
1394
|
+
* (uploaded:true entries skip re-upload at send). Returns whether staged.
|
|
1395
|
+
* Shared by the TUI upload modal and clipboard paste.
|
|
1396
|
+
*/
|
|
1397
|
+
stageUploadedImageAttachment(result, sessionId) {
|
|
1398
|
+
const contentType = String(result?.contentType || "").toLowerCase();
|
|
1399
|
+
if (!IMAGE_PROMPT_ATTACHMENT_TYPES.has(contentType)) return false;
|
|
1400
|
+
if ((Number(result?.sizeBytes) || 0) > IMAGE_PROMPT_ATTACHMENT_MAX_BYTES) return false;
|
|
1401
|
+
const stagedImages = this.getPromptAttachments().filter((a) => a?.kind === "image");
|
|
1402
|
+
if (stagedImages.length >= IMAGE_PROMPT_ATTACHMENT_MAX_COUNT) return false;
|
|
1403
|
+
this.setPromptAttachments([
|
|
1404
|
+
...this.getPromptAttachments(),
|
|
1405
|
+
{
|
|
1406
|
+
kind: "image",
|
|
1407
|
+
uploaded: true,
|
|
1408
|
+
sessionId,
|
|
1409
|
+
filename: result.filename,
|
|
1410
|
+
contentType,
|
|
1411
|
+
sizeBytes: Number(result?.sizeBytes) || 0,
|
|
1412
|
+
},
|
|
1413
|
+
]);
|
|
1414
|
+
return true;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* TUI Ctrl+V: read an image off the OS clipboard (the transport shells to
|
|
1419
|
+
* the platform clipboard tool — terminals never deliver image bytes),
|
|
1420
|
+
* upload it as a session artifact, and stage it on the next message.
|
|
1421
|
+
*/
|
|
1422
|
+
async pasteImageFromClipboard() {
|
|
1423
|
+
if (typeof this.transport.captureClipboardImage !== "function"
|
|
1424
|
+
|| typeof this.transport.uploadArtifactFromPath !== "function") {
|
|
1425
|
+
this.dispatch({ type: "ui/status", text: "Clipboard image paste is not supported by this transport" });
|
|
1426
|
+
return false;
|
|
1427
|
+
}
|
|
1428
|
+
let capture = null;
|
|
1429
|
+
try {
|
|
1430
|
+
capture = await this.transport.captureClipboardImage();
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
this.dispatch({ type: "ui/status", text: `Clipboard read failed: ${error?.message || error}` });
|
|
1433
|
+
return false;
|
|
1434
|
+
}
|
|
1435
|
+
if (!capture?.path) {
|
|
1436
|
+
this.dispatch({ type: "ui/status", text: "No image on the clipboard" });
|
|
1437
|
+
return false;
|
|
1438
|
+
}
|
|
1439
|
+
this.dispatch({ type: "ui/status", text: "Attaching clipboard image..." });
|
|
1440
|
+
try {
|
|
1441
|
+
const sessionId = this.getPromptDraftSessionId() || await this.ensurePromptAttachmentSessionId();
|
|
1442
|
+
const upload = await this.transport.uploadArtifactFromPath(sessionId, capture.path);
|
|
1443
|
+
const result = await this.finalizeArtifactUpload(upload, { sessionId, suppressStatus: true });
|
|
1444
|
+
if (this.stageUploadedImageAttachment(result, sessionId)) {
|
|
1445
|
+
this.dispatch({ type: "ui/status", text: `Clipboard image attached to your next message (${result.filename})` });
|
|
1446
|
+
return true;
|
|
1447
|
+
}
|
|
1448
|
+
this.dispatch({ type: "ui/status", text: `Uploaded ${result.filename} (not attachable: type/size/count limit)` });
|
|
1449
|
+
return false;
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
this.dispatch({ type: "ui/status", text: `Clipboard image attach failed: ${error?.message || error}` });
|
|
1452
|
+
return false;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
removePendingImageAttachment(index) {
|
|
1457
|
+
const current = this.getPromptAttachments();
|
|
1458
|
+
const images = current.filter((a) => a?.kind === "image");
|
|
1459
|
+
const target = images[index];
|
|
1460
|
+
if (!target) return;
|
|
1461
|
+
this.setPromptAttachments(current.filter((a) => a !== target));
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Upload staged image attachments as session artifacts and return send
|
|
1466
|
+
* refs. Filenames are derived from the outbox client-message id so a
|
|
1467
|
+
* retry overwrites the same artifact instead of duplicating it.
|
|
1468
|
+
* Entries staged from an already-uploaded artifact (TUI attach-by-path,
|
|
1469
|
+
* marked uploaded:true with no File) skip the upload and ref directly.
|
|
1470
|
+
*/
|
|
1471
|
+
async uploadPendingImageAttachments(sessionId, images, clientMessageId) {
|
|
1472
|
+
const refs = [];
|
|
1473
|
+
const idStem = String(clientMessageId || Date.now()).replace(/[^a-zA-Z0-9_-]/g, "").slice(-24) || "img";
|
|
1474
|
+
let uploadIndex = 0;
|
|
1475
|
+
for (const image of images) {
|
|
1476
|
+
if (!image.file) {
|
|
1477
|
+
if (image.uploaded && image.filename) refs.push({ filename: image.filename });
|
|
1478
|
+
continue;
|
|
1479
|
+
}
|
|
1480
|
+
uploadIndex += 1;
|
|
1481
|
+
const ext = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" }[image.contentType] || "png";
|
|
1482
|
+
const filename = `attach-${idStem}-${uploadIndex}.${ext}`;
|
|
1483
|
+
await this.transport.uploadArtifactFromFile(sessionId, image.file, filename);
|
|
1484
|
+
refs.push({ filename });
|
|
1485
|
+
}
|
|
1486
|
+
return refs;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1333
1489
|
acknowledgeOutboxPrompt(sessionId, promptText, clientMessageId = null) {
|
|
1334
1490
|
if (!sessionId) return false;
|
|
1335
1491
|
const items = this.getSessionOutbox(sessionId);
|
|
@@ -1446,6 +1602,9 @@ export class PilotSwarmUiController {
|
|
|
1446
1602
|
: [item.id]
|
|
1447
1603
|
));
|
|
1448
1604
|
const mergedText = pendingItems.map((item) => String(item.text || "")).join("\n\n");
|
|
1605
|
+
const mergedAttachments = pendingItems.flatMap((item) => (
|
|
1606
|
+
Array.isArray(item.attachments) ? item.attachments : []
|
|
1607
|
+
));
|
|
1449
1608
|
const mergedItem = {
|
|
1450
1609
|
id: pendingItems[0].id,
|
|
1451
1610
|
text: mergedText,
|
|
@@ -1453,6 +1612,7 @@ export class PilotSwarmUiController {
|
|
|
1453
1612
|
phase: "pending",
|
|
1454
1613
|
attempted: true,
|
|
1455
1614
|
clientMessageIds: mergedClientMessageIds,
|
|
1615
|
+
...(mergedAttachments.length > 0 ? { attachments: mergedAttachments } : {}),
|
|
1456
1616
|
};
|
|
1457
1617
|
const pendingIdSet = new Set(pendingItems.map((item) => item.id));
|
|
1458
1618
|
const otherItems = this.getSessionOutbox(sessionId).filter((item) => !pendingIdSet.has(item.id));
|
|
@@ -1469,6 +1629,9 @@ export class PilotSwarmUiController {
|
|
|
1469
1629
|
await this.transport.sendMessage(sessionId, mergedItem.text, {
|
|
1470
1630
|
enqueueOnly: true,
|
|
1471
1631
|
clientMessageIds: mergedItem.clientMessageIds,
|
|
1632
|
+
...(Array.isArray(mergedItem.attachments) && mergedItem.attachments.length > 0
|
|
1633
|
+
? { attachments: mergedItem.attachments }
|
|
1634
|
+
: {}),
|
|
1472
1635
|
});
|
|
1473
1636
|
|
|
1474
1637
|
// Promote pending → queued for the merged item.
|
|
@@ -4294,6 +4457,16 @@ export class PilotSwarmUiController {
|
|
|
4294
4457
|
const upload = await this.transport.uploadArtifactFromPath(sessionId, filePath);
|
|
4295
4458
|
const result = await this.finalizeArtifactUpload(upload, { sessionId, suppressStatus: true });
|
|
4296
4459
|
this.dispatch({ type: "ui/modal", modal: null });
|
|
4460
|
+
// TUI attach-by-path: an uploaded raster image is also staged as a
|
|
4461
|
+
// prompt attachment so the next message shows it to the model —
|
|
4462
|
+
// same send path as the portal's paste/drop chips.
|
|
4463
|
+
if (this.stageUploadedImageAttachment(result, sessionId)) {
|
|
4464
|
+
this.dispatch({
|
|
4465
|
+
type: "ui/status",
|
|
4466
|
+
text: `Uploaded ${result.filename} — attached to your next message`,
|
|
4467
|
+
});
|
|
4468
|
+
return;
|
|
4469
|
+
}
|
|
4297
4470
|
this.dispatch({
|
|
4298
4471
|
type: "ui/status",
|
|
4299
4472
|
text: `Uploaded ${result.filename}`,
|
|
@@ -5208,7 +5381,10 @@ export class PilotSwarmUiController {
|
|
|
5208
5381
|
|
|
5209
5382
|
// Empty Enter on a session with pending outbox items forces an immediate
|
|
5210
5383
|
// dispatch of any pending merge group; this is the "send batch" affordance.
|
|
5211
|
-
|
|
5384
|
+
// Staged image attachments make an empty prompt sendable (a default
|
|
5385
|
+
// caption is filled in below), so they bypass this early return.
|
|
5386
|
+
const hasStagedImages = promptAttachments.some((a) => a?.kind === "image");
|
|
5387
|
+
if (!prompt.trim() && !hasStagedImages) {
|
|
5212
5388
|
if (sessionId && this.getPendingOutboxItems(sessionId).length > 0) {
|
|
5213
5389
|
await this.dispatchPendingOutbox(sessionId).catch(() => {});
|
|
5214
5390
|
}
|
|
@@ -5296,7 +5472,32 @@ export class PilotSwarmUiController {
|
|
|
5296
5472
|
// `controller.sendPrompt()` calls without intervening `await`s) still
|
|
5297
5473
|
// merge because the second call enters before the first dispatcher
|
|
5298
5474
|
// microtask runs.
|
|
5299
|
-
|
|
5475
|
+
//
|
|
5476
|
+
// Image attachments upload FIRST (artifact = source of truth), then the
|
|
5477
|
+
// message references them by filename. A failed upload keeps the prompt
|
|
5478
|
+
// and the staged images — a message never goes out half-attached.
|
|
5479
|
+
const pendingImages = promptAttachments.filter((a) => a?.kind === "image");
|
|
5480
|
+
let attachmentRefs = null;
|
|
5481
|
+
if (pendingImages.length > 0) {
|
|
5482
|
+
const needsUpload = pendingImages.some((a) => a.file);
|
|
5483
|
+
if (needsUpload && typeof this.transport.uploadArtifactFromFile !== "function") {
|
|
5484
|
+
this.dispatch({ type: "ui/status", text: "This transport does not support image attachments." });
|
|
5485
|
+
return;
|
|
5486
|
+
}
|
|
5487
|
+
const uploadItem = this.buildOutboxItem("", "pending");
|
|
5488
|
+
this.dispatch({ type: "ui/status", text: `Uploading ${pendingImages.length} image${pendingImages.length > 1 ? "s" : ""}...` });
|
|
5489
|
+
try {
|
|
5490
|
+
attachmentRefs = await this.uploadPendingImageAttachments(sessionId, pendingImages, uploadItem.id);
|
|
5491
|
+
this.dispatch({ type: "ui/status", text: "" });
|
|
5492
|
+
} catch (error) {
|
|
5493
|
+
this.dispatch({ type: "ui/status", text: `Image upload failed: ${error?.message || error}` });
|
|
5494
|
+
return;
|
|
5495
|
+
}
|
|
5496
|
+
}
|
|
5497
|
+
const outboundPrompt = prompt.trim()
|
|
5498
|
+
? prompt
|
|
5499
|
+
: (attachmentRefs && attachmentRefs.length > 0 ? "See the attached image(s)." : prompt);
|
|
5500
|
+
this.queuePromptInOutbox(sessionId, outboundPrompt, attachmentRefs ? { attachments: attachmentRefs } : null);
|
|
5300
5501
|
this.setPrompt("", 0);
|
|
5301
5502
|
this.setPromptAttachments([]);
|
|
5302
5503
|
await this.dispatchPendingOutbox(sessionId).catch(() => {});
|
package/ui/core/src/history.js
CHANGED
|
@@ -380,6 +380,11 @@ function buildChatMessage(event, role) {
|
|
|
380
380
|
const sender = event?.data?.sender && typeof event.data.sender === "object"
|
|
381
381
|
? event.data.sender
|
|
382
382
|
: null;
|
|
383
|
+
// Image attachment refs recorded by the runTurn activity — rendered as
|
|
384
|
+
// chips under the user message (bytes stay in the artifact store).
|
|
385
|
+
const attachments = Array.isArray(event?.data?.attachments)
|
|
386
|
+
? event.data.attachments.filter((a) => a && typeof a.filename === "string" && a.filename)
|
|
387
|
+
: [];
|
|
383
388
|
return {
|
|
384
389
|
id: `${event.sessionId}:${event.seq}`,
|
|
385
390
|
role: deriveChatRole(event, role, text),
|
|
@@ -388,6 +393,7 @@ function buildChatMessage(event, role) {
|
|
|
388
393
|
createdAt: event.createdAt instanceof Date ? event.createdAt.getTime() : new Date(event.createdAt).getTime(),
|
|
389
394
|
...(clientMessageIds.length > 0 ? { clientMessageIds } : {}),
|
|
390
395
|
...(sender ? { sender } : {}),
|
|
396
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
391
397
|
};
|
|
392
398
|
}
|
|
393
399
|
|
package/ui/core/src/reducer.js
CHANGED
|
@@ -224,15 +224,33 @@ const TERMINAL_SESSION_STATUSES = new Set(["completed", "failed", "cancelled", "
|
|
|
224
224
|
// update is genuinely newer. Deliberately not restricted to idle-like
|
|
225
225
|
// statuses: the status actually observed clobbering a live run was "waiting",
|
|
226
226
|
// and an earlier version of this guard missed the bug by excluding it.
|
|
227
|
-
// Terminal statuses are exempt so a finished run can never be stranded
|
|
228
|
-
//
|
|
229
|
-
//
|
|
227
|
+
// Terminal statuses are exempt so a finished run can never be stranded.
|
|
228
|
+
//
|
|
229
|
+
// "Genuinely newer" is decided by the server's monotonic statusVersion when
|
|
230
|
+
// both sides carry one — wall-clock updatedAt is only the fallback. The
|
|
231
|
+
// timestamp heuristic alone can WEDGE a finished turn as "running" forever:
|
|
232
|
+
// client-side event merges (turn_completed, context usage) inflate the held
|
|
233
|
+
// session's updatedAt past the server row's idle-write timestamp, so every
|
|
234
|
+
// subsequent idle poll compares as stale and only a terminal status could
|
|
235
|
+
// ever land. Observed live (2026-07-21, local portal): server row idle at
|
|
236
|
+
// statusVersion 5 while the client showed "Working.." indefinitely.
|
|
230
237
|
function shouldPreserveRunningStatus(previousSession, nextSession) {
|
|
231
238
|
const nextStatus = String(nextSession?.status ?? "").toLowerCase();
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
239
|
+
if (previousSession?.status !== "running"
|
|
240
|
+
|| nextStatus === "running"
|
|
241
|
+
|| TERMINAL_SESSION_STATUSES.has(nextStatus)) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
const previousVersion = Number(previousSession?.statusVersion);
|
|
245
|
+
const nextVersion = Number(nextSession?.statusVersion);
|
|
246
|
+
if (Number.isFinite(previousVersion) && previousVersion > 0
|
|
247
|
+
&& Number.isFinite(nextVersion) && nextVersion > 0) {
|
|
248
|
+
// Monotonic server counter: a higher version is authoritative and
|
|
249
|
+
// must land; a same-or-lower version is provably stale and is held
|
|
250
|
+
// regardless of what the timestamps claim.
|
|
251
|
+
return nextVersion <= previousVersion;
|
|
252
|
+
}
|
|
253
|
+
return isSameOrOlderSessionUpdate(previousSession, nextSession);
|
|
236
254
|
}
|
|
237
255
|
|
|
238
256
|
function mergeDefinedSessionFields(previousSession = {}, nextSession = {}) {
|
|
@@ -543,6 +561,9 @@ function normalizePromptAttachments(prompt, attachments) {
|
|
|
543
561
|
const safePrompt = String(prompt || "");
|
|
544
562
|
const list = Array.isArray(attachments) ? attachments.filter(Boolean) : [];
|
|
545
563
|
return list.filter((attachment) => {
|
|
564
|
+
// Image attachments are chip-managed (staged File objects, no inline
|
|
565
|
+
// token in the prompt) — they live until sent or explicitly removed.
|
|
566
|
+
if (attachment?.kind === "image") return true;
|
|
546
567
|
const token = String(attachment?.token || "").trim();
|
|
547
568
|
return token && safePrompt.includes(token);
|
|
548
569
|
});
|
package/ui/core/src/selectors.js
CHANGED
|
@@ -2017,13 +2017,16 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
|
|
|
2017
2017
|
: message?.role === "user" ? USER_CHAT_COLOR : null,
|
|
2018
2018
|
);
|
|
2019
2019
|
const prefix = options.skipPrefix ? [] : buildChatMessagePrefix(message, options);
|
|
2020
|
+
const attachmentChipLines = buildAttachmentChipLines(message, options);
|
|
2020
2021
|
|
|
2021
2022
|
if (tintedMarkdownLines.length === 0) {
|
|
2022
|
-
|
|
2023
|
+
const bare = prefix.length > 0 ? [prefix] : [];
|
|
2024
|
+
return attachmentChipLines.length > 0 ? [...bare, ...attachmentChipLines] : bare;
|
|
2023
2025
|
}
|
|
2024
2026
|
|
|
2025
2027
|
if (startsWithStructuredBlock(tintedMarkdownLines)) {
|
|
2026
|
-
|
|
2028
|
+
const structured = prefix.length > 0 ? [prefix, ...tintedMarkdownLines] : tintedMarkdownLines;
|
|
2029
|
+
return attachmentChipLines.length > 0 ? [...structured, ...attachmentChipLines] : structured;
|
|
2027
2030
|
}
|
|
2028
2031
|
|
|
2029
2032
|
const renderedLines = [];
|
|
@@ -2044,9 +2047,35 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
|
|
|
2044
2047
|
renderedLines.push(lineRuns);
|
|
2045
2048
|
}
|
|
2046
2049
|
}
|
|
2050
|
+
renderedLines.push(...attachmentChipLines);
|
|
2047
2051
|
return renderedLines;
|
|
2048
2052
|
}
|
|
2049
2053
|
|
|
2054
|
+
// Image attachment chips under a message. In sentinel mode (browser portal)
|
|
2055
|
+
// a single structured line carries the refs so the web renderer can show
|
|
2056
|
+
// authenticated thumbnails; everywhere else (TUI) each file renders as a
|
|
2057
|
+
// `[image: name · 312 KB]` text chip.
|
|
2058
|
+
function buildAttachmentChipLines(message, options = {}) {
|
|
2059
|
+
const attachments = Array.isArray(message?.attachments) ? message.attachments : [];
|
|
2060
|
+
if (attachments.length === 0) return [];
|
|
2061
|
+
if (options.tableMode === "sentinel") {
|
|
2062
|
+
// message.id is `${sessionId}:${seq}` — the renderer needs the session
|
|
2063
|
+
// to fetch artifact bytes through the authenticated transport.
|
|
2064
|
+
const sessionId = String(message?.id || "").split(":")[0] || null;
|
|
2065
|
+
return [{ kind: "imageAttachments", sessionId, attachments }];
|
|
2066
|
+
}
|
|
2067
|
+
return attachments.map((attachment) => {
|
|
2068
|
+
const size = Number(attachment?.sizeBytes) || 0;
|
|
2069
|
+
const sizeLabel = size >= 1024 * 1024
|
|
2070
|
+
? `${(size / (1024 * 1024)).toFixed(1)} MB`
|
|
2071
|
+
: size >= 1024 ? `${Math.round(size / 1024)} KB` : `${size} B`;
|
|
2072
|
+
return [
|
|
2073
|
+
{ text: " ", color: null },
|
|
2074
|
+
{ text: `[image: ${attachment.filename}${size > 0 ? ` · ${sizeLabel}` : ""}]`, color: "cyan" },
|
|
2075
|
+
];
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2050
2079
|
// Activity-log event types that are not surfaced as live "recent action" lines
|
|
2051
2080
|
// in the chat pane: bookkeeping, high-frequency streaming noise, and the turn
|
|
2052
2081
|
// bracket markers (which don't read well as prose).
|