@galda/cli 0.10.16 → 0.10.19

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.
package/app/index.html CHANGED
@@ -610,10 +610,14 @@
610
610
  .confetti-piece{position:fixed;pointer-events:none;z-index:9999;will-change:transform,opacity}
611
611
 
612
612
  /* connect modal */
613
- .overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);backdrop-filter:blur(6px);z-index:90;display:none;place-items:center}
614
- /* z-index:90 (was 60): the customize-panel discard confirm (#deleteConfirmOverlay, shares this
615
- class) can now fire while .gearpop (the customize panel, z-index:80) is open it was
616
- rendering BEHIND the panel it's confirming a discard for (Masa 2026-07-16, real screenshot). */
613
+ /* A confirm dialog (deleteConfirmOverlay etc.) must always sit above other
614
+ floating chrome it can be opened from was 60, then 90 (cleared .gearpop's
615
+ 80 for the customize-panel discard confirm), but that still sat under the
616
+ context menu (95) and the settings panel rendered in front of its own Log
617
+ out confirm (Masa 指示 2026-07-16). 100 clears every other floating layer
618
+ (gearpop 80, context menu 95) while staying under true full-screen gates
619
+ (lightbox 200, #connectGate 1000). */
620
+ .overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);backdrop-filter:blur(6px);z-index:100;display:none;place-items:center}
617
621
  .overlay.show{display:grid}
618
622
  .modal{width:640px;max-width:92vw;background:var(--surface);border:1px solid var(--hair2);border-radius:16px;padding:20px 22px;box-shadow:0 24px 70px rgba(0,0,0,.6)}
619
623
  .modal h3{margin:0 0 6px;font-size:15px;letter-spacing:-.01em}
@@ -634,6 +638,10 @@
634
638
  .mbtn.danger{background:color-mix(in srgb,var(--danger) 10%,var(--panel));color:color-mix(in srgb,var(--danger) 78%,var(--ink));
635
639
  box-shadow:inset 0 0 0 1px rgba(var(--danger-rgb),.35)}
636
640
  .mbtn.danger:hover{box-shadow:inset 0 0 0 1px var(--danger);background:color-mix(in srgb,var(--danger) 16%,var(--panel))}
641
+ /* Neutral confirm (not destructive/AI — e.g. Log out): same ink-solid tone as
642
+ the settings Save button (.cfoot .cbtn.save), not the iri default (AI-only, §5.5). */
643
+ .mbtn.solid{background:var(--ink);color:var(--invink)}
644
+ .mbtn.solid:hover{opacity:.9}
637
645
 
638
646
  /* Slack-style centered delete confirmation (replaces native confirm() — restores a
639
647
  decision lost in the flagship rewrite; see docs/confirmed-behaviors.md). Deliberately
@@ -980,7 +988,7 @@
980
988
  .gearbtn{display:grid;place-items:center;width:30px;height:28px;border:1px solid var(--hair2);border-radius:8px;background:transparent;color:var(--ink2);cursor:pointer}
981
989
  .gearbtn:hover{background:var(--surface2);color:var(--ink)}
982
990
  .gearbtn svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
983
- .gearpop{display:none;position:absolute;top:36px;right:0;z-index:80;background:var(--panel);border-radius:12px;box-shadow:0 10px 30px rgba(20,23,31,.18),0 0 0 1px var(--hair);padding:12px 13px;width:250px;
991
+ .gearpop{display:none;position:absolute;top:36px;right:0;z-index:80;background:var(--panel);border-radius:12px;box-shadow:0 10px 30px rgba(20,23,31,.18),0 0 0 1px var(--hair);padding:12px 13px;width:320px;
984
992
  max-height:78vh;overflow-y:auto;scrollbar-width:thin; /* TB edge + colour sections made it taller than the viewport */
985
993
  /* opaque chip fill (HANDOFF-v45 §9): a translucent --canvas would let the wallpaper
986
994
  bleed through and colour each row differently — this token mixes off the OPAQUE
@@ -1017,7 +1025,7 @@
1017
1025
  the rail (left is set on open from the measured rail edge), max-height = viewport −
1018
1026
  margins, internal scroll. White-translucent per §3: color-mix 74% + blur28 (theme-tinted).
1019
1027
  The node is adopted to <body> in flagship (positionLayout) — .top is display:none there. */
1020
- body[data-layout="flagship"] .gearpop{position:fixed;top:120px;right:auto;width:300px;max-height:calc(100vh - 160px);border-radius:14px;
1028
+ body[data-layout="flagship"] .gearpop{position:fixed;top:120px;right:auto;width:380px;max-height:calc(100vh - 160px);border-radius:14px;
1021
1029
  background:color-mix(in srgb,var(--panel) 74%,transparent);
1022
1030
  -webkit-backdrop-filter:blur(28px) saturate(1.35);backdrop-filter:blur(28px) saturate(1.35)}
1023
1031
  /* settings = EDIT SESSION (HANDOFF-v45 §3, flagship .cfoot/.mcpbox port): sticky footer,
@@ -1267,6 +1275,29 @@
1267
1275
  #fsRoot .fsstream::-webkit-scrollbar{display:none}
1268
1276
  #fsFeed{display:flex;flex-direction:column;gap:8px;padding-top:2px}
1269
1277
  #fsRoot .cc-say{font-size:13.5px;color:var(--ink)}
1278
+ /* Pattern A (H23 / AMENDMENT-center-feed-2026-07-16, CDO): the center feed is
1279
+ a conversation — the user's own turns live here too, not just Claude Code's.
1280
+ 器を増やさない(§1.5-1): no bubbles — a speaker label + a hairline turn border. */
1281
+ #fsRoot .cc-you{padding-top:11px;border-top:1px solid var(--hair)}
1282
+ #fsRoot .cc-you:first-child{padding-top:0;border-top:0}
1283
+ #fsRoot .cc-you .cy-n{display:block;font:600 8.5px/1 var(--mono);letter-spacing:.13em;text-transform:uppercase;color:var(--ink3);margin-bottom:5px}
1284
+ #fsRoot .cc-you .cy-t{font-size:13.5px;color:var(--ink);line-height:1.45}
1285
+ /* Typing cursor on the streaming reply frontier (rides the client-side reveal —
1286
+ the CLI returns the reply as one block, not token deltas). Solid block, hard
1287
+ terminal blink (steps(1)); --ink adapts across all themes. */
1288
+ #fsRoot .cc-caret{display:inline-block;width:6px;height:14px;margin-left:2px;transform:translateY(2px);
1289
+ background:var(--ink);animation:ccblink 1.05s steps(1) infinite}
1290
+ @keyframes ccblink{50%{opacity:0}}
1291
+ @media (prefers-reduced-motion:reduce){#fsRoot .cc-caret{animation:none}}
1292
+ /* "→ Added to To Do #NN" — shown quietly in the flow when a turn became a task.
1293
+ Undo appears on hover only (§1.5-10). No filled surface. */
1294
+ #fsRoot .cc-mark{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px;color:var(--ink3);padding:1px 0}
1295
+ #fsRoot .cc-mark .cm-ar{color:var(--ink2);font-size:12.5px;line-height:1}
1296
+ #fsRoot .cc-mark .cm-t{color:var(--ink2)}
1297
+ #fsRoot .cc-mark .cm-no{color:var(--ink3);font-variant-numeric:tabular-nums}
1298
+ #fsRoot .cc-mark .cm-undo{margin-left:2px;padding:0;border:0;background:none;font:inherit;color:var(--ink3);cursor:pointer;opacity:0;transition:opacity .15s,color .15s}
1299
+ #fsRoot .cc-mark:hover .cm-undo{opacity:1}
1300
+ #fsRoot .cc-mark .cm-undo:hover{color:var(--blue)}
1270
1301
  /* Claude Code CLI language (HANDOFF-v45 §12, flagship .cc-t port): gap:0 — the bullet's
1271
1302
  own margin-right does the spacing, not a flex gap (keeps ⏺ tight against the tool name
1272
1303
  the way the real CLI reads). */
@@ -1313,6 +1344,11 @@
1313
1344
  discard path. */
1314
1345
  #fsRoot .cc-ask .acts{position:absolute;top:8px;right:8px;display:flex;opacity:0;transition:opacity .15s}
1315
1346
  #fsRoot .cc-ask:hover .acts{opacity:1}
1347
+ /* Touch devices have no hover → the hover-only trash was permanently invisible
1348
+ and the「Needs your answer」card had no reachable discard path (Masa
1349
+ 2026-07-16: 右上のdeleteが押せない). Reveal it (dimmed, so it still reads as
1350
+ secondary per §1.5-10) whenever the pointer can't hover. */
1351
+ @media (hover: none){ #fsRoot .cc-ask .acts{opacity:.55} }
1316
1352
  #fsRoot .qchips{display:flex;gap:6px;flex-wrap:wrap}
1317
1353
  #fsRoot .achip{height:28px;padding:0 12px;border-radius:8px;border:1px solid var(--hair2);background:var(--panel);
1318
1354
  color:var(--ink2);font:inherit;font-size:12.5px;cursor:pointer;transition:border-color .15s,color .15s,background .15s}
@@ -1321,6 +1357,10 @@
1321
1357
  #fsRoot .fs-askbar{display:none;border:1px solid var(--hair2);border-bottom:0;border-left:2px solid var(--blue);border-radius:14px 14px 0 0;padding:9px 13px 11px} /* 枠1px (v3 §6.5) */
1322
1358
  #fsRoot .compwrap{max-width:var(--fscenterw,760px);width:100%;margin:10px auto 0;flex:0 0 auto;position:relative;z-index:1;
1323
1359
  transition:max-width .28s cubic-bezier(.4,0,.2,1)}
