fraim-hub 2.0.315 → 2.0.316

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.
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.renameWithRetry = renameWithRetry;
7
7
  exports.writeJsonAtomic = writeJsonAtomic;
8
+ exports.writeFileAtomic = writeFileAtomic;
8
9
  const fs_1 = __importDefault(require("fs"));
9
10
  const path_1 = __importDefault(require("path"));
10
11
  // Atomic JSON file write for the conversation store and its derived caches.
@@ -47,9 +48,23 @@ function renameWithRetry(from, to) {
47
48
  * writing the same destination cannot collide on the temp file itself.
48
49
  */
49
50
  function writeJsonAtomic(filePath, value) {
51
+ writeFileAtomic(filePath, JSON.stringify(value), 'utf8');
52
+ }
53
+ /**
54
+ * Write-temp-then-rename for arbitrary content (binary or text) — the same durability guarantee
55
+ * as `writeJsonAtomic` without the JSON serialization step. Issue #1651: attachment blobs need the
56
+ * identical atomic-write + Windows-retry discipline as conversation JSON, so this is the shared
57
+ * primitive both now go through rather than a second copy of the temp-write+rename dance.
58
+ */
59
+ function writeFileAtomic(filePath, data, encoding) {
50
60
  fs_1.default.mkdirSync(path_1.default.dirname(filePath), { recursive: true });
51
61
  const tempPath = `${filePath}.${process.pid}.${tempSeq++}.tmp`;
52
- fs_1.default.writeFileSync(tempPath, JSON.stringify(value), 'utf8');
62
+ if (typeof data === 'string') {
63
+ fs_1.default.writeFileSync(tempPath, data, encoding || 'utf8');
64
+ }
65
+ else {
66
+ fs_1.default.writeFileSync(tempPath, data);
67
+ }
53
68
  try {
54
69
  renameWithRetry(tempPath, filePath);
55
70
  }
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.AttachmentRejectedError = exports.MAX_ATTACHMENTS_PER_SEND = exports.MAX_UPLOAD_BODY_BYTES = exports.MAX_ATTACHMENT_BYTES = exports.ACCEPTED_ATTACHMENT_MIME_TYPES = void 0;
7
+ exports.assertAttachmentUploadAllowed = assertAttachmentUploadAllowed;
8
+ exports.sanitizeAttachmentBuffer = sanitizeAttachmentBuffer;
9
+ exports.safeAttachmentBaseName = safeAttachmentBaseName;
10
+ exports.attachmentDiskFilename = attachmentDiskFilename;
11
+ exports.safeAttachmentMetaPath = safeAttachmentMetaPath;
12
+ /**
13
+ * Hub message attachments — Issue #1651 (Hub attachments and screenshots).
14
+ *
15
+ * Deterministic, dependency-isolated helpers for validating and sanitizing a
16
+ * manager-uploaded attachment before it ever reaches disk. Kept separate from
17
+ * `server.ts` so the route handler stays a thin composition of these pure
18
+ * steps (architecture-standards §2/§3).
19
+ *
20
+ * Security posture (spec R10/R11, technical design "Sanitization"): an
21
+ * uploaded file is untrusted input. Images are re-encoded (not merely
22
+ * renamed/copied) via `sharp`, which strips any non-pixel payload — embedded
23
+ * scripts, polyglot content appended after the image's real end-of-data
24
+ * marker — as an inherent side effect of decoding + re-encoding pixel data.
25
+ * PDFs are scanned for `/JavaScript` / `/JS` object markers and rejected if
26
+ * found; this is a narrow heuristic, not a full sanitizer (documented as a
27
+ * known limitation in the technical design, not a claimed complete defense).
28
+ * Plain text/log files are stored unmodified — this delivery model (R12)
29
+ * never renders or executes attachment content, only hands the agent a file
30
+ * path its own file-read tool opens.
31
+ */
32
+ const path_1 = __importDefault(require("path"));
33
+ const sharp_1 = __importDefault(require("sharp"));
34
+ /** Accepted upload MIME types (spec R1/R11). */
35
+ exports.ACCEPTED_ATTACHMENT_MIME_TYPES = new Set([
36
+ 'image/png',
37
+ 'image/jpeg',
38
+ 'image/gif',
39
+ 'application/pdf',
40
+ 'text/plain',
41
+ ]);
42
+ const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif']);
43
+ /** Per-file cap (spec R5/R11): matches the client-side pre-check exactly. */
44
+ exports.MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
45
+ /**
46
+ * Server route body-size cap (technical design "Server-side enforcement"): 1 MB
47
+ * headroom above `MAX_ATTACHMENT_BYTES` absorbs transfer overhead. The 10 MB
48
+ * per-file cap itself is still enforced explicitly against the decoded body
49
+ * length so a file just under this outer limit but over the real cap is a
50
+ * clean 413, not a silent accept.
51
+ */
52
+ exports.MAX_UPLOAD_BODY_BYTES = 11 * 1024 * 1024;
53
+ /** Design-owned bound (technical design "Count/size bounds not specified by the spec"). */
54
+ exports.MAX_ATTACHMENTS_PER_SEND = 5;
55
+ /** Thrown by validation/sanitization for a request-level (never a server-fault) rejection. */
56
+ class AttachmentRejectedError extends Error {
57
+ constructor(status, message) {
58
+ super(message);
59
+ this.name = 'AttachmentRejectedError';
60
+ this.status = status;
61
+ }
62
+ }
63
+ exports.AttachmentRejectedError = AttachmentRejectedError;
64
+ /** R11: server-side size/type enforcement, independent of the client-side R5 check. */
65
+ function assertAttachmentUploadAllowed(mimeType, sizeBytes) {
66
+ if (!exports.ACCEPTED_ATTACHMENT_MIME_TYPES.has(mimeType)) {
67
+ throw new AttachmentRejectedError(415, `Unsupported attachment type: ${mimeType || '(none)'}`);
68
+ }
69
+ if (sizeBytes > exports.MAX_ATTACHMENT_BYTES) {
70
+ throw new AttachmentRejectedError(413, `Attachment exceeds the ${exports.MAX_ATTACHMENT_BYTES / (1024 * 1024)} MB limit.`);
71
+ }
72
+ }
73
+ /**
74
+ * PDF `/JavaScript` or `/JS` object-marker scan (technical design "Sanitization").
75
+ * Deliberately narrow: this is a reject-on-suspicious-marker gate, not a full
76
+ * PDF parser or sanitizer — sufficient because R1 requires only safe storage
77
+ * and a later file-path handoff to the agent (R12), never PDF rendering.
78
+ */
79
+ function pdfContainsScriptMarker(buffer) {
80
+ // Scan the raw bytes as latin1 so multi-byte content can't hide a marker;
81
+ // PDF object names are always ASCII (`/JavaScript`, `/JS`).
82
+ const text = buffer.toString('latin1');
83
+ return /\/JavaScript\b/.test(text) || /\/JS\b/.test(text);
84
+ }
85
+ /**
86
+ * Re-encode an image (stripping any non-pixel payload) or reject a suspicious
87
+ * PDF. Text/plain passes through unmodified. Throws `AttachmentRejectedError`
88
+ * for a PDF script marker or a buffer `sharp` cannot decode as a valid image.
89
+ */
90
+ async function sanitizeAttachmentBuffer(buffer, mimeType) {
91
+ if (IMAGE_MIME_TYPES.has(mimeType)) {
92
+ try {
93
+ const format = mimeType === 'image/jpeg' ? 'jpeg' : mimeType === 'image/gif' ? 'gif' : 'png';
94
+ return await (0, sharp_1.default)(buffer).toFormat(format).toBuffer();
95
+ }
96
+ catch (err) {
97
+ throw new AttachmentRejectedError(415, `Not a valid ${mimeType} image.`);
98
+ }
99
+ }
100
+ if (mimeType === 'application/pdf') {
101
+ if (pdfContainsScriptMarker(buffer)) {
102
+ throw new AttachmentRejectedError(415, 'PDF contains an embedded script marker and was rejected.');
103
+ }
104
+ return buffer;
105
+ }
106
+ // text/plain (the only remaining accepted type): no execution surface in
107
+ // this delivery model, stored as-is.
108
+ return buffer;
109
+ }
110
+ /** Strip path separators/control characters and cap length for a disk-safe filename. */
111
+ function safeAttachmentBaseName(filename) {
112
+ const trimmed = typeof filename === 'string' ? filename.trim() : '';
113
+ // eslint-disable-next-line no-control-regex
114
+ const cleaned = trimmed.replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').slice(0, 150);
115
+ return cleaned || 'file';
116
+ }
117
+ /** Disk filename for an attachment: `<id>-<safeName>` (technical design "Storage"). */
118
+ function attachmentDiskFilename(id, filename) {
119
+ return `${id}-${safeAttachmentBaseName(filename)}`;
120
+ }
121
+ /**
122
+ * Resolve `<dir>/<id>.meta.json`, rejecting an `id` that would escape `dir`.
123
+ *
124
+ * Security: `id` reaches this function from a URL route param (`GET .../attachments/:attachmentId`)
125
+ * or a client-supplied `attachmentIds[]` entry (message send) with no format constraint of its own —
126
+ * `attachmentsDirFor` only validates `conversationId`, not this per-attachment id. Without this
127
+ * check, an id like `../../../etc/passwd` would let a caller read (or, combined with a write path,
128
+ * write) outside the intended attachments directory via ordinary `path.join`/`path.resolve` `..`
129
+ * handling.
130
+ */
131
+ function safeAttachmentMetaPath(dir, id) {
132
+ const metaPath = path_1.default.resolve(dir, `${id}.meta.json`);
133
+ if (!metaPath.startsWith(path_1.default.resolve(dir) + path_1.default.sep)) {
134
+ throw new Error('Invalid attachment id.');
135
+ }
136
+ return metaPath;
137
+ }
@@ -440,6 +440,34 @@ class AiHubConversationStore {
440
440
  lockPath(bucketDir) {
441
441
  return path_1.default.join(bucketDir, '.lock');
442
442
  }
443
+ /**
444
+ * Directory holding attachment blobs for one conversation, nested inside that
445
+ * conversation's own bucket directory (issue #1651).
446
+ *
447
+ * This is the only sanctioned entry point into bucket-path resolution for
448
+ * attachments — every other module reaches attachment storage through this
449
+ * method rather than re-deriving the bucket key, so `bucketKey`'s sentinel
450
+ * handling (`@manager`/`@company`) stays centralized in one place. Nesting
451
+ * under the bucket directory (rather than a sibling root) means
452
+ * `removeProject`'s existing recursive delete already reaches attachments
453
+ * with no new deletion code — see the technical design's Correction 1.
454
+ */
455
+ attachmentsDirFor(bucketKey, conversationId) {
456
+ const key = normalizeConversationKey(bucketKey);
457
+ const attachmentsRoot = path_1.default.join(this.bucketDir(key), 'attachments');
458
+ const dir = path_1.default.resolve(attachmentsRoot, conversationId);
459
+ // Security: conversationId arrives from a URL route param / client-supplied
460
+ // field with no format constraint (it is not required to be a UUID). Without
461
+ // this containment check, a conversationId like '../../../etc' would resolve
462
+ // outside attachmentsRoot via path.resolve's own '..' handling, giving an
463
+ // attacker a write primitive (upload) and read primitive (download/message
464
+ // send) onto arbitrary paths on disk. Reject anything that does not resolve
465
+ // to a direct child of attachmentsRoot.
466
+ if (!dir.startsWith(attachmentsRoot + path_1.default.sep)) {
467
+ throw new Error('Invalid conversationId.');
468
+ }
469
+ return dir;
470
+ }
443
471
  invalidateProjectPathCache() {
444
472
  this.projectPathCache = null;
445
473
  }
@@ -3060,12 +3060,15 @@ class ScriptedHostRuntime {
3060
3060
  }
3061
3061
  }
3062
3062
  exports.ScriptedHostRuntime = ScriptedHostRuntime;
3063
- const createHubMessage = (role, text, deliveryStatus) => ({
3063
+ const createHubMessage = (role, text, deliveryStatus,
3064
+ // Issue #1651: files/screenshots the manager attached to this message.
3065
+ attachments) => ({
3064
3066
  id: (0, crypto_1.randomUUID)(),
3065
3067
  role,
3066
3068
  text,
3067
3069
  createdAt: new Date().toISOString(),
3068
3070
  ...(deliveryStatus ? { deliveryStatus } : {}),
3071
+ ...(attachments && attachments.length > 0 ? { attachments } : {}),
3069
3072
  });
3070
3073
  exports.createHubMessage = createHubMessage;
3071
3074
  const createHubEvent = (channel, text) => ({
@@ -70,6 +70,8 @@ const manager_turns_1 = require("./manager-turns");
70
70
  const preferences_1 = require("./preferences");
71
71
  const conversation_store_1 = require("./conversation-store");
72
72
  const raw_event_log_store_1 = require("./raw-event-log-store");
73
+ const atomic_json_file_1 = require("./atomic-json-file");
74
+ const attachment_store_1 = require("./attachment-store");
73
75
  const hub_main_diagnostics_1 = require("./hub-main-diagnostics");
74
76
  const run_working_directory_1 = require("./run-working-directory");
75
77
  const conversation_search_1 = require("./conversation-search");
@@ -3974,6 +3976,12 @@ class AiHubServer {
3974
3976
  text: message.text,
3975
3977
  createdAt: message.createdAt,
3976
3978
  ...(Number.isFinite(at) && at > 0 ? { at } : {}),
3979
+ // Issue #1651: attachment metadata must survive the run -> persisted-record
3980
+ // fold, or a reloaded conversation loses its attachment chips even though
3981
+ // the blobs remain on disk (Wire All Read Paths discipline).
3982
+ ...(Array.isArray(message.attachments) && message.attachments.length > 0
3983
+ ? { attachments: message.attachments }
3984
+ : {}),
3977
3985
  };
3978
3986
  }),
3979
3987
  events: run.events.map((event) => ({
@@ -5110,6 +5118,38 @@ class AiHubServer {
5110
5118
  ].filter(Boolean).join('\n\n');
5111
5119
  return this.prepareStartPayload(deployment.projectPath, deployment.hostId, deployment.jobId, context);
5112
5120
  }
5121
+ /**
5122
+ * Issue #1651 R7/R12: resolve staged attachment ids (uploaded via
5123
+ * POST /api/ai-hub/conversations/:conversationId/attachments) into persisted
5124
+ * metadata for AiHubMessage.attachments, plus a "[Attached: ...]" reference
5125
+ * suffix appended to the host-bound message text. An id that fails to
5126
+ * resolve (already deleted, wrong conversation) is skipped rather than
5127
+ * failing the whole send — the manager's own text still reaches the agent.
5128
+ */
5129
+ resolveMessageAttachments(bucketKey, conversationId, attachmentIds) {
5130
+ const ids = Array.isArray(attachmentIds)
5131
+ ? attachmentIds.filter((id) => typeof id === 'string' && id.trim().length > 0)
5132
+ : [];
5133
+ if (ids.length === 0)
5134
+ return { attachments: undefined, referenceSuffix: '' };
5135
+ const dir = this.conversationStore.attachmentsDirFor(bucketKey, conversationId);
5136
+ const attachments = [];
5137
+ const refLines = [];
5138
+ for (const id of ids) {
5139
+ try {
5140
+ const raw = JSON.parse(fs_1.default.readFileSync((0, attachment_store_1.safeAttachmentMetaPath)(dir, id), 'utf8'));
5141
+ attachments.push({ id: raw.id, filename: raw.filename, sizeBytes: raw.sizeBytes, mimeType: raw.mimeType });
5142
+ refLines.push(`[Attached: ${raw.filename} at ${path_1.default.resolve(path_1.default.join(dir, raw.diskFilename))}]`);
5143
+ }
5144
+ catch {
5145
+ // Stale/unknown/path-escaping attachment id — skip; the message still sends.
5146
+ }
5147
+ }
5148
+ return {
5149
+ attachments: attachments.length > 0 ? attachments : undefined,
5150
+ referenceSuffix: refLines.length > 0 ? `\n${refLines.join('\n')}` : '',
5151
+ };
5152
+ }
5113
5153
  prepareContinueMessage(run, instructions, coachingJobId) {
5114
5154
  // coachingJobId is set when the user selected a manager coaching template
5115
5155
  // (e.g. follow-your-mentor). When present, it overrides the run's own jobId
@@ -5972,6 +6012,78 @@ class AiHubServer {
5972
6012
  return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not switch agents.' });
5973
6013
  }
5974
6014
  });
6015
+ // Issue #1651: Hub message attachments — binary upload transport.
6016
+ // Route-scoped raw body parser (never `app.use()` at global scope): the client sends the
6017
+ // attachment's real MIME type as Content-Type (image/png, application/pdf, ...), never
6018
+ // application/json or multipart/*. express.json() (registered globally above) only consumes
6019
+ // the request stream when req.is('json') matches; for any other Content-Type it calls next()
6020
+ // without touching the stream, leaving it untouched for this route's express.raw() — confirmed
6021
+ // by a real spike against this exact middleware order (see the technical design's "Spike
6022
+ // Findings") and pinned by the byte-identity regression test in test-ai-hub-attachments.ts.
6023
+ this.app.post('/api/ai-hub/conversations/:conversationId/attachments', express_1.default.raw({ type: () => true, limit: attachment_store_1.MAX_UPLOAD_BODY_BYTES }), async (req, res) => {
6024
+ try {
6025
+ const conversationId = req.params.conversationId;
6026
+ const scope = scopeParam(req.query.scope);
6027
+ if (!scope && (typeof req.query.projectPath !== 'string' || req.query.projectPath.trim().length === 0)) {
6028
+ return res.status(400).json({ error: 'projectPath required for project conversations' });
6029
+ }
6030
+ const bucketKey = scope ? (0, conversation_store_1.conversationScopeKey)(scope, '') : ensureDirectoryPath(req.query.projectPath);
6031
+ const body = req.body;
6032
+ if (!Buffer.isBuffer(body) || body.length === 0) {
6033
+ return res.status(400).json({ error: 'Expected a non-empty binary request body.' });
6034
+ }
6035
+ const mimeType = (req.headers['content-type'] || '').toString().split(';')[0].trim().toLowerCase();
6036
+ (0, attachment_store_1.assertAttachmentUploadAllowed)(mimeType, body.length);
6037
+ const filenameHeader = req.headers['x-attachment-filename'];
6038
+ let rawFilename = 'attachment';
6039
+ if (typeof filenameHeader === 'string' && filenameHeader) {
6040
+ try {
6041
+ rawFilename = decodeURIComponent(filenameHeader);
6042
+ }
6043
+ catch {
6044
+ rawFilename = filenameHeader;
6045
+ }
6046
+ }
6047
+ const sanitized = await (0, attachment_store_1.sanitizeAttachmentBuffer)(body, mimeType);
6048
+ const id = (0, crypto_1.randomUUID)();
6049
+ const dir = this.conversationStore.attachmentsDirFor(bucketKey, conversationId);
6050
+ const diskFilename = (0, attachment_store_1.attachmentDiskFilename)(id, rawFilename);
6051
+ const filename = (0, attachment_store_1.safeAttachmentBaseName)(rawFilename);
6052
+ (0, atomic_json_file_1.writeFileAtomic)(path_1.default.join(dir, diskFilename), sanitized);
6053
+ (0, atomic_json_file_1.writeFileAtomic)(path_1.default.join(dir, `${id}.meta.json`), JSON.stringify({ id, filename, sizeBytes: sanitized.length, mimeType, diskFilename }));
6054
+ console.info('[ai-hub] hub.attachment_uploaded', { conversationId, id, mimeType, sizeBytes: sanitized.length });
6055
+ return res.status(201).json({ id, filename, sizeBytes: sanitized.length, mimeType });
6056
+ }
6057
+ catch (error) {
6058
+ if (error instanceof attachment_store_1.AttachmentRejectedError) {
6059
+ console.warn('[ai-hub] hub.attachment_rejected', { status: error.status, reason: error.message });
6060
+ return res.status(error.status).json({ error: error.message });
6061
+ }
6062
+ console.error('[ai-hub] hub.attachment_upload_failed', error instanceof Error ? error.message : error);
6063
+ return res.status(500).json({ error: error instanceof Error ? error.message : 'Attachment upload failed.' });
6064
+ }
6065
+ });
6066
+ // Issue #1651: byte-serve one attachment for transcript thumbnail/chip rendering
6067
+ // (`<img src="...">` for images; metadata-driven chip for everything else).
6068
+ this.app.get('/api/ai-hub/conversations/:conversationId/attachments/:attachmentId', (req, res) => {
6069
+ try {
6070
+ const scope = scopeParam(req.query.scope);
6071
+ if (!scope && (typeof req.query.projectPath !== 'string' || req.query.projectPath.trim().length === 0)) {
6072
+ return res.status(400).json({ error: 'projectPath required for project conversations' });
6073
+ }
6074
+ const bucketKey = scope ? (0, conversation_store_1.conversationScopeKey)(scope, '') : ensureDirectoryPath(req.query.projectPath);
6075
+ const dir = this.conversationStore.attachmentsDirFor(bucketKey, req.params.conversationId);
6076
+ const metadata = JSON.parse(fs_1.default.readFileSync((0, attachment_store_1.safeAttachmentMetaPath)(dir, req.params.attachmentId), 'utf8'));
6077
+ const bytes = fs_1.default.readFileSync(path_1.default.join(dir, metadata.diskFilename));
6078
+ res.setHeader('Content-Type', metadata.mimeType);
6079
+ res.setHeader('Cache-Control', 'private, max-age=31536000, immutable');
6080
+ res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(metadata.filename)}"`);
6081
+ return res.send(bytes);
6082
+ }
6083
+ catch {
6084
+ return res.status(404).json({ error: 'Attachment not found.' });
6085
+ }
6086
+ });
5975
6087
  // Issue #1065: conversation search. Served by the existing loopback listener (C6) — no new
5976
6088
  // endpoint surface, no new trust boundary. Reads only the bucket index (header facts) and the
5977
6089
  // searchable projection (thread text); never a conversation body, which is where the raw host
@@ -6655,6 +6767,15 @@ class AiHubServer {
6655
6767
  });
6656
6768
  if (!continuity.ok)
6657
6769
  return res.status(continuity.status).json(continuity.body);
6770
+ // Issue #1651 R7/R8: attachments staged before the first message of a brand-new
6771
+ // conversation. Requires the client to have already generated/supplied a
6772
+ // conversationId (StartRunBody.conversationId already exists for this reason).
6773
+ if (Array.isArray(req.body.attachmentIds) && req.body.attachmentIds.length > 0 && !requestedConversationId) {
6774
+ return res.status(400).json({ error: 'conversationId is required to attach files.' });
6775
+ }
6776
+ const { attachments: startAttachments, referenceSuffix: startAttachmentSuffix } = requestedConversationId
6777
+ ? this.resolveMessageAttachments((0, conversation_store_1.conversationScopeKey)(scope, projectPath), requestedConversationId, req.body.attachmentIds)
6778
+ : { attachments: undefined, referenceSuffix: '' };
6658
6779
  const run = {
6659
6780
  id: (0, crypto_1.randomUUID)(),
6660
6781
  conversationId: requestedConversationId,
@@ -6669,7 +6790,7 @@ class AiHubServer {
6669
6790
  status: 'running',
6670
6791
  createdAt: startTimestamp,
6671
6792
  updatedAt: startTimestamp,
6672
- messages: [(0, hosts_1.createHubMessage)('manager', managerDisplay)],
6793
+ messages: [(0, hosts_1.createHubMessage)('manager', managerDisplay, undefined, startAttachments)],
6673
6794
  events: [(0, hosts_1.createHubEvent)('system', `Starting ${configuredAgent.label} (${hostId}) in ${projectPath}`)],
6674
6795
  // Issue #347 — seed phase + totals state on creation.
6675
6796
  currentPhase: null,
@@ -6735,7 +6856,7 @@ class AiHubServer {
6735
6856
  this.runRegistry.create(directRun, {});
6736
6857
  this.scheduleRunConversationPersistence(directRun, directRun.id);
6737
6858
  }
6738
- const child = this.hostRuntime.startRun(hostId, projectPath, message, {
6859
+ const child = this.hostRuntime.startRun(hostId, projectPath, message + startAttachmentSuffix, {
6739
6860
  onEvent: (event, channel) => {
6740
6861
  this.runRegistry.update(run.id, (current) => {
6741
6862
  if (event.sessionId) {
@@ -6919,9 +7040,13 @@ class AiHubServer {
6919
7040
  ? this.prepareContinueMessage(run, '', coachingJobId)
6920
7041
  : { message: (req.body.message || '').trim(), display: (req.body.message || '').trim() };
6921
7042
  const message = prepared.message;
6922
- if (!message) {
7043
+ const hasAttachments = Array.isArray(req.body.attachmentIds) && req.body.attachmentIds.length > 0;
7044
+ if (!message && !hasAttachments) {
6923
7045
  return res.status(400).json({ error: 'Coach your employee before sending the next turn.' });
6924
7046
  }
7047
+ // Issue #1651 R7/R12: resolve staged attachments into persisted metadata
7048
+ // (attached to the manager's message) and a host-bound reference suffix.
7049
+ const { attachments, referenceSuffix: attachmentSuffix } = this.resolveMessageAttachments((0, conversation_store_1.conversationScopeKey)(run.scope, run.projectPath), run.conversationId || run.id, req.body.attachmentIds);
6925
7050
  // No resumable session — start fresh using a handoff prompt so the agent
6926
7051
  // picks up from the preserved conversation state + manager coaching.
6927
7052
  const reviewApprovalSystemEventText = buildReviewApprovalSystemEventText(prepared.display || message);
@@ -6945,7 +7070,7 @@ class AiHubServer {
6945
7070
  // to failed/stopped by the stale flag from the original stop.
6946
7071
  current.stoppedByUser = false;
6947
7072
  current.sessionId = undefined;
6948
- const managerMessage = (0, hosts_1.createHubMessage)('manager', prepared.display || message);
7073
+ const managerMessage = (0, hosts_1.createHubMessage)('manager', prepared.display || message, undefined, attachments);
6949
7074
  current.messages.push(managerMessage);
6950
7075
  appendDeliveredManagerMicroEvent(current, managerMessage);
6951
7076
  if (reviewApprovalSystemEventText)
@@ -6969,7 +7094,7 @@ class AiHubServer {
6969
7094
  this.persistRunConversation(startedFresh, startedFresh.conversationId || startedFresh.id);
6970
7095
  this.runRegistry.create(run, {});
6971
7096
  const freshLaunch = this.resolveLaunchAgent(run.configuredAgentId, run.hostId);
6972
- const freshChild = this.hostRuntime.startRun(run.hostId, run.projectPath, freshPayload.message, {
7097
+ const freshChild = this.hostRuntime.startRun(run.hostId, run.projectPath, freshPayload.message + attachmentSuffix, {
6973
7098
  onEvent: (event, channel) => {
6974
7099
  this.runRegistry.update(run.id, (current) => {
6975
7100
  if (event.sessionId) {
@@ -7021,7 +7146,7 @@ class AiHubServer {
7021
7146
  // to failed/stopped by the stale flag from the original stop.
7022
7147
  current.stoppedByUser = false;
7023
7148
  // #521: bubble shows the manager's words; the agent gets the full message.
7024
- const managerMessage = (0, hosts_1.createHubMessage)('manager', prepared.display || message, deliveryStatus);
7149
+ const managerMessage = (0, hosts_1.createHubMessage)('manager', prepared.display || message, deliveryStatus, attachments);
7025
7150
  current.messages.push(managerMessage);
7026
7151
  appendDeliveredManagerMicroEvent(current, managerMessage);
7027
7152
  if (reviewApprovalSystemEventText)
@@ -7054,7 +7179,7 @@ class AiHubServer {
7054
7179
  // them, so any other continueRun call site delivering the turn (or a
7055
7180
  // turn that exits before its first event) left the badge stuck
7056
7181
  // forever. See clearStaleDeliveryStatuses and handleRunExit.
7057
- const child = this.hostRuntime.continueRun(run.hostId, run.projectPath, run.sessionId, message, {
7182
+ const child = this.hostRuntime.continueRun(run.hostId, run.projectPath, run.sessionId, message + attachmentSuffix, {
7058
7183
  onEvent: (event, channel) => {
7059
7184
  if (run.hostId === 'codex' && reviewApprovalSystemEventText) {
7060
7185
  codexMissingReasoningResumeError = codexMissingReasoningResumeError ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.315",
3
+ "version": "2.0.316",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "author": "Sid Mathur <sid.mathur@gmail.com>",
6
6
  "homepage": "https://github.com/mathursrus/FRAIM#readme",
@@ -211,7 +211,7 @@
211
211
  "electron-updater": "^6.8.9",
212
212
  "express": "^5.2.1",
213
213
  "extract-zip": "^2.0.1",
214
- "fraim": "2.0.315",
214
+ "fraim": "2.0.316",
215
215
  "mongodb": "^7.0.0",
216
216
  "node-cron": "4.2.1",
217
217
  "node-edge-tts": "^1.2.10",
@@ -220,6 +220,7 @@
220
220
  "resend": "^6.9.3",
221
221
  "selfsigned": "^5.5.0",
222
222
  "semver": "^7.7.4",
223
+ "sharp": "^0.34.5",
223
224
  "stripe": "^20.3.1",
224
225
  "tar": "^7.4.3",
225
226
  "toml": "^3.0.0",
@@ -499,7 +499,10 @@
499
499
  </span>
500
500
  </summary>
501
501
  <div class="panel-body">
502
- <div class="coach">
502
+ <div class="coach" id="coach">
503
+ <!-- Issue #1651: dashed overlay shown only while a drag is over .coach
504
+ (toggled via the coach.drag-active class in script.js). -->
505
+ <div class="composer-dropzone" id="composer-dropzone">Drop to attach</div>
503
506
  <!-- Issue #512 R7.7: review actions live in the SAME bar as the
504
507
  coaching chips. Hidden until a deliverable is awaiting review;
505
508
  the coaching chips below never disappear. -->
@@ -589,10 +592,16 @@
589
592
  <button class="delivery-option delivery-option--redirect" type="button" id="delivery-redirect" aria-pressed="false">Redirect now</button>
590
593
  </div>
591
594
  <p class="delivery-help" id="delivery-help" hidden></p>
595
+ <!-- Issue #1651: staged attachment chips (thumbnail/file icon, filename,
596
+ size, remove control), rendered before Send by renderStagedAttachments(). -->
597
+ <div class="staged-attachments" id="staged-attachments"></div>
592
598
  <div class="coach-input">
593
- <textarea id="coach-text" aria-label="Coach the employee" placeholder="Tell the employee what to do next..."></textarea>
599
+ <button class="attach-btn" type="button" id="attach-btn" aria-label="Attach a file or screenshot" title="Attach a file or screenshot">&#128206;</button>
600
+ <input type="file" id="attach-file-input" multiple accept="image/png,image/jpeg,image/gif,.pdf,.txt,.log" hidden>
601
+ <textarea id="coach-text" aria-label="Coach the employee" placeholder="Tell the employee what to do next... (paste a screenshot with Ctrl+V)"></textarea>
594
602
  <button class="send-button" type="button" id="send" aria-label="Send coaching message" title="Send coaching message" disabled>↑</button>
595
603
  </div>
604
+ <div class="composer-hint">Attach or paste a screenshot, log, or PDF, up to 10&nbsp;MB each.</div>
596
605
  <div class="coach-note" id="coach-note"></div>
597
606
  </div>
598
607
  </div>
@@ -100,6 +100,11 @@ const state = {
100
100
  brandEditorStatusWarn: false,
101
101
  // Issue #540 R10: pending run params captured when hire strip is shown.
102
102
  _hireStripPending: null,
103
+ // Issue #1651: files/screenshots staged in the composer before Send.
104
+ // { localId, file, dataUrl (images only), error }. Scoped to the active
105
+ // conversation the same way pendingCoachingJobId is: cleared on send and on
106
+ // conversation switch (see clearStagedAttachments and the switch handler).
107
+ stagedAttachments: [],
103
108
  };
104
109
 
105
110
  const bootstrapCache = new Map();
@@ -122,6 +127,8 @@ function gatherElements() {
122
127
  'messages',
123
128
  'coach-text', 'send', 'micro-manage', 'micro-log', 'resume-command', 'resume-command-code', 'resume-command-copy',
124
129
  'status-line', 'coach-note',
130
+ // Issue #1651: composer attachments.
131
+ 'coach', 'composer-dropzone', 'staged-attachments', 'attach-btn', 'attach-file-input',
125
132
  // Issue #1176: continue-run delivery control.
126
133
  'delivery-control', 'delivery-after', 'delivery-redirect', 'delivery-help',
127
134
  'coach-panel', 'coach-summary',
@@ -1866,6 +1873,16 @@ function conversationStartTimestamp(conv) {
1866
1873
  return timestampMillis(conv.createdAt) || conversationTimestamp(conv);
1867
1874
  }
1868
1875
 
1876
+ // Stable kickoff-time comparator for ordering runs by when they were kicked off, not by
1877
+ // their last activity. Shared by the Projects rail (renderRail's activeConvs/doneConvs
1878
+ // sort) and the Manager/Company rail (tfAreaScopedConversations) so "In Progress"
1879
+ // ordering is identical across all three tabs (#1652). Sorting by lastUpdatedAt instead
1880
+ // reshuffles the list on every update a run receives; createdAt pins a run where it was
1881
+ // kicked off and never changes again.
1882
+ function compareByKickoffTime(a, b) {
1883
+ return conversationStartTimestamp(b) - conversationStartTimestamp(a) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1884
+ }
1885
+
1869
1886
  function timestampMillis(value) {
1870
1887
  if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
1871
1888
  if (typeof value === 'string') {
@@ -2726,8 +2743,8 @@ function renderRail() {
2726
2743
  }
2727
2744
  }
2728
2745
  // Latest kickoff first within each section.
2729
- activeConvs.sort((a, b) => convStart(b) - convStart(a) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
2730
- doneConvs.sort((a, b) => convStart(b) - convStart(a) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
2746
+ activeConvs.sort(compareByKickoffTime);
2747
+ doneConvs.sort(compareByKickoffTime);
2731
2748
 
2732
2749
  // Build per-employee groups for completed runs.
2733
2750
  // Completed runs with a personaKey group under that employee; completed runs
@@ -3896,6 +3913,9 @@ function renderActive() {
3896
3913
  // A pending coaching job is scoped to the active conversation — discard it
3897
3914
  // whenever the user switches to a different conversation.
3898
3915
  clearPendingCoachingJob();
3916
+ // Issue #1651: staged attachments are equally scoped to the active
3917
+ // conversation — a switch must not carry them into a different thread.
3918
+ clearStagedAttachments();
3899
3919
  // Issue #1176 R40: the delivery control resets to "After current" whenever
3900
3920
  // the active conversation changes, so a prior urgent choice can't redirect
3901
3921
  // a different conversation's next guidance.
@@ -5556,6 +5576,29 @@ function appendMessageDom(role, text, conv, message) {
5556
5576
  article.appendChild(raw);
5557
5577
  }
5558
5578
  article.appendChild(bubble);
5579
+ // Issue #1651 R13: one chip per persisted attachment — thumbnail for images
5580
+ // (byte-served by GET .../attachments/:id), a generic file glyph otherwise.
5581
+ if (message && Array.isArray(message.attachments)) {
5582
+ message.attachments.forEach((att) => {
5583
+ const chip = document.createElement('span');
5584
+ chip.className = 'msg-attachment';
5585
+ const thumb = document.createElement('span');
5586
+ thumb.className = 'thumb';
5587
+ if (att.mimeType && att.mimeType.startsWith('image/')) {
5588
+ const img = document.createElement('img');
5589
+ img.src = attachmentUrlFor(conv, att.id);
5590
+ img.alt = att.filename;
5591
+ thumb.appendChild(img);
5592
+ } else {
5593
+ thumb.textContent = '\u{1F4C4}'; // 📄
5594
+ }
5595
+ const label = document.createElement('span');
5596
+ label.textContent = `${att.filename} (${formatAttachmentSize(att.sizeBytes)})`;
5597
+ chip.appendChild(thumb);
5598
+ chip.appendChild(label);
5599
+ article.appendChild(chip);
5600
+ });
5601
+ }
5559
5602
  els['messages'].appendChild(article);
5560
5603
  }
5561
5604
 
@@ -5782,6 +5825,160 @@ function scrollThreadForReview(conv) {
5782
5825
  host.scrollTop = desiredTop;
5783
5826
  }
5784
5827
 
5828
+ // ---------------------------------------------------------------------------
5829
+ // Issue #1651: composer attachments (attach button, paste, drag-drop, upload)
5830
+ // ---------------------------------------------------------------------------
5831
+
5832
+ const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
5833
+ const MAX_ATTACHMENTS_PER_SEND = 5;
5834
+ const ACCEPTED_ATTACHMENT_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'application/pdf', 'text/plain']);
5835
+ let stagedAttachmentSeq = 0;
5836
+
5837
+ function formatAttachmentSize(bytes) {
5838
+ if (bytes < 1024) return bytes + ' B';
5839
+ if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB';
5840
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
5841
+ }
5842
+
5843
+ function hasValidStagedAttachment() {
5844
+ return state.stagedAttachments.some((a) => !a.error);
5845
+ }
5846
+
5847
+ function renderStagedAttachments() {
5848
+ const row = els['staged-attachments'];
5849
+ if (!row) return;
5850
+ row.innerHTML = '';
5851
+ state.stagedAttachments.forEach((item) => {
5852
+ const chip = document.createElement('div');
5853
+ chip.className = 'attachment-chip' + (item.error ? ' chip-error' : '');
5854
+
5855
+ const thumb = document.createElement('span');
5856
+ thumb.className = 'thumb';
5857
+ if (item.dataUrl) {
5858
+ const img = document.createElement('img');
5859
+ img.src = item.dataUrl;
5860
+ img.alt = '';
5861
+ thumb.appendChild(img);
5862
+ } else {
5863
+ thumb.textContent = '\u{1F4C4}'; // 📄
5864
+ }
5865
+
5866
+ const meta = document.createElement('span');
5867
+ meta.className = 'meta';
5868
+ const fname = document.createElement('span');
5869
+ fname.className = 'fname';
5870
+ fname.textContent = item.file.name;
5871
+ meta.appendChild(fname);
5872
+ meta.appendChild(document.createElement('br'));
5873
+ if (item.error) {
5874
+ const err = document.createElement('span');
5875
+ err.className = 'chip-error-text';
5876
+ err.textContent = item.error;
5877
+ meta.appendChild(err);
5878
+ } else {
5879
+ const size = document.createElement('span');
5880
+ size.className = 'fsize';
5881
+ size.textContent = formatAttachmentSize(item.file.size);
5882
+ meta.appendChild(size);
5883
+ }
5884
+
5885
+ const remove = document.createElement('button');
5886
+ remove.className = 'chip-remove';
5887
+ remove.type = 'button';
5888
+ remove.setAttribute('aria-label', `Remove ${item.file.name}`);
5889
+ remove.textContent = '×'; // ×
5890
+ remove.addEventListener('click', () => {
5891
+ state.stagedAttachments = state.stagedAttachments.filter((a) => a.localId !== item.localId);
5892
+ renderStagedAttachments();
5893
+ syncSendButton();
5894
+ });
5895
+
5896
+ chip.appendChild(thumb);
5897
+ chip.appendChild(meta);
5898
+ chip.appendChild(remove);
5899
+ row.appendChild(chip);
5900
+ });
5901
+ }
5902
+
5903
+ function clearStagedAttachments() {
5904
+ state.stagedAttachments = [];
5905
+ renderStagedAttachments();
5906
+ }
5907
+
5908
+ // R5: an oversized or unsupported file stages as a visually distinct error
5909
+ // chip and is excluded from send; R4: everything else stages with a
5910
+ // thumbnail (images, via FileReader) or a generic file icon.
5911
+ function stageAttachmentFile(file) {
5912
+ if (!file || state.stagedAttachments.length >= MAX_ATTACHMENTS_PER_SEND) return;
5913
+ const localId = 'att-' + (stagedAttachmentSeq += 1);
5914
+ if (file.size > MAX_ATTACHMENT_BYTES) {
5915
+ state.stagedAttachments.push({ localId, file, dataUrl: null, error: `Over ${MAX_ATTACHMENT_BYTES / (1024 * 1024)} MB limit, not attached` });
5916
+ renderStagedAttachments();
5917
+ syncSendButton();
5918
+ return;
5919
+ }
5920
+ if (!ACCEPTED_ATTACHMENT_MIME_TYPES.has(file.type)) {
5921
+ state.stagedAttachments.push({ localId, file, dataUrl: null, error: 'Unsupported file type, not attached' });
5922
+ renderStagedAttachments();
5923
+ syncSendButton();
5924
+ return;
5925
+ }
5926
+ if (file.type.startsWith('image/')) {
5927
+ const reader = new FileReader();
5928
+ reader.onload = () => {
5929
+ state.stagedAttachments.push({ localId, file, dataUrl: reader.result, error: null });
5930
+ renderStagedAttachments();
5931
+ syncSendButton();
5932
+ };
5933
+ reader.readAsDataURL(file);
5934
+ } else {
5935
+ state.stagedAttachments.push({ localId, file, dataUrl: null, error: null });
5936
+ renderStagedAttachments();
5937
+ syncSendButton();
5938
+ }
5939
+ }
5940
+
5941
+ // R7: upload every valid staged file to the per-conversation attachment
5942
+ // endpoint, returning the ids the subsequent message-send call references.
5943
+ async function uploadStagedAttachments(conv) {
5944
+ const valid = state.stagedAttachments.filter((a) => !a.error);
5945
+ if (valid.length === 0) return [];
5946
+ const scope = convScope(conv);
5947
+ const params = new URLSearchParams();
5948
+ if (scope === 'manager' || scope === 'company') {
5949
+ params.set('scope', scope);
5950
+ } else {
5951
+ params.set('projectPath', (conv && conv.projectPath) || state.projectPath || '');
5952
+ }
5953
+ const url = `/api/ai-hub/conversations/${encodeURIComponent(conv.id)}/attachments?${params.toString()}`;
5954
+ const ids = [];
5955
+ for (const item of valid) {
5956
+ const result = await requestJson(url, {
5957
+ method: 'POST',
5958
+ headers: {
5959
+ 'Content-Type': item.file.type || 'application/octet-stream',
5960
+ 'X-Attachment-Filename': encodeURIComponent(item.file.name),
5961
+ },
5962
+ body: item.file,
5963
+ });
5964
+ if (result && result.id) ids.push(result.id);
5965
+ }
5966
+ return ids;
5967
+ }
5968
+
5969
+ // R13: URL the transcript renderer points an <img> (or download affordance)
5970
+ // at for one persisted attachment.
5971
+ function attachmentUrlFor(conv, attachmentId) {
5972
+ const scope = convScope(conv);
5973
+ const params = new URLSearchParams();
5974
+ if (scope === 'manager' || scope === 'company') {
5975
+ params.set('scope', scope);
5976
+ } else {
5977
+ params.set('projectPath', (conv && conv.projectPath) || state.projectPath || '');
5978
+ }
5979
+ return `/api/ai-hub/conversations/${encodeURIComponent(conv.id)}/attachments/${encodeURIComponent(attachmentId)}?${params.toString()}`;
5980
+ }
5981
+
5785
5982
  function syncSendButton() {
5786
5983
  const conv = activeConversation();
5787
5984
  const hasText = els['coach-text'].value.trim().length > 0;
@@ -5801,7 +5998,9 @@ function syncSendButton() {
5801
5998
  // is enough to enable Send even with an empty textarea — the user just
5802
5999
  // wants to fire that coaching directive, optionally with added context.
5803
6000
  const hasPendingJob = !!state.pendingCoachingJobId;
5804
- els['send'].disabled = !((hasText || hasPendingJob) && (resumable || canRestartWithCoaching));
6001
+ // Issue #1651 R6: a valid staged attachment is enough to enable Send even
6002
+ // with an empty textarea, same as a pending coaching job.
6003
+ els['send'].disabled = !((hasText || hasPendingJob || hasValidStagedAttachment()) && (resumable || canRestartWithCoaching));
5805
6004
  }
5806
6005
 
5807
6006
  function defaultCoachNote(conv) {
@@ -8745,6 +8944,9 @@ async function continueRun(text, options) {
8745
8944
  // Issue #1176: 'stop' course-corrects the active turn instead of queueing
8746
8945
  // behind it; omitted/'after' is the default queue-after-current behavior.
8747
8946
  const deliveryIntent = opts.deliveryIntent === 'stop' ? 'stop' : undefined;
8947
+ // Issue #1651 R7: ids already uploaded via uploadStagedAttachments(), resolved
8948
+ // server-side into AiHubMessage.attachments plus a host-bound reference.
8949
+ const attachmentIds = Array.isArray(opts.attachmentIds) && opts.attachmentIds.length > 0 ? opts.attachmentIds : undefined;
8748
8950
  if (!opts.preserveReviewApproved) {
8749
8951
  conv.reviewApproved = false;
8750
8952
  conv.pendingDeliveryActionId = null;
@@ -8771,7 +8973,13 @@ async function continueRun(text, options) {
8771
8973
  run = await requestJson(`/api/ai-hub/runs/${conv.runId}/messages`, {
8772
8974
  method: 'POST',
8773
8975
  headers: { 'Content-Type': 'application/json' },
8774
- body: JSON.stringify({ instructions: effectiveText, ...(coachingJobId ? { coachingJobId } : {}), ...(deliveryIntent ? { deliveryIntent } : {}), ...(opts.selectedReviewAction ? { selectedReviewAction: opts.selectedReviewAction } : {}) }),
8976
+ body: JSON.stringify({
8977
+ instructions: effectiveText,
8978
+ ...(coachingJobId ? { coachingJobId } : {}),
8979
+ ...(deliveryIntent ? { deliveryIntent } : {}),
8980
+ ...(opts.selectedReviewAction ? { selectedReviewAction: opts.selectedReviewAction } : {}),
8981
+ ...(attachmentIds ? { attachmentIds } : {}),
8982
+ }),
8775
8983
  });
8776
8984
  } catch (e) {
8777
8985
  const isNotFound = /not found/i.test((e && e.message) || '');
@@ -8821,6 +9029,7 @@ async function continueRun(text, options) {
8821
9029
  instructions: effectiveText,
8822
9030
  ...(coachingJobId ? { coachingJobId } : {}),
8823
9031
  ...(opts.selectedReviewAction ? { selectedReviewAction: opts.selectedReviewAction } : {}),
9032
+ ...(attachmentIds ? { attachmentIds } : {}),
8824
9033
  }),
8825
9034
  });
8826
9035
  conv.runId = run.id; // bind the conversation to the new run
@@ -8921,6 +9130,9 @@ function foldRunIntoConversation(conv, run) {
8921
9130
  if (typeof m.createdAt === 'string') record.createdAt = m.createdAt;
8922
9131
  // Issue #1176: carry the submit-time queued/redirecting badge, if any.
8923
9132
  if (m.deliveryStatus) record.deliveryStatus = m.deliveryStatus;
9133
+ // Issue #1651: carry attachment metadata, or a reloaded/re-folded conversation
9134
+ // silently loses its attachment chips even though the blobs remain on disk.
9135
+ if (Array.isArray(m.attachments) && m.attachments.length > 0) record.attachments = m.attachments;
8924
9136
  const parsed = timestampMillis(m.createdAt);
8925
9137
  if (parsed) record.at = parsed;
8926
9138
  return record;
@@ -10094,9 +10306,10 @@ function wireEvents() {
10094
10306
  }
10095
10307
  els['send'].addEventListener('click', async () => {
10096
10308
  const text = els['coach-text'].value.trim();
10097
- // Allow send when textarea is empty IFF a coaching job is pending the
10098
- // user clicked a quick-coach button and just wants to fire that job.
10099
- if (!text && !state.pendingCoachingJobId) return;
10309
+ // Allow send when textarea is empty IFF a coaching job is pending, or a
10310
+ // valid attachment is staged (#1651 R6) — the user just wants to fire
10311
+ // that job, or hand over the attachment(s), with no accompanying text.
10312
+ if (!text && !state.pendingCoachingJobId && !hasValidStagedAttachment()) return;
10100
10313
  els['coach-text'].value = '';
10101
10314
  syncSendButton();
10102
10315
  const conv = activeConversation();
@@ -10115,11 +10328,26 @@ function wireEvents() {
10115
10328
  const chosenAgent = normalizeActiveAgentId(
10116
10329
  els['active-employee-select'] && els['active-employee-select'].value
10117
10330
  );
10331
+ // Issue #1651 R7: upload any staged attachments before the message send so
10332
+ // the ids can ride along on the same request. Uploading is a no-op when
10333
+ // nothing valid is staged.
10334
+ let attachmentIds = [];
10335
+ if (conv && hasValidStagedAttachment()) {
10336
+ try {
10337
+ attachmentIds = await uploadStagedAttachments(conv);
10338
+ } catch (error) {
10339
+ showStatus(error.message, true);
10340
+ }
10341
+ }
10342
+ clearStagedAttachments();
10118
10343
  if (conv && chosenAgent && chosenAgent !== normalizeActiveAgentId(conversationAgentName(conv))) {
10344
+ // Known limitation: an agent switch on the same send that carries
10345
+ // attachments uploads the files (they land on disk) but does not yet
10346
+ // thread attachmentIds through restartConvWithAgent's fresh-run message.
10119
10347
  state.selectedEmployeeId = chosenAgent;
10120
10348
  await restartConvWithAgent(conv, chosenAgent, text);
10121
10349
  } else {
10122
- await continueRun(text, { deliveryIntent });
10350
+ await continueRun(text, { deliveryIntent, attachmentIds });
10123
10351
  }
10124
10352
  });
10125
10353
 
@@ -10133,6 +10361,50 @@ function wireEvents() {
10133
10361
  });
10134
10362
  }
10135
10363
 
10364
+ // Issue #1651 R1/R2/R3: attach button (file picker), drag-drop onto the
10365
+ // composer, and paste-image support — ported from the validated mock
10366
+ // (docs/feature-specs/mocks/1651-view.html).
10367
+ if (els['attach-btn'] && els['attach-file-input']) {
10368
+ els['attach-btn'].addEventListener('click', () => els['attach-file-input'].click());
10369
+ els['attach-file-input'].addEventListener('change', () => {
10370
+ Array.from(els['attach-file-input'].files || []).forEach(stageAttachmentFile);
10371
+ els['attach-file-input'].value = '';
10372
+ });
10373
+ }
10374
+ if (els['coach']) {
10375
+ ['dragenter', 'dragover'].forEach((evt) => {
10376
+ els['coach'].addEventListener(evt, (e) => {
10377
+ e.preventDefault();
10378
+ els['coach'].classList.add('drag-active');
10379
+ });
10380
+ });
10381
+ ['dragleave', 'drop'].forEach((evt) => {
10382
+ els['coach'].addEventListener(evt, (e) => {
10383
+ e.preventDefault();
10384
+ els['coach'].classList.remove('drag-active');
10385
+ });
10386
+ });
10387
+ els['coach'].addEventListener('drop', (e) => {
10388
+ Array.from((e.dataTransfer && e.dataTransfer.files) || []).forEach(stageAttachmentFile);
10389
+ });
10390
+ }
10391
+ if (els['coach-text']) {
10392
+ els['coach-text'].addEventListener('paste', (e) => {
10393
+ const items = (e.clipboardData && e.clipboardData.items) || [];
10394
+ Array.from(items).forEach((item) => {
10395
+ if (!item.type || !item.type.startsWith('image/')) return;
10396
+ const file = item.getAsFile();
10397
+ if (!file) return;
10398
+ try {
10399
+ Object.defineProperty(file, 'name', { value: 'pasted-screenshot.png' });
10400
+ } catch {
10401
+ /* some browsers disallow redefining File.name; stage it under its own name */
10402
+ }
10403
+ stageAttachmentFile(file);
10404
+ });
10405
+ });
10406
+ }
10407
+
10136
10408
  // Issue #442: keep A/B toggle visible only when the selected employee supports
10137
10409
  // direct-path invocation (supportsRaw). Also wire the explanation paragraph.
10138
10410
  if (els['employee-select']) {
@@ -14011,9 +14283,13 @@ function tfResolveAreaView(area, fallbackConv) {
14011
14283
  }
14012
14284
 
14013
14285
  function tfAreaScopedConversations(area) {
14286
+ // Uses the same stable kickoff-time comparator as renderRail()'s Projects-rail sort
14287
+ // (#1652). Sorting by lastUpdatedAt instead (as this used to) reshuffled the
14288
+ // Manager/Company "In Progress" list on every poll tick, since lastUpdatedAt changes
14289
+ // with every message a running job emits; createdAt pins a run where it was kicked off.
14014
14290
  return Object.values(state.conversations || {}).flat()
14015
14291
  .filter((conv) => conv && convScope(conv) === area)
14016
- .sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0));
14292
+ .sort(compareByKickoffTime);
14017
14293
  }
14018
14294
 
14019
14295
  // Every area has FRAIMworker. Named employees join an area when that area is
@@ -1987,6 +1987,8 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
1987
1987
  border: none;
1988
1988
  border-radius: 0;
1989
1989
  padding: 0;
1990
+ /* Issue #1651: anchors the drag-over dropzone overlay. */
1991
+ position: relative;
1990
1992
  }
1991
1993
  /* Send button embedded inside the textarea wrapper */
1992
1994
  .coach-input {
@@ -1999,7 +2001,7 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
1999
2001
  resize: vertical;
2000
2002
  border: 1px solid var(--line);
2001
2003
  border-radius: 8px;
2002
- padding: 12px 52px 12px 14px;
2004
+ padding: 12px 52px 12px 44px;
2003
2005
  font: inherit;
2004
2006
  color: var(--text);
2005
2007
  background: var(--surface);
@@ -2022,6 +2024,122 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
2022
2024
  justify-content: center;
2023
2025
  border: none;
2024
2026
  }
2027
+
2028
+ /* Issue #1651: attach button sits to the left inside the same textarea
2029
+ wrapper, sized to match #send (32x32, 6px radius). */
2030
+ .attach-btn {
2031
+ position: absolute;
2032
+ bottom: 7px;
2033
+ left: 7px;
2034
+ width: 32px;
2035
+ height: 32px;
2036
+ padding: 0;
2037
+ border-radius: 6px;
2038
+ border: 1px solid var(--line);
2039
+ background: var(--surface);
2040
+ color: var(--muted);
2041
+ font-size: 15px;
2042
+ line-height: 1;
2043
+ display: flex;
2044
+ align-items: center;
2045
+ justify-content: center;
2046
+ cursor: pointer;
2047
+ }
2048
+ .attach-btn:hover { background: var(--soft); color: var(--text); }
2049
+ .attach-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2050
+
2051
+ /* Staged attachments: chips shown above the composer before send. Thumbnail
2052
+ sizing/radius matches .brand-logo-drop .blp; remove control matches
2053
+ .srch-clear (bare glyph button). */
2054
+ .staged-attachments {
2055
+ display: flex;
2056
+ flex-wrap: wrap;
2057
+ gap: 8px;
2058
+ margin-bottom: 8px;
2059
+ }
2060
+ .staged-attachments:empty { display: none; }
2061
+ .attachment-chip {
2062
+ position: relative;
2063
+ display: flex;
2064
+ align-items: center;
2065
+ gap: 8px;
2066
+ max-width: 220px;
2067
+ border: 1px solid var(--line);
2068
+ border-radius: 10px;
2069
+ background: var(--surface);
2070
+ box-shadow: var(--shadow);
2071
+ padding: 6px 28px 6px 6px;
2072
+ }
2073
+ .attachment-chip .thumb {
2074
+ width: 36px;
2075
+ height: 36px;
2076
+ border-radius: 8px;
2077
+ box-shadow: 0 0 0 1px var(--line);
2078
+ flex-shrink: 0;
2079
+ overflow: hidden;
2080
+ background: var(--soft);
2081
+ display: flex;
2082
+ align-items: center;
2083
+ justify-content: center;
2084
+ font-size: 15px;
2085
+ }
2086
+ .attachment-chip .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
2087
+ .attachment-chip .meta { min-width: 0; }
2088
+ .attachment-chip .fname { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 140px; display: block; }
2089
+ .attachment-chip .fsize { font-size: 11px; color: var(--muted); }
2090
+ .attachment-chip .chip-remove {
2091
+ position: absolute;
2092
+ top: 4px;
2093
+ right: 4px;
2094
+ border: none;
2095
+ background: none;
2096
+ color: var(--muted);
2097
+ cursor: pointer;
2098
+ font-size: 15px;
2099
+ line-height: 1;
2100
+ padding: 2px;
2101
+ }
2102
+ .attachment-chip .chip-remove:hover { color: var(--danger); }
2103
+ .attachment-chip.chip-error { border-color: var(--danger); }
2104
+ .attachment-chip .chip-error-text { font-size: 10.5px; color: var(--danger); }
2105
+
2106
+ /* Drag-over state: dashed accent overlay across the whole coach block, same
2107
+ visual language as .brand-logo-drop. */
2108
+ .composer-dropzone {
2109
+ position: absolute;
2110
+ inset: -6px;
2111
+ display: none;
2112
+ align-items: center;
2113
+ justify-content: center;
2114
+ border: 1px dashed color-mix(in srgb, var(--accent) 55%, var(--line));
2115
+ border-radius: 12px;
2116
+ background: var(--accent-soft);
2117
+ color: var(--accent);
2118
+ font-size: 12.5px;
2119
+ font-weight: 650;
2120
+ z-index: 3;
2121
+ pointer-events: none;
2122
+ }
2123
+ .coach.drag-active .composer-dropzone { display: flex; }
2124
+
2125
+ /* Attachment rendered inline inside a sent transcript message (appendMessageDom). */
2126
+ .msg-attachment {
2127
+ display: inline-flex;
2128
+ align-items: center;
2129
+ gap: 6px;
2130
+ margin-top: 6px;
2131
+ margin-right: 6px;
2132
+ padding: 5px 8px 5px 5px;
2133
+ border: 1px solid var(--line);
2134
+ border-radius: 8px;
2135
+ background: var(--surface);
2136
+ font-size: 11.5px;
2137
+ color: var(--text);
2138
+ }
2139
+ .msg-attachment .thumb { width: 26px; height: 26px; border-radius: 6px; overflow: hidden; background: var(--soft); flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
2140
+ .msg-attachment .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
2141
+
2142
+ .composer-hint { font-size: 11px; color: var(--muted); margin-top: 6px; }
2025
2143
  .coach-note {
2026
2144
  margin-top: 6px;
2027
2145
  color: var(--muted);