@worca/app 1.1.1 → 1.2.0-rc.1

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.
@@ -6,8 +6,8 @@
6
6
  //
7
7
  // Frame classes (spec §6.6 / the P2→P3 contract): job frames carry
8
8
  // {threadId, messageId, seq} and are deduped by the per-job monotonic seq;
9
- // out-of-turn frames (ask-message / ask-title / ask-run-status) upsert by their
10
- // own key. A seq gap is REPORTED ({gap:true}), never healed here — the panel
9
+ // out-of-turn frames (ask-message / ask-title / ask-run-status / ask-worktrees)
10
+ // upsert by their own key. A seq gap is REPORTED ({gap:true}), never healed here — the panel
11
11
  // re-fetches the thread over REST and resubscribes (spec §10.8).
12
12
 
13
13
  const TERMINAL = new Set(['done', 'stopped', 'error']);
@@ -20,10 +20,11 @@ export function createThreadModel({ threadId }) {
20
20
  let live = null;
21
21
  let inFlight = null;
22
22
  let dirty = newDirty();
23
+ let worktrees = []; // P4 §10: the chat's open worktrees (snapshot + ask-worktrees frames); the panel mirrors it
23
24
 
24
25
  function newDirty() {
25
26
  // runLinks dirt is produced but not yet consumed — no v1 UI renders run links directly; the follower notices carry the visible state.
26
- return { structure: false, messages: new Set(), blocks: new Map(), answer: new Set(), label: false, meters: false, title: false, runLinks: false };
27
+ return { structure: false, messages: new Set(), blocks: new Map(), answer: new Set(), label: false, meters: false, title: false, runLinks: false, worktrees: false };
27
28
  }
28
29
 
29
30
  function rowById(id) {
@@ -31,6 +32,17 @@ export function createThreadModel({ threadId }) {
31
32
  return null;
32
33
  }
33
34
 
35
+ // Agent blocks on the LIVE row, unique by id (upsertBlock replaces in place).
36
+ // Finished turns are already inside thread.totals.agents; ask-done replaces
37
+ // those totals and nulls `live` in ONE frame, so the two never overlap.
38
+ function liveAgentCount() {
39
+ const row = live ? rowById(live.messageId) : null;
40
+ if (!row || !Array.isArray(row.blocks)) return 0;
41
+ let n = 0;
42
+ for (const b of row.blocks) if (b && b.kind === 'agent') n += 1;
43
+ return n;
44
+ }
45
+
34
46
  function upsertRow(message) {
35
47
  const i = rows.findIndex((r) => r && r.id === message.id);
36
48
  if (i >= 0) {
@@ -52,6 +64,19 @@ export function createThreadModel({ threadId }) {
52
64
  dirty.structure = true;
53
65
  }
54
66
 
67
+ // The thread's attachment ledger (attachmentsBytes → the composer's budget
68
+ // pre-check) is seeded by the snapshot; without this it would never learn of
69
+ // an upload made in this session, and the composer would let a whole over-
70
+ // budget base64 POST through to the server's 413. Keyed by the store id, so a
71
+ // row seen through both the broadcast and the local echo counts once.
72
+ function noteAttachmentBlocks(blocks) {
73
+ for (const b of Array.isArray(blocks) ? blocks : []) {
74
+ if (!b || b.kind !== 'attachment' || typeof b.id !== 'string') continue;
75
+ if (attachments.some((a) => a && a.id === b.id)) continue;
76
+ attachments.push({ id: b.id, name: b.name, bytes: Number.isFinite(b.bytes) ? b.bytes : 0, kind: b.attKind ?? 'text', mime: b.mime ?? null });
77
+ }
78
+ }
79
+
55
80
  function markBlockDirty(messageId, blockId) {
56
81
  if (!dirty.blocks.has(messageId)) dirty.blocks.set(messageId, new Set());
57
82
  dirty.blocks.get(messageId).add(blockId);
@@ -118,14 +143,14 @@ export function createThreadModel({ threadId }) {
118
143
  }
119
144
  } else if (frame.type === 'ask-start') {
120
145
  ensureStreamingRow(frame.messageId, frame);
121
- live = { messageId: frame.messageId, userMessageId: frame.userMessageId ?? null, label: 'Thinking', startedAt: frame.startedAt ?? null, lastSeq: frame.seq, text: '', usage: null, costUsd: null };
146
+ live = { messageId: frame.messageId, userMessageId: frame.userMessageId ?? null, label: 'Thinking', startedAt: frame.startedAt ?? null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, estimatedCostUsd: null };
122
147
  inFlight = { messageId: frame.messageId };
123
148
  dirty.label = true;
124
149
  } else if (!live && inFlight && frame.messageId === inFlight.messageId) {
125
150
  // Adoption: the ring buffer may have evicted the prefix — accept the first
126
151
  // frame at whatever seq it carries; ask-done.text heals the missing text.
127
152
  ensureStreamingRow(frame.messageId);
128
- live = { messageId: frame.messageId, userMessageId: null, label: null, startedAt: null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, adopted: true };
153
+ live = { messageId: frame.messageId, userMessageId: null, label: null, startedAt: null, lastSeq: frame.seq, text: '', usage: null, costUsd: null, estimatedCostUsd: null, adopted: true };
129
154
  } else {
130
155
  return { dropped: 'no-live' };
131
156
  }
@@ -149,11 +174,13 @@ export function createThreadModel({ threadId }) {
149
174
  if (frame.block && frame.block.id != null) {
150
175
  upsertBlock(row, frame.block);
151
176
  markBlockDirty(frame.messageId, frame.block.id);
177
+ if (frame.type === 'ask-block' && frame.block.kind === 'agent') dirty.meters = true; // "N agents" moves live
152
178
  }
153
179
  break;
154
180
  case 'ask-usage':
155
181
  live.usage = frame.usage ?? null;
156
182
  live.costUsd = frame.costUsd ?? null;
183
+ live.estimatedCostUsd = Number.isFinite(frame.estimatedCostUsd) ? frame.estimatedCostUsd : null; // display-only
157
184
  dirty.meters = true;
158
185
  break;
159
186
  case 'ask-done':
@@ -187,6 +214,7 @@ export function createThreadModel({ threadId }) {
187
214
  const m = frame.message;
188
215
  if (!m || typeof m.id !== 'string') return { dropped: 'no-live' };
189
216
  upsertRow(m);
217
+ noteAttachmentBlocks(m.blocks);
190
218
  dirty.messages.add(m.id);
191
219
  return { ok: true };
192
220
  }
@@ -202,6 +230,11 @@ export function createThreadModel({ threadId }) {
202
230
  dirty.runLinks = true;
203
231
  return { ok: true };
204
232
  }
233
+ case 'ask-worktrees':
234
+ if (!Array.isArray(frame.worktrees)) return { dropped: 'no-live' };
235
+ worktrees = frame.worktrees.slice();
236
+ dirty.worktrees = true;
237
+ return { ok: true };
205
238
  default:
206
239
  return { dropped: 'no-live' };
207
240
  }
@@ -217,6 +250,7 @@ export function createThreadModel({ threadId }) {
217
250
  for (const l of Array.isArray(snapshot.runLinks) ? snapshot.runLinks : []) {
218
251
  links.set(l.runId, { pipelineId: l.pipelineId ?? null, cardId: l.cardId ?? null, status: l.status ?? null, phase: l.phase ?? null });
219
252
  }
253
+ worktrees = Array.isArray(snapshot.worktrees) ? snapshot.worktrees.slice() : []; // the fresh-thread load carries no key
220
254
  live = null;
221
255
  inFlight = snapshot.inFlight ?? null;
222
256
  dirty = newDirty();
@@ -225,6 +259,7 @@ export function createThreadModel({ threadId }) {
225
259
  dirty.meters = true;
226
260
  dirty.runLinks = true;
227
261
  dirty.label = true;
262
+ dirty.worktrees = true;
228
263
  },
229
264
  apply(frame) {
230
265
  if (!frame || frame.threadId !== threadId) return { dropped: 'other-thread' };
@@ -239,11 +274,22 @@ export function createThreadModel({ threadId }) {
239
274
  messages() { return rows; },
240
275
  thread() { return thread; },
241
276
  totals() {
242
- return { ...thread.totals, live: live ? { usage: live.usage, costUsd: live.costUsd } : null };
277
+ const base = thread.totals && typeof thread.totals === 'object' ? thread.totals : {};
278
+ const liveAgents = live ? liveAgentCount() : 0;
279
+ return {
280
+ ...base,
281
+ ...(liveAgents ? { agents: (Number.isFinite(base.agents) ? base.agents : 0) + liveAgents } : {}),
282
+ live: live ? { usage: live.usage, costUsd: live.costUsd, estimatedCostUsd: live.estimatedCostUsd } : null,
283
+ };
243
284
  },
244
285
  inFlight() { return inFlight; },
245
286
  live() { return live; },
246
287
  runLinks() { return links; },
288
+ worktrees() { return worktrees; },
289
+ setWorktrees(list) { // the panel's heal (refreshWorktrees) feeds the same store the frames do
290
+ worktrees = Array.isArray(list) ? list.slice() : [];
291
+ dirty.worktrees = true;
292
+ },
247
293
  attachmentsBytes() { return attachments.reduce((n, a) => n + (a && Number.isFinite(a.bytes) ? a.bytes : 0), 0); },
248
294
  findCard(cardId) {
249
295
  for (const r of rows) {
@@ -253,11 +299,18 @@ export function createThreadModel({ threadId }) {
253
299
  return null;
254
300
  },
255
301
  noteLocalUserMessage({ id, text, attachments: atts }) {
302
+ // The POST-side ask-message broadcast can land BEFORE the 202 resolves. That
303
+ // row is the persisted one (seq, store-minted attachment ids); an echo that
304
+ // replaced it would turn an image thumbnail back into a name pill until the
305
+ // next reload. Nothing the echo carries is newer than it, so keep it.
306
+ if (rowById(id)) { dirty.messages.add(id); return; }
307
+ const blocks = (Array.isArray(atts) ? atts : []).map((a) => ({ kind: 'attachment', id: a.id ?? null, name: a.name, bytes: a.bytes, attKind: a.attKind ?? 'text', mime: a.mime ?? null }));
256
308
  upsertRow({
257
309
  id, threadId, seq: undefined, role: 'user', text: String(text ?? ''),
258
- blocks: (Array.isArray(atts) ? atts : []).map((a) => ({ kind: 'attachment', id: a.id ?? null, name: a.name, bytes: a.bytes })),
310
+ blocks,
259
311
  status: null, reason: null, model: null, effort: null, usage: null, costUsd: null, durationMs: null, createdAt: null,
260
312
  });
313
+ noteAttachmentBlocks(blocks);
261
314
  dirty.messages.add(id);
262
315
  },
263
316
  });