pilotswarm 0.5.16 → 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/formatting.js +4 -1
- package/ui/core/src/history.js +6 -0
- package/ui/core/src/reducer.js +72 -0
- package/ui/core/src/selectors.js +31 -2
- package/ui/react/src/web-app.js +319 -20
- package/web/api/router.js +7 -0
- package/web/dist/assets/index-CxkLM8_a.css +1 -0
- package/web/dist/assets/{index-Df1mD9p0.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/index-whIft7M6.css +0 -1
- package/web/dist/assets/pilotswarm-DR5j-z8y.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(() => {});
|
|
@@ -567,8 +567,11 @@ function splitMarkdownTableCells(line) {
|
|
|
567
567
|
return cells;
|
|
568
568
|
}
|
|
569
569
|
|
|
570
|
+
// GFM delimiter cells are `:?-+:?` — one dash is enough, so `--:` and `-` are
|
|
571
|
+
// as valid as `---`. Requiring three dashes silently demoted whole tables to
|
|
572
|
+
// raw pipe text whenever a model emitted the shorter alignment form.
|
|
570
573
|
function isMarkdownTableSeparatorRow(cells) {
|
|
571
|
-
return cells.length > 0 && cells.every((cell) =>
|
|
574
|
+
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(String(cell || "").replace(/\s+/g, "")));
|
|
572
575
|
}
|
|
573
576
|
|
|
574
577
|
function displayWidth(value) {
|
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
|
@@ -186,10 +186,79 @@ function areStructuredValuesEqual(left, right) {
|
|
|
186
186
|
return true;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
function sessionUpdateTimestampMs(session) {
|
|
190
|
+
const value = session?.updatedAt;
|
|
191
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
192
|
+
if (value instanceof Date) return value.getTime();
|
|
193
|
+
const parsed = Date.parse(value || "");
|
|
194
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// True when `nextSession` carries no newer information than what we already
|
|
198
|
+
// hold. Both timestamps must be present — an update with no `updatedAt` is
|
|
199
|
+
// treated as newer, because we cannot prove otherwise.
|
|
200
|
+
function isSameOrOlderSessionUpdate(previousSession, nextSession) {
|
|
201
|
+
const previousAt = sessionUpdateTimestampMs(previousSession);
|
|
202
|
+
const nextAt = sessionUpdateTimestampMs(nextSession);
|
|
203
|
+
return previousAt > 0 && nextAt > 0 && nextAt <= previousAt;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// A run that has ended for good. These are authoritative and must always land,
|
|
207
|
+
// even from an update that looks stale — stranding a finished session as
|
|
208
|
+
// "running" is far worse than a brief wrong status.
|
|
209
|
+
const TERMINAL_SESSION_STATUSES = new Set(["completed", "failed", "cancelled", "error"]);
|
|
210
|
+
|
|
211
|
+
// Session state is written from several concurrent sources: the session-list
|
|
212
|
+
// poll, live events, and a per-session detail fetch. They do not agree
|
|
213
|
+
// instant-to-instant, and the list poll in particular can report a status the
|
|
214
|
+
// orchestration has already moved on from — observed live as:
|
|
215
|
+
//
|
|
216
|
+
// sessions/loaded running + waiting -> waiting prevAt == incomingAt
|
|
217
|
+
// sessions/merged waiting + running -> running (105ms later)
|
|
218
|
+
//
|
|
219
|
+
// while the server's own session row and orchestration both read "running"
|
|
220
|
+
// throughout. Consumers gated on status === "running" — the live-activity
|
|
221
|
+
// strip, the composer's Stop button — blink off for the width of that gap.
|
|
222
|
+
//
|
|
223
|
+
// Refuse ANY non-terminal downgrade out of "running" unless the incoming
|
|
224
|
+
// update is genuinely newer. Deliberately not restricted to idle-like
|
|
225
|
+
// statuses: the status actually observed clobbering a live run was "waiting",
|
|
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
|
+
// "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.
|
|
237
|
+
function shouldPreserveRunningStatus(previousSession, nextSession) {
|
|
238
|
+
const nextStatus = String(nextSession?.status ?? "").toLowerCase();
|
|
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);
|
|
254
|
+
}
|
|
255
|
+
|
|
189
256
|
function mergeDefinedSessionFields(previousSession = {}, nextSession = {}) {
|
|
190
257
|
let merged = previousSession || {};
|
|
258
|
+
const preserveRunning = shouldPreserveRunningStatus(previousSession, nextSession);
|
|
191
259
|
for (const [key, value] of Object.entries(nextSession || {})) {
|
|
192
260
|
if (value === undefined) continue;
|
|
261
|
+
if (key === "status" && preserveRunning) continue;
|
|
193
262
|
if (key === "pendingQuestion" && isAnsweredPendingQuestion(previousSession, value)) {
|
|
194
263
|
if (merged === previousSession) {
|
|
195
264
|
merged = { ...(previousSession || {}) };
|
|
@@ -492,6 +561,9 @@ function normalizePromptAttachments(prompt, attachments) {
|
|
|
492
561
|
const safePrompt = String(prompt || "");
|
|
493
562
|
const list = Array.isArray(attachments) ? attachments.filter(Boolean) : [];
|
|
494
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;
|
|
495
567
|
const token = String(attachment?.token || "").trim();
|
|
496
568
|
return token && safePrompt.includes(token);
|
|
497
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).
|