1360
+ /* #errbar is adopted into .compwrap by positionLayout() (flagship hides .main
1361
+ outright, its original home) — float it just above the input, matching the
1362
+ legacy layout's "just above the composer" placement (CDO判定 2026-07-16). */
1363
+ #fsRoot .compwrap>.errbar{position:absolute;left:50%;bottom:calc(100% + 10px);transform:translateX(-50%)}
1324
1364
  #fsRoot.lanefold{--fscenterw:1020px} /* folded space flows to the center (§1.5-7) — one var flip keeps feed+composer in lockstep */
1325
1365
  /* adopted composer (#composerWrap moves in — same node, same handlers, focus fix intact) */
1326
1366
  body[data-layout="flagship"] #composerWrap{padding:0;border-top:0;max-width:none;width:auto;margin:0}
@@ -1937,7 +1977,7 @@
1937
1977
  #fsClawdHero span{display:block;margin-top:5px;font:500 10px/1.4 var(--mono);letter-spacing:.08em;color:var(--ink3)}
1938
1978
  /* Welcome P1 (H17 item 4, flagship f9c75ad): grounded hop + clean-cut eyes on settle + our CSS confetti.
1939
1979
  Two pixel frames (neutral black-eyes / laughing), no arm-raise, no official confetti. reduce-motion=static. */
1940
- #fsClawdHero .cstage{position:relative;width:170px;height:132px;display:flex;align-items:flex-end;justify-content:center;margin:0 auto 2px}#fsClawdHero .cw{position:relative;width:150px;transform-origin:50% 100%;will-change:transform;animation:chHop 2.4s linear infinite}#fsClawdHero .cw img{position:absolute;left:0;bottom:0;width:100%;display:block;image-rendering:pixelated}#fsClawdHero .cw .chNeu{animation:chNeu 2.4s linear infinite}#fsClawdHero .cw .chLau{opacity:0;animation:chLau 2.4s linear infinite}#fsClawdHero .chConf{position:absolute;left:50%;top:34px;width:0;height:0;pointer-events:none;z-index:3}#fsClawdHero .chConf i{position:absolute;width:4px;height:7px;border-radius:1.5px;opacity:0;transform:translate(-50%,-50%);animation:chPop 2.4s cubic-bezier(.2,.7,.3,1) infinite}@keyframes chHop{0%,52%{transform:translateY(0) scale(1,1)}55%{transform:translateY(0) scale(1.08,.85)}58%{transform:translateY(-16px) scale(.95,1.14)}61%{transform:translateY(-27px) scale(.98,1.05)}64%{transform:translateY(-31px) scale(1,1)}67%{transform:translateY(-26px) scale(.98,1.05)}70%{transform:translateY(-14px) scale(.95,1.12)}73%{transform:translateY(0) scale(1.1,.82)}77%{transform:translateY(-4px) scale(.99,1.02)}80%,100%{transform:translateY(0) scale(1,1)}}@keyframes chLau{0%,53.8%{opacity:0}54%,85.8%{opacity:1}86%,100%{opacity:0}}@keyframes chNeu{0%,53.8%{opacity:1}54%,85.8%{opacity:0}86%,100%{opacity:1}}@keyframes chPop{0%,54%{opacity:0;transform:translate(0,6px) rotate(0) scale(.5)}60%{opacity:1;transform:translate(calc(var(--dx)*.5),calc(var(--dy)*.5)) rotate(calc(var(--rot)*.5)) scale(1)}72%{opacity:1;transform:translate(var(--dx),var(--dy)) rotate(var(--rot))}100%{opacity:0;transform:translate(calc(var(--dx)*1.1),calc(var(--dy) + 44px)) rotate(calc(var(--rot)*1.4)) scale(.9)}}@media (prefers-reduced-motion:reduce){#fsClawdHero .cw,#fsClawdHero .cw .chNeu,#fsClawdHero .cw .chLau{animation:none}#fsClawdHero .cw .chLau{opacity:0}#fsClawdHero .chConf{display:none}}
1980
+ #fsClawdHero .cstage{position:relative;left:-13px;width:170px;height:132px;display:flex;align-items:flex-end;justify-content:center;margin:0 auto 2px}#fsClawdHero .cw{position:relative;width:150px;transform-origin:50% 100%;will-change:transform;animation:chHop 2.4s linear infinite}#fsClawdHero .cw img{position:absolute;left:0;bottom:0;width:100%;display:block;image-rendering:pixelated}#fsClawdHero .cw .chNeu{animation:chNeu 2.4s linear infinite}#fsClawdHero .cw .chLau{opacity:0;animation:chLau 2.4s linear infinite}#fsClawdHero .chConf{position:absolute;left:50%;top:34px;width:0;height:0;pointer-events:none;z-index:3}#fsClawdHero .chConf i{position:absolute;width:4px;height:7px;border-radius:1.5px;opacity:0;transform:translate(-50%,-50%);animation:chPop 2.4s cubic-bezier(.2,.7,.3,1) infinite}@keyframes chHop{0%,52%{transform:translateY(0) scale(1,1)}55%{transform:translateY(0) scale(1.08,.85)}58%{transform:translateY(-16px) scale(.95,1.14)}61%{transform:translateY(-27px) scale(.98,1.05)}64%{transform:translateY(-31px) scale(1,1)}67%{transform:translateY(-26px) scale(.98,1.05)}70%{transform:translateY(-14px) scale(.95,1.12)}73%{transform:translateY(0) scale(1.1,.82)}77%{transform:translateY(-4px) scale(.99,1.02)}80%,100%{transform:translateY(0) scale(1,1)}}@keyframes chLau{0%,53.8%{opacity:0}54%,85.8%{opacity:1}86%,100%{opacity:0}}@keyframes chNeu{0%,53.8%{opacity:1}54%,85.8%{opacity:0}86%,100%{opacity:1}}@keyframes chPop{0%,54%{opacity:0;transform:translate(0,6px) rotate(0) scale(.5)}60%{opacity:1;transform:translate(calc(var(--dx)*.5),calc(var(--dy)*.5)) rotate(calc(var(--rot)*.5)) scale(1)}72%{opacity:1;transform:translate(var(--dx),var(--dy)) rotate(var(--rot))}100%{opacity:0;transform:translate(calc(var(--dx)*1.1),calc(var(--dy) + 44px)) rotate(calc(var(--rot)*1.4)) scale(.9)}}@media (prefers-reduced-motion:reduce){#fsClawdHero .cw,#fsClawdHero .cw .chNeu,#fsClawdHero .cw .chLau{animation:none}#fsClawdHero .cw .chLau{opacity:0}#fsClawdHero .chConf{display:none}}
1941
1981
  #fsRoot.clawdhero #fsClawdHero{display:block}
1942
1982
  /* Non-Claude agent (e.g. Codex) hero: no Clawd mascot (that's a Claude Code brand mark,
1943
1983
  DESIGN-RULES v3 §6.5) — a plain glyph badge stands in so the hero still names the
@@ -2047,6 +2087,11 @@
2047
2087
  .bx-lb{font:600 9px/1 var(--mono);letter-spacing:.11em;text-transform:uppercase;color:var(--red)}
2048
2088
  .bx-q{font-size:13px;color:var(--ink);margin-top:4px}
2049
2089
  .bx-act{flex:none}
2090
+ /* neutral ask (not a limit/error — e.g. the sign-in nudge): same card, --blue
2091
+ accent instead of --red, matching the existing AI-confirmation blue
2092
+ (§7決定ログ「AI確認:色は青系」) rather than inventing a new color. */
2093
+ .bx-ask.info{border-left-color:var(--blue)}
2094
+ .bx-ask.info .bx-lb{color:var(--blue)}
2050
2095
  /* ② post-checkout return bar */
2051
2096
  .bx-cobar{box-shadow:inset 0 0 0 1px var(--hair2);border-radius:11px;padding:10px 13px;display:flex;align-items:center;gap:10px;font-size:12.5px;margin:0 auto 12px;width:100%;max-width:var(--fscenterw,760px)}
2052
2097
  .bx-cobar[hidden]{display:none}
@@ -2092,7 +2137,7 @@
2092
2137
  <button class="laybtn" data-theme="cinema">Cinema</button>
2093
2138
  </div>
2094
2139
  <div class="gearwrap">
2095
- <button class="gearbtn" id="gearBtn" title="カスタマイズ" aria-label="Customize"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
2140
+ <button class="gearbtn" id="gearBtn" title="Settings" aria-label="Settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
2096
2141
  <div class="gearpop" id="gearPop"></div>
2097
2142
  </div>
2098
2143
  <button class="pill-btn" id="connect">Connect MCP</button>
@@ -2107,6 +2152,12 @@
2107
2152
  <div class="errbar" id="errbar"></div>
2108
2153
  <div class="composer-wrap" id="composerWrap">
2109
2154
  <div class="queue" id="queue"></div>
2155
+ <!-- Conversation-first composer (SLICE 2, docs/HANDOFF-ONBOARDING-conversational.md):
2156
+ a chat/help/settings message gets a quiet reply HERE instead of
2157
+ becoming a To-Do. Reuses the .peraanswer surface; hidden until a
2158
+ reply exists (progressive disclosure — DESIGN-RULES §1.5-3, "存在
2159
+ しないものの器を先に見せない"). -->
2160
+ <div class="peraanswer" id="chatReply" hidden></div>
2110
2161
  <div class="composer">
2111
2162
  <div class="attachbar" id="attachbar" style="display:none"></div>
2112
2163
  <!-- Slash-command palette (Masa決定 2026-07-07: chips→slash). Floats
@@ -2199,9 +2250,8 @@
2199
2250
  <div class="lbrow"><span class="lb">Projects</span>
2200
2251
  <button class="ghostbtn morebtn" id="fsMoreBtn" title="Settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1.7 1.7 0 00.34 1.87l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.7 1.7 0 00-1.87-.34 1.7 1.7 0 00-1 1.56V21a2 2 0 11-4 0v-.09a1.7 1.7 0 00-1-1.56 1.7 1.7 0 00-1.87.34l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.7 1.7 0 00.34-1.87 1.7 1.7 0 00-1.56-1H3a2 2 0 110-4h.09a1.7 1.7 0 001.56-1 1.7 1.7 0 00-.34-1.87l-.06-.06a2 2 0 112.83-2.83l.06.06a1.7 1.7 0 001.87.34h.09a1.7 1.7 0 001-1.56V3a2 2 0 114 0v.09a1.7 1.7 0 001 1.56h.09a1.7 1.7 0 001.87-.34l.06-.06a2 2 0 112.83 2.83l-.06.06a1.7 1.7 0 00-.34 1.87v.09a1.7 1.7 0 001.56 1H21a2 2 0 110 4h-.09a1.7 1.7 0 00-1.56 1z"/></svg></button>
2201
2252
  <div class="moremenu" id="fsMoreMenu">
2202
- <button class="ghostbtn" id="fsCust">Customize…</button>
2253
+ <button class="ghostbtn" id="fsCust">Theme</button>
2203
2254
  <button class="ghostbtn" id="fsConn">Connect MCP</button>
2204
- <button class="ghostbtn" id="fsTodoBd">To Do board</button>
2205
2255
  <button class="ghostbtn" id="fsHist">History</button>
2206
2256
  <button class="ghostbtn" id="fsSet">Settings</button>
2207
2257
  </div>
@@ -2455,7 +2505,7 @@ const savedTheme = (() => {
2455
2505
  if (v !== s) localStorage.setItem('theme', v);
2456
2506
  return v;
2457
2507
  })();
2458
- const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, active: 'galda2', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null,
2508
+ const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: 'galda2', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null,
2459
2509
  // per-message send overrides set via slash-commands (reset after each send,
2460
2510
  // except model which is sticky in localStorage). palette = open command list.
2461
2511
  msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
@@ -2463,6 +2513,28 @@ const state = { projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {
2463
2513
  window.state = state; // top-level const isn't a window property; external checks probe window.state
2464
2514
  const $ = (id) => document.getElementById(id);
2465
2515
 
2516
+ // Question free-text answer boxes (qansinput/fsansinput) are rebuilt from
2517
+ // scratch (container.innerHTML = ...) on every SSE-driven render, which — same
2518
+ // class of bug as the composer's c32fda1 fix — detaches whatever the user is
2519
+ // mid-typing into and drops focus + cursor position + the in-progress text.
2520
+ // Since these nodes can't be kept alive like the composer (they're generated
2521
+ // HTML strings, not a persistent DOM subtree), save the focused box's value
2522
+ // and selection before the rebuild and restore them onto the freshly-rendered
2523
+ // node with the same data-attribute id afterward.
2524
+ function captureFocusedAnswerInput(container, attr){
2525
+ const el = document.activeElement;
2526
+ if (!el || !container || !container.contains(el) || !el.hasAttribute(attr)) return null;
2527
+ return { id: el.getAttribute(attr), value: el.value, start: el.selectionStart, end: el.selectionEnd };
2528
+ }
2529
+ function restoreFocusedAnswerInput(container, attr, saved){
2530
+ if (!saved || !container) return;
2531
+ const el = container.querySelector(`[${attr}="${saved.id}"]`);
2532
+ if (!el) return;
2533
+ el.value = saved.value;
2534
+ el.focus();
2535
+ try { el.setSelectionRange(saved.start, saved.end); } catch { /* input type may not support it */ }
2536
+ }
2537
+
2466
2538
  const STATUS_LABEL = {
2467
2539
  sending: 'Sending…', stacked: 'Queued', planning: 'Planning…', queued: 'To do', running: 'Working…', needsInput: 'Needs input',
2468
2540
  review: 'In review', done: 'Done', failed: 'Failed', partial: 'Partly failed', interrupted: 'Interrupted', skipped: 'Skipped',
@@ -2553,10 +2625,15 @@ function setAgentEffort(agent){
2553
2625
  localStorage.setItem('sel:effort', state.stickyEffort);
2554
2626
  }
2555
2627
  function renderAppSelect(){
2556
- if (!CONNECTED_APPS.some((a) => a.id === state.connectedApp)) state.connectedApp = CONNECTED_APPS[0].id;
2557
- const app = CONNECTED_APPS.find((a) => a.id === state.connectedApp) ?? CONNECTED_APPS[0];
2628
+ // Only offer agents whose CLI is actually installed (state.agents comes from the
2629
+ // server = AVAILABLE_AGENTS). A claude-only machine never shows Codex; a
2630
+ // Codex-only one never shows Claude Code. Never render an empty picker.
2631
+ const apps = CONNECTED_APPS.filter((a) => (state.agents || []).includes(a.id));
2632
+ const list = apps.length ? apps : CONNECTED_APPS.slice(0, 1);
2633
+ if (!list.some((a) => a.id === state.connectedApp)) state.connectedApp = list[0].id;
2634
+ const app = list.find((a) => a.id === state.connectedApp) ?? list[0];
2558
2635
  $('appbtn').innerHTML = `${app.icon}<span>${esc(app.label)}</span>${ICONS.caret}`;
2559
- $('appmenu').innerHTML = CONNECTED_APPS.map((a) =>
2636
+ $('appmenu').innerHTML = list.map((a) =>
2560
2637
  `<button type="button" class="appopt${a.id === state.connectedApp ? ' active' : ''}" data-val="${a.id}">${a.icon}<span>${esc(a.label)}</span></button>`
2561
2638
  ).join('');
2562
2639
  }
@@ -2768,6 +2845,14 @@ function buildReviewDigest({ goals, tasks } = {}){
2768
2845
  // Mirrors engine/lib.mjs buildReviewSummary()/requirementSummaryLine() (task 96)
2769
2846
  // — kept in sync manually since the browser script isn't a module. Used by both
2770
2847
  // the review checklist top summary and the chat-side review导线.
2848
+ // task 6 (2026-07-16): mirrors engine/lib.mjs goalTokenHistory() — one
2849
+ // {weightedTokens, requirementCount} sample per finished goal (excluding the
2850
+ // one being summarized), oldest-first, for the token-savings baseline below.
2851
+ function goalTokenHistoryLocal(goals, tasks, excludeGoalId){
2852
+ return (goals ?? [])
2853
+ .filter((g) => g && g.id !== excludeGoalId && GOAL_COMPLETE_STATUSES.includes(g.status) && g.runTokens != null)
2854
+ .map((g) => ({ weightedTokens: g.runTokens, requirementCount: (tasks ?? []).filter((t) => t.goalId === g.id && !t.reply).length }));
2855
+ }
2771
2856
  function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}){
2772
2857
  const reqs = (tasks ?? []).filter((t) => !t.reply);
2773
2858
  const total = reqs.length;
@@ -2777,6 +2862,9 @@ function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}){
2777
2862
  const source = plan.length ? plan : reqs.map((t) => t.title);
2778
2863
  const what = source.map((s) => String(s ?? '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 3).join(' / ');
2779
2864
  const fallback = String(goal?.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 80);
2865
+ // History is scoped to the same project as this goal — a goal in a small
2866
+ // toy project shouldn't be judged against a large project's average.
2867
+ const sameProjectGoals = (state.goals || []).filter((g) => g.projectId === goal?.projectId);
2780
2868
  return { approvedN, total, headline: `${approvedN} / ${total} approved`, what: what || fallback,
2781
2869
  detail: `${done}/${total} done${proofs ? ` · proof ×${proofs}` : ''}`,
2782
2870
  failureMemory: goal?.failureMemory?.length ? goal.failureMemory[goal.failureMemory.length - 1] : null,
@@ -2789,6 +2877,8 @@ function buildReviewSummary({ goal, tasks, approvedN = 0 } = {}){
2789
2877
  usedCompact: goal?.executionPlan?.sessionMode === 'compact',
2790
2878
  usedClear: Boolean(goal?.sessionClosedAt),
2791
2879
  agentModel: goal?.model,
2880
+ requirementCount: total,
2881
+ history: goalTokenHistoryLocal(sameProjectGoals, state.tasks || [], goal?.id),
2792
2882
  }) };
2793
2883
  }
2794
2884
  function requirementSummaryLine(task){
@@ -2856,28 +2946,65 @@ function usageBudgetTokensLocal(usage, cacheReadWeight = 0.1){
2856
2946
  const weight = Number.isFinite(Number(cacheReadWeight)) ? Math.max(0, Number(cacheReadWeight)) : 0.1;
2857
2947
  return Math.round((u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + ((u.cache_read_input_tokens ?? 0) * weight));
2858
2948
  }
2859
- function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null } = {}){
2949
+ // task 6 (2026-07-16): mirrors engine/lib.mjs tokenBaselinePerRequirement()
2950
+ // moving average of tokens-per-requirement over up to the last 20 finished
2951
+ // goals (needs at least 3 to be meaningful).
2952
+ const TOKEN_BASELINE_MIN_HISTORY = 3;
2953
+ const TOKEN_BASELINE_MAX_HISTORY = 20;
2954
+ function tokenBaselinePerRequirementLocal(history){
2955
+ const samples = (history ?? [])
2956
+ .map((h) => {
2957
+ if (typeof h === 'number') return h;
2958
+ const tokens = Number(h?.weightedTokens);
2959
+ const reqN = Math.max(1, Number(h?.requirementCount) || 1);
2960
+ return Number.isFinite(tokens) ? tokens / reqN : null;
2961
+ })
2962
+ .filter((n) => Number.isFinite(n) && n > 0)
2963
+ .slice(-TOKEN_BASELINE_MAX_HISTORY);
2964
+ if (samples.length < TOKEN_BASELINE_MIN_HISTORY) return null;
2965
+ return samples.reduce((a, b) => a + b, 0) / samples.length;
2966
+ }
2967
+ // Mirrors engine/lib.mjs estimateTokenOptimization() — see the comment there
2968
+ // for why "saved X%" now compares against a real per-project baseline
2969
+ // (basis: 'baseline') instead of always reusing this goal's own cache-hit
2970
+ // ratio as a stand-in for "cheaper than usual" (basis: 'cache-weight',
2971
+ // used only until there's enough finished-goal history).
2972
+ function estimateTokenOptimizationLocal({ usage = null, weightedTokens = null, cacheReadWeight = 0.1, sessionMode = null, usedHandoff = false, usedCompact = false, usedClear = false, agentModel = null, requirementCount = 1, history = [] } = {}){
2860
2973
  const u = usage ?? {};
2861
2974
  const raw = (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
2975
+ if (raw <= 0) return null;
2862
2976
  const weighted = weightedTokens != null && Number.isFinite(Number(weightedTokens)) ? Number(weightedTokens) : usageBudgetTokensLocal(u, cacheReadWeight);
2863
- const saved = Math.max(0, raw - weighted);
2864
- const percent = raw > 0 ? Math.round((saved / raw) * 100) : 0;
2977
+ const reqN = Math.max(1, Number(requirementCount) || 1);
2978
+ const perReq = weighted / reqN;
2979
+ const baseline = tokenBaselinePerRequirementLocal(history);
2980
+ let percent, basis, savedTokens;
2981
+ if (baseline != null) {
2982
+ percent = Math.max(-100, Math.min(100, Math.round(((baseline - perReq) / baseline) * 100)));
2983
+ basis = 'baseline';
2984
+ savedTokens = Math.max(0, Math.round((baseline - perReq) * reqN));
2985
+ } else {
2986
+ savedTokens = Math.max(0, raw - weighted);
2987
+ percent = Math.round((savedTokens / raw) * 100);
2988
+ basis = 'cache-weight';
2989
+ }
2865
2990
  const techniques = [];
2866
- if ((u.cache_read_input_tokens ?? 0) > 0) techniques.push('cache-read weighting');
2867
- if (['compact', 'cold-handoff'].includes(sessionMode)) techniques.push(sessionMode === 'compact' ? 'compact handoff' : 'fresh handoff');
2868
- if (usedHandoff) techniques.push('AI handoff');
2869
- if (usedCompact) techniques.push('/compact');
2870
- if (usedClear) techniques.push('/clear');
2871
- if (agentModel) techniques.push(`model ${agentModel}`);
2872
- if (!percent && !techniques.length) return null;
2873
- return { rawTokens: raw, weightedTokens: weighted, savedTokens: saved, percent, techniques: [...new Set(techniques)] };
2991
+ const cacheRatio = Math.round(((u.cache_read_input_tokens ?? 0) / raw) * 100);
2992
+ if (cacheRatio > 0) techniques.push(`${cacheRatio}% of tokens served from cache`);
2993
+ if (usedHandoff || sessionMode === 'cold-handoff') techniques.push('fresh handoff instead of a bloated session');
2994
+ if (usedCompact || sessionMode === 'compact') techniques.push('/compact before continuing');
2995
+ if (usedClear) techniques.push('/clear between unrelated goals');
2996
+ if (agentModel) techniques.push(`${agentModel} for this run`);
2997
+ if (basis === 'baseline' && percent > 0) techniques.push(`${Math.round(perReq / 1000)}k tokens/requirement vs your ${Math.round(baseline / 1000)}k average`);
2998
+ if (!techniques.length) techniques.push(`${Math.round(raw / 1000)}k tokens this run (no cache reuse yet)`);
2999
+ return { rawTokens: raw, weightedTokens: weighted, savedTokens, percent, basis, techniques: [...new Set(techniques)] };
2874
3000
  }
2875
3001
  function tokenOptimizationHtml(opt){
2876
3002
  if (!opt) return '';
2877
- const techniques = opt.techniques?.length ? opt.techniques.slice(0, 4).join(' / ') : 'session planning';
3003
+ const techniques = opt.techniques?.length ? opt.techniques.slice(0, 4).join(' / ') : 'this run';
2878
3004
  const pct = Number(opt.percent);
3005
+ const cmp = opt.basis === 'baseline' ? 'vs your recent average' : 'from prompt caching';
2879
3006
  const text = pct > 0
2880
- ? `Token optimized: saved about ${pct}% via ${techniques}`
3007
+ ? `Token optimized: saved about ${pct}% ${cmp} (${techniques})`
2881
3008
  : `Token optimized: ${techniques}`;
2882
3009
  return `<div class="rvtokenopt">${esc(text)}</div>`;
2883
3010
  }
@@ -3500,6 +3627,7 @@ function renderTasks(){
3500
3627
  <div class="cond">shelved — press Start to queue it</div></div>
3501
3628
  <button type="button" class="prmini pstart" data-pstart="${g.id}">Start</button>
3502
3629
  <button type="button" class="pdel" data-pdel="${g.id}" title="Delete">✕</button></div>`).join('');
3630
+ const savedQAns = captureFocusedAnswerInput($('tasklist'), 'data-qansinput');
3503
3631
  $('tasklist').innerHTML =
3504
3632
  sec('Attention', attention, 'attn')
3505
3633
  + sec(pickColumnLabel(cols, 'review', 'Review'), reviewDigest + review, 'reviewsec', reviewDefinitionFor(state.active).description, REVIEW_DEF_HINT)
@@ -3507,6 +3635,7 @@ function renderTasks(){
3507
3635
  + sec(pickColumnLabel(cols, 'todo', 'To do'), todo, 'todosec')
3508
3636
  + sec('Pending', pendingRows, 'dim')
3509
3637
  + sec(pickColumnLabel(cols, 'done', 'Done'), doneRows, 'dim');
3638
+ restoreFocusedAnswerInput($('tasklist'), 'data-qansinput', savedQAns);
3510
3639
  // faint pencil beside the Review subtitle → open the review-definition editor
3511
3640
  $('tasklist').querySelector('[data-editreviewdef]')?.addEventListener('click', (e) => { e.stopPropagation(); openWfEditor(); });
3512
3641
  for (const el of $('tasklist').querySelectorAll('.task[data-task]')) {
@@ -3663,7 +3792,7 @@ function renderTasks(){
3663
3792
  // event listeners, so all wiring above still works. Anchor comments mark each
3664
3793
  // node's home so other layouts restore it exactly.
3665
3794
  function ensureLayoutAnchors(){
3666
- for (const id of ['tasklist', 'composerWrap', 'actpanel']) {
3795
+ for (const id of ['tasklist', 'composerWrap', 'actpanel', 'errbar']) {
3667
3796
  const el = $(id);
3668
3797
  if (el && !el._anchor) { const a = document.createComment('home:' + id); el.parentNode.insertBefore(a, el); el._anchor = a; }
3669
3798
  }
@@ -3683,11 +3812,19 @@ function positionLayout(){
3683
3812
  // is never detached mid-typing on SSE-driven renders.
3684
3813
  const host = $('fsCompwrap'), cw = $('composerWrap');
3685
3814
  if (host && cw && cw.parentNode !== host) host.appendChild(cw);
3815
+ // #errbar (showErr/showBillingBlocked/showSignInNudge) lives inside .main,
3816
+ // which flagship hides outright (display:none) — every transient message
3817
+ // silently never rendered. Adopt it beside the composer, same technique as
3818
+ // #composerWrap above; a flagship CSS rule floats it just above the input
3819
+ // (CDO判定 2026-07-16 — same fix as #gearPop's body[data-layout] escape).
3820
+ const eb = $('errbar');
3821
+ if (host && eb && eb.parentNode !== host) host.appendChild(eb);
3686
3822
  // tasklist / actpanel go back home (they live hidden inside .main here)
3687
3823
  for (const id of ['tasklist', 'actpanel']) { const el = $(id); if (el && el._anchor && el.parentNode !== el._anchor.parentNode) el._anchor.parentNode.insertBefore(el, el._anchor); }
3688
3824
  return;
3689
3825
  }
3690
3826
  if (state.layout === 'bflow') {
3827
+ { const el = $('errbar'); if (el && el._anchor && el.parentNode !== el._anchor.parentNode) el._anchor.parentNode.insertBefore(el, el._anchor); }
3691
3828
  const bc = $('bcenter'), bt = $('brightTop'), bb = $('brightBottom');
3692
3829
  if (!bc) return;
3693
3830
  const tl = $('tasklist'), cw = $('composerWrap'), ap = $('actpanel');
@@ -3707,7 +3844,7 @@ function positionLayout(){
3707
3844
  if (!bb.querySelector('.brbot-head')) { const h = document.createElement('div'); h.className = 'brbot-head'; h.textContent = 'Implementing'; bb.appendChild(h); }
3708
3845
  if (ap.parentNode !== bb) bb.appendChild(ap); // right-bottom: Log → Implementing
3709
3846
  } else {
3710
- for (const id of ['tasklist', 'composerWrap', 'actpanel']) { const el = $(id); if (el && el._anchor && el.parentNode !== el._anchor.parentNode) el._anchor.parentNode.insertBefore(el, el._anchor); }
3847
+ for (const id of ['tasklist', 'composerWrap', 'actpanel', 'errbar']) { const el = $(id); if (el && el._anchor && el.parentNode !== el._anchor.parentNode) el._anchor.parentNode.insertBefore(el, el._anchor); }
3711
3848
  }
3712
3849
  }
3713
3850
 
@@ -4866,12 +5003,15 @@ function renderFsFeed(){
4866
5003
  // dogfood PRs (質問カードの自由記述回答欄 / 「Needs your answer」カードを削除できるように
4867
5004
  // — see docs/confirmed-behaviors.md): the chips-only card left no way to answer a question
4868
5005
  // with no matching preset option, or to discard a question you don't want to answer.
5006
+ // Questions render at the FEED BOTTOM (composer-adjacent) — built into askHtml
5007
+ // so they sit AFTER the conversation turns (H18/H19 / Pattern A: 質問は末尾固定).
5008
+ let askHtml = '';
4869
5009
  for (const g of state.goals.filter((g) => g.projectId === state.active && g.status === 'needsInput' && g.question)) {
4870
5010
  const chips = (g.question.options || []).map((o) => `<button class="achip" data-fsanswer="${g.id}" data-a="${esc(o)}">${esc(o)}</button>`).join('');
4871
5011
  // QUESTION card (H18): mono label + prompt + option chips + always-on free-text
4872
5012
  // answer + hover trash. Data hooks (data-fsanswer / -ansinput / -anssend /
4873
5013
  // -answerdel) are unchanged so the existing wiring below keeps working.
4874
- html += `<div class="cc-ask"><div class="cc-ask-lb">Question</div>`
5014
+ askHtml += `<div class="cc-ask"><div class="cc-ask-lb">Question</div>`
4875
5015
  + `<div class="cc-ask-q">${esc(g.question.text)}</div>`
4876
5016
  + (chips ? `<div class="qchips">${chips}</div>` : '')
4877
5017
  + `<div class="answ-inline"><input type="text" data-fsansinput="${g.id}" placeholder="Type your answer…" aria-label="Type your answer">`
@@ -4895,17 +5035,44 @@ function renderFsFeed(){
4895
5035
  // longer flash in and wipe what the user was looking at. While work exists but
4896
5036
  // this pass produced no lines, keep the last known feed (or a calm one-line
4897
5037
  // status) instead of blanking to the hero.
5038
+ // Pattern A (H23): the conversation turns — the user's own messages, Claude
5039
+ // Code's replies (typed out with a caret on the streaming frontier), and
5040
+ // "→ Added to To Do #NN" markers — rendered from state.chatTurns so the
5041
+ // whole-feed re-render (SSE act events) can't drop them. Order per turn:
5042
+ // You → (mark, if it became a task) and, for a reply, speaker line → reply.
5043
+ const turns = (state.chatTurns[state.active] || []).slice(-40);
5044
+ let chatHtml = '';
5045
+ for (const turn of turns) {
5046
+ if (turn.role === 'you') {
5047
+ chatHtml += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
5048
+ if (turn.markGoalId != null && !turn.undone) {
5049
+ const mg = state.goals.find((x) => x.id === turn.markGoalId);
5050
+ const n = mg ? goalReviewNumber({ goal: mg, tasks: state.tasks }) : turn.markGoalId;
5051
+ chatHtml += `<div class="cc-mark"><span class="cm-ar">→</span><span class="cm-t">Added to To Do</span><span class="cm-no">#${n}</span><button class="cm-undo" type="button" data-ccundo="${turn.markGoalId}">Undo</button></div>`;
5052
+ }
5053
+ } else { // Claude Code reply — speaker line first (You → speaker → reply), then the text
5054
+ const app = connectedAppMeta(turn.agent);
5055
+ chatHtml += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
5056
+ const shown = esc((turn.text || '').slice(0, turn.revealed || 0));
5057
+ chatHtml += turn.done
5058
+ ? `<div class="cc-say">${shown}</div>`
5059
+ : `<div class="cc-say"><span id="ccTyping">${shown}</span><span class="cc-caret"></span></div>`;
5060
+ }
5061
+ }
4898
5062
  const pgoals = state.goals.filter((g) => g.projectId === state.active);
4899
5063
  const ptasks = state.tasks.filter((t) => t.projectId === state.active);
4900
- const hasWork = pgoals.length > 0 || ptasks.length > 0;
4901
- let out = html;
5064
+ const hasWork = pgoals.length > 0 || ptasks.length > 0 || turns.length > 0;
5065
+ // task feed (with its own speaker line) → conversation turns → question (bottom)
5066
+ let out = html + chatHtml + askHtml;
4902
5067
  if (out) _fsLastFeed[state.active] = out;
4903
5068
  else if (hasWork) out = _fsLastFeed[state.active] || `<div class="cc-c">⎿ ${esc(running.length ? 'working…' : 'no active task')}</div>`;
4904
5069
  $('fsRoot').classList.toggle('clawdhero', !hasWork);
4905
5070
  const sc = feed.parentNode;
4906
5071
  const nearBottom = sc.scrollHeight - sc.clientHeight - sc.scrollTop <= 80;
5072
+ const savedFsAns = captureFocusedAnswerInput(feed, 'data-fsansinput');
4907
5073
  feed.innerHTML = hasWork ? out : '';
4908
5074
  if (nearBottom) sc.scrollTop = sc.scrollHeight;
5075
+ restoreFocusedAnswerInput(feed, 'data-fsansinput', savedFsAns);
4909
5076
  window.__fsCineSync?.(); // Cinema dynamic rule: content growth may reach the band region
4910
5077
  for (const b of feed.querySelectorAll('[data-fsretry]')) b.onclick = () => fsRetryTask(b.dataset.fsretry);
4911
5078
  for (const b of feed.querySelectorAll('[data-fslog]')) b.onclick = () => fsToggleLog(Number(b.dataset.fslog));
@@ -4923,6 +5090,19 @@ function renderFsFeed(){
4923
5090
  // Delete a "needs your answer" card outright (restores 「Needs your answer」カードを削除
4924
5091
  // できるように — a question you don't want to answer had no discard path in flagship).
4925
5092
  for (const b of feed.querySelectorAll('[data-fsanswerdel]')) b.onclick = (e) => { e.stopPropagation(); fsTaskDelete(Number(b.dataset.fsanswerdel)); };
5093
+ // Pattern A (H23): Undo a "→ Added to To Do" marker — cancel the created goal
5094
+ // and drop the mark. A just-created (planning) goal is deletable; if it already
5095
+ // started the server returns non-OK (canDeleteGoal) and we say so quietly.
5096
+ for (const b of feed.querySelectorAll('[data-ccundo]')) b.onclick = async (e) => {
5097
+ e.stopPropagation();
5098
+ const gid = Number(b.dataset.ccundo);
5099
+ const r = await fetch(withKey(`/api/goals/${gid}`), { method: 'DELETE' });
5100
+ if (!r.ok) { showErr('Could not undo — the task may already be running'); return; }
5101
+ const turn = (state.chatTurns[state.active] || []).find((t) => t.markGoalId === gid);
5102
+ if (turn) turn.undone = true;
5103
+ state.goals = state.goals.filter((g) => g.id !== gid);
5104
+ render();
5105
+ };
4926
5106
  }
4927
5107
  async function fsAnswerQuestion(goalId, answer){
4928
5108
  if (!answer) return;
@@ -5367,10 +5547,15 @@ function applyFsBand(){
5367
5547
  const CLAWD_HERO_IMGS = '<img class="chNeu" src="data:image/png;base64,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" alt=""><img class="chLau" src="data:image/png;base64,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" alt=""></div></div>';
5368
5548
  function clawdHeroHTML(agent){
5369
5549
  const app = connectedAppMeta(agent);
5550
+ // Codex: wordmark + status only — no Clawd mascot (that's a Claude Code brand
5551
+ // mark, §6.5) and no glyph badge either (Masa 2026-07-16: 「codexの時は文字だけでよい」).
5370
5552
  const stage = agent === 'codex'
5371
- ? `<div class="cstage"><div class="chBadge">${app.icon}</div></div>`
5553
+ ? ''
5372
5554
  : `<div class="cstage"><div class="chConf"></div><div class="cw">${CLAWD_HERO_IMGS}`;
5373
- return stage + `<b>${esc(app.label)}</b><span>CONNECTED · WAITING FOR GOALS</span>`;
5555
+ // .hstat: the status line is the ONLY element renderClawdHero()/bootDisconnected()
5556
+ // may rewrite — never target it by bare `span` (the codex badge used to carry an
5557
+ // .appglyph <span>, so querySelector('span') clobbered the glyph with the status).
5558
+ return stage + `<b>${esc(app.label)}</b><span class="hstat">CONNECTED · WAITING FOR GOALS</span>`;
5374
5559
  }
5375
5560
  function spawnHeroConfetti(){
5376
5561
  const conf = document.querySelector('#fsClawdHero .chConf'); if (!conf) return;
@@ -5391,9 +5576,9 @@ function spawnHeroConfetti(){
5391
5576
  }
5392
5577
  function renderClawdHero(){
5393
5578
  const el = $('fsClawdHero'); if (!el) return;
5394
- const prevStatus = el.querySelector('span')?.textContent;
5579
+ const prevStatus = el.querySelector('.hstat')?.textContent;
5395
5580
  el.innerHTML = clawdHeroHTML(state.connectedApp);
5396
- if (prevStatus) { const s = el.querySelector('span'); if (s) s.textContent = prevStatus; }
5581
+ if (prevStatus) { const s = el.querySelector('.hstat'); if (s) s.textContent = prevStatus; }
5397
5582
  if (state.connectedApp !== 'codex') spawnHeroConfetti(); // Welcome P1 confetti (H17 item 4)
5398
5583
  }
5399
5584
  // one-time static wiring for the flagship shell (folds, lane resize, rail menu, band picker)
@@ -5440,11 +5625,10 @@ function renderClawdHero(){
5440
5625
  });
5441
5626
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMobilePanels(); });
5442
5627
  // v46 (HANDOFF-v45 §3): the menu entries deep-link into the 4-tab settings panel —
5443
- // Customize…→THEME / Connect MCP→MCP / History→HISTORY / Settings→SETTINGS.
5628
+ // Theme→THEME / Connect MCP→MCP / History→HISTORY / Settings→SETTINGS.
5444
5629
  // stopPropagation: the document click-away handler otherwise closes it in the same event.
5445
5630
  $('fsCust').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.remove('open'); custOpen('theme'); };
5446
5631
  $('fsConn').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.remove('open'); custOpen('mcp'); };
5447
- $('fsTodoBd').onclick = () => { $('fsMoreMenu').classList.remove('open'); $('toggle').click(); }; // To Do board stays reachable from the menu; ⌘K itself now opens the project switcher (HANDOFF-v45 §11)
5448
5632
  $('fsHist').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.remove('open'); custOpen('history'); };
5449
5633
  $('fsSet').onclick = (e) => { e.stopPropagation(); $('fsMoreMenu').classList.remove('open'); custOpen('settings'); };
5450
5634
  // parallel-runs switcher (§1.5-1/-7): same toggle+click-away shape as fsMoreMenu above.
@@ -5551,12 +5735,13 @@ function fsGearAlignX(){
5551
5735
  function upsert(arr, item){ const i = arr.findIndex((x) => x.id === item.id); if (i >= 0) arr[i] = item; else arr.push(item); }
5552
5736
 
5553
5737
  let errTimer = null;
5738
+ let authDown = false; // true while the worker-auth banner is up → keep auto-hide timers from clearing it
5554
5739
  function showErr(msg){
5555
5740
  const bar = $('errbar');
5556
5741
  bar.classList.remove('billing'); // shed the paywall styling if it was last used for that
5557
5742
  bar.textContent = msg; bar.style.display = 'block';
5558
5743
  clearTimeout(errTimer);
5559
- errTimer = setTimeout(() => { bar.style.display = 'none'; }, 6000);
5744
+ errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; }, 6000);
5560
5745
  }
5561
5746
 
5562
5747
  // Minimal placeholder for the free-tier paywall pending CDO design
@@ -5583,6 +5768,55 @@ function showBillingBlocked(blocked){
5583
5768
  const btn = bar.querySelector('#bxUpgrade');
5584
5769
  if (btn) btn.onclick = () => startCheckout();
5585
5770
  clearTimeout(errTimer);
5771
+ errTimer = setTimeout(() => { if (authDown) return; bar.style.display = 'none'; bar.classList.remove('billing'); }, 20000);
5772
+ }
5773
+
5774
+ // Worker sign-in banner. The server preflights `claude` at boot (/api/state.auth
5775
+ // + SSE ev:'auth'); if it isn't signed in, every worker would fail with "Not
5776
+ // logged in", so the queue is held server-side and this standing banner tells
5777
+ // the user how to fix it. Reuses #errbar + the .bx-ask card (bordered, red as a
5778
+ // left rule only — no new chrome), but WITHOUT an auto-hide timer since the
5779
+ // condition persists until the user relaunches signed-in. Chrome is English to
5780
+ // match the rest of the app.
5781
+ const AUTH_REASON_TEXT = {
5782
+ 'logged-out': 'Not signed in to claude. Run `claude` in a terminal and /login, then relaunch the manager from that terminal — tasks are paused until then.',
5783
+ 'bad-credentials': 'Worker sign-in failed. Check ANTHROPIC_API_KEY, or run `claude` and /login, then relaunch the manager from that terminal.',
5784
+ 'probe-failed': 'Couldn’t verify claude sign-in. Ensure the `claude` CLI is installed and signed in, then relaunch the manager.',
5785
+ };
5786
+ function renderAuthBanner(auth){
5787
+ const bar = $('errbar');
5788
+ if (auth && auth.ok === false){
5789
+ authDown = true;
5790
+ const text = AUTH_REASON_TEXT[auth.reason] || AUTH_REASON_TEXT['logged-out'];
5791
+ bar.classList.add('billing'); // strips #errbar's error fill so the card stands alone
5792
+ bar.innerHTML = `<div class="bx-ask"><div class="bx-qt"><div class="bx-lb">Sign in</div>`
5793
+ + `<div class="bx-q">${esc(text)}</div></div></div>`;
5794
+ bar.style.display = 'block';
5795
+ clearTimeout(errTimer); // this banner persists — no auto-hide
5796
+ } else if (authDown) {
5797
+ authDown = false;
5798
+ bar.classList.remove('billing');
5799
+ bar.style.display = 'none';
5800
+ bar.innerHTML = '';
5801
+ }
5802
+ }
5803
+
5804
+ // Post-logout nudge (Masa指示 2026-07-16): shown once, the next time a signed-out
5805
+ // send() actually lands, so it reads as "you just logged out" rather than nagging
5806
+ // every anonymous user (the free tier needs no account — this never blocks send).
5807
+ // Same #errbar "ask card" look as showBillingBlocked, Sign in instead of Upgrade.
5808
+ // .bx-ask.info (blue, not red — CDO判定 2026-07-16): this isn't a limit/error,
5809
+ // red is reserved for those (§5.6).
5810
+ function showSignInNudge(){
5811
+ const bar = $('errbar');
5812
+ bar.classList.add('billing');
5813
+ bar.innerHTML = `<div class="bx-ask info"><div class="bx-qt"><div class="bx-lb">Signed out</div>`
5814
+ + `<div class="bx-q">Sign in with Google to keep your usage synced across devices.</div></div>`
5815
+ + `<div class="bx-act"><button class="bx-cta" id="bxSignin">Sign in</button></div></div>`;
5816
+ bar.style.display = 'block';
5817
+ const btn = bar.querySelector('#bxSignin');
5818
+ if (btn) btn.onclick = () => beginGoogleSignin((err) => showErr(err));
5819
+ clearTimeout(errTimer);
5586
5820
  errTimer = setTimeout(() => { bar.style.display = 'none'; bar.classList.remove('billing'); }, 20000);
5587
5821
  }
