@galda/cli 0.10.44 → 0.10.45

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 (2) hide show
  1. package/app/index.html +186 -30
  2. package/package.json +1 -1
package/app/index.html CHANGED
@@ -2046,6 +2046,14 @@
2046
2046
  .redesign .rtag{letter-spacing:.02em;font-variant-numeric:tabular-nums}
2047
2047
  .redesign .rst{display:inline-flex;align-items:center;gap:6px;color:var(--ink2)}
2048
2048
  .redesign .rdot{width:6px;height:6px;border-radius:50%;background:var(--amber,#e0a021);display:inline-block}
2049
+ /* reply → in-place "Working on it" (REVIEW-FEEDBACK-FLOW 決定1-4): a reply does NOT
2050
+ dismiss the card. The header turns green "Working on it"; your line + the AI's one-line
2051
+ voice live in the reply thread below the box (.replynote .wkline) so sending only appends
2052
+ — no re-layout, no flash. */
2053
+ .redesign .rst.working{color:var(--green)}
2054
+ .redesign .rst.working .rdot{background:var(--green)}
2055
+ .sa-item.working{box-shadow:0 1px 2px rgba(20,23,31,.05),0 0 0 1px color-mix(in oklab,var(--green) 22%,var(--hair))}
2056
+ .replynote .wkline .wkdot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 0 3px color-mix(in oklab,var(--green) 22%,transparent);margin-right:9px;vertical-align:middle;position:relative;top:-1px}
2049
2057
  .redesign .rtitle{font-family:var(--ui);font-size:18px;font-weight:680;letter-spacing:-.018em;line-height:1.3;margin:8px 0 0 !important;color:var(--ink)}
2050
2058
  /* meta — Linear sans, green/neutral chips, hairline under (spec §4) */
2051
2059
  .redesign .rmeta{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin:12px 0 0 !important;padding-bottom:12px !important;
@@ -5620,9 +5628,19 @@ function paintReplyNote(){
5620
5628
  function paintSettledThreads(){
5621
5629
  for (const inp of document.querySelectorAll('#seeall .sa-chat[data-goal]')) {
5622
5630
  const goalId = Number(inp.dataset.goal);
5623
- loadReplyThread(goalId);
5624
5631
  if (replyNoteState?.goalId === goalId) continue; // the live note draws its own history
5625
- const html = replyPastHtmlFor(goalId);
5632
+ const item = typeof SA !== 'undefined' ? SA.items.find((x) => x.goalId === goalId) : null;
5633
+ let html;
5634
+ // A card with a live See-all conversation (it.rthread / a transient status) renders from
5635
+ // its OWN client state — send-ordered, per-card, never re-fetched. This is what keeps
5636
+ // replies from vanishing, reordering, or wiping another card (Masa 2026-07-24). A card
5637
+ // with no client thread (reopened later) falls back to the server-persisted thread.
5638
+ if (item && (item.rthread?.length || item.rstatus)) {
5639
+ html = saCardThreadHtml(item);
5640
+ } else {
5641
+ loadReplyThread(goalId);
5642
+ html = replyPastHtmlFor(goalId);
5643
+ }
5626
5644
  if (!html) continue;
5627
5645
  const row = inp.closest('.sa-act') ?? inp.parentElement;
5628
5646
  if (!row?.parentElement) continue;
@@ -5630,6 +5648,8 @@ function paintSettledThreads(){
5630
5648
  el.className = 'qsub replynote';
5631
5649
  el.innerHTML = html;
5632
5650
  row.after(el);
5651
+ // wire the unsure route chips (they carry the goal id so a repaint keeps them working)
5652
+ for (const b of el.querySelectorAll('[data-rcgoal]')) b.onclick = () => saPickRoute(Number(b.dataset.rcgoal), b.dataset.rc);
5633
5653
  }
5634
5654
  }
5635
5655
  function setReplyNote(anchorSel, html, resolve){
@@ -5661,6 +5681,9 @@ function replyPastHtml(){
5661
5681
  return replyPastHtmlFor(goalId);
5662
5682
  }
5663
5683
  function replyPastHtmlFor(goalId){
5684
+ // The SERVER-persisted thread (via:'review'). Used for a card reopened later (its client
5685
+ // it.rthread is gone) and for the #reviewFixInput dialog. The live See-all card renders from
5686
+ // its own client it.rthread (saCardThreadHtml) instead — see paintSettledThreads.
5664
5687
  const msgs = goalId == null ? [] : (replyThread[goalId] ?? []);
5665
5688
  return msgs.map((m) => (m.from === 'you' ? replySaidHtml(m.text)
5666
5689
  : m.from === 'ai' ? replyAnsweredHtml(m.text)
@@ -5679,12 +5702,68 @@ function replySaidHtml(you){
5679
5702
  function replyWaitHtml(label){
5680
5703
  return `<div class="txr note"><span class="w"></span><span class="m"><span class="shimmer">${esc(label)}</span></span></div>`;
5681
5704
  }
5705
+ // The settled AI voice for a `continue` reply (REVIEW-FEEDBACK-FLOW 決定2): replaces the
5706
+ // "Thinking…" line once the reply is picked up. A steady green dot (not a shimmer — it is
5707
+ // no longer waiting) beside the one-line voice; the card header turns "Working on it" too.
5708
+ function replyWorkingHtml(){
5709
+ return `<div class="txr note wkline"><span class="w"></span><span class="m"><span class="wkdot"></span>Picking this back up — I'll be back when it's ready.</span></div>`;
5710
+ }
5682
5711
  function replyNoteRowHtml(body){
5683
5712
  return `<div class="txr note"><span class="w"></span><span class="m">${body}</span></div>`;
5684
5713
  }
5685
5714
  function replyAnsweredHtml(text){
5686
5715
  return `<div class="txr cl"><span class="w">Claude</span><span class="m">${esc(text)}</span></div>`;
5687
5716
  }
5717
+ // ── See-all card reply thread (unified, Masa 2026-07-24) ────────────────────────────────
5718
+ // One per-card model instead of the tangle of a single live note + server reloads that kept
5719
+ // losing/reordering lines. Each card owns its conversation in CLIENT state:
5720
+ // it.rthread = [{ who:'you'|'ai'|'note', text, seq }] (rendered SORTED by seq, so send
5721
+ // order is preserved even when a reply's routing round-trip resolves late)
5722
+ // it.rstatus = 'thinking'|'answering'|'working'|'unsure'|null (the transient trailer)
5723
+ // it.rchoices = the four routes, when rstatus==='unsure'
5724
+ // It is never re-fetched (no reload flash) and never shared between cards (no cross-wipe).
5725
+ let saReplySeq = 0;
5726
+ function saNextSeq(){ return ++saReplySeq; }
5727
+ function saCardThreadHtml(it){
5728
+ const rows = [...(it.rthread || [])].sort((a, b) => a.seq - b.seq).map((m) =>
5729
+ m.who === 'you' ? replySaidHtml(m.text)
5730
+ : m.who === 'ai' ? replyAnsweredHtml(m.text)
5731
+ : replyNoteRowHtml(esc(m.text))).join('');
5732
+ const trailer = it.rstatus === 'thinking' ? replyWaitHtml(REPLY_WAIT_ROUTING)
5733
+ : it.rstatus === 'answering' ? replyWaitHtml(REPLY_WAIT_ANSWERING)
5734
+ : it.rstatus === 'working' ? replyWorkingHtml()
5735
+ : it.rstatus === 'unsure' ? (replyNoteRowHtml('Which one is this?')
5736
+ + `<div class="qchips">${(it.rchoices || []).map((c) =>
5737
+ `<button type="button" class="qchip" data-rc="${esc(c)}" data-rcgoal="${it.goalId}">${esc(REPLY_CHOICE_LABEL[c] ?? c)}</button>`).join('')}</div>`)
5738
+ : '';
5739
+ return rows + trailer;
5740
+ }
5741
+ // Seed a card's client thread from the server-persisted conversation the FIRST time we reply
5742
+ // to it. Without this, a card that already has history (its client it.rthread is empty — the
5743
+ // normal state right after a page reload, or a card reopened later) would render only the new
5744
+ // line the moment you reply, and the earlier replies would vanish (Masa 2026-07-24). Seeded
5745
+ // rows get their seq in server order (before this reply's), so the thread reads oldest→newest.
5746
+ async function saEnsureThread(it){
5747
+ if (!it || it.rthread) return;
5748
+ let msgs = replyThread[it.goalId];
5749
+ if (msgs === undefined) {
5750
+ try { const j = await (await fetch(withKey(`/api/goals/${it.goalId}/messages`))).json(); msgs = (j.messages ?? []).filter((m) => m.via === 'review'); replyThread[it.goalId] = msgs; }
5751
+ catch { msgs = []; }
5752
+ }
5753
+ it.rthread = (msgs || []).map((m) => ({ who: (m.from === 'ai' || m.from === 'manager') ? 'ai' : 'you', text: m.text, seq: saNextSeq() }));
5754
+ }
5755
+ // Poll a goal's conversation for the next AI/manager line after `seen` (an answer, §1.3 —
5756
+ // there is no task to watch, it arrives as a message). Returns the text, or '' on timeout.
5757
+ async function saPollAnswer(goalId, seen){
5758
+ for (let i = 0; i < 120; i++) {
5759
+ await new Promise((r) => setTimeout(r, 1000));
5760
+ let msgs = [];
5761
+ try { msgs = (await (await fetch(withKey(`/api/goals/${goalId}/messages`))).json()).messages ?? []; } catch { continue; }
5762
+ const said = msgs.slice(seen).find((m) => m.from === 'ai' || m.from === 'manager');
5763
+ if (said) return said.text;
5764
+ }
5765
+ return '';
5766
+ }
5688
5767
  // Sent, and nothing decided yet. Shown immediately — the wait is real (a routing
5689
5768
  // call, then the answer), so the honest thing is to say it is thinking, not to
5690
5769
  // pretend the reply landed somewhere it has not.
@@ -8779,7 +8858,10 @@ function saWorkflowHtml(it){
8779
8858
  return '<div class="rwf">' + steps.map(([s, st]) => `<div class="wf-s ${st}"><span class="mk">${st === 'ok' ? '✓' : st === 'now' ? '→' : '□'}</span>${s}</div>`).join('') + '</div>';
8780
8859
  }
8781
8860
  function saItemHtml(it, i){
8782
- const done = { 1: ['ok', '✓', 'Approved'], 2: ['no', '↩', 'Dismissed'], 3: ['no', '↩', 'Feedback sent returned to To Do'], 4: ['ar', '—', 'Archived'], 5: ['no', '↩', 'Reverted — change undone'] }[it.st];
8861
+ // st===3 is NOT a terminal row (REVIEW-FEEDBACK-FLOW 決定3): a reply is not a dismissal.
8862
+ // The old sa-done "feedback returned to To Do" line is gone; st===3 now renders the full
8863
+ // card in its "Working on it" state (see `working` below). Terminal rows: 1/2/4/5 only.
8864
+ const done = { 1: ['ok', '✓', 'Approved'], 2: ['no', '↩', 'Dismissed'], 4: ['ar', '—', 'Archived'], 5: ['no', '↩', 'Reverted — change undone'] }[it.st];
8783
8865
  if (done) {
8784
8866
  // Revert deliberately has NO undo: the PR is closed and its branch deleted —
8785
8867
  // an "UNDO" here would be a lie (HANDOFF-v45 §5.3 honesty over symmetry).
@@ -9004,8 +9086,17 @@ function saItemHtml(it, i){
9004
9086
  ? `<section class="rsec rconv"><div class="rsec-h">Conversation</div><details class="lacc" data-detkey="${gk}:msg" data-msgs="${it.goalId}"${msgOpen}><summary><i class="ltog">›</i><div class="rtx lfirst">${msgSummary}</div></summary><div class="lbody"><div class="rtx" data-msgbody="${it.goalId}">${msgBody}</div></div></details></section>`
9005
9087
  : '';
9006
9088
  const S = (label, body) => body ? `<section class="rsec"><div class="rsec-h">${label}</div>${body}</section>` : '';
9007
- return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}" data-gid="${it.goalId}">
9008
- <div class="rhead"><span class="rtag">${it.no != null ? `#${it.no}` : ''}</span><span class="rst"><span class="rdot"></span>In review</span></div>
9089
+ // st===3 = a reply was sent and the goal is being picked back up (REVIEW-FEEDBACK-FLOW
9090
+ // 決定1-4). The card is NOT collapsed: it stays open and the header reads "Working on it".
9091
+ // Your reply + the AI's one-line voice ("Picking this back up…") live in the reply thread
9092
+ // BELOW the box (the same .replynote surface the answer flow uses) — so sending only APPENDS
9093
+ // a line and never re-lays-out the card. It leaves Review only when the card is closed.
9094
+ const working = it.st === 3;
9095
+ const rst = working
9096
+ ? '<span class="rst working"><span class="rdot"></span>Working on it</span>'
9097
+ : '<span class="rst"><span class="rdot"></span>In review</span>';
9098
+ return `<div class="sa-item lg redesign${it.goalId === SA.cur ? ' cur' : ''}${working ? ' working' : ''}" data-gid="${it.goalId}">
9099
+ <div class="rhead"><span class="rtag">${it.no != null ? `#${it.no}` : ''}</span>${rst}</div>
9009
9100
  <h2 class="rtitle">${esc(it.title)}</h2>
9010
9101
  ${meta}
9011
9102
  ${reqSec}
@@ -9149,7 +9240,10 @@ function saSync(){
9149
9240
  const rows = saRows();
9150
9241
  for (const row of rows) {
9151
9242
  const ex = SA.items.find((x) => x.goalId === row.goalId);
9152
- if (ex) { const st = ex.st, inflight = ex.inflight; Object.assign(ex, row); ex.st = (st > 0 && !inflight) ? 0 : st; }
9243
+ // Preserve st===3 ("Working on it", 決定1-4): a reply held the card open in place; if a
9244
+ // tick still saw it as 'review' before the /dismiss→running upsert landed, resetting st
9245
+ // would snap the card back to "In review". Terminal verdicts (1/2/4/5) reset as before.
9246
+ if (ex) { const st = ex.st, inflight = ex.inflight; Object.assign(ex, row); ex.st = (st > 0 && st !== 3 && !inflight) ? 0 : st; }
9153
9247
  else SA.items.push({ ...row, st: 0 });
9154
9248
  }
9155
9249
  saRender();
@@ -9230,49 +9324,111 @@ async function saCall(ids, path, body){
9230
9324
  }
9231
9325
  return r;
9232
9326
  }
9233
- // The review card's reply box (§1.3/§1.4). It says "Reply, ask, or send back"
9234
- // until now all three did the same thing: sent the goal back to be redone.
9235
- //
9236
- // Unlike saAct this does NOT show its outcome first. "Feedback sent — returned to
9237
- // To Do" is a lie when the reply was a question, and an optimistic lie is worse
9238
- // than a moment of waiting: the card would say the work was sent back while the
9239
- // goal sat untouched in review.
9327
+ // The review card's reply box (§1.3/§1.4). The reply is routed by the AI itself
9328
+ // (outcome:'auto', ONE-CONVERSATION §1.4) this layer never guesses continue vs
9329
+ // question. Unlike saAct it does NOT show its outcome first: "Feedback sent" would
9330
+ // be a lie when the reply was a question.
9240
9331
  async function saReplyCall(it, text, outcome){
9241
9332
  const r = await saCall(it.ids, 'dismiss', { text, outcome });
9242
9333
  if (!r?.ok) throw new Error('reply failed');
9243
9334
  return r.json().catch(() => null);
9244
9335
  }
9336
+ function saCardEl(goalId){ return document.querySelector(`#seeall .sa-item[data-gid="${goalId}"]`); }
9337
+ // Flip one card to its "Working on it" head without redrawing anything else (決定2).
9338
+ function saFlipWorking(goalId){
9339
+ const card = saCardEl(goalId);
9340
+ if (!card) return;
9341
+ card.classList.add('working');
9342
+ const rst = card.querySelector('.rhead .rst');
9343
+ if (rst) { rst.className = 'rst working'; rst.innerHTML = '<span class="rdot"></span>Working on it'; }
9344
+ }
9345
+ // Repaint one card's reply thread IN PLACE from its client state — no saRender, so nothing
9346
+ // above the box moves. Header is flipped to "Working on it" iff the card is in that state,
9347
+ // and the cursor is kept in this box (a blurred box would let an SSE tick fire a full
9348
+ // saRender = a flash). Everything the reply UI does routes through here.
9349
+ function saPaintCard(goalId){
9350
+ const it = SA.items.find((x) => x.goalId === goalId);
9351
+ if (it?.rstatus === 'working') saFlipWorking(goalId);
9352
+ paintReplyNote(); // clears + repaints every card's thread via paintSettledThreads
9353
+ saCardEl(goalId)?.querySelector('.sa-chat')?.focus({ preventScroll: true });
9354
+ }
9355
+ // A reply is "one more line in the conversation" (REVIEW-FEEDBACK-FLOW 決定1-4), NOT a verdict.
9356
+ // Every reply is appended to the card's OWN client thread (it.rthread) in send order and shown
9357
+ // in place — no saRender (no flash/shift), no shared live note (no cross-card wipe), no lost
9358
+ // line (a follow-up while the AI is working goes via /reply, which accepts a running goal, so
9359
+ // it never 409s). The FIRST reply on a fresh review card is routed by the AI (answer / unsure /
9360
+ // continue, §1.3/§1.4); a follow-up on a card already in conversation is always another
9361
+ // instruction (continue).
9245
9362
  async function saSendReply(it, i, text){
9246
9363
  const goalId = it.goalId;
9247
- // By goal, not by row index: the list re-sorts while the AI is thinking, and an
9248
- // index-based anchor then points at someone else's card (or nothing).
9249
- const sel = `.sa-chat[data-goal="${goalId}"]`;
9364
+ await saEnsureThread(it); // fold in any existing history BEFORE this reply (so it isn't hidden)
9365
+ const seq = saNextSeq(); // reserve send order NOW, so a late-resolving route keeps its place
9366
+ // Clear the box in place — keep it FOCUSED, not blurred (saSync skips a rebuild while an
9367
+ // input in #seeall has focus, which is what keeps an SSE tick from flashing the panel).
9368
+ document.querySelectorAll(`#seeall .sa-item[data-gid="${goalId}"] .sa-chat`).forEach((inp) => { inp.value = ''; });
9369
+ // Append the line to the card's own thread immediately — this is what the person sees, and
9370
+ // its `seq` fixes its order regardless of when any round trip returns.
9371
+ it.rthread = [...(it.rthread || []), { who: 'you', text, seq }];
9372
+ const followUp = it.st === 3 || it.inflight; // card already in conversation → another instruction
9373
+
9374
+ if (followUp) {
9375
+ it.st = 3; it.rstatus = 'working'; SA.cur = goalId;
9376
+ saPaintCard(goalId);
9377
+ try {
9378
+ const r = await fetch(withKey(`/api/goals/${goalId}/reply`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) });
9379
+ if (!r.ok) throw new Error('reply failed');
9380
+ } catch {
9381
+ it.rthread = it.rthread.filter((m) => m.seq !== seq); // roll back just this line
9382
+ if (!it.rthread.some((m) => m.who === 'you')) { it.st = 0; it.rstatus = null; }
9383
+ saPaintCard(goalId); showErr('Reply failed.');
9384
+ }
9385
+ return;
9386
+ }
9387
+
9388
+ // First reply: route it. Show "Thinking…" under the line while the AI decides.
9250
9389
  const seen = state.goals.find((g) => g.id === goalId)?.messageCount ?? 0;
9251
- it.inflight = true; saRender();
9252
- showReplySent(sel, text, goalId); // BEFORE the round trip — this is the ~7s that felt broken
9390
+ it.inflight = true; it.rstatus = 'thinking'; SA.cur = goalId;
9391
+ saPaintCard(goalId);
9253
9392
  try {
9254
9393
  let body = await saReplyCall(it, text, 'auto');
9255
9394
  if (body?.outcome === 'unsure') {
9256
- it.inflight = false; saRender();
9257
- const picked = await askReplyDestination(sel, body.choices);
9258
- if (!picked) return; // nothing was touched; the words stay in the box
9259
- it.inflight = true; saRender();
9395
+ it.inflight = false; it.rstatus = 'unsure'; it.rchoices = body.choices || REPLY_OUTCOMES_CLIENT;
9396
+ saPaintCard(goalId);
9397
+ const picked = await new Promise((resolve) => { it._routeResolve = resolve; });
9398
+ it._routeResolve = null;
9399
+ if (!picked) { // cancelled: drop this line, revert the card
9400
+ it.rthread = it.rthread.filter((m) => m.seq !== seq); it.rstatus = null;
9401
+ if (!it.rthread.length) it.st = 0;
9402
+ saPaintCard(goalId); return;
9403
+ }
9404
+ it.inflight = true; it.rstatus = 'thinking'; saPaintCard(goalId);
9260
9405
  body = await saReplyCall(it, text, picked);
9261
9406
  }
9262
9407
  it.inflight = false;
9263
- if (body?.outcome === 'answer') {
9264
- // The goal never moved, so the card stays exactly where it is and the
9265
- // answer appears under the box that asked for it.
9266
- saRender();
9267
- showAnswerWhenItArrives(sel, goalId, seen + 1);
9408
+ if (body?.outcome === 'answer') { // a question: the AI answers, the goal never moves
9409
+ it.rstatus = 'answering'; saPaintCard(goalId);
9410
+ const ans = await saPollAnswer(goalId, seen + 1);
9411
+ it.rthread = [...it.rthread, { who: 'ai', text: ans || 'No answer came back.', seq: saNextSeq() }];
9412
+ it.rstatus = null; saPaintCard(goalId);
9268
9413
  return;
9269
9414
  }
9270
- it.st = 3; saSync(); saAdvanceFocus(goalId);
9415
+ // continue: the card is now "Working on it".
9416
+ it.st = 3; it.rstatus = 'working'; SA.cur = goalId;
9417
+ saPaintCard(goalId);
9271
9418
  } catch {
9272
- it.inflight = false; saRender();
9273
- showErr('Action failed.');
9419
+ it.inflight = false;
9420
+ it.rthread = it.rthread.filter((m) => m.seq !== seq);
9421
+ if (!it.rthread.length) it.rstatus = null;
9422
+ saPaintCard(goalId); showErr('Action failed.');
9274
9423
  }
9275
9424
  }
9425
+ // The unsure chips resolve the pending route (see saSendReply's Promise). Kept keyed by goal
9426
+ // so a repaint (which re-creates the buttons) still lands on the right pending reply.
9427
+ const REPLY_OUTCOMES_CLIENT = ['continue', 'rescope', 'split', 'answer'];
9428
+ function saPickRoute(goalId, route){
9429
+ const it = SA.items.find((x) => x.goalId === goalId);
9430
+ if (it?._routeResolve) it._routeResolve(route);
9431
+ }
9276
9432
  // one action round-trip: optimistic one-liner → endpoint → settle or roll back
9277
9433
  async function saAct(it, st, path, body){
9278
9434
  it.st = st; it.inflight = true; saRender();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.44",
3
+ "version": "0.10.45",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {