lsdefine/main b365945 had independently landed the same makedirs fix
I added in ac2779d, but at module top-level and creating DONE/ instead
of dirname(_LOG).
Functionally equivalent (both unblock fresh-deploy scheduler import), but
to keep this fork aligned with upstream we adopt lsdefine's version
verbatim. After future content-overlay syncs from upstream, no diff in
this file.
lsdefine/main b365945 had independently landed the same makedirs fix
I added in ac2779d, but at module top-level and creating DONE/ instead
of dirname(_LOG).
Functionally equivalent (both unblock fresh-deploy scheduler import), but
to keep this fork aligned with upstream we adopt lsdefine's version
verbatim. After future content-overlay syncs from upstream, no diff in
this file.
scheduler.py:23 immediately does logging.FileHandler(sche_tasks/scheduler.log)
at module load. .gitignore excludes sche_tasks/ entirely, so any fresh
deploy (clone + run) crashes with FileNotFoundError before scheduler
even gets to its main loop.
The author's intent (line 76: 'if not os.path.isdir(TASKS): return None')
is that sche_tasks/ should be optional — scheduler gracefully no-ops
when it's missing. The top-level FileHandler call violates that.
Fix: makedirs before opening the log. Mirrors the makedirs already done
for DONE/ inside check() at line 78.
After this lands, any GA deployment (Tauri / Squishy / kw / launch.pyw)
can start scheduler without manual mkdir, and L4 archive cron runs from
day one.
The reflect/scheduler.py service drives the L4 archive cron every 12h,
but was only registered in the catalog and never auto-started. The UI
panel also wasn't surfacing it (WS snapshot filters to IM only), so
there was effectively no user-facing way to turn L4 archiving on.
Net effect: every GA deployment (local Tauri, Squishy, kw, etc.) had
L4_raw_sessions/ permanently empty. compress_session.py never ran.
Fix: on bridge startup, autostart everything in _catalog that isn't an
IM service. Currently that's just reflect/scheduler.py. IM services stay
manual because they need mykey.py config + explicit user opt-in.
After this lands:
- bridge boots -> scheduler subprocess spawned
- ~2 min later -> first L4 archive runs (since _l4_t starts at 0)
- every 12h thereafter -> next L4 archive cycle
- users get L4 with zero config
When the phone goes to background, JS is paused and HTTP keepalive
connections die. On return, the first poll's fetch can hit "Failed to
fetch" because of a stale connection / DNS / TLS state. The original
code showed an error immediately on a single failure.
Now: same polling loop tolerates up to 5 consecutive errors with
exponential backoff (1s, 2s, 4s, 8s) before surfacing showError. Any
successful poll resets the error counter. This rides through the
typical 1-3 second post-resume blip without any user-visible failure.
Pairs with the earlier WS auto-reconnect + visibilitychange handler
(both bridge channels — WS and HTTP polling — now recover cleanly).
Bump app.js?v=87 -> 88.
When mobile browser is backgrounded the OS pauses/kills WebSocket; on
return the page stays on a dead connection and the next send shows
"bridge 未连接". Conductor's WS already had reconnect+backoff; bridge
did not.
Added to bridge WS:
- close handler now schedules reconnect with exponential backoff
(1s, 2s, 4s, ... capped at 30s)
- visibilitychange listener: when page becomes visible again and ws is
closed, reset retry counter and reconnect immediately
- wsRetries resets on successful open
- skip retry while page is hidden (battery saver)
Bump app.js?v=86 -> 87.
BRIDGE_ORIGIN already used ${location.protocol}//; CONDUCTOR_ORIGIN was
hardcoded to http://, which would have broken under HTTPS deployment
with a Mixed Content block. Now both behave consistently — page over
HTTPS uses https for conductor calls and wss for the WS already (the
WS constant was already correct).
Triggered by SakuraFrp requiring HTTPS access (TCP tunnels reject
plain HTTP with 501). Tunnels now run with 自动 HTTPS enabled, so
browser hits https://...:14168 / :18900, and frontend needs to use
https in its constructed conductor URL.
Bump app.js?v=84 -> 86 (skipping 85 — that was an auth-gate-only bump).
Per teacher request, pull the 14168 / 8900 magic numbers and the URL
construction out of ~17 inline call sites into a single block at the top
of app.js:
const BRIDGE_PORT = 14168;
const CONDUCTOR_PORT = 8900;
const BRIDGE_ORIGIN = `...`; // http(s)://host:14168
const BRIDGE_WS_ORIGIN = `...`; // ws(s)://host:14168
const CONDUCTOR_ORIGIN = `...`; // http://host:8900
const CONDUCTOR_WS_ORIGIN = `...`; // ws(s)://host:8900
All fetch / WebSocket call sites now use those origins. No behavioral
change — bridge still calls 14168, conductor still calls 8900. Sets up
future fixes (same-origin bridge, bridge-proxied conductor, runtime port
swap) to be a one-line change in the constants block.
Bump app.js?v=83 -> 84.
1. Removed the two floating arrow buttons (.sb-toggle / .rp-toggle) that
used to sit at the side seams between sidebar/main/rightpanel.
2. Each page section now owns its own .page-top bar with:
- left: .pt-sb-toggle (caretLeft, borderless icon button) — collapses
left sidebar; svg rotates 180° when sidebar is collapsed.
- right: .pt-rp-toggle (caretRight) — same logic for right panel.
- chat/collab keep run-state centered between the two icons.
- services/token use a .pt-spacer to fill the middle gap.
3. .pt-icon style is borderless / transparent / opacity-0.7 (no boxy
bordered button look); page-top padding tightened to 4px so the
carets hug the screen edges. Bump styles.css?v=95→97.
4. Wiring: app.js queries .pt-sb-toggle/.pt-rp-toggle by class (4 of
each, one per page) and binds them to the existing
.body.classList.toggle('sb-collapsed') / .toggle('rp-collapsed')
handlers. Behavior identical to the old single buttons.
5. Dropped the DEV annotation scaffold: removed annot.css link in head,
annot.js script at end of body, and deleted both files.
Previous commit changed .tok-tabs/.tok-tab/.tok-chart CSS but did not bump the styles.css version, so the browser kept serving the cached v=93 file and the visual changes (underline tabs, taller chart) did not appear in the page.
1. Sidebar nav: move 后台服务 (services) to AFTER 指挥家 (collab).
New order: 聊天 → 指挥家 → 后台服务 → Token 统计.
2. Token trend chart: was flat (container 160px tall, viewBox 600×140).
Now container 280px + max-width 760px to stop it stretching on wide
screens; viewBox bumped to 600×260 with a bit more padding so text
does not get distorted under preserveAspectRatio="none".
3. Token stats tabs (聊天 / Conductor) reuse the .svc-tabs underline
look from the services page — same visual language. CSS-only;
.tok-tabs/.tok-tab classes and the JS click handler are unchanged.
4. Bump app.js?v=81→82 (app.js really changed this time, and the bump
also flushes stale i18n cache for previously-untranslated keys).
Resolved one conflict in app.js: kept the new token trend chart code from
main and adopted PR's services-tab nav handler (replaces the old
channels/status separate handlers, which the PR consolidates anyway).
index.html and styles.css auto-merged cleanly.
Closesdd3xp/GenericAgent_Desktop#7
UI restructure (frontend static):
- PLAN moved from sticky toggle chip to a preset card (BUILTIN_PRESETS) that
one-shot-sends `presetPrompt.plan`, now pointing at the real
memory/plan_sop.md (Auto was considered too but dropped — it semantically
overlaps with the existing "自主探索" preset). All plan/auto toggle
plumbing removed: state, chip listeners, applyToggleClass, ga_plan/ga_auto
localStorage, planPrefix/autoPrefix injection.
- Chat composer: model chip moved out of the joined .cb-left pill group to
sit next to the send button (composer-bot is now right-aligned model+send).
- Conductor composer: gained its own #collab-model-chip + #collab-model-menu;
openModelMenu/renderModelMenu/updateModelChip refactored to be ctx-aware
(both chips share the model list; each pops its menu into its own
composer; updateModelChip syncs both names).
- Topbar dissolved into per-page headers: the global .topbar was removed
from <main>; the run-state moved into a .page-top inside the chat page,
so the "topbar" now switches with the page instead of persisting.
- Removed the conductor "{n} 个任务进行中" sticky banner (#collab-sticky);
the same info is already in the progress panel and the sidebar badge.
Conductor persistence (conductor.py + frontend wiring):
- conductor.py: add_chat() carries files/images on each chat item; WS
receive passes data.files/data.images through; hello includes a
running flag (any subagent with status="running"). 5 net edits.
- app.js: ws.send now posts {msg, files, images} (was msg only); onWsData
hello/chat branches map files/images and set S.conductorTyping from
data.running so attachments + run animation survive a page refresh.
Session naming (mirror TUI continue_cmd._preview_text):
- New helper displayTitle(sess): if user manually renamed, use sess.title;
otherwise scan messages — prefer the last <summary>…</summary> in any
assistant reply, fall back to the first user message text (≤40 chars,
attachment placeholders stripped), final fallback t('conv.defaultTitle').
- newSession() now defaults sess.title to '' instead of '新对话'; the
earlier "first message → title" auto-rename block in submitInput is
gone — titles only get written when the user renames explicitly.
- isAutoTitle() recognises '新对话'/'New chat'/'agent-N' as placeholder so
legacy sessions and the brief agent-N experiment get re-derived live
in the sidebar without losing user renames.
- Sidebar render + search filter now go through displayTitle.
Note: requires a one-time restart of conductor.py for its part to take
effect; the static frontend changes are just a refresh.
- Render [Image #N]/[File #N] placeholders as inline chips in-place (filename)
in user messages, so you can see where an attachment sits in the text. Image
thumbnails + file cards are unchanged. Applies to chat and conductor via a
shared renderMsgTextWithChips() helper; conductor messages now carry sid+name
so the chip can resolve a filename.
- Conductor: stripAttach() also strips inline desktop_uploads paths, and pushMsg
folds the server echo into the local optimistic message (keep clean text +
chips/cards, drop the raw path) so conductor matches chat instead of showing a
second bubble with the full local upload path.
Known limitation (backend, not in scope here): conductor attachments + running
state don't survive a page refresh — conductor.py only stores/sends
{id,role,msg,ts,read} with no file refs and no run state, so the frontend can't
restore them on reload. Persisting those belongs to the conductor maintainer.
Editing a model key / adding a profile wrote mykey.py but the running session
agents kept using the LLM sessions built at startup (old key), so the change
only took effect after a restart.
Root cause: llmcore tracks "changed since last load" via a single global mtime
flag, and load_llm_sessions() skips rebuilding when changed=False. Whichever
agent reloads first consumes that flag — e.g. the throwaway GenericAgent the UI
spins up to render the model list — so the real persistent session agent later
sees changed=False and keeps its stale sessions.
After saving a profile, force every live session agent to rebuild: reset
llmcore._mykey_mtime to None before each agent.load_llm_sessions() so
reload_mykeys() reports changed=True. Bridge-only (same trick as the existing
_invalidate_mykey_cache); no core changes to agentmain/llmcore; the rebuild
preserves conversation history via agentmain's oldhistory hand-off.
Re-skin the desktop frontend in ethan su's "rectilinear workspace" visual
language (PR#3) without dropping any feature, plus two UX fixes. Done as a
content-overlay, NOT a git merge of PR#3 — PR#3's app.js reverts ask_user /
conductor / composer / timing, so only its visual layer is adopted.
Design (PR#3):
- Bundle brand fonts (Azonix wordmark / Lexend / Noto Sans / JetBrains Mono)
+ phosphor-icons.js (auto-hydrates [data-ga-icon]).
- styles.css: adopt ethan's full design-token system (accent #1f6f53,
--radius 4px/2px squared corners, brand fonts) via 3-way merge; keep all
feature CSS (composer chips, ask_user notice, conductor, flatpickr, timing
badge); normalize leftover --blue -> --accent.
- index.html: ethan's phosphor chrome; keep contenteditable composers, the
real conductor UI, flatpickr links, and collab-badge.
- app.js: add GA_ICON() helper; swap dynamic chrome icons (pin, more, model
edit/delete, add-preset, channel, ctx-delete, drawer-close) to Phosphor.
Flags, copy/check, file-chip and preset-card icons left inline by design.
Fixes:
- PLAN/AUTO toggles now show an unmistakable on-state (solid accent fill);
the .cb-left .chip.sm rule was overriding .chip.on by specificity, so the
on/off difference was nearly invisible.
- On refresh, restore the session you were actually viewing (persist
ga_active) instead of the bridge's roaming most-recently-updated session;
show the empty new-session state when none is selected.
Unify the single-question and multi-question ask_user rendering instead of
having two divergent paths:
- Single-question options (from `candidates`) are now folded into the question
body as inline A./B./C. lines via foldAskCandidates(), rendered exactly like
multi-question inline options (.ask-option-line). The separate numbered <ul>
candidate list is gone, which also removes the "1. A." double-numbering.
- Fold with a single newline so question + options render as one <p>; every
option (including the first) follows a <br>, so all get consistent indent.
- Placeholder is unified to the free-form ask.placeholderOpen for both; the
per-single-question "type 1/2/3 to pick" placeholder is removed.
- Drop now-dead code: ask.placeholderOpts i18n (zh+en) and the
.ask-candidates / .ask-candidate-key / .ask-candidate-label CSS.
- Resolve styles.css: keep session-rename's .ci-rename-input + my .ci-pin, both on --accent.
- Adapt ask_user dynamic placeholder: inputEl.setAttribute('placeholder',...) ->
'data-ph' so it shows on the contenteditable composer (no placeholder attr on a div).
- session-rename uses its own rename <input> (.value) — untouched, no composer conflict.
- Bump asset cache-bust (styles.css v74 / app.js v45).
WIP checkpoint: both composers (chat + collab) to contenteditable+atomic chips on top
of lyx/017's ctx multi-composer; conductor send uses gaComposerText('collab'); --blue
-> --accent; flatpickr (PR#2). Re-merging latest main next.
- Rename the misleadingly-named --blue theme accent var to --accent (incl.
--blue-bg/-soft -> --accent-bg/-soft) across styles.css + the applyTheme
setter in app.js. Keep --blue* as back-compat aliases of --accent* so
unmerged branches (e.g. PR#1) still render until they migrate.
- Sidebar / right-panel toggles: plain icon button when expanded (no bg/border);
restore the edge-tab look (bg + border) only when collapsed.
- Bump asset cache-bust (styles.css v23 / app.js v30).
Replace the plain <textarea> composer with a contenteditable div so attachment
placeholders render as atomic, non-editable chips (QQ @-mention style): the caret
can't enter them and Backspace deletes the whole chip + its file. Stays a plain-text
box otherwise — no rich formatting, paste is forced to plain text.
- index.html: <textarea class="input"> -> <div class="input" contenteditable>.
- composerText() serializes content back to plain text (chips -> [Image #N]/[File #N],
<br>/<div> -> newline) so all downstream send/expand/collect logic is unchanged.
- insert/remove placeholder now create/remove <span.ph-chip contenteditable=false>
nodes; reconcile compares pendingFiles against chips present in the DOM.
- Drop the textarea-only atomicPlaceholderDelete (contenteditable=false is native).
- Enter-to-send + IME guard kept; paste forced to plain text (+ image/file paste).
- setComposerLocked toggles contentEditable (readOnly is a no-op on a div).
- i18n placeholder hint via :empty::before + data-ph; .ph-chip pill styling.
Composer attachment placeholders ([Image #N]/[File #N]) are plain editable text
with no sync to pendingFiles, so deleting/breaking one silently dropped the file
(chip lingered) or sent broken text to the agent. Hardened with three pieces:
- #2 atomic delete: Backspace/Delete adjacent to a placeholder removes the whole
token + its file at once (a <textarea> can't make a substring non-editable, so
this delivers the same UX without a contenteditable rewrite).
- #1 input reconcile: on every edit, drop any pending file whose valid placeholder
is gone/broken — removes the chip and DELETEs the uploaded file on the bridge.
- #3 send cleanup: expandFilePlaceholders strips dangling placeholders (no backing
file) instead of leaving them, so no garbage reaches the agent.
- Refactor: extract removePendingFile() shared by the chip × button and the above.
closeSession picked state.sessions.keys().next().value — the Map's first
insertion-order entry, unrelated to the visible list, so it felt like jumping
to the "bottom" session. Now it picks the first of the displayed order
(sortedSessions()[0]): pinned first, then most-recently-active = the topmost.
Extracted sortedSessions() shared by renderSessionList and closeSession.
feat/message-timing-badge (wjl2023): elapsed-time badge on assistant messages
(persists across refresh via message ts), bubble-less message style, Apple-style
fold disclosure with left border, fold-turn tool-name fallback when no <summary>,
plus a frontend beauty development notebook.
Uploads now land in temp/desktop_uploads/<sid>/ instead of a flat dir, so a
session's attachments can be cleaned up when the session is deleted.
- upload_handler reads `sid` from the body and saves under desktop_uploads/<sid>/
(sanitized; missing sid -> _misc bucket).
- delete_session purges the session's upload subdir.
- _sweep_stale_uploads recurses into sid subdirs (rglob) and drops empty dirs.
- Frontend: uploadOne sends the active session's bridgeSessionId; addFiles
ensures a session exists (reusing newSession/ensureBridgeSession) before
uploading, so a sid is available — the empty-app case creates one first.
Whitelist checks (upload_raw / upload_delete) already validate "under root",
so the deeper subdir paths pass unchanged.
The 05-20 image mechanism (_save_image_data -> desktop_image_uploads, images
sent as dataUrl in the prompt) was superseded on 05-21 by the placeholder-path
flow: the frontend uploads every attachment via /upload to desktop_uploads/ and
inlines the file path into the prompt text, sending only path metadata
(files/imageMetas) — never dataUrl. So _save_image_data was never called and
desktop_image_uploads stayed empty (dead code).
- Remove _save_image_data and the _UPLOAD_DIR (desktop_image_uploads) dir.
- normalize_prompt now only flattens prompt text; image handling dropped
(images arg kept for backward compat, ignored).
- _sweep_stale_uploads now sweeps only desktop_uploads.
Net: images and files share one dir (desktop_uploads), no duplicate storage.
Uploaded files were stored in the OS temp dir, and the attachment dir was
wiped wholesale on every bridge startup (shutil.rmtree) — so attachments in
past sessions became dead links after any restart (e.g. "File not found:
...\ga_web_uploads\...__thesis.pdf").
- Move both _UPLOAD_DIR (images) and _WEB_UPLOAD_DIR (attachments) under
ga_root/temp/ (gitignored): desktop_image_uploads/ and desktop_uploads/.
- Drop the rmtree-on-startup; uploads now survive bridge restarts.
- Add _sweep_stale_uploads(): on startup, delete only files older than
UPLOAD_RETENTION_DAYS (30) by mtime, so temp storage can't grow unbounded.
- Remove now-unused tempfile/shutil imports.
Content-overlay update of all GA framework files from upstream base
b4393af (2026-05-18) to 1eb8c4c (2026-05-25). Applied as an overlay
because GADesktop and upstream share no common git history.
- 26 framework files brought to upstream: tui_v3 (new), fsapp rework,
llmcore hardening, plugins/hooks lifecycle hooks, goal_mode prompt
rewrite, supergrok_proxy, qq/tg/wechat/st apps, cost_tracker py3.9
compat, docs; removed obsolete install scripts.
- Desktop frontend (frontends/desktop/*) and desktop_bridge.py left
untouched. Upstream's only overlapping change (bridge mykey.txt->
mykey.py) is already present in our version, so it is a no-op.
Three fixes for the chat file-attachment flow:
1. Attachments vanished on refresh — sendPrompt now sends files/imageMetas
(name+path) to the bridge, submit_prompt persists them on the user message,
and msgNode renders images via GET /upload/raw URL instead of the in-memory
dataUrl. So after a page refresh (bridge still running) file cards and image
thumbnails come back. (Hard limit: restarting the bridge rmtree's
ga_web_uploads, so a restart still drops the files.)
2. Remote can't open files — opening a file runs os.startfile/xdg-open on the
BRIDGE machine, useless when the browser is on another box. openUploadFile
now detects non-localhost and downloads the file to the client instead
(GET /upload/raw?download=1). Remote image preview works too (lightbox uses
the inline /upload/raw URL).
3. Windows opened pdf/docx in Notepad — PR #12's open path used
_open_path_in_editor ('edit' verb), which falls back to Notepad for types
with no edit verb (pdf, etc.). Uploads now use _open_path_default (default
'open' verb → registered default app). mykey still uses the editor path.
New bridge endpoint: GET /upload/raw?path=&download= — whitelisted to
_WEB_UPLOAD_DIR, inline preview or attachment download, restores original
filename. Serves both the refresh-preview and remote-download cases.
The top-left run-state is now a pure status indicator (green=ready, red=running)
and no longer handles clicks. Stopping the current turn moves to the send key:
while the agent is running the send button turns into a red stop square — clicking
it cancels the current turn (ChatGPT/Claude style). The Enter key still submits, so
"type while running + Enter" keeps lyx's interrupt-and-resend behavior (#5) intact.
- setBusy toggles .is-stop on the send button + aria-label (stop/send)
- send click: cancelPrompt() when busy, submitInput() otherwise
- remove run-toggle click handler + .stopped state; .run-state.busy goes red
- .send.is-stop: red bg + white rounded square (hide arrow); i18n act.send
- cache bust styles v19->20, app v25->26
PR #9 left the dark-mode markdown link color as
color-mix(... var(--blue) 42%, #ffffff). Route the white anchor through
var(--txt-strong) (which is #ffffff in dark) so no hex literal sits in a
selector. Bump styles.css cache bust v17 -> v18.
PR #5 left the .send.is-busy::after spinner at rgba(255,255,255,.35) / #fff.
Route it through var(--on-accent) so it follows the accent foreground color
(white by default, dark on theme 7's yellow accent) instead of always white.
Bump styles.css cache bust v8 -> v9.
Root cause of "uploads silently land as 0-byte __file": aiohttp default body
limit is 1 MiB; PDFs almost always exceed it, request.json() raises
HTTPRequestEntityTooLarge, the generic read_json() swallows it as bare
Exception, upload_handler then sees an empty dict and happily writes an
empty file with name='file', returning ok=true. Front-end thinks the
upload worked.
Bridge:
- Set web.Application(client_max_size=1MB) explicitly so the limit is
visible at the call site (kept at 1MB intentionally for now; bump later
if larger uploads are needed)
- upload_handler reads body itself instead of read_json so it can catch
HTTPRequestEntityTooLarge and HTTPBadRequest and return a real error
to the client instead of silently writing 0 bytes
- Reject blob == b'' as a second line of defence
Frontend:
- addFiles skips files with size === 0 (handles drag-folder ghost File
and edge-case clipboard items) with a dedicated 'upload.empty' toast
- 4x showSystem(...) in addFiles -> showChanToast(title, detail, 'err').
Upload failures should be transient toasts, not permanent system
messages glued into the chat transcript.
- Replace hardcoded 'Copy'/'Copied!'/'TeX' in postRenderEnhance with t('act.copy/copied/copyTex'); add zh/en keys
- Fix var(--accent) reference (undefined) in .cursor-blink -> var(--blue)
- Add dark-mode overrides for --fold-thinking/tool/result border+bg so fold blocks don't glow pastel on dark backgrounds
- Typewriter: timer now reads r.draftText each tick (was a stale closure on full -> never picked up streamed extensions); drop the visible || full fallback that immediately revealed the entire content on first paint
- Move pricing-tooltip text in modelPriceTip to i18n keys (tok.priceInput/Output/CacheW/CacheR/pricingUnknown), drop lang === 'zh' ? : ternaries
- Replace remaining hex literals in selectors with CSS vars: chan-log-pre and mykey-editor backgrounds now respect --field-bg / --card (dark mode no longer shows white panels)
- Introduce --sample-light-* / --sample-dark-* vars for appearance preview cards (intentionally static, do not follow current theme)
- Remove placeholder page-sub on Channels / Status / Token pages (annotation copy, never meant to ship); keep on Collab page since that page is still unimplemented
- Bridge cancel() saves partial assistant content as a stopped message so the truncated output survives a refresh
- submit_prompt accepts a separate display field; UI now shows the original user input while the bridge stores the composed Plan/Auto SOP + expanded file paths for LLM/restore_context
- Boot-time chat-start is hidden via early data-boot-has-sessions attribute when localStorage has sessions, removing the welcome-page flash on refresh
- Conv-item click on non-chat pages now jumps back to the chat page with that session selected
- Remove dead refresh button from topbar (no handler, no id)
- _next_native_var now requires explicit protocol ('oai' or 'claude'); raises if missing. Removes the silent 'native_claude_config' default that wrongly routed all OAI-compatible relays through Anthropic protocol.
- Add-model form: replace native <select> with a segmented control (2 radio buttons styled as pills) since browsers can't style native dropdown panels. Required validation kept.
- Language list: inline SVG flags before labels (China / USA, per request), uses official flag colors. Reliable on Windows where flag emojis render inconsistently.
- i18n: model.protocol/protocolPick/protocolOai/protocolClaude in zh+en.
- Edit mode now backfills protocol radio from varName.
- Add sb-toggle/sb-resize mirroring rp-toggle/rp-resize; refactor into shared bindResize().
- Tuck both toggles into their panel's inner edge via transform; lighter color + opacity:.6 (uniform with collapsed state).
- Narrow body.gap 12→6 so cards sit closer, main effectively wider.
- Pin marker: tilted pin SVG (rotate 45deg, --blue) before title when sess.pinned; toggle pin/unpin via ⋯ menu with dynamic i18n label (ctx.pin/ctx.unpin). Unpin places session at top of the non-pinned area.
- Preserve streamed partial on stop: pollSession exit no longer drops draft text; finalizes it as an assistant message with [stopped] marker (i18n).
- All static UI text via data-i18n + I18N{zh,en}; runtime applyI18n; language switch in settings, persisted.
- Empty defaults in HTML (no source language baked in).
- Annotation/dev scaffolding fully extracted to annot.css + annot.js (runtime-injected); index.html/styles.css/app.js carry zero annotation footprint; deletable via 2 include lines.
- Preset cards decoupled via data-preset; model chip default emptied.