5588
5822
 
@@ -5654,6 +5888,21 @@ function startGoogleSignin(url){
5654
5888
  const w = window.open(url, 'galda-signin', 'width=520,height=680');
5655
5889
  if (!w) location.href = url; // popup blocked → same-tab fallback
5656
5890
  }
5891
+ // Shared by the Settings "Sign in with Google" button and the post-logout nudge
5892
+ // (showSignInNudge below) so both go through the same signin-url → popup path.
5893
+ async function beginGoogleSignin(onError){
5894
+ try {
5895
+ const r = await fetch(withKey('/api/signin-url'));
5896
+ const data = await r.json().catch(() => ({}));
5897
+ if (r.ok && data.url) { startGoogleSignin(data.url); return true; }
5898
+ onError?.(data.error || 'Sign-in is not available.');
5899
+ } catch { onError?.('Sign-in failed — check your connection.'); }
5900
+ return false;
5901
+ }
5902
+ // Set right after a confirmed Log out; consumed once by the next successful
5903
+ // send() while still signed out, to nudge the user back toward signing in
5904
+ // (Masa指示 2026-07-16) — never blocks the send itself (free tier needs no account).
5905
+ let recentlySignedOut = false;
5657
5906
  async function refreshAfterSignin(){
5658
5907
  await refresh();
5659
5908
  if (state.billing?.licensed) signinPending = false;
@@ -5697,6 +5946,8 @@ async function refresh(){
5697
5946
  for (const t of state.tasks) { if (t.todos?.length) state.todos[t.id] = t.todos; }
5698
5947
  state.connectCommand = s.connectCommand ?? ''; state.models = s.models ?? ['sonnet']; state.agentModels = s.agentModels ?? null; state.agentEfforts = s.agentEfforts ?? null; state.agents = s.agents ?? ['claude-code', 'codex']; state.uiVersion = s.uiVersion;
5699
5948
  state.billing = s.billing ?? { licensed: false, email: null };
5949
+ renderAppSelect(); // picker follows the server's installed-agent set; may correct a stale connectedApp
5950
+ renderAuthBanner(s.auth); // show/clear the worker sign-in banner from the boot preflight
5700
5951
  setAgentModel(state.connectedApp);
5701
5952
  setAgentEffort(state.connectedApp);
5702
5953
  renderModelSelect();
@@ -5753,6 +6004,7 @@ function connectSSE(){
5753
6004
  if (!state.projects.some((p) => p.id === state.active)) state.active = state.projects[0]?.id;
5754
6005
  }
5755
6006
  else if (m.ev === 'ping') { checkUiVersion(m.uiVersion); return; }
6007
+ else if (m.ev === 'auth') { renderAuthBanner(m.auth); return; } // worker sign-in preflight result
5756
6008
  scheduleRender();
5757
6009
  };
5758
6010
  es.onerror = () => {
@@ -5782,7 +6034,7 @@ function bootDisconnected(info){
5782
6034
  state.active = '__onboard';
5783
6035
  state.billing = { licensed: false, email };
5784
6036
  try { render(); } catch (e) { console.warn('disconnected render', e); }
5785
- const heroSpan = document.querySelector('#fsClawdHero span');
6037
+ const heroSpan = document.querySelector('#fsClawdHero .hstat');
5786
6038
  if (heroSpan) heroSpan.textContent = 'CONNECT YOUR MACHINE TO BEGIN';
5787
6039
 
5788
6040
  const style = document.createElement('style');
@@ -5800,10 +6052,13 @@ function bootDisconnected(info){
5800
6052
  #connectGate .cg-cmd button{font:500 11px/1 var(--mono,monospace);color:#a3a097;background:transparent;
5801
6053
  border:1px solid rgba(255,255,255,.18);border-radius:7px;padding:6px 9px;cursor:pointer}
5802
6054
  #connectGate .cg-cmd button:hover{color:#fff}
6055
+ #connectGate .cg-hint{font-size:12px;line-height:1.6;color:var(--ink3,#6b6862);margin:0 0 16px}
6056
+ #connectGate .cg-hint a{color:var(--blue,#8fb0ff);text-decoration:none}
6057
+ #connectGate .cg-hint a:hover{text-decoration:underline}
5803
6058
  #connectGate .cg-who{font-size:12px;color:var(--ink3,#6b6862);margin:0 0 4px}
5804
6059
  #connectGate .cg-who b{color:var(--ink2,#a3a097)}
5805
6060
  #connectGate .cg-switch{font-size:12px;color:var(--ink3,#6b6862);margin:14px 0 0}
5806
- #connectGate .cg-switch a{color:#8fb0ff;text-decoration:none}
6061
+ #connectGate .cg-switch a{color:var(--blue,#8fb0ff);text-decoration:none}
5807
6062
  #connectGate .cg-x{position:absolute;top:14px;right:16px;width:28px;height:28px;border:0;background:transparent;color:var(--ink3,#6b6862);font-size:20px;line-height:1;cursor:pointer}
5808
6063
  #connectGate .cg-wait{display:inline-flex;align-items:center;gap:7px;font:500 10.5px/1 var(--mono,monospace);letter-spacing:.08em;color:var(--ink3,#6b6862);margin-top:16px}
5809
6064
  #connectGate .cg-wait i{width:6px;height:6px;border-radius:50%;background:var(--green,#3DDC97);animation:cgpulse 1.8s ease-in-out infinite}
@@ -5817,6 +6072,13 @@ function bootDisconnected(info){
5817
6072
  + '<h2>Connect this device to start</h2>'
5818
6073
  + '<p>Galda runs on your own machine. Run this where you use Claude Code or Codex — this board comes alive the moment it connects, no refresh needed:</p>'
5819
6074
  + '<div class="cg-cmd"><code>npx @galda/cli</code><button class="cg-copy">Copy</button></div>'
6075
+ // One command connects the machine, not an agent: the CLI probes PATH and hands
6076
+ // the whole detected set to the server. So we never split this per agent — we
6077
+ // only point people without either one at the official installers.
6078
+ + '<p class="cg-hint">Don\'t have either yet? Install '
6079
+ + '<a href="https://claude.com/claude-code" target="_blank" rel="noreferrer">Claude Code</a> or '
6080
+ + '<a href="https://openai.com/codex/" target="_blank" rel="noreferrer">Codex</a>, then run the same command '
6081
+ + '— Galda uses whichever agent you have.</p>'
5820
6082
  + '<p class="cg-who">Signed in as <b></b></p>'
5821
6083
  + '<p class="cg-switch">Already running Galda under a different account? Run <code>npx @galda/cli --signin</code> and pick this one. Or <a href="/logout">switch this browser →</a></p>'
5822
6084
  + '<div class="cg-wait"><i></i>WAITING FOR THIS DEVICE</div></div>';
@@ -5853,7 +6115,17 @@ function bootDisconnected(info){
5853
6115
  const es = new EventSource('/presence');
5854
6116
  es.addEventListener('presence', (e) => {
5855
6117
  let d; try { d = JSON.parse(e.data); } catch { return; }
5856
- if (d.state === 'connected') { es.close(); location.reload(); } // one reload, only on a stable connect
6118
+ if (d.state === 'connected') {
6119
+ // Confirm the engine is REALLY reachable before reloading. A stale/spurious
6120
+ // 'connected' presence otherwise reloads us into a white flash that lands
6121
+ // right back on this same connect screen — the welcome animation plays, then
6122
+ // 一瞬真っ白 (Masa 2026-07-16). Reuse the proven 6d71c32 contract: the relay
6123
+ // 503s every engine-backed path while offline and 200s once proxied, so
6124
+ // status !== 503 flips exactly at a genuine connect.
6125
+ fetch('/api/state', { cache: 'no-store', credentials: 'same-origin' })
6126
+ .then((r) => { if (r.status !== 503) { es.close(); location.reload(); } })
6127
+ .catch(() => {}); // engine unreachable → not connected yet; keep waiting
6128
+ }
5857
6129
  else if (cgWait) cgWait.innerHTML = '<i></i>WAITING FOR THIS DEVICE';
5858
6130
  });
5859
6131
  window.addEventListener('beforeunload', () => { try { es.close(); } catch {} });
@@ -5917,9 +6189,38 @@ async function postOutboxItem(item){
5917
6189
  }
5918
6190
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
5919
6191
  const goal = await r.json();
6192
+ if (goal.kind === 'chat') {
6193
+ // Conversation-first (SLICE 2 + Pattern A H23): a chat/help/settings turn,
6194
+ // not a To-Do. Retire the optimistic outbox item — a chat turn is ephemeral
6195
+ // by design, so never-lose (which protects real tasks/goals) is untouched.
6196
+ // In flagship the reply is a turn in the center feed (typed out with a
6197
+ // caret); the board layout has no feed, so it falls back to the quiet
6198
+ // .peraanswer bar. Never land()/upsert(): a kind:'chat' response has no id.
6199
+ outbox.remove(item.oid);
6200
+ if (state.layout === 'flagship') {
6201
+ const turn = { role: 'clawd', text: String(goal.reply || ''), revealed: 0, done: false, agent: item.agent };
6202
+ (state.chatTurns[item.projectId] ||= []).push(turn);
6203
+ renderFsFeed();
6204
+ typeChatTurn(turn);
6205
+ } else {
6206
+ showChatReply(goal.reply);
6207
+ }
6208
+ render();
6209
+ return; // finally still runs posting.delete(); skip land()/upsert()
6210
+ }
5920
6211
  goal.imageUrls = item.imageUrls;
5921
6212
  outbox.land(item.oid, goal.id);
5922
6213
  upsert(state.goals, goal);
6214
+ if (recentlySignedOut && !state.billing?.licensed) { recentlySignedOut = false; showSignInNudge(); }
6215
+ // Pattern A (H23): this send became a real To-Do — mark the most recent
6216
+ // unmarked user turn "→ Added to To Do #NN" in the feed. Skip a needsInput
6217
+ // goal (it's still asking; shown as the question card at the feed bottom).
6218
+ if (goal.status !== 'needsInput') {
6219
+ const turns = state.chatTurns[item.projectId] || [];
6220
+ for (let k = turns.length - 1; k >= 0; k--) {
6221
+ if (turns[k].role === 'you' && turns[k].markGoalId == null) { turns[k].markGoalId = goal.id; break; }
6222
+ }
6223
+ }
5923
6224
  } catch {
5924
6225
  outbox.mark(item.oid, 'unsent');
5925
6226
  } finally {
@@ -5928,6 +6229,58 @@ async function postOutboxItem(item){
5928
6229
  render();
5929
6230
  }
5930
6231
 
6232
+ // SLICE 2 / Pattern A (H23): the conversational reply TYPES out — "read along as
6233
+ // it types" (ChatGPT / Claude Code). The claude CLI returns the reply as one
6234
+ // block, NOT token deltas (verified), so this is a client-side reveal of the full
6235
+ // text, not network streaming. Reveal progress lives on the turn MODEL
6236
+ // (turn.revealed), so the whole-feed re-render (SSE `act` events rebuild #fsFeed)
6237
+ // can't reset it; between re-renders we patch just the #ccTyping text node so we
6238
+ // don't rebuild the feed 60×/sec. Duration-normalized (~2s) so short/long both
6239
+ // feel right.
6240
+ let _chatTyper = null;
6241
+ function stopChatTyper(){ if (_chatTyper) { clearInterval(_chatTyper); _chatTyper = null; } }
6242
+ // Flagship: reveal a reply turn in the center feed. The caret sits next to
6243
+ // #ccTyping (a separate span) so patching the text node never wipes it.
6244
+ function typeChatTurn(turn){
6245
+ stopChatTyper();
6246
+ const full = turn.text || '';
6247
+ if (!full) { turn.done = true; return; }
6248
+ const step = Math.max(1, Math.round(full.length / 120)); // ~120 frames ≈ 2s
6249
+ _chatTyper = setInterval(() => {
6250
+ turn.revealed = Math.min(full.length, turn.revealed + step);
6251
+ const node = $('ccTyping');
6252
+ if (node) node.textContent = full.slice(0, turn.revealed);
6253
+ if (turn.revealed >= full.length) { turn.done = true; stopChatTyper(); renderFsFeed(); }
6254
+ }, 16);
6255
+ }
6256
+ // A new send starts: finish any in-flight reply cleanly (complete the model turn +
6257
+ // drop the caret, hide the board bar) so a half-typed reply never lingers.
6258
+ function finalizeChatTyping(){
6259
+ stopChatTyper();
6260
+ const turns = state.chatTurns[state.active] || [];
6261
+ for (let k = turns.length - 1; k >= 0; k--) {
6262
+ if (turns[k].role === 'clawd' && !turns[k].done) { turns[k].revealed = (turns[k].text || '').length; turns[k].done = true; break; }
6263
+ }
6264
+ const el = $('chatReply'); if (el) { el.hidden = true; el.textContent = ''; }
6265
+ }
6266
+ // Board layout (no center feed) keeps the quiet .peraanswer bar; same reveal.
6267
+ function showChatReply(text){
6268
+ const el = $('chatReply'); if (!el) return;
6269
+ stopChatTyper();
6270
+ const full = (text || '').trim();
6271
+ if (!full) { el.hidden = true; el.textContent = ''; return; }
6272
+ el.className = 'peraanswer'; el.textContent = ''; el.hidden = false;
6273
+ let i = 0;
6274
+ const step = Math.max(1, Math.round(full.length / 120));
6275
+ _chatTyper = setInterval(() => {
6276
+ i = Math.min(full.length, i + step);
6277
+ el.textContent = full.slice(0, i);
6278
+ el.scrollTop = el.scrollHeight;
6279
+ if (i >= full.length) stopChatTyper();
6280
+ }, 16);
6281
+ }
6282
+ function clearChatReply(){ finalizeChatTyping(); }
6283
+
5931
6284
  async function flushOutbox(){
5932
6285
  // Only (re)send items that still need it — never re-POST 'landed' memos that
5933
6286
  // are merely waiting for reconcile() (that was the double-send path).
@@ -5998,12 +6351,17 @@ async function send(){
5998
6351
  status: 'sending', ts: Date.now(),
5999
6352
  };
6000
6353
  outbox.add(item); // saved locally BEFORE any network call
6354
+ // Pattern A (H23): the user's own turn enters the center feed immediately, so
6355
+ // the composer reads like a conversation. If this send becomes a To-Do, a
6356
+ // .cc-mark is attached to this turn later; if it's chat, a reply turn follows.
6357
+ (state.chatTurns[state.active] ||= []).push({ role: 'you', text, ts: Date.now() });
6001
6358
  state.attach = [];
6002
6359
  // mode/skill/later/review are per-message → clear after send (model stays sticky).
6003
6360
  state.msg = { mode: null, model: null, effort: null, skill: null, later: false, review: false };
6004
6361
  renderMsgHint();
6005
6362
 
6006
6363
  $('input').value = ''; $('input').style.height = 'auto';
6364
+ clearChatReply(); // a new turn starts — drop any prior conversational reply
6007
6365
  closePalette();
6008
6366
  localStorage.removeItem('draft');
6009
6367
  renderAttach(); render();
@@ -6193,6 +6551,8 @@ function saRows(){
6193
6551
  usedCompact: g?.executionPlan?.sessionMode === 'compact',
6194
6552
  usedClear: Boolean(g?.sessionClosedAt),
6195
6553
  agentModel: g?.model,
6554
+ requirementCount: ts.filter((t) => t.goalId === g?.id && !t.reply).length,
6555
+ history: goalTokenHistoryLocal(gs, ts, g?.id),
6196
6556
  }),
6197
6557
  };
6198
6558
  });
@@ -7504,11 +7864,11 @@ function gearSetHTML(){
7504
7864
  <button class="optchip" data-gsignin="1">Sign in with Google</button></div>
7505
7865
  <div class="srow dis" id="licKeyMsg" style="display:none"></div>`;
7506
7866
  } else if (bill.plan === 'paid') {
7507
- billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google</span></div><span class="bx-sval">${esc(bill.email)}</span></div>
7867
+ billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google · ${esc(bill.email)}</span></div><button class="optchip" data-gsignout="1">Log out</button></div>
7508
7868
  <div class="srow"><div class="sl">Plan</div><span class="bx-pdot"><i></i>Paid</span></div>
7509
7869
  <div class="srow"><div class="sl">Billing<span class="sd">manage or cancel in Stripe</span></div><button class="bx-cchip" data-gportal="1">Manage</button></div>`;
7510
7870
  } else {
7511
- billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google</span></div><span class="bx-sval">${esc(bill.email)}</span></div>
7871
+ billingRow = `<div class="srow"><div class="sl">Account<span class="sd">Signed in with Google · ${esc(bill.email)}</span></div><button class="optchip" data-gsignout="1">Log out</button></div>
7512
7872
  <div class="srow"><div class="sl">Plan</div><span class="bx-sval">Free</span></div>
7513
7873
  <div class="srow"><div class="sl">To Dos<span class="sd">pending, this install</span></div><span class="bx-sval bx-num">${u.pending ?? 0} / ${lim.pending ?? 5}</span></div>
7514
7874
  <div class="srow"><div class="sl">Lifetime</div><span class="bx-sval bx-num">${u.cumulative ?? 0} / ${lim.cumulative ?? 19}</span></div>
@@ -7558,18 +7918,20 @@ function buildGearPop(){
7558
7918
  if (signBtn) signBtn.onclick = async () => {
7559
7919
  const msg = pop.querySelector('#licKeyMsg');
7560
7920
  signBtn.disabled = true;
7561
- try {
7562
- const r = await fetch(withKey('/api/signin-url'));
7563
- const data = await r.json().catch(() => ({}));
7564
- if (r.ok && data.url) { startGoogleSignin(data.url); }
7565
- else { msg.style.display = ''; msg.textContent = data.error || 'Sign-in is not available.'; }
7566
- } catch { msg.style.display = ''; msg.textContent = 'Sign-in failed — check your connection.'; }
7567
- finally { signBtn.disabled = false; }
7921
+ await beginGoogleSignin((err) => { msg.style.display = ''; msg.textContent = err; });
7922
+ signBtn.disabled = false;
7568
7923
  };
7569
7924
  const upBtn = pop.querySelector('[data-gupgrade]');
7570
7925
  if (upBtn) upBtn.onclick = () => startCheckout();
7571
7926
  const portalBtn = pop.querySelector('[data-gportal]');
7572
7927
  if (portalBtn) portalBtn.onclick = () => openBillingPortal();
7928
+ const signOutBtn = pop.querySelector('[data-gsignout]');
7929
+ if (signOutBtn) signOutBtn.onclick = async () => {
7930
+ if (!(await confirmDelete({ title: 'Log out of Google?', body: 'You can sign back in anytime.', confirmLabel: 'Log out', confirmDanger: false }))) return;
7931
+ signOutBtn.disabled = true;
7932
+ try { await fetch(withKey('/api/signout'), { method: 'POST' }); recentlySignedOut = true; }
7933
+ finally { await refresh(); if ($('gearPop')) buildGearPop(); }
7934
+ };
7573
7935
  return;
7574
7936
  }
7575
7937
  if (custTab === 'mcp') {
@@ -7753,11 +8115,14 @@ $('gearBtn').onclick = (e) => { e.stopPropagation(); const p = $('gearPop'); if
7753
8115
  // outside-tap = Cancel (v45 §3). e.composedPath() is frozen at dispatch, so a swatch that
7754
8116
  // buildGearPop() re-rendered (detached) mid-click still reads as INSIDE the pop —
7755
8117
  // selections never close the panel. Known trap (v45 §3): #fsMoreMenu clicks are excluded
7756
- // so a menu item is never swallowed as an outside tap.
8118
+ // so a menu item is never swallowed as an outside tap. #deleteConfirmOverlay is excluded
8119
+ // too (Masa指示 2026-07-16): it's a separate top-level overlay a settings row can open
8120
+ // (e.g. Log out) — without this, confirming/cancelling it read as an outside tap and
8121
+ // closed the settings panel underneath it as a surprise side effect.
7757
8122
  document.addEventListener('click', (e) => {
7758
8123
  const p = $('gearPop'); if (!p.classList.contains('open')) return;
7759
8124
  if (e.composedPath().includes(p)) return;
7760
- if (e.target.closest('.gearwrap') || e.target.closest('#fsCust') || e.target.closest('#fsMoreMenu') || e.target.closest('#fsMoreBtn')) return;
8125
+ if (e.target.closest('.gearwrap') || e.target.closest('#fsCust') || e.target.closest('#fsMoreMenu') || e.target.closest('#fsMoreBtn') || e.target.closest('#deleteConfirmOverlay')) return;
7761
8126
  custCancel();
7762
8127
  });
7763
8128
  // Banff BACKDROP free upload (v3 §6.5): file → dataURL → #themebg. Persists via