@galda/cli 0.10.100 → 0.10.102

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 +171 -39
  2. package/package.json +1 -1
package/app/index.html CHANGED
@@ -3793,7 +3793,7 @@ function buildReviewDigest({ goals, tasks } = {}){
3793
3793
  for (const g of goals ?? []) {
3794
3794
  if (g?.status !== 'review') continue;
3795
3795
  const goalTasks = (tasks ?? []).filter((t) => t.goalId === g.id);
3796
- const proofTask = goalTasks.find((t) => t.proof);
3796
+ const proofTask = latestProofTask(goalTasks);
3797
3797
  const checkLine = (g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').trim().slice(0, 66);
3798
3798
  // Mirrors lib.mjs isPlanReview: a plan awaiting approval reads "PLAN — approve
3799
3799
  // to execute" and carries its plan body so the reviewer sees the plan in place.
@@ -4520,7 +4520,7 @@ function renderQueue(){
4520
4520
  // mirror connectionBannerState({online}) from engine/lib.mjs (a browser static file
4521
4521
  // can't import the module) — offline ⇒ red inline line + Reconnect atop the tray.
4522
4522
  const connHtml = offline
4523
- ? '<div class="connline"><span class="cdot"></span><span class="cw">Galda is offline</span><span class="cmsg">— reconnecting…</span><button class="errbtn creconn" data-reconnect>Reconnect</button></div>'
4523
+ ? `<div class="connline"><span class="cdot"></span><span class="cw">Galda is offline</span><span class="cmsg">${esc(connMsg ?? '— reconnecting…')}</span><button class="errbtn creconn" data-reconnect>Reconnect</button></div>`
4524
4524
  : '';
4525
4525
  // Optimistic outbox items (saved to localStorage BEFORE the network call) render
4526
4526
  // first — instant, offline-safe, never lost. 'unsent' rows are tap-to-resend.
@@ -4568,7 +4568,7 @@ function renderQueue(){
4568
4568
  <button class="qico del qd" data-id="${g.id}" title="Delete">${ICONS.trash}</button></div>`;
4569
4569
  }).join('');
4570
4570
  for (const el of q.querySelectorAll('[data-oresend]')) el.onclick = () => { const item = outbox.load().find((x) => x.oid === el.dataset.oresend); if (item) postOutboxItem(item); };
4571
- for (const el of q.querySelectorAll('[data-reconnect]')) el.onclick = () => forceReconnect();
4571
+ for (const el of q.querySelectorAll('[data-reconnect]')) el.onclick = () => reconnectTapped(el);
4572
4572
  const qrows = [...q.querySelectorAll('.qrow[data-goal]')];
4573
4573
  for (const el of qrows) {
4574
4574
  el.addEventListener('dragstart', (e) => { state.dragGoal = Number(el.dataset.goal); el.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
@@ -8631,8 +8631,39 @@ let sseUp = false; // live engine link — the honest signal behind the MCP tab'
8631
8631
  // sub-second SSE blips don't flash a red line. connOffline is the debounced flag
8632
8632
  // renderQueue() reads; connSetOnline/Offline flip it around real sseUp changes.
8633
8633
  let connOffline = false, _connGraceT = null, _sseReconnectT = null, _es = null, _healthT = null, _healthFailStreak = 0;
8634
- function connSetOnline(){ if (_connGraceT) { clearTimeout(_connGraceT); _connGraceT = null; } if (connOffline) { connOffline = false; renderQueue(); } }
8634
+ function connSetOnline(){ if (_connGraceT) { clearTimeout(_connGraceT); _connGraceT = null; } connMsg = null; if (connOffline) { connOffline = false; renderQueue(); } }
8635
8635
  function connSetOffline(){ if (_connGraceT || connOffline) return; _connGraceT = setTimeout(() => { _connGraceT = null; connOffline = true; renderQueue(); }, 800); }
8636
+ // What the offline line says under "Galda is offline". Null = the default
8637
+ // "— reconnecting…"; a failed manual attempt leaves its own standing reason here
8638
+ // so the button can reset without the explanation disappearing with it.
8639
+ let connMsg = null;
8640
+ let _reconnectTapT = null, _reconnectTapArmed = false;
8641
+ // Reconnect must answer the tap (goal-837: 「reconnectをタップしてもフィードバックがない」).
8642
+ // The button used to call forceReconnect() straight, which changes nothing visible:
8643
+ // a retry that failed looked exactly like a tap that never registered. So: relabel and
8644
+ // disable at once, then — if we are still offline a few seconds later — probe health
8645
+ // once, say so in a toast, and put the reason on the line before resetting the button.
8646
+ function reconnectTapped(btn){
8647
+ if (btn) { btn.disabled = true; btn.textContent = 'Reconnecting…'; }
8648
+ _reconnectTapArmed = true;
8649
+ connMsg = null;
8650
+ forceReconnect();
8651
+ if (_reconnectTapT) clearTimeout(_reconnectTapT);
8652
+ _reconnectTapT = setTimeout(async () => {
8653
+ _reconnectTapT = null;
8654
+ if (!connOffline) { _reconnectTapArmed = false; return; } // recovered — renderQueue already reset the button
8655
+ await checkHealth();
8656
+ if (!connOffline) { _reconnectTapArmed = false; return; }
8657
+ _reconnectTapArmed = false;
8658
+ const ja = failureLang() === 'ja';
8659
+ // The line already says "Galda is offline"; this half says what the TAP did.
8660
+ connMsg = ja ? '— 再接続できませんでした' : '— reconnect failed';
8661
+ // showErr, not showToast: a failed reconnect is not a success, and showToast
8662
+ // paints the bar green (§5.5 — state colours are not decoration).
8663
+ showErr(ja ? '再接続できませんでした。ネットワークかGaldaサーバを確認してください。' : 'Reconnect failed. Check the network or the Galda server.');
8664
+ renderQueue();
8665
+ }, 3000);
8666
+ }
8636
8667
  // Reconnect button: drop any pending backoff timer and reopen the EventSource now.
8637
8668
  function forceReconnect(){ if (_sseReconnectT) { clearTimeout(_sseReconnectT); _sseReconnectT = null; } try { _es && _es.close(); } catch { /* already closed */ } esRetryMs = 1000; connectSSE(); }
8638
8669
  // Robust liveness backstop that does NOT depend on EventSource onerror firing.
@@ -9216,7 +9247,7 @@ function peraData(){
9216
9247
  const gs = (state.goals || []).filter((g) => g.projectId === state.active);
9217
9248
  const ts = (state.tasks || []).filter((t) => t.projectId === state.active);
9218
9249
  return buildReviewDigest({ goals: gs, tasks: ts }).map((r) => {
9219
- const pt = ts.find((t) => r.goalIds.includes(t.goalId) && t.proof && (t.proof.png || t.proof.gif));
9250
+ const pt = latestProofTask(ts.filter((t) => r.goalIds.includes(t.goalId)), true);
9220
9251
  return { ...r, proofTaskId: pt?.id ?? null, proofImg: pt?.proof?.png ?? pt?.proof?.gif ?? null };
9221
9252
  });
9222
9253
  }
@@ -9329,6 +9360,8 @@ function closeSummaryPera(){ $('summaryOverlay').classList.remove('show'); }
9329
9360
  // settles it (see saSync's "returned to review" reset).
9330
9361
  // ============================================================================
9331
9362
  const SA = { open: false, items: [], diffs: {}, actLog: {}, msgs: {}, convOpen: new Set(), shotBox: {}, detOpen: new Set(), diffOpen: new Set(), attachments: {}, focusGid: null, focusScrolled: false, focusTimer: null, cur: null, solo: null, lastMarkup: null };
9363
+ // Same convention as the existing ?dev=1 devbar flag — off unless someone opts in by URL.
9364
+ const SA_DEV = new URLSearchParams(location.search).get('dev') === '1';
9332
9365
  function saReplyAttachmentsHtml(goalId){
9333
9366
  return (SA.attachments[goalId] || []).map((a, i) => `<span class="sa-replythumb"><img src="${esc(a.url)}" alt="Attached image"><button type="button" data-saarm="${goalId}:${i}" aria-label="Remove image">×</button></span>`).join('');
9334
9367
  }
@@ -9363,6 +9396,17 @@ function latestGoalOutcomeTask(goalId, tasks){
9363
9396
  }
9364
9397
  function taskAttemptId(t){ return t?.attemptId || (t?.id != null ? `task-${t.id}` : null); }
9365
9398
  function taskSortTime(t){ return Date.parse(t?.finishedAt || t?.startedAt || t?.createdAt || 0) || 0; }
9399
+ // The proof a reviewer is being asked to look at is the NEWEST one. A reply re-runs
9400
+ // the work and takes its own shot, so the goal accumulates several. Every call site
9401
+ // used to be `tasks.find((t) => t.proof)`, which returns the OLDEST — the card then
9402
+ // showed the first attempt's screenshot underneath the latest attempt's words
9403
+ // (Result already sorts by time via latestGoalOutcomeTask). `image` = the call site
9404
+ // renders a thumbnail and needs a png/gif, not a proof that only carries a video.
9405
+ function latestProofTask(tasks, image = false){
9406
+ return (tasks || [])
9407
+ .filter((t) => t.proof && (!image || t.proof.png || t.proof.gif))
9408
+ .sort((a, b) => taskSortTime(b) - taskSortTime(a))[0] || null;
9409
+ }
9366
9410
  function latestGoalRunForOutcome(goalId, tasks, outcome){
9367
9411
  if (outcome?.run) return { taskId: outcome.id, attemptId: taskAttemptId(outcome), inherited: false, ...outcome.run };
9368
9412
  const runs = (tasks || []).filter((t) => t.goalId === goalId && t.run)
@@ -9393,7 +9437,7 @@ function saRowFromDigest(r, gs, ts){
9393
9437
  // here so the card never calls that state a generic failure.
9394
9438
  const interruptedTask = [...ts].filter((t) => r.goalIds.includes(t.goalId) && t.status === 'interrupted')
9395
9439
  .sort((a, b) => taskSortTime(b) - taskSortTime(a))[0] || null;
9396
- const pt = ts.find((t) => r.goalIds.includes(t.goalId) && t.proof && (t.proof.png || t.proof.gif));
9440
+ const pt = latestProofTask(ts.filter((t) => r.goalIds.includes(t.goalId)), true);
9397
9441
  // "What we did" (H23 §2, replaces the old Plan/Review-thread folds): one bullet
9398
9442
  // per finished requirement task's own report; falls back to the goal's plan
9399
9443
  // steps when nothing has finished yet (e.g. a plan awaiting approval — the plan
@@ -9432,9 +9476,9 @@ function saRowFromDigest(r, gs, ts){
9432
9476
  attempt: g?.reviewAttempt ?? null,
9433
9477
  unmappedChangedFiles: outcome?.unmappedChangedFiles ?? [],
9434
9478
  requirementVerification: outcome?.requirementVerification ?? null,
9435
- requirementCoverage: ts.filter((t) => t.goalId === g?.id && (t.detail || t.title)).sort((a, b) => (a.num ?? 0) - (b.num ?? 0)).map((t) => ({ text: String(t.reply ? t.detail : t.title).replace(/\s+/g, ' ').trim(), taskStatus: t.status, evidenceStatus: t.requirementEvidence?.requirements?.[0]?.status ?? null })),
9479
+ requirementCoverage: ts.filter((t) => t.goalId === g?.id && (t.detail || t.title)).sort((a, b) => (a.num ?? 0) - (b.num ?? 0)).map((t) => ({ text: String((t.reply ? t.detail : t.title) ?? t.title ?? '').replace(/\s+/g, ' ').trim(), taskStatus: t.status, evidenceStatus: t.requirementEvidence?.requirements?.[0]?.status ?? null })),
9436
9480
  requirementEvidence: outcome?.requirementEvidence?.requirements ?? [],
9437
- requirementCoverage: ts.filter((t) => t.goalId === g?.id && (t.detail || t.title) && (!t.reply || !t.replyFrom || t.replyFrom === 'you')).sort((a, b) => (a.num ?? 0) - (b.num ?? 0)).map((t) => ({ text: String(t.reply ? t.detail : t.title).replace(/\s+/g, ' ').trim(), status: t.status })),
9481
+ requirementCoverage: ts.filter((t) => t.goalId === g?.id && (t.detail || t.title) && (!t.reply || !t.replyFrom || t.replyFrom === 'you')).sort((a, b) => (a.num ?? 0) - (b.num ?? 0)).map((t) => ({ text: String((t.reply ? t.detail : t.title) ?? t.title ?? '').replace(/\s+/g, ' ').trim(), status: t.status })),
9438
9482
  did, siblings, risk: g?.risk ?? null,
9439
9483
  verificationInfraError: g?.verificationInfraError ?? r.verificationInfraError ?? null,
9440
9484
  prNote: g?.prNote ?? null,
@@ -9542,7 +9586,7 @@ function saRowFromDigest(r, gs, ts){
9542
9586
  // the goal + its tasks exactly as buildReviewDigest does for a review goal.
9543
9587
  function saSoloDigestRow(g, ts){
9544
9588
  const goalTasks = (ts ?? []).filter((t) => t.goalId === g.id);
9545
- const proofTask = goalTasks.find((t) => t.proof);
9589
+ const proofTask = latestProofTask(goalTasks);
9546
9590
  const checkLine = (g.reviewSummary?.check || g.plan?.[0] || g.text || '').replace(/\s+/g, ' ').trim().slice(0, 66);
9547
9591
  return {
9548
9592
  goalIds: [g.id], reviewNumber: goalReviewNumber({ goal: g, tasks: ts }), checkLine,
@@ -10373,26 +10417,37 @@ function saRender(){
10373
10417
  const S = $('seeall'); if (!S) return;
10374
10418
  // SSE, diff fetches and action responses rebuild the dialog. Remember the
10375
10419
  // review body's position before replacing it so reading never jumps to top.
10420
+ // A raw scrollTop number is not enough: if a new review card lands ABOVE the
10421
+ // one being read (saSync pushes it earlier in SA.items), restoring the same
10422
+ // scrollTop lands on different content — the card visually jumps ("ガクッ").
10423
+ // Anchor on the card currently at/near the top of the viewport instead, and
10424
+ // after the rebuild re-align that same card back to the same offset.
10376
10425
  const previousBody = S.querySelector('.sa-body');
10377
10426
  const previousScrollTop = previousBody?.scrollTop ?? 0;
10378
- const restoreScroll = Boolean(previousBody) && SA.focusGid == null;
10379
- // A reply changes the height of the card being replied to (new thread line, status
10380
- // change) restoring a raw scrollTop pixel then leaves the viewport at the same
10381
- // number but showing DIFFERENT content, a visible jolt on whatever card the person
10382
- // was actually reading (2026-07-29). Anchor on that card's own goalId instead: find
10383
- // the topmost visible card before the rebuild, and after the rebuild scroll so that
10384
- // SAME card sits at the SAME on-screen position, regardless of how much other cards
10385
- // above it grew or shrank.
10427
+ // Preserve scroll even while a focus-flash is in flight: a new incoming review
10428
+ // (saSync from SSE) used to skip this whenever SA.focusGid was still set, snapping
10429
+ // the body back to scrollTop 0 and jerking the person's read position. The focus
10430
+ // block below only force-scrolls the focused card to the top ONCE per flash window
10431
+ // (SA.focusScrolled see below), so it no longer fights this restore on later
10432
+ // renders within that window.
10433
+ const restoreScroll = Boolean(previousBody);
10386
10434
  let anchorGid = null, anchorOffset = 0;
10435
+ // No `previousScrollTop > 0` guard here on purpose (task-917): right after opening,
10436
+ // or right after the focus-flash auto-scroll lands a card at the top, scrollTop IS 0
10437
+ // while a real card is still on screen. Skipping the anchor capture at exactly that
10438
+ // scrollTop silently let a newly-inserted card take over position 0 with no scroll
10439
+ // adjustment — the card someone was looking at got replaced under them without the
10440
+ // scrollbar moving at all, which read as the exact same "ガクッ" jump the anchor
10441
+ // logic below was built to prevent. Anchor whenever there is a previous body, full stop.
10387
10442
  if (restoreScroll) {
10388
- const containerTop = previousBody.getBoundingClientRect().top;
10389
- // The card actually being read sits near the middle of the viewport, not whatever
10390
- // sliver of a previous card's bottom edge still peeks in at the very top.
10391
- const midY = containerTop + previousBody.clientHeight / 2;
10392
- for (const el of previousBody.querySelectorAll('.sa-item')) {
10443
+ const bodyRect = previousBody.getBoundingClientRect();
10444
+ const midY = bodyRect.top + previousBody.clientHeight / 2;
10445
+ const cards = [...previousBody.querySelectorAll('.sa-item[data-gid]')];
10446
+ const anchor = cards.find((el) => {
10393
10447
  const r = el.getBoundingClientRect();
10394
- if (r.top <= midY && r.bottom >= midY) { anchorGid = el.dataset.gid; anchorOffset = r.top - containerTop; break; }
10395
- }
10448
+ return r.top <= midY && r.bottom >= midY;
10449
+ }) || cards.find((el) => el.getBoundingClientRect().bottom > bodyRect.top);
10450
+ if (anchor) { anchorGid = anchor.dataset.gid; anchorOffset = anchor.getBoundingClientRect().top - bodyRect.top; }
10396
10451
  }
10397
10452
  const focusedReplyGoal = document.activeElement?.matches?.('#seeall .sa-chat')
10398
10453
  ? document.activeElement.dataset.goal
@@ -10407,17 +10462,51 @@ function saRender(){
10407
10462
  const curIdx = pending.findIndex((x) => x.goalId === SA.cur);
10408
10463
  const posLabel = solo ? '' : (pending.length && curIdx >= 0 ? `${curIdx + 1} / ${pending.length}` : '');
10409
10464
  const hdKeys = solo ? '↑↓ MOVE · ESC CLOSE' : '↑↓ MOVE · A APPROVE · D DISMISS · ESC CLOSE';
10410
- const markup = `<div class="sa-panel"><div class="sa-hd"><span class="t">${hdTitle}</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">${hdKeys}</span><button class="sa-x" data-saact="close:0">✕</button></div>
10465
+ // Manual repro aid for the "ガクッ" scroll-jump bug (task-870): behind ?dev=1 only,
10466
+ // never in a normal session. Lets a person scroll into a review card and then trigger
10467
+ // a synthetic "new review just arrived" render the same way saSync does mid-read.
10468
+ const devInjectBtn = SA_DEV ? '<button class="sa-x" data-saact="devinject:0" title="Insert a fake review above the current scroll (dev only)">+TEST</button>' : '';
10469
+ const markup = `<div class="sa-panel"><div class="sa-hd"><span class="t">${hdTitle}</span><span class="c">${c}</span><span class="sa-count">${posLabel}</span><span class="keys">${hdKeys}</span>${devInjectBtn}<button class="sa-x" data-saact="close:0">✕</button></div>
10411
10470
  <div class="sa-body">${SA.items.length ? SA.items.map((it, i) => saItemHtml(it, i)).join('') : '<div class="sa-empty">Nothing waiting for review.</div>'}</div></div>`;
10412
10471
  // Most SSE goal/task events are unrelated to the card the person is reading.
10413
10472
  // Replacing #seeall with identical markup destroyed and recreated every node,
10414
10473
  // producing a blank flash and resetting browser-owned state. Keep the current
10415
10474
  // dialog DOM when its semantic markup did not change; the board behind it can
10416
10475
  // still refresh normally.
10417
- if (SA.lastMarkup === markup && S.querySelector('.sa-panel')) return;
10476
+ if (SA.lastMarkup === markup && S.querySelector('.sa-panel')) {
10477
+ // A focus change does not alter the card markup, but it still has a visual
10478
+ // side effect: move the newly focused card before paint. Handle that on the
10479
+ // existing DOM instead of returning early and waiting for the later rAF.
10480
+ if (SA.focusGid != null && !SA.focusScrolled) {
10481
+ const el = S.querySelector(`.sa-item[data-gid="${SA.focusGid}"]`);
10482
+ if (el) {
10483
+ saScrollCardToTop(el);
10484
+ SA.focusScrolled = true;
10485
+ el.classList.add('sa-flash');
10486
+ if (!SA.focusTimer) SA.focusTimer = setTimeout(() => { SA.focusGid = null; SA.focusTimer = null; }, 1400);
10487
+ }
10488
+ }
10489
+ return;
10490
+ }
10418
10491
  SA.lastMarkup = markup;
10419
10492
  S.innerHTML = markup;
10420
10493
  const body = S.querySelector('.sa-body');
10494
+ let focusPlacedNow = false;
10495
+ // H14's "scroll the focused card to top" used to always run one requestAnimationFrame
10496
+ // after this point. That's still required for the very FIRST render of a fresh
10497
+ // saOpen(): #seeall is still `display:none` here (the .open class is added by the
10498
+ // caller right after saRender() returns), so getBoundingClientRect() on anything
10499
+ // inside is all-zero and a synchronous scroll here would be a no-op.
10500
+ // But once the panel is already open (S.classList has 'open'), a LATER render
10501
+ // arriving mid-flash (saSync/devinject, task-909) can and must place the card
10502
+ // synchronously here, in the same pass as the anchor restore below — deferring it
10503
+ // to the next rAF left a one-frame window where the browser could paint with the
10504
+ // OLD (pre-focus) scrollTop, then snap to the correct position a frame later: a
10505
+ // visible double-jump ("ガクッ") the first time a card arrived during that window.
10506
+ if (body && S.classList.contains('open') && SA.focusGid != null && !SA.focusScrolled) {
10507
+ const el = body.querySelector(`.sa-item[data-gid="${SA.focusGid}"]`);
10508
+ if (el) { saScrollCardToTop(el); SA.focusScrolled = true; focusPlacedNow = true; }
10509
+ }
10421
10510
  // Once the person scrolls, their position wins over the short opening-focus
10422
10511
  // animation. Programmatic opening scroll fires this too, which is fine: the
10423
10512
  // first placement happened and later SSE ticks should preserve it.
@@ -10433,12 +10522,16 @@ function saRender(){
10433
10522
  // also what actually grows a replied-to card's height, so the scroll anchor
10434
10523
  // below must run AFTER it, not before (2026-07-29 jolt fix).
10435
10524
  if (body && restoreScroll) {
10436
- const anchorEl = anchorGid != null ? body.querySelector(`.sa-item[data-gid="${anchorGid}"]`) : null;
10437
- if (anchorEl) {
10438
- const newOffset = anchorEl.getBoundingClientRect().top - body.getBoundingClientRect().top;
10439
- body.scrollTop += (newOffset - anchorOffset);
10440
- } else {
10441
- body.scrollTop = previousScrollTop;
10525
+ // An explicit focus move wins for this render. Later SSE renders have
10526
+ // focusScrolled=true and return to the normal reading-anchor preservation.
10527
+ if (!focusPlacedNow) {
10528
+ const anchorEl = anchorGid != null ? body.querySelector(`.sa-item[data-gid="${anchorGid}"]`) : null;
10529
+ if (anchorEl) {
10530
+ const newOffset = anchorEl.getBoundingClientRect().top - body.getBoundingClientRect().top;
10531
+ body.scrollTop += (newOffset - anchorOffset);
10532
+ } else {
10533
+ body.scrollTop = previousScrollTop;
10534
+ }
10442
10535
  }
10443
10536
  }
10444
10537
  for (const det of S.querySelectorAll('[data-detkey]')) {
@@ -10471,17 +10564,18 @@ function saRender(){
10471
10564
  if (im.complete && im.naturalWidth === 0) drop();
10472
10565
  else im.addEventListener('error', drop, { once: true });
10473
10566
  }
10474
- // H14: if a single-review focus is pending, bring the card's HEADER (#tag + In review
10475
- // + title) to the very top of the scroll area never mid-card (Masa 2026-07-20) — and
10476
- // flash a blue ring. Re-scroll on EVERY render for the focus window the post-diff
10477
- // saSync rebuild resets .sa-body scroll to the top, so a one-shot scroll would land back
10478
- // there. Self-clears after ~1.4s (outlasts the localhost diff fetch) so live SSE ticks
10479
- // stop yanking the view.
10567
+ // H14: if a single-review focus is pending, flash a blue ring on its card. The
10568
+ // scroll-to-top placement already happened synchronously above whenever the panel
10569
+ // was visible at render time; this rAF is the fallback for the still-`display:none`
10570
+ // first render of saOpen() (SA.focusScrolled stays false above, since the panel
10571
+ // isn't open yet), plus it always adds the flash class and starts the self-clear
10572
+ // timer, neither of which affect scroll position. Self-clears after ~1.4s (outlasts
10573
+ // the localhost diff fetch) so live SSE ticks stop yanking the view.
10480
10574
  if (SA.focusGid != null) {
10481
10575
  requestAnimationFrame(() => {
10482
10576
  const el = S.querySelector(`.sa-item[data-gid="${SA.focusGid}"]`);
10483
10577
  if (!el) return;
10484
- saScrollCardToTop(el);
10578
+ if (!SA.focusScrolled) { saScrollCardToTop(el); SA.focusScrolled = true; }
10485
10579
  el.classList.add('sa-flash');
10486
10580
  if (!SA.focusTimer) SA.focusTimer = setTimeout(() => { SA.focusGid = null; SA.focusTimer = null; }, 1400);
10487
10581
  });
@@ -10821,6 +10915,15 @@ $('seeall').addEventListener('click', async (e) => {
10821
10915
  // #reviewOverlay sits above #seeall, and a folded goal shows read-only there since
10822
10916
  // canReview=false hides Approve/Dismiss), or restore it via /unfold. Handled before the
10823
10917
  // SA.items[i] lookup below.
10918
+ // Dev-only repro aid (task-870, gated by SA_DEV/?dev=1): simulate a new review card
10919
+ // arriving mid-read the same way saSync splices one in from SSE, so the scroll-jump
10920
+ // fix above can be checked by hand without waiting for a real worker to finish.
10921
+ if (k === 'devinject') {
10922
+ if (!SA_DEV) return;
10923
+ SA.items.unshift(saDevSeedItem());
10924
+ saRender();
10925
+ return;
10926
+ }
10824
10927
  if (k === 'foldopen') return openReviewChecklist(Number(v));
10825
10928
  if (k === 'foldundo') {
10826
10929
  let r; try { r = await fetch(withKey(`/api/goals/${v}/unfold`), { method: 'POST' }); }
@@ -11472,6 +11575,35 @@ $('toggle').onclick = () => { $('side').classList.toggle('collapsed'); rememberS
11472
11575
  // V3-C dev escape hatch (Masa決定 2026-07-07): flagship hides the top bar; ?dev=1
11473
11576
  // (or localStorage devbar=1) shows it there for development (layout/theme switchers).
11474
11577
  if (new URLSearchParams(location.search).get('dev') === '1' || localStorage.getItem('devbar') === '1') document.body.classList.add('devbar');
11578
+ // Manual repro aid for the scroll-jump-on-new-review bug (task-870), ?dev=1 only: a
11579
+ // floating button that opens the Review dialog pre-loaded with several synthetic cards
11580
+ // (no real worker/PR needed), so the "+TEST" button inside the dialog (see saRender) has
11581
+ // something to scroll through. Never shown outside an explicit ?dev=1 session.
11582
+ let saDevSeq = 0;
11583
+ function saDevSeedItem(){
11584
+ saDevSeq++;
11585
+ const n = saDevSeq;
11586
+ return {
11587
+ goalId: -n, ids: [-n], st: 0, goal: {}, title: `Test review #${n}`,
11588
+ report: `Synthetic review card #${n} — for manually checking the scroll position stays put when a new card is inserted (task-870).`,
11589
+ pr: null, testResult: null, tokenOpt: null,
11590
+ metaFiles: [`fixtures/dev-review-${n}.mjs +${n} −${n}`], metaAdd: `+${n}`, metaDel: `−${n}`,
11591
+ };
11592
+ }
11593
+ if (SA_DEV) {
11594
+ const btn = document.createElement('button');
11595
+ btn.textContent = 'Open test review (dev)';
11596
+ btn.title = 'Opens the Review dialog with fake cards so the scroll-jump fix (task-870) can be checked by hand: scroll down, then press +TEST in the dialog header.';
11597
+ btn.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:9999;padding:8px 12px;border-radius:8px;border:1px solid var(--line,#444);background:var(--panel,#222);color:var(--ink,#eee);cursor:pointer;font:12px/1.2 inherit';
11598
+ btn.addEventListener('click', () => {
11599
+ SA.open = true; SA.solo = null; SA.detOpen = new Set(); SA.focusGid = null;
11600
+ SA.items = Array.from({ length: 6 }, () => saDevSeedItem());
11601
+ SA.cur = SA.items[0].goalId;
11602
+ saRender();
11603
+ $('seeall').classList.add('open');
11604
+ });
11605
+ document.body.appendChild(btn);
11606
+ }
11475
11607
  // Feedback 7: layout switcher — apply + persist the chosen arrangement.
11476
11608
  function applyLayout(){
11477
11609
  document.body.dataset.layout = state.layout || 'current';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.100",
3
+ "version": "0.10.102",
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": {