create-walle 0.9.32 → 0.9.33

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.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/template/claude-task-manager/docs/postgres-storage-abstraction.html +732 -0
  3. package/template/claude-task-manager/lib/headless-term-service.js +29 -1
  4. package/template/claude-task-manager/lib/state-sync/emit-clamp.js +32 -0
  5. package/template/claude-task-manager/lib/state-sync/frame-emitter.js +1 -1
  6. package/template/claude-task-manager/lib/walle-ctm-history.js +115 -6
  7. package/template/claude-task-manager/public/css/walle-session.css +57 -1
  8. package/template/claude-task-manager/public/index.html +66 -8
  9. package/template/claude-task-manager/public/js/message-renderer.js +87 -0
  10. package/template/claude-task-manager/public/js/state-sync-client.js +32 -0
  11. package/template/claude-task-manager/public/js/walle-session.js +324 -90
  12. package/template/claude-task-manager/server.js +122 -7
  13. package/template/claude-task-manager/workers/state-detectors/codex.js +25 -6
  14. package/template/claude-task-manager/workers/state-detectors/index.js +1 -0
  15. package/template/package.json +1 -1
  16. package/template/wall-e/chat.js +7 -75
  17. package/template/wall-e/coding/coding-run-controller.js +118 -195
  18. package/template/wall-e/coding/compaction-service.js +91 -10
  19. package/template/wall-e/coding/no-progress-guard.js +4 -1
  20. package/template/wall-e/coding/output-boundary.js +94 -0
  21. package/template/wall-e/coding/permission-service.js +15 -0
  22. package/template/wall-e/coding/session-workspaces.js +70 -0
  23. package/template/wall-e/coding/tool-execution-controller.js +54 -3
  24. package/template/wall-e/coding-orchestrator.js +40 -299
  25. package/template/wall-e/coding-prompts.js +62 -8
  26. package/template/wall-e/context/token-counter.js +35 -15
  27. package/template/wall-e/tools/local-tools.js +45 -17
  28. package/template/wall-e/tools/permission-checker.js +5 -0
@@ -710,6 +710,24 @@ async function handleMessage(msg) {
710
710
  break;
711
711
  }
712
712
 
713
+ // Option B: bottom-anchor clamp. The main thread sets this to the held primary SHRINK target while a
714
+ // rows-only resize is deferred mid-stream, so extractGrid emits only the bottom N viewport rows (the
715
+ // agent's composer stays inside the frame instead of CUP-clamping blank onto a shorter client). rows<=0
716
+ // clears it. The emitted grid SHAPE changes, so force a clean keyframe on any change (diffing a
717
+ // bottom-anchored grid against a top-anchored acked grid would smear the transition).
718
+ case 'set-emit-clamp': {
719
+ const entry = terminals.get(msg.sessionId);
720
+ if (!entry) break;
721
+ const next = Math.max(0, Number(msg.rows) || 0);
722
+ if (Number(entry.emitClampRows || 0) === next) break; // no change → no keyframe churn
723
+ entry.emitClampRows = next;
724
+ if (_stateSyncEmitter) {
725
+ if (_stateSyncFramesSuppressed(msg.sessionId)) _stateSyncMarkHiddenDirty(msg.sessionId);
726
+ else _stateSyncEmitter.onKeyframeReq(msg.sessionId, {});
727
+ }
728
+ break;
729
+ }
730
+
713
731
  // Option B: client confirmed it applied frame `seq` (no-op when state-sync is off).
714
732
  case 'ack': {
715
733
  if (_stateSyncEmitter) _stateSyncEmitter.onAck(msg.sessionId, Number(msg.seq || 0));
@@ -946,8 +964,18 @@ function extractGrid(sessionId, opts) {
946
964
  if (!length || !viewportRows) return { cols: Number(term.cols || 0), rows: 0, rowAnsi: [], baseRow: 0 };
947
965
  const scrollbackRows = Math.max(0, Number(opts && opts.scrollbackRows) || 0);
948
966
  const baseY = Math.max(0, Number(buf.baseY || 0));
949
- const start = Math.max(0, baseY - scrollbackRows);
967
+ // Bottom-anchor clamp: while a primary rows-only SHRINK resize is HELD (StreamResizePolicy flap-hold),
968
+ // the PTY/mirror stays TALLER than the already-shrunk client. A top-aligned full-height frame would push
969
+ // the agent's bottom-pinned composer past the client's last row, where every CUP write clamps onto it and
970
+ // the composer renders blank. Emit only the BOTTOM `emitClampRows` viewport rows so the composer is always
971
+ // inside the frame and the (matching-height) client paints it 1:1. Sacrifices the top (held-delta) viewport
972
+ // rows for the turn; the held resize reconciles them on apply (keyframe). 0 / >= viewportRows ⇒ no clamp.
973
+ // Only narrows the LIVE viewport window (scrollbackRows === 0) — snapshot/scrollback extraction is untouched.
974
+ const clampRows = Math.max(0, Number(entry.emitClampRows || 0));
975
+ const topTrim = (scrollbackRows === 0 && clampRows > 0 && clampRows < viewportRows) ? (viewportRows - clampRows) : 0;
950
976
  const end = Math.min(length - 1, baseY + viewportRows - 1);
977
+ let start = Math.max(0, baseY + topTrim - scrollbackRows);
978
+ if (start > end) start = Math.max(0, baseY - scrollbackRows); // defensive: never invert the range
951
979
  const rowAnsi = [];
952
980
  for (let r = start; r <= end; r++) {
953
981
  rowAnsi.push(serializeRow(buf.getLine(r), term.cols));
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+ // Option B state-sync — bottom-anchor emit clamp decision (server side).
3
+ //
4
+ // While a primary rows-only SHRINK resize is HELD mid-stream (StreamResizePolicy flap-hold), the PTY/mirror
5
+ // stays TALLER than the already-shrunk client. A top-aligned full-height frame then positions the agent's
6
+ // bottom-pinned composer at rows past the client's last row, where xterm CUP-clamps every write onto the
7
+ // bottom row and the composer renders BLANK (the "running Claude composer is blank, reflow won't fix" bug).
8
+ // The fix bottom-anchors the worker's emitted grid to the held target height so the composer stays inside
9
+ // the frame; this predicate decides the clamp value from the PTY + held-resize dims.
10
+ //
11
+ // Returns the clampRows (the held target row count) when a bottom-anchor clamp should be engaged, else 0
12
+ // (no clamp / full viewport). Engages ONLY for the rows-only shrink signature:
13
+ // - a resize is held (pend present, positive dims), AND
14
+ // - cols are unchanged (a cols change is a real resize handled promptly via width arbitration — never
15
+ // bottom-anchor it), AND
16
+ // - the held target is strictly SHORTER than the current PTY (a GROW is the client-taller / orphan-rows
17
+ // case, compensated on the client, not here).
18
+ // Fail-safe: any non-finite / non-positive input ⇒ 0 (no clamp).
19
+ function bottomAnchorClampRows(opts) {
20
+ const o = opts || {};
21
+ const ptyCols = Number(o.ptyCols);
22
+ const ptyRows = Number(o.ptyRows);
23
+ const pendCols = Number(o.pendCols);
24
+ const pendRows = Number(o.pendRows);
25
+ if (![ptyCols, ptyRows, pendCols, pendRows].every(Number.isFinite)) return 0;
26
+ if (ptyCols <= 0 || ptyRows <= 0 || pendCols <= 0 || pendRows <= 0) return 0;
27
+ if (pendCols !== ptyCols) return 0; // cols change ⇒ real resize, not the chrome-flap shrink
28
+ if (pendRows >= ptyRows) return 0; // grow / equal ⇒ nothing to bottom-anchor
29
+ return pendRows;
30
+ }
31
+
32
+ module.exports = { bottomAnchorClampRows };
@@ -105,7 +105,7 @@ function createFrameEmitter(deps) {
105
105
  const cursorMoved = !cursorsEqual(grid.cursor, s.ackedGrid && s.ackedGrid.cursor);
106
106
  if (!force && !cursorMoved && gridsEqual(grid, s.ackedGrid)) return false; // nothing changed since last applied frame
107
107
  const patch = computeAnsiPatch(force ? EMPTY_GRID : s.ackedGrid, grid);
108
- if (patch.changedRows === 0 && !force && !cursorMoved) return false;
108
+ if (!patch.ansi && !force && !cursorMoved) return false;
109
109
  s.seq += 1;
110
110
  noteEmit(s.fr, t);
111
111
  s.dirty = false;
@@ -398,8 +398,27 @@ function compactWalleToolCallSummary(call = {}) {
398
398
  return summary ? `${head}: ${summary}` : head;
399
399
  }
400
400
 
401
+ function turnActivityCalls(toolCalls = [], sawThinking = false, completed = false) {
402
+ const calls = Array.isArray(toolCalls) ? cloneToolCalls(toolCalls).filter(Boolean) : [];
403
+ if (sawThinking && !calls.some((call) => call && (call.name === 'think' || call.tool === 'think'))) {
404
+ calls.unshift({
405
+ name: 'think',
406
+ tool: 'think',
407
+ summary: completed ? 'Reasoning step completed' : 'Wall-E is thinking',
408
+ status: completed ? 'done' : 'working',
409
+ output: '',
410
+ });
411
+ }
412
+ if (completed) {
413
+ for (const call of calls) {
414
+ if (call && (!call.status || call.status === 'working')) call.status = 'done';
415
+ }
416
+ }
417
+ return calls;
418
+ }
419
+
401
420
  function openTurnActivityMessage(toolCalls = [], sawThinking = false, timestamp = '') {
402
- const calls = Array.isArray(toolCalls) ? toolCalls.filter(Boolean) : [];
421
+ const calls = turnActivityCalls(toolCalls, sawThinking, false);
403
422
  if (!sawThinking && calls.length === 0) return null;
404
423
  const lines = ['Wall-E is working on this prompt.'];
405
424
  const visibleCalls = calls.slice(-6);
@@ -413,6 +432,12 @@ function openTurnActivityMessage(toolCalls = [], sawThinking = false, timestamp
413
432
  role: 'system',
414
433
  content: lines.join('\n'),
415
434
  timestamp,
435
+ // Stamp a uuid on this synthesized live-activity row (it has no backing jsonl
436
+ // record) so EVERY displayed message carries a uuid. Without it, the client's
437
+ // all-or-nothing uuid gate used to disable the branch pager for the whole session
438
+ // whenever an open turn was present. It threads to its linear predecessor on the
439
+ // client (no parentUuid), and is replaced on the next durable reload.
440
+ uuid: `walle-open-turn-${timestamp || 'live'}`,
416
441
  toolCalls: calls.length
417
442
  ? cloneToolCalls(calls)
418
443
  : [{ name: 'think', tool: 'think', summary: 'Wall-E is thinking', status: 'working' }],
@@ -422,6 +447,27 @@ function openTurnActivityMessage(toolCalls = [], sawThinking = false, timestamp
422
447
  };
423
448
  }
424
449
 
450
+ function completedTurnActivityMessage(toolCalls = [], sawThinking = false, timestamp = '', key = '') {
451
+ const calls = turnActivityCalls(toolCalls, sawThinking, true);
452
+ if (!sawThinking && calls.length === 0) return null;
453
+ const lines = ['Wall-E activity log for this prompt.'];
454
+ const visibleCalls = calls.slice(-8);
455
+ if (visibleCalls.length) {
456
+ for (const call of visibleCalls) lines.push(`- ${compactWalleToolCallSummary(call)}`);
457
+ if (calls.length > visibleCalls.length) lines.push(`- +${calls.length - visibleCalls.length} earlier activities`);
458
+ }
459
+ return {
460
+ role: 'system',
461
+ content: lines.join('\n'),
462
+ timestamp,
463
+ uuid: `walle-completed-turn-${key || timestamp || 'activity'}`,
464
+ toolCalls: calls,
465
+ liveActivity: true,
466
+ agentLabel: 'Wall-E',
467
+ metadata: { source: 'walle-completed-turn-activity', completed: true },
468
+ };
469
+ }
470
+
425
471
  function terminalBoundaryMessageFromEntry(entry) {
426
472
  if (!entry || typeof entry !== 'object') return null;
427
473
  const type = String(entry.type || '').trim();
@@ -597,6 +643,11 @@ function readWalleCtmHistory(filePath) {
597
643
  let openTurnHasUser = false;
598
644
  let pendingAssistantText = '';
599
645
  let pendingAssistantTimestamp = '';
646
+ // A coding_summary part is written just BEFORE the turn's final assistant entry
647
+ // (the event fires right before `done`), so buffer it and stamp it onto that
648
+ // assistant message when it's pushed. Also attached to the last assistant at EOF
649
+ // for the rare case the part trails the assistant entry.
650
+ let pendingCodingSummary = null;
600
651
  // Durable approval cards: a permission_request part becomes a card message; a
601
652
  // later permission_resolved part marks that same card resolved (so a reload
602
653
  // shows an answered request as resolved, and a still-pending one as actionable).
@@ -607,14 +658,27 @@ function readWalleCtmHistory(filePath) {
607
658
  const parentByUuid = Object.create(null);
608
659
  let pendingAssistantUuid = '';
609
660
  let pendingAssistantParent = null;
661
+ let syntheticActivityOrdinal = 0;
662
+ const pushCompletedTurnActivity = (toolCalls, sawThinking, timestamp, key) => {
663
+ const activity = completedTurnActivityMessage(
664
+ toolCalls,
665
+ sawThinking,
666
+ timestamp,
667
+ `${key || 'turn'}-${syntheticActivityOrdinal++}`
668
+ );
669
+ if (!activity) return false;
670
+ messages.push(activity);
671
+ return true;
672
+ };
610
673
  const flushPendingAssistantText = (options = {}) => {
611
674
  const content = normalizeWalleDisplayContent(pendingAssistantText);
612
675
  if (!content) { pendingAssistantUuid = ''; pendingAssistantParent = null; return false; }
613
- messages.push({
676
+ const assistantToolCalls = cloneToolCalls(pendingToolCalls);
677
+ const assistantMessage = {
614
678
  role: 'assistant',
615
679
  content,
616
680
  timestamp: pendingAssistantTimestamp || openTurnActivityTimestamp || 0,
617
- toolCalls: cloneToolCalls(pendingToolCalls),
681
+ toolCalls: assistantToolCalls,
618
682
  uuid: pendingAssistantUuid || undefined,
619
683
  parentUuid: pendingAssistantParent != null ? pendingAssistantParent : undefined,
620
684
  metadata: {
@@ -622,7 +686,16 @@ function readWalleCtmHistory(filePath) {
622
686
  partial: options.partial !== false,
623
687
  boundary: options.boundary || '',
624
688
  },
625
- });
689
+ };
690
+ if (pushCompletedTurnActivity(
691
+ assistantToolCalls,
692
+ openTurnSawThinking,
693
+ assistantMessage.timestamp,
694
+ pendingAssistantUuid || assistantMessage.timestamp || 'text'
695
+ )) {
696
+ assistantMessage.metadata.activityLogged = true;
697
+ }
698
+ messages.push(assistantMessage);
626
699
  pendingAssistantText = '';
627
700
  pendingAssistantTimestamp = '';
628
701
  pendingAssistantUuid = '';
@@ -721,6 +794,14 @@ function readWalleCtmHistory(filePath) {
721
794
  }
722
795
  continue;
723
796
  }
797
+ if (entry.type === 'walle_part' && entry.partType === 'coding_summary') {
798
+ // Buffer it — the assistant entry it belongs to is written NEXT. Stored under
799
+ // metadata.codingSummary (not a top-level field) so it survives the durable DB
800
+ // row store, which persists `metadata` but not arbitrary message fields
801
+ // (db._messageRowMeta).
802
+ pendingCodingSummary = entry.data && typeof entry.data === 'object' ? entry.data : {};
803
+ continue;
804
+ }
724
805
  const textPart = walleTextPartContent(entry);
725
806
  if (textPart) {
726
807
  if (!pendingAssistantUuid && entry.uuid) {
@@ -783,7 +864,7 @@ function readWalleCtmHistory(filePath) {
783
864
  openTurnHasUser = false;
784
865
  continue;
785
866
  }
786
- messages.push({
867
+ const assistantMessage = {
787
868
  role: 'assistant',
788
869
  content: candidateContent,
789
870
  model: entry.message?.model || entry.model || entry.modelId || '',
@@ -792,7 +873,20 @@ function readWalleCtmHistory(filePath) {
792
873
  uuid: entry.uuid || undefined,
793
874
  parentUuid: entry.parentUuid != null ? entry.parentUuid : undefined,
794
875
  toolCalls: dedupeToolCallsById(Array.isArray(entry.toolCalls) ? cloneToolCalls(entry.toolCalls) : cloneToolCalls(pendingToolCalls)),
795
- });
876
+ };
877
+ if (pushCompletedTurnActivity(
878
+ assistantMessage.toolCalls,
879
+ openTurnSawThinking,
880
+ assistantMessage.timestamp,
881
+ entry.uuid || entry.timestamp || 'assistant'
882
+ )) {
883
+ assistantMessage.metadata = Object.assign({}, assistantMessage.metadata, { activityLogged: true });
884
+ }
885
+ if (pendingCodingSummary) {
886
+ assistantMessage.metadata = Object.assign({}, assistantMessage.metadata, { codingSummary: pendingCodingSummary });
887
+ pendingCodingSummary = null;
888
+ }
889
+ messages.push(assistantMessage);
796
890
  pendingToolCalls = [];
797
891
  openTurnSawThinking = false;
798
892
  openTurnActivityTimestamp = '';
@@ -800,6 +894,17 @@ function readWalleCtmHistory(filePath) {
800
894
  }
801
895
  }
802
896
  flushPendingAssistantText({ boundary: 'eof' });
897
+ // Rare: a coding_summary part that trailed the assistant entry (or whose turn
898
+ // ended without a fresh assistant push) — stamp it onto the last assistant.
899
+ if (pendingCodingSummary) {
900
+ for (let k = messages.length - 1; k >= 0; k--) {
901
+ if (messages[k] && messages[k].role === 'assistant') {
902
+ messages[k].metadata = Object.assign({}, messages[k].metadata, { codingSummary: pendingCodingSummary });
903
+ break;
904
+ }
905
+ }
906
+ pendingCodingSummary = null;
907
+ }
803
908
  const openActivity = openTurnHasUser
804
909
  ? openTurnActivityMessage(pendingToolCalls, openTurnSawThinking, openTurnActivityTimestamp)
805
910
  : null;
@@ -1041,6 +1146,10 @@ function cloneMessage(message) {
1041
1146
  if (message?.metadata) {
1042
1147
  try { out.metadata = JSON.parse(JSON.stringify(message.metadata)); } catch { out.metadata = message.metadata; }
1043
1148
  }
1149
+ if (message?.codingSummary) {
1150
+ // Whitelist clone — carry the pinned summary-card payload through the merge.
1151
+ try { out.codingSummary = JSON.parse(JSON.stringify(message.codingSummary)); } catch { out.codingSummary = message.codingSummary; }
1152
+ }
1044
1153
  if (message?.runtimeDiagnostic) {
1045
1154
  try { out.runtimeDiagnostic = JSON.parse(JSON.stringify(message.runtimeDiagnostic)); } catch { out.runtimeDiagnostic = message.runtimeDiagnostic; }
1046
1155
  }
@@ -179,11 +179,13 @@
179
179
  flex: 0 1 auto;
180
180
  min-width: 3ch;
181
181
  }
182
+ /* cwd · msgs meta now LEADS the right-aligned control cluster (no title/icon on
183
+ the left — CTM-faithful), so it centre-aligns against the 24px chip/nav/⋯. */
182
184
  .walle-session-header .session-meta {
183
185
  font-size: 12px;
184
186
  color: var(--fg-dim, #565f89);
185
187
  display: flex;
186
- align-items: baseline;
188
+ align-items: center;
187
189
  gap: 6px;
188
190
  min-width: 0;
189
191
  flex: 0 1 auto;
@@ -1187,6 +1189,12 @@ body.walle-composer-resizing {
1187
1189
  display: grid;
1188
1190
  gap: 5px;
1189
1191
  margin-top: 7px;
1192
+ /* Append-only progress log (WS1): show every step, but cap the panel height and
1193
+ scroll internally (pinned to the newest step) so a long turn reads like a
1194
+ scrollback without pushing the composer off-screen. */
1195
+ max-height: 340px;
1196
+ overflow-y: auto;
1197
+ overscroll-behavior: contain;
1190
1198
  }
1191
1199
  .walle-live-activity-item {
1192
1200
  display: block;
@@ -2620,3 +2628,51 @@ html[data-theme="light"] .walle-error-details pre {
2620
2628
  html[data-theme="light"] .walle-image-viewer {
2621
2629
  background: var(--overlay-bg);
2622
2630
  }
2631
+
2632
+ /* ── Coding summary card (WS2) ─────────────────────────────────────────────
2633
+ Deterministic, actionable wrap-up pinned atop a completed coding turn. Built
2634
+ by MR.renderCodingSummaryHtml; data from coding-run-controller buildCodingSummary. */
2635
+ .coding-summary-card{
2636
+ border:1px solid var(--border,#3b4261);border-radius:10px;
2637
+ background:var(--bg-card,#24283b);
2638
+ margin:8px 0 12px;overflow:hidden;
2639
+ box-shadow:0 3px 12px rgba(0,0,0,.22);
2640
+ }
2641
+ .coding-summary-card .csum-top{
2642
+ display:flex;align-items:center;gap:9px;padding:9px 13px;
2643
+ border-bottom:1px solid var(--border,#3b4261);
2644
+ background:linear-gradient(180deg,rgba(122,162,247,.07),transparent);
2645
+ }
2646
+ .coding-summary-card .csum-badge{
2647
+ font-size:11px;font-weight:700;letter-spacing:.02em;padding:3px 9px;border-radius:999px;
2648
+ }
2649
+ .coding-summary-card .csum-badge.changed{background:rgba(224,175,104,.16);color:#e0af68;border:1px solid rgba(224,175,104,.4)}
2650
+ .coding-summary-card .csum-badge.ok{background:rgba(158,206,106,.16);color:#9ece6a;border:1px solid rgba(158,206,106,.4)}
2651
+ .coding-summary-card .csum-badge.err{background:rgba(247,118,142,.16);color:#f7768e;border:1px solid rgba(247,118,142,.42)}
2652
+ .coding-summary-card .csum-verify{font-size:11px;font-weight:600}
2653
+ .coding-summary-card .csum-verify.ok{color:#9ece6a}
2654
+ .coding-summary-card .csum-verify.warn{color:#787c99}
2655
+ .coding-summary-card .csum-sec{padding:9px 13px;border-bottom:1px solid var(--border,#3b4261)}
2656
+ .coding-summary-card .csum-sec:last-child{border-bottom:0}
2657
+ .coding-summary-card .csum-lbl{
2658
+ font-size:10px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;
2659
+ color:var(--fg-dim,#565f89);margin-bottom:6px;
2660
+ }
2661
+ .coding-summary-card .csum-file{display:flex;align-items:baseline;gap:8px;
2662
+ font-family:var(--mono,ui-monospace,Menlo,monospace);font-size:12.5px;padding:1.5px 0}
2663
+ .coding-summary-card .csum-mk{color:#9ece6a;flex:0 0 auto}
2664
+ .coding-summary-card .csum-path{color:#7dcfff;word-break:break-all}
2665
+ .coding-summary-card .csum-runbox{
2666
+ background:var(--bg,#1a1b26);border:1px solid var(--border,#3b4261);border-radius:7px;
2667
+ padding:8px 10px;font-family:var(--mono,ui-monospace,Menlo,monospace);font-size:12px;
2668
+ }
2669
+ .coding-summary-card .csum-url{margin-bottom:3px}
2670
+ .coding-summary-card .csum-url a{color:#7dcfff;text-decoration:none}
2671
+ .coding-summary-card .csum-url a:hover{text-decoration:underline}
2672
+ .coding-summary-card .csum-cwd{color:var(--fg-dim,#565f89)}
2673
+ .coding-summary-card .csum-note{color:var(--fg,#c0caf5)}
2674
+ .coding-summary-card .csum-source{padding:2px 0;word-break:break-word}
2675
+ .coding-summary-card .csum-source-tool{color:#bb9af7;font-weight:700}
2676
+ .coding-summary-card .csum-source-detail{color:var(--fg,#c0caf5)}
2677
+ html[data-theme="light"] .coding-summary-card{background:#fff}
2678
+ html[data-theme="light"] .coding-summary-card .csum-runbox{background:#f4f5fb}
@@ -4200,6 +4200,19 @@
4200
4200
  #tabbar-session-controls .session-toolbar {
4201
4201
  background: transparent; border-bottom: none; padding: 0; z-index: auto;
4202
4202
  }
4203
+ /* Wall-E coding sessions hoist their whole `.walle-session-header` here (same
4204
+ reparent as the Claude/Codex `.session-toolbar` above). Shed the full-width
4205
+ band chrome — background, bottom border, 44px min-height, padding — so it
4206
+ reads as an inline control cluster (cwd · msgs · model chip · nav · ⋯) on
4207
+ the tab strip instead of a band. The inner `.walle-header-right` already
4208
+ carries the chip/nav styling. */
4209
+ #tabbar-session-controls .walle-session-header {
4210
+ background: transparent; border-bottom: none; border-top: none;
4211
+ padding: 0; min-height: 0; height: 100%; gap: 0; flex: 0 0 auto;
4212
+ }
4213
+ #tabbar-session-controls .walle-session-header .walle-header-right { margin-left: 0; }
4214
+ /* The cwd · msgs meta is a touch smaller/dimmer on the dense tab strip. */
4215
+ #tabbar-session-controls .walle-session-header .session-meta { font-size: 11px; }
4203
4216
  .session-toolbar-btn .tb-label { margin-left: 4px; }
4204
4217
  /* Compact density to match the approved mock: when merged onto the tab strip,
4205
4218
  Reflow shows icon-only and the view toggle abbreviates to Term | Conv. The
@@ -39903,7 +39916,7 @@ function _disposeSessionClientSurface(s) {
39903
39916
  try {
39904
39917
  const sid = (s && s._id) || (s && s.container && s.container.dataset && s.container.dataset.sessionId) || '';
39905
39918
  const slot = sid && document.getElementById('tabbar-session-controls');
39906
- const parked = slot && slot.querySelector(':scope > .session-toolbar[data-session-id="' + sid + '"]');
39919
+ const parked = slot && slot.querySelector(':scope > .session-toolbar[data-session-id="' + sid + '"], #tabbar-session-controls > .walle-session-header[data-session-id="' + sid + '"]');
39907
39920
  if (parked) parked.remove();
39908
39921
  } catch {}
39909
39922
  }
@@ -43044,6 +43057,22 @@ function renderTabs(force) {
43044
43057
  // (model · view toggle · reflow · ⋯) onto the right edge of the tab strip so the
43045
43058
  // chrome reads as one row. In split view, restore each toolbar to its own pane.
43046
43059
  // Idempotent + cheap; safe to call from renderTabs / split layout changes.
43060
+ // A session's hoistable toolbar lives in a different container per agent type:
43061
+ // - Claude/Codex/OpenCode: <.term-container data-session-id> > .session-toolbar
43062
+ // - Wall-E coding: <#walle-session-ID> > .walle-session-header
43063
+ // These two helpers paper over that so the hoist logic is agent-agnostic and
43064
+ // Wall-E sessions get their controls on the tab strip exactly like the rest.
43065
+ function _hoistHomeContainer(sid) {
43066
+ if (!sid) return null;
43067
+ return document.querySelector('.term-container[data-session-id="' + sid + '"]')
43068
+ || document.getElementById('walle-session-' + sid);
43069
+ }
43070
+ function _hoistToolbarIn(cont) {
43071
+ if (!cont) return null;
43072
+ return cont.querySelector(':scope > .session-toolbar')
43073
+ || cont.querySelector(':scope > .walle-session-header');
43074
+ }
43075
+
43047
43076
  function _syncTabbarSessionControls() {
43048
43077
  const slot = document.getElementById('tabbar-session-controls');
43049
43078
  if (!slot) return;
@@ -43055,9 +43084,9 @@ function _syncTabbarSessionControls() {
43055
43084
  // prefer its container's own child, else a copy already parked in the slot.
43056
43085
  let keep = null;
43057
43086
  if (activeId) {
43058
- const activeCont = document.querySelector('.term-container[data-session-id="' + activeId + '"]');
43059
- keep = (activeCont && activeCont.querySelector(':scope > .session-toolbar'))
43060
- || slot.querySelector(':scope > .session-toolbar[data-session-id="' + activeId + '"]');
43087
+ keep = _hoistToolbarIn(_hoistHomeContainer(activeId))
43088
+ || slot.querySelector(':scope > .session-toolbar[data-session-id="' + activeId + '"]')
43089
+ || slot.querySelector(':scope > .walle-session-header[data-session-id="' + activeId + '"]');
43061
43090
  }
43062
43091
  // Evict every parked toolbar except the one we keep: restore it to its own pane if
43063
43092
  // that pane has no toolbar of its own, otherwise drop it. This clears both orphans
@@ -43067,8 +43096,8 @@ function _syncTabbarSessionControls() {
43067
43096
  Array.prototype.slice.call(slot.children).forEach((tb) => {
43068
43097
  if (tb === keep) return;
43069
43098
  const sid = tb && tb.dataset ? tb.dataset.sessionId : '';
43070
- const cont = sid ? document.querySelector('.term-container[data-session-id="' + sid + '"]') : null;
43071
- const paneHasOwnToolbar = !!(cont && cont.querySelector(':scope > .session-toolbar'));
43099
+ const cont = _hoistHomeContainer(sid);
43100
+ const paneHasOwnToolbar = !!_hoistToolbarIn(cont);
43072
43101
  if (cont && !paneHasOwnToolbar) cont.insertBefore(tb, cont.firstChild);
43073
43102
  else { try { slot.removeChild(tb); } catch {} }
43074
43103
  });
@@ -44437,9 +44466,9 @@ function showNewSessionModal() {
44437
44466
  // `custom`, which aren't LLM-branded surfaces.
44438
44467
  var agentDefs = [
44439
44468
  { id: 'claude:', name: 'Claude Code', sub: 'Anthropic', color: '#D97757', agent: 'claude', detect: 'claude' },
44469
+ { id: 'codex:', name: 'Codex', sub: 'OpenAI', color: '#22c55e', agent: 'codex', detect: 'codex' },
44440
44470
  { id: 'opencode:', name: 'OpenCode', sub: 'GLM 5.2', color: '#e0af68', agent: 'opencode', detect: 'opencode' },
44441
44471
  { id: 'walle:', name: 'Wall-E', sub: 'Smart routing', color: '#f59e0b', agent: 'walle', detect: null },
44442
- { id: 'codex:', name: 'Codex', sub: 'OpenAI', color: '#22c55e', agent: 'codex', detect: 'codex' },
44443
44472
  { id: 'cursor:', name: 'Cursor', sub: 'Anysphere', color: '#111827', accent: '#7aa2f7', agent: 'cursor', detect: 'cursor-agent' },
44444
44473
  { id: 'gemini:', name: 'Gemini CLI', sub: 'Google', color: '#3b82f6', agent: 'gemini', detect: 'gemini' },
44445
44474
  { id: 'shell:', name: 'Shell', sub: 'Terminal', color: '#6b7280', icon: '$', detect: null },
@@ -52190,7 +52219,12 @@ function onDataChanged(msg) {
52190
52219
  // Reload current queue draft if viewing the same session
52191
52220
  const sid = getQpSessionId();
52192
52221
  if (sid) {
52193
- if (qpDraftSavePending(sid)) {
52222
+ // Defer the reload while a save is in flight (avoid clobbering our own
52223
+ // write) OR while an inline edit is open: loadQpDraftForSession resets
52224
+ // qpEditingIdx and re-renders, which would close the editor mid-edit
52225
+ // (e.g. right after pasting an image, whose upload echoes a draft-change
52226
+ // broadcast back). The deferred reload runs when the edit ends.
52227
+ if (qpDraftSavePending(sid) || qpEditingIdx >= 0) {
52194
52228
  qpDeferredDraftReloads.add(sid);
52195
52229
  } else {
52196
52230
  loadQpDraftForSession(sid);
@@ -54340,6 +54374,7 @@ function saveQpEdit(idx) {
54340
54374
  }
54341
54375
  qpEditingIdx = -1;
54342
54376
  renderQpItems();
54377
+ qpProcessDeferredDraftReload();
54343
54378
  }
54344
54379
 
54345
54380
  async function syncQpItemToPrompt(promptId, title, content) {
@@ -54373,6 +54408,7 @@ function cancelQpEdit(idx) {
54373
54408
  }
54374
54409
  qpEditingIdx = -1;
54375
54410
  renderQpItems();
54411
+ qpProcessDeferredDraftReload();
54376
54412
  }
54377
54413
 
54378
54414
  async function qpUploadImageBlob(blob, filename) {
@@ -54835,6 +54871,22 @@ function clearQpItems() {
54835
54871
  renderQpItems();
54836
54872
  }
54837
54873
 
54874
+ // Run any draft reload that was deferred while an inline edit was open. Draft
54875
+ // changes (our own image-paste save echo, or an external edit) are held back
54876
+ // during an edit so they can't reset qpEditingIdx and close the editor; this
54877
+ // flushes the held reload once the edit ends. A no-op while still editing, or
54878
+ // when a save is in flight (that save's completion runs the reload itself).
54879
+ function qpProcessDeferredDraftReload() {
54880
+ if (qpEditingIdx >= 0) return;
54881
+ const sid = qpCurrentSession;
54882
+ if (!sid) return;
54883
+ if (qpDraftSavePending(sid)) return;
54884
+ if (qpDeferredDraftReloads.has(sid)) {
54885
+ qpDeferredDraftReloads.delete(sid);
54886
+ if (!qpDraftLoading) loadQpDraftForSession(sid);
54887
+ }
54888
+ }
54889
+
54838
54890
  function saveQpDraft() {
54839
54891
  const sessionId = qpCurrentSession;
54840
54892
  if (!sessionId) return Promise.resolve();
@@ -54871,6 +54923,12 @@ function saveQpDraft() {
54871
54923
  }
54872
54924
  qpDraftSavePendingCounts.delete(sessionId);
54873
54925
  if (qpDeferredDraftReloads.has(sessionId)) {
54926
+ // Hold the deferred reload while an inline edit is open — reloading
54927
+ // resets qpEditingIdx and re-renders, closing the editor mid-edit
54928
+ // (the image-paste case: the upload's draft-save echo would otherwise
54929
+ // close the editor). It runs from qpProcessDeferredDraftReload() once
54930
+ // the edit ends.
54931
+ if (qpEditingIdx >= 0) return;
54874
54932
  qpDeferredDraftReloads.delete(sessionId);
54875
54933
  if (sessionId === qpCurrentSession && !qpDraftLoading) loadQpDraftForSession(sessionId);
54876
54934
  }
@@ -979,6 +979,93 @@
979
979
  + '</div>';
980
980
  };
981
981
 
982
+ // ------------------------------------------------------------------
983
+ // Coding summary card — a DETERMINISTIC, actionable wrap-up pinned above the
984
+ // model's prose for a completed Wall-E coding turn. Data is composed server-side
985
+ // (coding-run-controller buildCodingSummary): status · changed files (absolute
986
+ // paths) · localhost preview URLs. The model's Fixed/Unsolved/next-step prose
987
+ // renders below in the normal message body. Shared by Conversation + Review so
988
+ // the two surfaces can't drift (HARD RULE). Read-only answers still get a
989
+ // lightweight envelope so the user can see that no files changed and which
990
+ // activity/source inputs the answer used.
991
+ const _CSUM_STATUS = {
992
+ completed_with_changes: { label: 'Changed', cls: 'changed' },
993
+ completed_no_changes: { label: 'No file changes', cls: 'ok' },
994
+ read_only_answer: { label: 'Answered', cls: 'ok' },
995
+ blocked: { label: 'Blocked', cls: 'err' },
996
+ failed: { label: 'Failed', cls: 'err' },
997
+ };
998
+ MR.renderCodingSummaryHtml = function (s) {
999
+ if (!s || typeof s !== 'object') return '';
1000
+ const files = Array.isArray(s.changedFiles) ? s.changedFiles : [];
1001
+ const urls = Array.isArray(s.previewUrls) ? s.previewUrls : [];
1002
+ const isProblem = s.status === 'blocked' || s.status === 'failed';
1003
+ const isReadOnly = s.status === 'read_only_answer';
1004
+ const activity = s.activity && typeof s.activity === 'object' ? s.activity : {};
1005
+ const sources = Array.isArray(activity.sources) ? activity.sources : [];
1006
+ const toolCount = Number(activity.toolCount || 0);
1007
+ // Nothing actionable → no card, except read-only Wall-E coding turns where
1008
+ // the absence of file changes is itself the useful completion state.
1009
+ if (!files.length && !urls.length && !isProblem && !isReadOnly) return '';
1010
+ const st = _CSUM_STATUS[s.status] || { label: s.status || 'Done', cls: 'ok' };
1011
+ const verify = s.status === 'completed_with_changes'
1012
+ ? (s.verified ? '<span class="csum-verify ok">verified</span>' : '<span class="csum-verify warn">unverified</span>')
1013
+ : '';
1014
+ let html = '<div class="coding-summary-card" role="group" aria-label="Coding summary">'
1015
+ + '<div class="csum-top">'
1016
+ + '<span class="csum-badge ' + st.cls + '">' + escHtml(st.label) + '</span>'
1017
+ + verify
1018
+ + '</div>';
1019
+ if (files.length) {
1020
+ html += '<div class="csum-sec"><div class="csum-lbl">What changed · '
1021
+ + files.length + (files.length === 1 ? ' file' : ' files') + '</div><div class="csum-files">';
1022
+ for (const f of files) {
1023
+ const p = f && (f.abs || f.path) ? (f.abs || f.path) : String(f || '');
1024
+ html += '<div class="csum-file"><span class="csum-mk">•</span>'
1025
+ + '<span class="csum-path">' + escHtml(p) + '</span></div>';
1026
+ }
1027
+ html += '</div></div>';
1028
+ } else if (isReadOnly) {
1029
+ html += '<div class="csum-sec"><div class="csum-lbl">What changed</div><div class="csum-runbox">'
1030
+ + '<div class="csum-note">No files changed. Wall-E delivered an answer only.</div>'
1031
+ + '</div></div>';
1032
+ }
1033
+ if (sources.length) {
1034
+ html += '<div class="csum-sec"><div class="csum-lbl">Sources checked · '
1035
+ + sources.length + (sources.length === 1 ? ' item' : ' items') + '</div><div class="csum-runbox">';
1036
+ for (const source of sources) {
1037
+ const tool = source && source.tool ? source.tool : 'tool';
1038
+ const detail = source && source.detail ? source.detail : '';
1039
+ html += '<div class="csum-source"><span class="csum-source-tool">' + escHtml(tool) + '</span>'
1040
+ + (detail ? '<span class="csum-source-detail"> — ' + escHtml(detail) + '</span>' : '')
1041
+ + '</div>';
1042
+ }
1043
+ if (toolCount > sources.length) {
1044
+ html += '<div class="csum-cwd">+' + escHtml(String(toolCount - sources.length)) + ' additional activity '
1045
+ + (toolCount - sources.length === 1 ? 'step' : 'steps') + '</div>';
1046
+ }
1047
+ html += '</div></div>';
1048
+ } else if (isReadOnly && toolCount > 0) {
1049
+ html += '<div class="csum-sec"><div class="csum-lbl">Activity checked</div><div class="csum-runbox">'
1050
+ + '<div class="csum-note">' + escHtml(String(toolCount)) + ' activity '
1051
+ + (toolCount === 1 ? 'step' : 'steps') + ' recorded.</div></div></div>';
1052
+ }
1053
+ if (urls.length) {
1054
+ html += '<div class="csum-sec"><div class="csum-lbl">Run &amp; test</div><div class="csum-runbox">';
1055
+ for (const u of urls) {
1056
+ html += '<div class="csum-url">▸ <a href="' + escHtml(u) + '" target="_blank" rel="noopener noreferrer">'
1057
+ + escHtml(u) + '</a></div>';
1058
+ }
1059
+ if (s.cwd) html += '<div class="csum-cwd">cwd: ' + escHtml(s.cwd) + '</div>';
1060
+ html += '</div></div>';
1061
+ } else if ((files.length || isReadOnly) && s.cwd) {
1062
+ html += '<div class="csum-sec"><div class="csum-lbl">Where</div><div class="csum-runbox">'
1063
+ + '<div class="csum-cwd">cwd: ' + escHtml(s.cwd) + '</div></div></div>';
1064
+ }
1065
+ html += '</div>';
1066
+ return html;
1067
+ };
1068
+
982
1069
  // ------------------------------------------------------------------
983
1070
  // Tool cards: one collapsible card per tool/shell/patch/web-search call,
984
1071
  // its result filled in (paired by metadata.callId at render time on the