diff --git a/docs/DESIGN_TOKENS.md b/docs/DESIGN_TOKENS.md new file mode 100644 index 0000000..66ebe55 --- /dev/null +++ b/docs/DESIGN_TOKENS.md @@ -0,0 +1,130 @@ +# Web shell design tokens + +> Source of truth: [`src/web/assets/client/main.css`](../src/web/assets/client/main.css). +> This document describes the tokens; the CSS defines them. When they disagree, the CSS wins — +> and the contrast table below is enforced by [`src/web/lisa-css.test.ts`](../src/web/lisa-css.test.ts), +> so a token change that breaks AA fails the test suite. + +The shell ships two complete themes. **Nebula** (dark, default) is the original glass-morphism +console; **Calm** (light) is a flat professional variant. They are one token set with two value +sets: every rule in the stylesheet reads `var(--…)`, and the theme swap is a single +`` attribute persisted in `localStorage` under `lisa-theme`. + +## Colour + +### Brand and semantics + +| Token | Nebula | Calm | Used for | +| --- | --- | --- | --- | +| `--accent` | `#6ad4ff` | `#4f5bd5` | Primary action, active nav, focus ring, links | +| `--accent-soft` | `rgba(106,212,255,.13)` | `rgba(79,91,213,.09)` | Active tile / chip background | +| `--accent-glow` | `rgba(106,212,255,.27)` | `rgba(79,91,213,.25)` | Active border, halo | +| `--proactive` | `#3ddc97` | `#1f9d6b` | Autonomy is live (heartbeat, watching) | +| `--warm` | `#ffd066` | `#d97706` | Attention, pending approval | +| `--dream` | `#b487ff` | `#7c5cd6` | Rêve / idle reflection | +| `--claude` | `#ff8c42` | `#e2681c` | Claude Code sessions | +| `--codex` | `#7ea6ff` | `#3d6fd8` | Codex sessions | +| `--err-color` | `#ff5577` | `#dc3545` | Errors, failed tools | + +Each accent has a `-soft` (fill) and some a `-glow` (border) variant. Never hard-code an accent +literal in a rule — the Calm theme only works because every consumer reads the token. + +### Surfaces and text + +| Token | Nebula | Calm | Role | +| --- | --- | --- | --- | +| `--bg-deep` | `#07091a` | `#f6f7f9` | Page ground | +| `--bg-1` … `--bg-3` | `#0b1024` … `#1a1f4a` | `#f6f7f9` … `#e7eaf0` | Raised surfaces | +| `--bg-card` | `rgba(20,26,64,.65)` | `#ffffff` | Panels, cards | +| `--bg-card-strong` | `rgba(20,26,64,.88)` | `#ffffff` | Composer, modals | +| `--border-new` | `rgba(255,255,255,.07)` | `#e4e7ec` | Default border | +| `--border-strong` | `rgba(255,255,255,.14)` | `#d5d9e2` | Emphasised border | +| `--hairline` | `rgba(255,255,255,.06)` | `#edf0f4` | Section dividers | +| `--fg` | `#e8eaff` | `#1b2430` | Body text | +| `--fg-2` | `#aeb5d3` | `#4d5666` | Secondary text | +| `--fg-3` | `#8189ae` | `#5f6878` | Tertiary text, metadata | +| `--fg-faint` | `#444a6e` | `#c2c7d1` | Decorative only — never text | + +`--bg`, `--panel`, `--border`, `--text`, `--you`, `--lisa`, `--tool`, `--error` are the older +pixel-art-era aliases kept for the Room and a few legacy rules. + +### Contrast (WCAG AA, measured) + +Every token that carries text clears 4.5:1 against the surfaces it is used on. Measured ratios: + +| Theme | Token | Surface | Ratio | +| --- | --- | --- | --- | +| Nebula | `--fg` | `--bg-deep` | 16.60:1 | +| Nebula | `--fg-2` | `--bg-deep` | 9.74:1 | +| Nebula | `--fg-3` | `--bg-deep` | 5.76:1 | +| Nebula | `--accent` | `--bg-deep` | 11.73:1 | +| Calm | `--fg` | `--bg-card` | 15.65:1 | +| Calm | `--fg-2` | `--bg-card` | 7.40:1 | +| Calm | `--fg-3` | `--bg-card` | 5.62:1 | +| Calm | `--fg-3` | `--bg-deep` | 5.24:1 | +| Calm | `--accent` | `--bg-card` | 5.54:1 | + +`--fg-faint` is deliberately below AA and is only allowed on non-text decoration (separators, +disabled glyphs). The test asserts the ratios above; it does not assert `--fg-faint`. + +## Type + +One family (system UI stack) and one mono stack (`ui-monospace, "SF Mono", Menlo`). The scale is +deliberately short: + +| Size | Role | +| --- | --- | +| 11.5px | The floor. Metadata, labels, tree rows, secondary lines. Nothing smaller carries text. | +| 12 – 13px | Body text, chat messages, form controls | +| 14 – 16px | Section headings, the composer on mobile (16px prevents iOS Safari auto-zoom) | +| 18 – 22px | Identity name, modal titles | + +8px survives only on three non-text decorations. Before this pass the shell used 10px and 10.5px +for metadata; those were raised to 11.5px, which is why it is by far the most common size. + +## Space, radius, motion + +- **Grid**: 8px. Padding and gaps are multiples of 4 with 6/10/14 as the common in-between steps. +- **Radius**: 8px is the default; 6px for small chips, 9–12px for cards and inputs, 999px for pills. +- **Motion**: 0.12s for colour/background transitions, 0.18–0.24s for layout and overlays. All + non-essential animation sits inside `@media (prefers-reduced-motion: no-preference)`, and a + `prefers-reduced-motion: reduce` block neutralises the rest. + +## Focus + +```css +--focus-ring: 2px solid var(--accent); +--focus-ring-offset: 2px; + +:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} +``` + +`:focus-visible`, not `:focus`, so a mouse click never paints a ring. Text inputs keep their own +accent-border-plus-halo treatment through more specific `:focus` rules. Nothing in the shell may +set `outline: none` without providing a replacement indicator. + +## Layout and breakpoints + +The shell is a three-column CSS grid: 300px session tree · fluid main · 320px right rail. + +| Width | Layout | +| --- | --- | +| > 1180px | Three columns. The rail collapses to two columns on demand (`body.rb-collapsed`, default). | +| ≤ 1180px | Two columns; the right rail is hidden. | +| ≤ 720px | One column, stacked: title bar · sidebar (capped at 38vh, scrolls) · main. The rail stays hidden, the function bar drops its five quick-panel buttons, and the composer switches to a 16px font and a short placeholder. | + +`body.rb-collapsed` is scoped inside `@media (min-width: 721px)`: its specificity is higher than a +bare `.frame` rule, so at phone widths an unscoped collapse rule would win over the stacked layout +and leave the main column a few dozen pixels wide. + +`body.force-compact` reproduces the stacked layout at any width so Lisa can be docked as a skinny +side panel. + +## Touch targets + +Interactive controls are at least 36px on the desktop layout. At ≤720px every small control +(function-bar buttons, tree controls, chip dismissers) gets a transparent `::after` inset that +expands its hit area to 44px without changing what is drawn — WCAG 2.5.8 without a visual redesign. diff --git a/package.json b/package.json index 1e2ef43..52870ee 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "start": "node dist/cli.js", "lisa": "node --enable-source-maps dist/cli.js", "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck:client": "tsc -p tsconfig.client.json", "test": "node --import tsx --test \"src/**/*.test.ts\"", "test:watch": "node --import tsx --test --watch \"src/**/*.test.ts\"", "generate:api-contract": "node scripts/generate-api-contract.mjs", diff --git a/src/web/assets/client/main.css b/src/web/assets/client/main.css new file mode 100644 index 0000000..9f5d5a6 --- /dev/null +++ b/src/web/assets/client/main.css @@ -0,0 +1,3242 @@ + :root { + color-scheme: dark; + + /* New design tokens (mockup) */ + --accent: #6ad4ff; + --accent-soft: rgba(106, 212, 255, 0.13); + --accent-glow: rgba(106, 212, 255, 0.27); + /* Proactive / autonomy "live" accent (green) — the console's watching state. */ + --proactive: #3ddc97; + --proactive-soft: rgba(61, 220, 151, 0.13); + --proactive-glow: rgba(61, 220, 151, 0.30); + --warm: #ffd066; + --warm-soft: rgba(255, 208, 102, 0.12); + --dream: #b487ff; + --claude: #ff8c42; + --claude-soft: rgba(255, 140, 66, 0.12); + --codex: #7ea6ff; + --err-color: #ff5577; + --err-soft: rgba(255, 85, 119, 0.12); + + --bg-deep: #07091a; + --bg-1: #0b1024; + --bg-2: #11163a; + --bg-3: #1a1f4a; + --bg-card: rgba(20, 26, 64, 0.65); + --bg-card-strong: rgba(20, 26, 64, 0.88); + --border-new: rgba(255, 255, 255, 0.07); + --border-strong: rgba(255, 255, 255, 0.14); + --hairline: rgba(255, 255, 255, 0.06); + --bg-inset: rgba(255, 255, 255, 0.035); + + --fg: #e8eaff; + --fg-2: #aeb5d3; + /* Secondary text. Was #6c7398 (4.3:1 on --bg-deep, 3.8:1 on a card) — + below the AA 4.5:1 floor for the 11.5px labels that use it. #8189ae + clears 4.5:1 on every Nebula surface it sits on (deep 5.8, bg-1 5.5, + card 5.1, bg-3 chips 4.6); lisa-css.test.ts pins this. */ + --fg-3: #8189ae; + /* Decorative only (idle pips, rules, disabled fills) — never text. */ + --fg-faint: #444a6e; + + /* Keyboard focus ring (UX-3). Two tokens so a component can restyle the + ring without redefining the rule; the color rides --accent, so the + Calm override below re-tints it for free. */ + --focus-ring: 2px solid var(--accent); + --focus-ring-offset: 2px; + + /* Legacy tokens — kept so the unchanged modal / cfg / birth + overlay styles below still resolve. The new shell + chat use + the modern tokens above. */ + --bg: #0a0d2b; + --panel: #1a1f4d; + --panel-light: #2a3270; + --border: #6a7ad9; + --border-light: #a4b2ff; + --text: #e7ecff; + --text-dim: #8090c0; + --you: #6cf6e1; + --lisa: #ffd167; + --tool: #ff7eb6; + --error: #ff5577; + } + + /* ── Theme C · "Calm" (静界) — light professional skin ────────────── + Toggled via (fnbar sun/moon button, persisted + in localStorage "lisa-theme"). Every themed surface reads the custom + properties above, so the light skin is one override block plus a few + patches below for the hardcoded dark backgrounds (frame gradient, + titlebar, sidebar glass). Token table: docs/archive/plans/PLAN_UI_SESSION_SHELL_v1.0.md §2. */ + body[data-theme="calm"] { + color-scheme: light; + + --accent: #4f5bd5; + --accent-soft: rgba(79, 91, 213, 0.09); + --accent-glow: rgba(79, 91, 213, 0.25); + --proactive: #1f9d6b; + --proactive-soft: rgba(31, 157, 107, 0.10); + --proactive-glow: rgba(31, 157, 107, 0.28); + --warm: #d97706; + --warm-soft: rgba(217, 119, 6, 0.10); + --dream: #7c5cd6; + --claude: #e2681c; + --claude-soft: rgba(226, 104, 28, 0.09); + --codex: #3d6fd8; + --err-color: #dc3545; + --err-soft: rgba(220, 53, 69, 0.08); + + --bg-deep: #f6f7f9; + --bg-1: #f6f7f9; + --bg-2: #eef0f4; + --bg-3: #e7eaf0; + --bg-card: #ffffff; + --bg-card-strong: #ffffff; + --border-new: #e4e7ec; + --border-strong: #d5d9e2; + --hairline: #edf0f4; + --bg-inset: rgba(16, 24, 40, 0.028); + + --fg: #1b2430; + --fg-2: #4d5666; + /* Was #8a919f — 3.2:1 on white. #5f6878 is 5.6:1 on white and ≥ 4.7:1 + on every Calm surface (bg-deep 5.2, bg-2 4.9, bg-3 chips 4.7). */ + --fg-3: #5f6878; + --fg-faint: #c2c7d1; + + /* Legacy tokens (modal / cfg / birth overlays) mapped to light. */ + --bg: #f6f7f9; + --panel: #ffffff; + --panel-light: #eef0f4; + --border: #d5d9e2; + --border-light: #c2c8d4; + --text: #1b2430; + --text-dim: #69707d; + --you: #0f766e; + --lisa: #b45309; + --tool: #c2417e; + --error: #dc3545; + } + * { box-sizing: border-box; } + + /* ── Accessibility floor (UX-3) ────────────────────────────────── + Keyboard focus is visible on every focusable thing — buttons, links, + tree rows, switches, the nav tiles. :focus-visible (not :focus) so a + mouse click doesn't paint a ring; text fields below keep their own + accent-border + halo treatment via their more specific :focus rules. */ + :focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + } + /* Visually hidden, still read by screen readers (live regions, labels). */ + .sr-only { + position: absolute !important; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + } + html, body { + height: 100%; + margin: 0; + background: #000; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", system-ui, sans-serif; + -webkit-font-smoothing: antialiased; + color: var(--fg); + overflow: hidden; + } + + /* ── App shell ─────────────────────────────────────────────── */ + .frame { + height: 100vh; + width: 100vw; + display: grid; + grid-template-columns: 300px 1fr 320px; + grid-template-rows: 36px 1fr; + grid-template-areas: + "titlebar titlebar titlebar" + "sidebar main rightbar"; + background: + radial-gradient(ellipse at 30% 20%, #1a1238 0%, transparent 50%), + radial-gradient(ellipse at 80% 70%, #0a1f3a 0%, transparent 60%), + linear-gradient(180deg, var(--bg-1) 0%, var(--bg-deep) 100%); + overflow: hidden; + } + /* Calm patches for the hardcoded dark chrome above/below. */ + body[data-theme="calm"] { background: var(--bg-deep); } + body[data-theme="calm"] .frame { background: var(--bg-deep); } + body[data-theme="calm"] .titlebar { background: rgba(246, 247, 249, 0.85); } + body[data-theme="calm"] .sidebar, + body[data-theme="calm"] .rightbar { + background: #ffffff; + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); + } + body[data-theme="calm"] .main { background: none; } + body[data-theme="calm"] .identity .avatar-wrap { background: var(--bg-2); } + body[data-theme="calm"] #roomFrame { border: 1px solid var(--border-new); border-radius: 12px; } + body[data-theme="calm"] #form { background: rgba(246, 247, 249, 0.85); } + body[data-theme="calm"] .fnbar { background: rgba(16, 24, 40, 0.02); } + body[data-theme="calm"] .fn-find, + body[data-theme="calm"] .sd-out, + body[data-theme="calm"] .delegate-modal .dm-kind, + body[data-theme="calm"] .delegate-modal .dm-task, + body[data-theme="calm"] .session-ctrl .mc-send { background: rgba(16, 24, 40, 0.04); } + body[data-theme="calm"] .session-ctrl .mc:hover, + body[data-theme="calm"] .ctrl-row .cr-quick:hover { background: rgba(16, 24, 40, 0.08); } + body[data-theme="calm"] #sendBtn { + background: var(--accent); + color: #ffffff; + box-shadow: 0 4px 14px rgba(79, 91, 213, 0.25); + } + + /* Title bar — visually shows "Lisa · session-id". The actual drag + behavior is handled Swift-side by a transparent NSView overlay + (DragHandleView) placed on top of the WKWebView for the same 36pt + strip — WebKit ignores the CSS -webkit-app-region: drag property, + so the cosmetic HTML and the functional drag region are two + separate things. + Padding-left reserves the ~78pt that the macOS traffic-light + buttons occupy at top-left. */ + .titlebar { + grid-area: titlebar; + background: rgba(7, 9, 26, 0.55); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border-new); + display: flex; + align-items: center; + justify-content: center; + padding: 0 14px 0 78px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--fg-2); + /* nothing here should ever capture pointer events — the NSView + overlay above handles dragging, and there are no interactive + elements in the HTML titlebar. */ + user-select: none; + pointer-events: none; + } + /* "reconnecting…" pill (UX-10): the backend going quiet used to be + invisible until a request failed outright. Sits beside the session tag, + inherits the title bar's pointer-events:none. */ + .conn-pill { + margin-left: 10px; + padding: 2px 10px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--warm); + background: rgba(255, 208, 102, 0.14); + border: 1px solid rgba(255, 208, 102, 0.38); + white-space: nowrap; + } + body[data-theme="calm"] .conn-pill { + background: rgba(217, 119, 6, 0.12); + border-color: rgba(217, 119, 6, 0.40); + } + .titlebar .session-tag { + color: var(--fg-3); + font-weight: 400; + margin-left: 6px; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11.5px; + letter-spacing: 0; + } + + /* ── Sidebar (floating panel — Claude-Code-style detached card) ──── */ + .sidebar { + grid-area: sidebar; + margin: 8px 6px 10px 10px; + background: linear-gradient(180deg, rgba(18, 23, 48, 0.62), rgba(10, 13, 33, 0.6)); + backdrop-filter: blur(30px); + -webkit-backdrop-filter: blur(30px); + border: 1px solid var(--border-new); + border-radius: 16px; + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.5); + overflow-y: auto; + padding: 18px 14px 14px; + display: flex; + flex-direction: column; + gap: 18px; + } + + /* Identity card */ + .identity { + display: grid; + grid-template-columns: 56px 1fr; + gap: 12px; + align-items: center; + padding: 12px; + background: var(--bg-card); + border: 1px solid var(--border-new); + border-radius: 14px; + } + .identity .avatar-wrap { + width: 56px; + height: 56px; + border-radius: 50%; + position: relative; + border: 1px solid var(--border-strong); + box-shadow: 0 0 0 3px var(--accent-soft); + background: #15192a; + } + .identity .avatar-wrap img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + object-position: 50% 22%; + image-rendering: pixelated; + display: block; + transition: opacity 250ms ease; + user-select: none; + -webkit-user-drag: none; + } + .identity .avatar-wrap img.fading { opacity: 0; } + .identity .avatar-wrap::after { + content: ""; + position: absolute; + right: -2px; + bottom: -2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #4ade80; + border: 2px solid var(--bg-1); + } + .identity h1 { + margin: 0 0 2px; + font-size: 15px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--fg); + } + .identity .sub { + margin: 0; + font-size: 11.5px; + color: var(--fg-3); + } + .identity .mood { + display: inline-flex; + align-items: center; + gap: 5px; + margin-top: 4px; + font-size: 11.5px; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; + } + .identity .mood::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 6px var(--accent-glow); + } + /* Lisa's current pursuit, two lines max (moved out of the right rail). */ + .identity .identity-desire { + margin: 5px 0 0; + font-size: 11.5px; + color: var(--fg-3); + line-height: 1.45; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + /* Sidebar plain text section ("currently wanting") */ + .sb-section { display: flex; flex-direction: column; gap: 6px; } + .sb-section h2 { + margin: 0 0 2px; + font-size: 11.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.10em; + color: var(--fg-3); + padding-left: 4px; + } + .sb-section .body-text { + margin: 0; + font-size: 12px; + line-height: 1.55; + color: var(--fg-2); + padding: 0 4px; + } + + /* Live mini-cards (Claude monitor / last reflection) */ + .card { + background: var(--bg-card); + border: 1px solid var(--border-new); + border-radius: 12px; + padding: 10px 12px; + font-size: 12px; + color: var(--fg-2); + line-height: 1.5; + } + .card.tint-claude { + border-color: rgba(255, 140, 66, 0.20); + background: linear-gradient(180deg, rgba(255, 140, 66, 0.06), rgba(255, 140, 66, 0.02)); + } + .card.tint-idle { + border-color: rgba(255, 208, 102, 0.22); + background: linear-gradient(180deg, rgba(255, 208, 102, 0.07), rgba(255, 208, 102, 0.02)); + } + .card.tint-mail { + border-color: rgba(106, 212, 255, 0.20); + background: linear-gradient(180deg, rgba(106, 212, 255, 0.06), rgba(106, 212, 255, 0.02)); + } + .card.tint-mail .h .left { color: var(--brand, #6ad4ff); } + .card .h { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + } + .card .h .left { + display: flex; + align-items: center; + gap: 6px; + font-size: 11.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.10em; + color: var(--claude); + } + .card.tint-idle .h .left { color: var(--warm); } + .card .h .count { + background: rgba(255, 140, 66, 0.16); + color: var(--claude); + font-size: 11.5px; + font-weight: 600; + padding: 2px 7px; + border-radius: 8px; + } + .card.tint-idle .h .count { + background: rgba(255, 208, 102, 0.16); + color: var(--warm); + } + .session-row { + display: grid; + grid-template-columns: 7px 1fr auto; + align-items: center; + gap: 7px; + padding: 5px 0; + font-size: 11.5px; + border-top: 1px dashed rgba(255, 140, 66, 0.10); + cursor: default; + } + .session-row:first-of-type { border-top: 0; } + .session-row .pip { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--fg-faint); + } + /* working = calm slow breathe (running, not actionable); + waiting = solid + a soft halo that draws the eye ("needs you"). */ + .session-row .pip.working { background: var(--claude); opacity: 1; animation: breathe 2.6s ease-in-out infinite; } + .session-row .pip.waiting { background: var(--claude); opacity: 1; animation: needsYou 2s ease-in-out infinite; } + .session-row .pip.error { background: var(--err-color); } + .session-row .name { + color: var(--fg); + font-weight: 600; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + /* D4a — agent-kind chip rendered inline before the project name, so the + multi-agent sidebar reads which tool each row belongs to. */ + .session-row .agent-badge { + display: inline-block; + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: lowercase; + color: var(--claude); + background: rgba(255, 140, 66, 0.12); + border: 1px solid rgba(255, 140, 66, 0.22); + border-radius: 999px; + padding: 0 5px; + margin-right: 5px; + vertical-align: 1px; + } + .session-row .when { + color: var(--fg-3); + font-variant-numeric: tabular-nums; + font-size: 11.5px; + } + /* Second line under name/when: structural activity (turns/tokens/tool·file). */ + .session-row .session-act { + grid-column: 2 / -1; + margin-top: 2px; + font-size: 11.5px; + color: var(--fg-3); + font-family: ui-monospace, "SF Mono", Menlo, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + /* Managed-agent controls (approve/deny · send follow-up · cancel). */ + .session-row .session-ctrl { + grid-column: 2 / -1; + margin-top: 4px; + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; + } + .session-ctrl .mc { + font-size: 11.5px; + padding: 2px 8px; + min-height: 28px; min-width: 28px; + position: relative; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--panel2, rgba(255,255,255,0.05)); + color: var(--fg); + cursor: pointer; + } + .session-ctrl .mc.approve { color: var(--green, #6bff9d); border-color: rgba(107,255,157,0.4); } + .session-ctrl .mc.deny, + .session-ctrl .mc.cancel { color: var(--err-color, #ff5577); border-color: rgba(255,85,119,0.4); } + .session-ctrl .mc.adopt { color: var(--brand, #6ad4ff); border-color: rgba(106,212,255,0.4); } + .session-ctrl .mc:hover { background: rgba(255,255,255,0.10); } + .session-ctrl .mc-send { + flex: 1; + min-width: 90px; + font-size: 11.5px; + padding: 2px 7px; + border-radius: 6px; + border: 1px solid var(--border); + background: rgba(0,0,0,0.25); + color: var(--fg); + } + /* ══ Control view: session roster (polished, clickable) ══════════ */ + .ctrl-policy { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; } + .ctrl-list { display: flex; flex-direction: column; gap: 8px; } + .ctrl-row { + position: relative; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 4px 12px; + padding: 12px 15px; + background: var(--bg-card); + border: 1px solid var(--border-new); + border-radius: 12px; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, transform 80ms ease; + } + .ctrl-row:hover { background: var(--bg-card-strong); border-color: var(--border-strong); } + .ctrl-row:active { transform: translateY(1px); } + .ctrl-row .cr-pip { width: 9px; height: 9px; border-radius: 50%; background: var(--fg-faint); } + .ctrl-row .cr-pip.working { background: var(--claude); animation: breathe 2.6s ease-in-out infinite; } + .ctrl-row .cr-pip.waiting { background: var(--warm); animation: needsYou 2s ease-in-out infinite; } + .ctrl-row .cr-pip.error { background: var(--err-color); } + .ctrl-row .cr-pip.done { background: var(--proactive); } + .ctrl-row .cr-name { display: flex; align-items: center; gap: 7px; min-width: 0; } + .ctrl-row .cr-id { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12.5px; font-weight: 600; color: var(--fg); + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + } + .ctrl-row .cr-badge { + flex-shrink: 0; + font-size: 11.5px; font-weight: 700; letter-spacing: 0.02em; text-transform: lowercase; + color: var(--claude); background: rgba(255,140,66,0.12); + border: 1px solid rgba(255,140,66,0.22); border-radius: 999px; padding: 1px 6px; + } + .ctrl-row .cr-arrow { color: var(--fg-faint); font-size: 13px; margin-left: 2px; opacity: 0; transition: opacity 120ms ease; } + .ctrl-row:hover .cr-arrow { opacity: 1; } + .ctrl-row .cr-sub { + grid-column: 2 / -1; margin-top: 3px; + font-size: 11.5px; color: var(--fg-3); + font-family: ui-monospace, "SF Mono", Menlo, monospace; + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + } + /* Status chip (right) */ + .st-chip { + flex-shrink: 0; + font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; + border-radius: 999px; padding: 3px 9px; + border: 1px solid var(--border-new); color: var(--fg-3); background: var(--bg-3); + } + .st-chip.working { color: var(--claude); border-color: rgba(255,140,66,0.4); background: rgba(255,140,66,0.12); } + .st-chip.waiting { color: var(--warm); border-color: rgba(255,183,77,0.4); background: rgba(255,183,77,0.12); } + .st-chip.error { color: var(--err-color); border-color: rgba(255,85,119,0.45); background: rgba(255,85,119,0.13); } + .st-chip.done { color: var(--proactive); border-color: var(--proactive-glow); background: var(--proactive-soft); } + /* Problem / pending rows: coloured left accent + inline line */ + .ctrl-row.problem { border-color: rgba(255,85,119,0.4); } + .ctrl-row.pending { border-color: rgba(255,183,77,0.45); } + .ctrl-row.problem::before, .ctrl-row.pending::before { + content: ""; position: absolute; left: 0; top: 11px; bottom: 11px; width: 3px; + border-radius: 0 3px 3px 0; + } + .ctrl-row.problem::before { background: var(--err-color); } + .ctrl-row.pending::before { background: var(--warm); } + .ctrl-row .cr-err { + grid-column: 2 / -1; margin-top: 6px; + font-size: 11.5px; color: var(--err-color); + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + } + .ctrl-row .cr-pend { grid-column: 2 / -1; margin-top: 7px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 11.5px; color: var(--warm); } + .ctrl-row .cr-quick { + font-family: inherit; font-size: 11.5px; font-weight: 600; + padding: 3px 10px; border-radius: 7px; cursor: pointer; + border: 1px solid var(--border-new); background: var(--bg-3); color: var(--fg); + } + .ctrl-row .cr-quick.ok { color: var(--proactive); border-color: var(--proactive-glow); } + .ctrl-row .cr-quick.no { color: var(--err-color); border-color: rgba(255,85,119,0.4); } + .ctrl-row .cr-quick:hover { background: rgba(255,255,255,0.09); } + + /* ══ Session detail (inspector modal) ════════════════════════════ */ + .sd { display: flex; flex-direction: column; gap: 14px; } + .sd-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } + .sd-id { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11.5px; color: var(--fg-3); overflow-wrap: anywhere; } + .sd-banner { border-radius: 10px; padding: 11px 13px; font-size: 12.5px; line-height: 1.5; display: flex; flex-direction: column; gap: 9px; } + .sd-banner.err { color: var(--err-color); background: rgba(255,85,119,0.10); border: 1px solid rgba(255,85,119,0.35); } + .sd-banner.pend { color: var(--warm); background: rgba(255,183,77,0.10); border: 1px solid rgba(255,183,77,0.35); } + .sd-banner .sd-b-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + .sd-grid { display: grid; grid-template-columns: max-content 1fr; gap: 8px 16px; font-size: 12.5px; margin: 0; } + .sd-grid dt { color: var(--fg-3); } + .sd-grid dd { margin: 0; color: var(--fg); overflow-wrap: anywhere; } + .sd-chips { display: flex; flex-wrap: wrap; gap: 5px; } + .sd-chip { font-size: 11.5px; color: var(--fg-2); background: var(--bg-3); border: 1px solid var(--border-new); border-radius: 6px; padding: 1px 7px; font-family: ui-monospace, "SF Mono", Menlo, monospace; } + .sd-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border-top: 1px solid var(--border-new); padding-top: 13px; } + .sd-send { flex: 1; min-width: 150px; font-family: inherit; font-size: 12.5px; padding: 8px 11px; border-radius: 8px; border: 1px solid var(--border-new); background: var(--bg-3); color: var(--fg); } + .sd-send:focus { outline: none; border-color: var(--accent-glow); box-shadow: 0 0 0 3px var(--accent-soft); } + .sd-btn { font-family: inherit; font-size: 12px; font-weight: 600; padding: 8px 13px; border-radius: 8px; cursor: pointer; border: 1px solid var(--border-new); background: var(--bg-3); color: var(--fg); transition: filter 120ms ease; } + .sd-btn.primary { background: var(--accent); border-color: var(--accent); color: #06141b; } + .sd-btn.danger { color: var(--err-color); border-color: rgba(255,85,119,0.4); } + .sd-btn.ok { color: var(--proactive); border-color: var(--proactive-glow); } + .sd-btn:hover { filter: brightness(1.1); } + .sd-out { margin: 0; max-height: 240px; overflow: auto; background: rgba(0,0,0,0.3); border: 1px solid var(--border-new); border-radius: 8px; padding: 10px; font-size: 11.5px; white-space: pre-wrap; word-break: break-word; color: var(--fg-2); font-family: ui-monospace, "SF Mono", Menlo, monospace; } + .sd-note { font-size: 11.5px; color: var(--fg-3); font-style: italic; } + + /* "Delegate a task" → a single full-width button that opens a modal. */ + .delegate-btn { + width: 100%; + margin: 2px 0 8px; + font-size: 11.5px; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid var(--claude, #ff8c42); + background: rgba(255,140,66,0.14); + color: var(--claude, #ff8c42); + cursor: pointer; + transition: background 0.12s ease; + } + .delegate-btn:hover { background: rgba(255,140,66,0.26); } + /* Delegate dialog (rendered in the shared modal). */ + .delegate-modal { display: flex; flex-direction: column; gap: 8px; } + .delegate-modal .dm-label { + font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--fg-2); margin-top: 4px; + } + .delegate-modal .dm-kind, + .delegate-modal .dm-task { + width: 100%; box-sizing: border-box; + font-size: 13px; padding: 8px 10px; border-radius: 8px; + border: 1px solid var(--border); background: rgba(0,0,0,0.25); color: var(--fg); + } + .delegate-modal .dm-task { resize: vertical; min-height: 88px; font-family: inherit; } + .delegate-modal .dm-actions { display: flex; justify-content: flex-end; margin-top: 4px; } + .delegate-modal .dm-start { + font-size: 13px; line-height: 1.2; padding: 8px 16px; border-radius: 8px; + border: 1px solid var(--brand, #6ad4ff); background: rgba(106,212,255,0.16); + color: var(--brand, #6ad4ff); cursor: pointer; + } + .delegate-modal .dm-start:hover { background: rgba(106,212,255,0.28); } + .delegate-modal .dm-start:disabled { opacity: 0.5; cursor: default; } + .delegate-modal .dm-err { color: var(--err-color, #ff5577); font-size: 12px; white-space: pre-wrap; } + .delegate-modal .dm-note { font-size: 11.5px; color: var(--fg-3); line-height: 1.4; margin-top: 2px; } + .delegate-modal .mm-providers { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 2px; } + .delegate-modal .mm-chip { + font-size: 12px; padding: 4px 11px; border-radius: 999px; + border: 1px solid var(--border); background: rgba(255,255,255,0.03); + color: var(--fg-2); cursor: pointer; + } + .delegate-modal .mm-chip:hover { border-color: var(--brand, #6ad4ff); color: var(--fg); } + .delegate-modal .mm-chip.on { + border-color: var(--brand, #6ad4ff); background: rgba(106,212,255,0.16); color: var(--brand, #6ad4ff); + } + .delegate-modal .mm-help { + border: 1px solid var(--border); border-radius: 8px; + background: rgba(106,212,255,0.05); padding: 10px 12px; + display: flex; flex-direction: column; gap: 9px; + } + .delegate-modal .mm-steps { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; } + .delegate-modal .mm-steps li { font-size: 12px; color: var(--fg-2); line-height: 1.45; } + .delegate-modal .mm-link { + /* Match .dm-start's box exactly so the two accent buttons share a hit area: + same font-size + line-height + padding + border, and the same block-flow + box (no inline-flex — it renders an 0.5px shorter than the '; + else if (p.available) btn = ''; + else btn = ''; + return '
' + escapeHtml(p.mark + ' ' + p.label + star) + '
' + escapeHtml(p.detail) + '
' + usage + btn + '
'; + }).join(''); + const clear = '
'; + modalBody.innerHTML = intro + rows + clear; + document.querySelectorAll('.plan-select').forEach(function (btn) { + if (btn.disabled) return; + btn.addEventListener('click', function () { selectPlan(btn.dataset.plan); }); + }); +} + +async function selectPlan(plan) { + try { + const res = await fetch('/api/plans/select', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ plan: plan }), + }); + if (!res.ok) { + const t = await res.text().catch(function () { return ''; }); + modalBody.insertAdjacentHTML('afterbegin', '
select failed: ' + escapeHtml(String(res.status) + (t ? ' — ' + t : '')) + '
'); + return; + } + showPlans(); + } catch (e) { + modalBody.insertAdjacentHTML('afterbegin', '
select error: ' + escapeHtml(e.message) + '
'); + } +} + +// ── Pair phone: mint a device token + show copyable pairing details ────────── +// Mirrors "lisa pair" / the Mac app Pair iPhone window for browser users. The mint +// endpoint is loopback-only, so this works from a localhost browser on the Mac +// (a LAN browser gets 403, handled below). The server detects the Mac's LAN host +// and returns the lisa-pair:// link + host/port/token so the phone can paste the +// link OR type the fields into Lisa Pocket → Settings → Pair. +function pairRow(label, value) { + return '
' + escapeHtml(label) + '' + + '' + escapeHtml(value) + '' + + '
'; +} +async function showPair() { + openModal('PAIR PHONE', '
minting a pairing code…
'); + let res; + try { + res = await fetch('/api/pair/start', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'phone', platform: 'ios' }), + }); + } catch (e) { + modalBody.innerHTML = '
Couldn\'t reach the Lisa backend.
'; + return; + } + if (res.status === 403) { + modalBody.innerHTML = '
Pairing can only be started on the Mac itself. Open this page at http://localhost:' + escapeHtml(location.port || '5757') + ' on your Mac, then try again.
'; + return; + } + if (!res.ok) { + modalBody.innerHTML = '
Pairing failed (HTTP ' + res.status + ').
'; + return; + } + const data = await res.json().catch(function () { return {}; }); + if (!data.token) { modalBody.innerHTML = '
The server returned no token.
'; return; } + const port = data.port || 5757; + const host = data.host || ''; + const link = data.url || ('lisa-pair://v1?host=' + encodeURIComponent(host) + '&port=' + port + '&token=' + encodeURIComponent(data.token) + '&name=phone'); + let html = ''; + if (data.qrSvg) html += '
' + data.qrSvg + '
'; + html += '
Scan the code in Lisa Pocket → Settings → Scan QR — or paste the link / type the three fields below. Keep the phone on the same Wi-Fi (or tailnet) as this Mac.
'; + html += pairRow('Link', link); + html += pairRow('Host', host || '(your Mac\'s Wi-Fi IP)'); + html += pairRow('Port', String(port)); + html += pairRow('Token', data.token); + // UX-11: the panel showed a live credential with no word about its lifetime. + // Device tokens (devices.ts mintDevice) have no expiry at all — they are + // valid until the device is revoked — so say exactly that. + html += '
' + escapeHtml(tr('pair.noExpiry')) + '
'; + if (!host) html += '
Couldn\'t detect your Mac\'s LAN address — enter its Wi-Fi IP or tailnet name on the phone.
'; + modalBody.innerHTML = html; + const codes = modalBody.querySelectorAll('.pair-row'); + for (let i = 0; i < codes.length; i++) { + const row = codes[i]; + const btn = row.querySelector('.pair-copy'); + const val = row.querySelector('.pair-val'); + btn.addEventListener('click', function () { + navigator.clipboard.writeText(val.textContent).then(function () { + const prev = btn.textContent; btn.textContent = tr('copied'); setTimeout(function () { btn.textContent = prev; }, 1200); + }).catch(function () {}); + }); + } +} + +async function showSoul() { + openModal('★ SOUL', '
loading…
'); + const data = await fetch('/api/soul').then(r => r.json()); + if (!data.born) { + modalBody.innerHTML = '
Lisa hasn\'t been born yet. Restart the CLI without --no-birth and the birth ritual will run.
'; + return; + } + const s = data.summary; + let html = ''; + html += '

name

' + escapeHtml(s.name) + '
'; + html += '

born

' + escapeHtml(s.seed.bornAt) + ' · big5(O' + Math.round(s.seed.bigFive.openness*100) + ' C' + Math.round(s.seed.bigFive.conscientiousness*100) + ' E' + Math.round(s.seed.bigFive.extraversion*100) + ' A' + Math.round(s.seed.bigFive.agreeableness*100) + ' N' + Math.round(s.seed.bigFive.neuroticism*100) + ')
'; + html += '

identity

' + escapeHtml(s.identity) + '
'; + html += '

purpose

' + escapeHtml(s.purpose) + '
'; + html += '

constitution

' + escapeHtml(s.constitution) + '
'; + if (s.values?.length) { + html += '

values

' + s.values.map(v => + '
' + escapeHtml(v.title) + '
' + escapeHtml(v.body) + '
' + ).join(''); + } + if (s.opinions?.length) { + html += '

opinions

' + s.opinions.map(o => + '
' + escapeHtml(o.stance) + ' (conf ' + o.confidence.toFixed(2) + ')
' + ).join(''); + } + if (s.desires?.length) { + html += '

desires

' + s.desires.map(d => + '
' + escapeHtml(d.what) + (d.actionable ? ' [heartbeat-active]' : '') + '
' + escapeHtml(d.why) + '
' + ).join(''); + } + html += '

emotions

' + Object.entries(s.emotions.values).map(([k, v]) => { + const len = 12; + const filled = Math.round(Math.abs(v) * len); + const bar = '█'.repeat(filled) + '░'.repeat(len - filled); + return '
' + escapeHtml(k) + '
' + (v < 0 ? '-' : ' ') + bar + ' ' + v.toFixed(2) + '
'; + }).join(''); + if (s.tampered?.length) { + html += '

⚠ tampered

External edits detected on: ' + s.tampered.map(escapeHtml).join(', ') + '
'; + } + html += '

privacy note

Her journal lives at ~/.lisa/soul/journal/ but is intentionally not shown here — that is hers to keep.
'; + modalBody.innerHTML = html; +} + +// Panel openers — the top function bar (.fbtn) + any legacy .badge share this. +document.querySelectorAll('[data-panel]').forEach(b => { + b.addEventListener('click', () => { + const which = b.dataset.panel; + if (which === 'soul') showSoul(); + else if (which === 'skills') showSkills(); + else if (which === 'memory') showMemory(); + else if (which === 'tools') showTools(); + else if (which === 'plans') showPlans(); + else if (which === 'pair') showPair(); + }); +}); + +// Find in conversation — toggle a filter box that hides non-matching log rows. +const fnSearchBtn = document.getElementById('fnSearchBtn'); +const fnFind = document.getElementById('fnFind'); +function filterLog(q) { + const logEl = document.getElementById('log'); + if (!logEl) return; + for (const child of logEl.children) { + child.style.display = !q || (child.textContent || '').toLowerCase().includes(q) ? '' : 'none'; + } +} +if (fnSearchBtn && fnFind) { + fnSearchBtn.addEventListener('click', () => { + const show = fnFind.style.display === 'none'; + fnFind.style.display = show ? '' : 'none'; + if (show) { fnFind.focus(); } else { fnFind.value = ''; filterLog(''); } + }); + fnFind.addEventListener('input', () => filterLog(fnFind.value.trim().toLowerCase())); + fnFind.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeFind(); + }); +} +function openFind() { + if (!fnFind) return; + fnFind.style.display = ''; + fnFind.focus(); + fnFind.select(); +} +function findIsOpen() { return !!fnFind && fnFind.style.display !== 'none'; } +function closeFind() { + if (!fnFind) return; + fnFind.value = ''; + filterLog(''); + fnFind.style.display = 'none'; +} + +// ── Keyboard shortcuts (UX-11) ─────────────────────────────────── +// Before this the shell had Enter / Shift+Enter / Esc and nothing else: +// switching sessions meant reaching for the sidebar tree, and find-in-chat +// meant finding a 36px magnifier in a twelve-icon bar. +const switcherOverlay = document.getElementById('switcherOverlay'); +const switcherInput = document.getElementById('switcherInput'); +const switcherList = document.getElementById('switcherList'); +let switcherRows = []; +let switcherIdx = 0; + +function isTypingTarget(node) { + if (!node) return false; + const tag = node.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || node.isContentEditable === true; +} +function switcherIsOpen() { return !!switcherOverlay && !switcherOverlay.hidden; } +function renderSwitcher() { + if (!switcherList) return; + const q = (switcherInput.value || '').trim().toLowerCase(); + const all = (typeof window.lisaSessionsForSwitcher === 'function' ? window.lisaSessionsForSwitcher() : []) || []; + switcherRows = all.filter(function (r) { + if (!q) return true; + return (r.label + ' ' + r.id).toLowerCase().indexOf(q) >= 0; + }).slice(0, 30); + if (switcherIdx >= switcherRows.length) switcherIdx = 0; + switcherList.innerHTML = ''; + if (!switcherRows.length) { + const empty = document.createElement('div'); + empty.className = 'kbd-empty'; + empty.textContent = tr('kbd.noSessions'); + switcherList.appendChild(empty); + return; + } + switcherRows.forEach(function (r, i) { + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'kbd-row' + (i === switcherIdx ? ' sel' : '') + (r.active ? ' current' : ''); + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', i === switcherIdx ? 'true' : 'false'); + const nm = document.createElement('span'); + nm.className = 'kbd-row-name'; + nm.textContent = r.label; + const meta = document.createElement('span'); + meta.className = 'kbd-row-meta'; + meta.textContent = r.meta; + row.appendChild(nm); + row.appendChild(meta); + row.title = r.id; + // Mousedown, not click: the input keeps focus and the overlay closes + // before a click could land on whatever is underneath. + row.addEventListener('mousedown', function (ev) { ev.preventDefault(); activateSwitcher(i); }); + switcherList.appendChild(row); + }); +} +function moveSwitcher(delta) { + if (!switcherRows.length) return; + switcherIdx = (switcherIdx + delta + switcherRows.length) % switcherRows.length; + renderSwitcher(); + const sel = switcherList.querySelector('.kbd-row.sel'); + if (sel && sel.scrollIntoView) sel.scrollIntoView({ block: 'nearest' }); +} +function activateSwitcher(i) { + const row = switcherRows[i == null ? switcherIdx : i]; + closeSwitcher(); + if (!row) return; + if (typeof window.lisaSwitchSession === 'function') window.lisaSwitchSession(row.id); +} +function openSwitcher() { + if (!switcherOverlay) return; + switcherIdx = 0; + switcherInput.value = ''; + switcherInput.placeholder = tr('kbd.switchPlaceholder'); + switcherOverlay.hidden = false; + renderSwitcher(); + switcherInput.focus(); +} +function closeSwitcher() { + if (!switcherOverlay) return; + switcherOverlay.hidden = true; +} +if (switcherOverlay) { + switcherInput.addEventListener('input', function () { switcherIdx = 0; renderSwitcher(); }); + switcherInput.addEventListener('keydown', function (e) { + if (e.key === 'ArrowDown') { e.preventDefault(); moveSwitcher(1); } + else if (e.key === 'ArrowUp') { e.preventDefault(); moveSwitcher(-1); } + else if (e.key === 'Enter') { e.preventDefault(); activateSwitcher(); } + else if (e.key === 'Escape') { e.preventDefault(); closeSwitcher(); } + }); + switcherOverlay.addEventListener('mousedown', function (e) { + if (e.target === switcherOverlay) closeSwitcher(); + }); +} + +const SHORTCUTS = [ + ['⌘K / Ctrl+K', 'kbd.switch'], + ['⌘/ / Ctrl+/', 'kbd.focus'], + ['⌘F / Ctrl+F', 'kbd.find'], + ['Esc', 'kbd.close'], + ['Enter / Shift+Enter', 'kbd.send'], + ['?', 'kbd.help'], +]; +function showShortcuts() { + const rows = SHORTCUTS.map(function (r) { + return '
' + escapeHtml(r[0]) + '' + escapeHtml(tr(r[1])) + '
'; + }).join(''); + openModal(tr('kbd.title'), '
' + rows + '
'); +} +window.lisaShowShortcuts = showShortcuts; + +document.addEventListener('keydown', function (e) { + const mod = e.metaKey || e.ctrlKey; + if (mod && !e.altKey && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); openSwitcher(); return; } + if (mod && !e.altKey && e.key === '/') { e.preventDefault(); input.focus(); return; } + if (mod && !e.altKey && (e.key === 'f' || e.key === 'F')) { e.preventDefault(); openFind(); return; } + // "?" only when the user is not typing one into a field. + if (e.key === '?' && !mod && !isTypingTarget(e.target)) { e.preventDefault(); showShortcuts(); return; } + if (e.key === 'Escape') { + // Most-nested first. The key gate and the birth ritual are deliberately + // NOT dismissible — they are the only way through first run. + if (switcherIsOpen()) { closeSwitcher(); return; } + if (findIsOpen()) { closeFind(); return; } + } +}); + +// Compact / sidebar mode — force the narrow stacked layout at any width, persisted. +// The toggle UI now lives in the Settings rail view; this block owns the state, +// restores it on load (always — no element dependency), and exposes get/set +// globals the Settings switch drives + reflects. +{ + let compactOn = false; + try { compactOn = localStorage.getItem('lisaCompact') === '1'; } catch (e) {} + const applyCompact = () => { + document.body.classList.toggle('force-compact', compactOn); + const sw = document.getElementById('setCompactToggle'); + if (sw) { + sw.classList.toggle('on', compactOn); + sw.setAttribute('aria-checked', compactOn ? 'true' : 'false'); + } + }; + applyCompact(); + window.lisaGetCompact = () => compactOn; + window.lisaSetCompact = (on) => { + const next = !!on; + if (next === compactOn) return; + compactOn = next; + try { localStorage.setItem('lisaCompact', compactOn ? '1' : '0'); } catch (e) {} + applyCompact(); + }; +} + +// Theme — Nebula (dark, default) vs Calm (light), persisted. The CSS keys off +// ; the fnbar #fnTheme button toggles, and the +// moon/sun glyph swap is pure CSS (body[data-theme] show/hide rules). +{ + let theme = 'nebula'; + try { theme = localStorage.getItem('lisa-theme') === 'calm' ? 'calm' : 'nebula'; } catch (e) {} + const applyTheme = () => { + if (theme === 'calm') document.body.setAttribute('data-theme', 'calm'); + else document.body.removeAttribute('data-theme'); + }; + applyTheme(); + window.lisaGetTheme = () => theme; + window.lisaSetTheme = (t) => { + const next = t === 'calm' ? 'calm' : 'nebula'; + if (next === theme) return; + theme = next; + try { localStorage.setItem('lisa-theme', theme); } catch (e) {} + applyTheme(); + }; + const themeBtn = document.getElementById('fnTheme'); + if (themeBtn) themeBtn.addEventListener('click', () => { + window.lisaSetTheme(theme === 'calm' ? 'nebula' : 'calm'); + }); + // Mail entry from the function bar (the sidebar mail card moved to the + // right panel, which collapses on narrow widths — this stays reachable). + const mailBtn = document.getElementById('fnMail'); + if (mailBtn) mailBtn.addEventListener('click', () => { + if (window.lisaShowView) window.lisaShowView('mail'); + }); +} + +// Right panel manual collapse (F4) — wide-screen only concern: the ≤1180px +// media query hides the panel regardless. Persisted; the fnbar button shows +// an active tint while collapsed. Collapsed is the DEFAULT: the panel only +// stays open once the user has explicitly opened it ("open" in localStorage), +// so a fresh profile boots into the two-column chat-first layout. +{ + let collapsed = true; + try { collapsed = localStorage.getItem('lisaRightbar') !== 'open'; } catch (e) {} + // UX-5: has the user ever driven this toggle themselves? Once they have, + // their choice is final and the auto-expand nudge below never fires again — + // including across reloads, which is why it is persisted. + let touched = false; + try { touched = localStorage.getItem('lisaRightbarTouched') === '1'; } catch (e) {} + let autoExpanded = false; + let attention = 0; + const PANEL_TITLE = tr('rail.toggle'); + // While the rail is collapsed the only sign that an agent is blocked on a + // decision was a 34px icon with a tooltip — the count now rides the icon. + const syncBadge = () => { + const btn = document.getElementById('fnPanel'); + if (!btn) return; + let dot = btn.querySelector('.fbtn-badge'); + if (!collapsed || attention <= 0) { + if (dot) dot.remove(); + btn.title = PANEL_TITLE; + btn.setAttribute('aria-label', tr('rail.toggleAria')); + return; + } + if (!dot) { + dot = document.createElement('span'); + dot.className = 'fbtn-badge'; + btn.appendChild(dot); + } + dot.textContent = attention > 9 ? '9+' : String(attention); + const noun = attention === 1 ? tr('rail.needs.one') : tr('rail.needs.many', { n: attention }); + btn.title = tr('rail.needs.open', { noun: noun }); + btn.setAttribute('aria-label', tr('rail.needs.openAria', { noun: noun })); + }; + const applyRb = () => { + document.body.classList.toggle('rb-collapsed', collapsed); + const btn = document.getElementById('fnPanel'); + if (btn) btn.classList.toggle('active', collapsed); + syncBadge(); + }; + applyRb(); + const panelBtn = document.getElementById('fnPanel'); + if (panelBtn) panelBtn.addEventListener('click', () => { + collapsed = !collapsed; + touched = true; + try { + localStorage.setItem('lisaRightbar', collapsed ? 'collapsed' : 'open'); + localStorage.setItem('lisaRightbarTouched', '1'); + } catch (e) {} + applyRb(); + }); + // Called from the "needs you" renderer on every roster tick. + window.lisaRightbarAttention = function (count, needsDecision) { + attention = count > 0 ? count : 0; + // One nudge per page load, and only for someone who has never expressed a + // preference: a blocked agent is worth stealing 320px for exactly once. + // Deliberately NOT persisted — the next reload returns to the collapsed + // default, so this can never become a sticky layout the user didn't pick. + if (needsDecision && collapsed && !touched && !autoExpanded) { + autoExpanded = true; + collapsed = false; + } + applyRb(); + }; +} + +let currentLisaSpan = null; +let pendingTools = new Map(); +let thinkingEl = null; +// Bumped by lisaResetChatLog on every session switch; runChat captures the +// value at send time and stops rendering once it goes stale. +let chatGeneration = 0; + +// UX-11: the inspector showed the full absolute cwd (90+ chars on this +// machine), which ellipsised into uselessness in a 320px rail. No regex — +// this file is a template literal and every backslash would need doubling. +function abbrevPath(p) { + var s = String(p || ''); + var roots = ['/Users/', '/home/']; + for (var i = 0; i < roots.length; i++) { + if (s.indexOf(roots[i]) !== 0) continue; + var rest = s.slice(roots[i].length); + var slash = rest.indexOf('/'); + if (slash < 0) return '~'; // the home directory itself + return '~' + rest.slice(slash); + } + return s; +} +// A small "Copy" button that reverts its own label. Used by the inspector and +// anywhere else a long unselectable value needs to be liftable. +function copyButton(getText, cls) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = cls || 'copy-btn'; + b.textContent = tr('copy'); + b.addEventListener('click', function (ev) { + ev.stopPropagation(); + const text = typeof getText === 'function' ? getText() : String(getText || ''); + if (!text) return; + const done = function () { + b.textContent = tr('copied'); + setTimeout(function () { b.textContent = tr('copy'); }, 1200); + }; + try { + navigator.clipboard.writeText(text).then(done).catch(function () {}); + } catch (e) {} + }); + return b; +} + +function el(tag, cls, text) { + const node = document.createElement(tag); + if (cls) node.className = cls; + if (text != null) node.textContent = text; + // The first thing appended to the log retires the empty-state card, so a + // reply never renders underneath "Say anything — or start here:". + removeChatEmpty(); + log.appendChild(node); + log.scrollTop = log.scrollHeight; + return node; +} + +// Screen-reader status for the chat (the #chatStatus aria-live region in the +// shell). Coarse turn state only — never the streamed text, which would be +// re-announced on every paint. +function setChatStatus(text) { + const s = document.getElementById('chatStatus'); + if (s) s.textContent = text || ''; +} + +function ensureLisaSpan() { + if (currentLisaSpan) return currentLisaSpan; + if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; } + el('div', 'role lisa', 'LISA'); + currentLisaSpan = el('span', 'msg', ''); + setChatStatus(tr('chat.replying')); + return currentLisaSpan; +} + +// ── live Markdown render for Lisa's streaming bubble ───────────────── +// Lisa streams standard Markdown. Accumulate the raw text on the span +// (span._md) and re-render it to HTML with the source-injected renderMarkdown +// (defined just above this script in lisa-html.ts), throttled to one paint per +// animation frame so long replies stay smooth. flushLisaRender() forces a +// final synchronous paint at segment/turn boundaries so nothing is left +// half-parsed (e.g. an as-yet-unclosed code fence). +let mdFrame = 0; +function paintLisaSpan() { + if (currentLisaSpan && currentLisaSpan._md != null) { + currentLisaSpan.innerHTML = renderMarkdown(currentLisaSpan._md); + log.scrollTop = log.scrollHeight; + } +} +function scheduleLisaRender() { + if (mdFrame) return; + mdFrame = requestAnimationFrame(function () { mdFrame = 0; paintLisaSpan(); }); +} +function flushLisaRender() { + if (mdFrame) { cancelAnimationFrame(mdFrame); mdFrame = 0; } + paintLisaSpan(); +} + +// Copy button on rendered code blocks. Event-delegated on the log container so +// it keeps working across the streaming bubble's innerHTML re-renders. +log.addEventListener('click', function (e) { + const btn = (e.target && e.target.closest) ? e.target.closest('.md-copy') : null; + if (!btn) return; + const block = btn.closest('.md-code'); + const pre = block ? block.querySelector('pre') : null; + if (!pre) return; + navigator.clipboard.writeText(pre.textContent || '').then(function () { + const prev = btn.textContent; + btn.textContent = 'copied'; + setTimeout(function () { btn.textContent = prev; }, 1200); + }).catch(function () {}); +}); + +function previewInput(name, input) { + if (!input || typeof input !== 'object') return ''; + const order = ['command', 'pattern', 'query', 'path', 'description', 'audio_path', 'text', 'name', 'action', 'entry']; + for (const k of order) { + if (typeof input[k] === 'string' && input[k]) { + let v = input[k].replace(/\s+/g, ' ').trim(); + if (v.length > 120) v = v.slice(0, 117) + '...'; + return v; + } + } + try { + const s = JSON.stringify(input); + return s.length > 120 ? s.slice(0, 117) + '...' : s; + } catch { return ''; } +} + +async function send(message) { + input.value = ''; + input.style.height = 'auto'; + el('div', 'role you', 'YOU'); + el('span', 'msg', message || '(attachment)'); + if (pendingFiles.length) { + const names = pendingFiles.map(f => f.name).join(', '); + el('span', 'msg attach-label', '📎 ' + names); + } + const filesToSend = [...pendingFiles]; + pendingFiles = []; + renderAttachPreview(); + maybeOfferKbIngest(message); + await runChat(message, filesToSend); +} + +// ── chat → KB: a bare URL in the user's message gets a one-tap save-to-KB +// chip under the bubble; it calls the same /api/kb/ingest the KB view uses. +function maybeOfferKbIngest(message) { + if (!message) return; + var m = message.match(/https?:\/\/[^\s<>"')\]]+/); + if (!m) return; + var url = m[0].replace(/[.,;:!?。,;:、]+$/, ''); + var chip = el('div', 'kb-ingest-chip', null); + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'kb-ingest-btn'; + btn.textContent = tr('kb.save'); + chip.appendChild(btn); + btn.addEventListener('click', function () { + btn.disabled = true; + btn.textContent = tr('kb.saving'); + fetch('/api/kb/ingest', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: url }) }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (d && d.ok) { + btn.textContent = d.deduped ? tr('kb.exists') : tr('kb.saved'); + if (typeof window.lisaReloadKb === 'function') window.lisaReloadKb(); + } else { + btn.disabled = false; + btn.textContent = tr('kb.save'); + if (typeof window.lisaKbToast === 'function') window.lisaKbToast((d && d.error) ? d.error : tr('kb.failed')); + } + }) + .catch(function () { + btn.disabled = false; + btn.textContent = tr('kb.save'); + if (typeof window.lisaKbToast === 'function') window.lisaKbToast(tr('kb.failed')); + }); + }); +} + +// On failure, show the error detail with a retry button that re-runs the same +// turn. Kept separate from send() so retry never re-appends the user's bubble +// or re-reads the (already-cleared) attachment tray. +function showError(detail, message, filesToSend) { + if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; } + const block = el('div', 'err-block', null); + const head = document.createElement('div'); + head.className = 'err-head'; + head.textContent = tr('err.request'); + block.appendChild(head); + const body = document.createElement('div'); + body.className = 'err-detail'; + body.textContent = detail || 'unknown error'; + block.appendChild(body); + const retry = document.createElement('button'); + retry.type = 'button'; + retry.className = 'err-retry'; + retry.textContent = tr('err.retry'); + retry.addEventListener('click', () => { + block.remove(); + runChat(message, filesToSend); + }); + block.appendChild(retry); + log.scrollTop = log.scrollHeight; +} + +async function runChat(message, filesToSend) { + sendBtn.disabled = true; + const gen = chatGeneration; + // F6 — bind this turn to the session it was sent from: the server routes + // it to that session's own ctx, so turns in different sessions run + // concurrently and this reply always persists into the right transcript. + const sid = window.lisaActiveSessionId || null; + currentLisaSpan = null; + pendingTools.clear(); + thinkingEl = el('div', 'thinking', '⋯ thinking'); + setChatStatus(tr('chat.thinking')); + // UX-10: /chat answers within a few hundred ms normally. If two seconds + // pass with no frame at all, the backend is busy or stalled — say so on the + // same line rather than leaving "⋯ thinking" to mean both. + let sawFrame = false; + let waitTimer = setTimeout(function () { + if (sawFrame || gen !== chatGeneration || !thinkingEl) return; + thinkingEl.classList.add('waiting'); + thinkingEl.textContent = tr('chat.waiting'); + }, 2000); + const noteFrame = () => { + if (sawFrame) return; + sawFrame = true; + clearTimeout(waitTimer); + if (thinkingEl && thinkingEl.classList.contains('waiting')) { + thinkingEl.classList.remove('waiting'); + thinkingEl.textContent = '⋯ thinking'; + } + }; + // The agent emits an error event AND the server re-sends it from its turn + // catch — dedupe so one failure renders exactly one error block. + let errored = false; + const fail = (detail) => { + if (errored || gen !== chatGeneration) return; + errored = true; + setChatStatus(tr('chat.failed')); + showError(detail, message, filesToSend); + }; + try { + const res = await fetch('/chat', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({message, files: filesToSend, sessionId: sid}), + }); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + while (true) { + const {value, done} = await reader.read(); + if (done) break; + buf += decoder.decode(value, {stream: true}); + let idx; + while ((idx = buf.indexOf('\n\n')) >= 0) { + const evRaw = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const m = evRaw.match(/^data: (.*)$/m); + if (!m) continue; + const ev = JSON.parse(m[1]); + noteFrame(); + // Stale generation (the user switched sessions mid-reply): drain the + // stream without touching the DOM; the reply persists server-side. + if (gen !== chatGeneration) continue; + if (ev.type === 'text') { + const span = ensureLisaSpan(); + span._md = (span._md || '') + ev.text; + scheduleLisaRender(); + } else if (ev.type === 'tool_start') { + if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; } + flushLisaRender(); + currentLisaSpan = null; + const block = el('div', 'tool-block', null); + const head = document.createElement('div'); + head.className = 'tool-head'; + head.innerHTML = ' ...'; + head.querySelector('.tool-name').textContent = ev.name; + block.appendChild(head); + const preview = previewInput(ev.name, ev.input); + if (preview) { + const p = document.createElement('div'); + p.className = 'tool-input'; + p.textContent = preview; + block.appendChild(p); + } + pendingTools.set(ev.name, block); + } else if (ev.type === 'tool_end') { + const block = pendingTools.get(ev.name); + if (block) { + const spinner = block.querySelector('.tool-spinner'); + if (spinner) spinner.textContent = ev.isError ? '✗' : '✓'; + if (ev.isError) block.classList.add('tool-error'); + if (ev.resultPreview) { + const r = document.createElement('div'); + r.className = 'tool-result'; + r.textContent = ev.resultPreview; + block.appendChild(r); + } + pendingTools.delete(ev.name); + } + } else if (ev.type === 'mood') { + setMood(ev.slug); + } else if (ev.type === 'error') { + fail(ev.message); + } else if (ev.type === 'done') { + if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; } + flushLisaRender(); + setChatStatus(tr('chat.done')); + } + } + } + if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; } + } catch (err) { + fail(err.message); + } finally { + // The turn is over one way or another — never let the 2s escalation fire + // onto a finished (or failed) turn. + clearTimeout(waitTimer); + sendBtn.disabled = false; + input.focus(); + // F6 — a reply that finished after its session was switched away: + // refresh if the user is back on that session, else mark it unread. + if (gen !== chatGeneration && sid) { + if (sid === window.lisaActiveSessionId) { + if (typeof window.lisaResetChatLog === 'function') window.lisaResetChatLog(); + } else if (typeof window.lisaMarkUnread === 'function') { + window.lisaMarkUnread(sid); + } + } + } +} + +form.addEventListener('submit', (ev) => { + ev.preventDefault(); + const msg = input.value.trim(); + if (msg || pendingFiles.length) send(msg); +}); + +input.addEventListener('keydown', (ev) => { + // Enter sends — but NEVER while an IME composition is active (Chinese / + // Japanese / Korean input). That Enter is confirming a candidate from the + // IME popup, not sending the message. isComposing covers modern browsers; + // keyCode 229 is the legacy signal some IMEs still fire on the confirming key. + if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing && ev.keyCode !== 229) { + ev.preventDefault(); + form.dispatchEvent(new Event('submit')); + } +}); +input.addEventListener('input', () => { + input.style.height = 'auto'; + input.style.height = Math.min(input.scrollHeight, 200) + 'px'; +}); + +// ── KB capture: select chat messages → save to the knowledge base ──── +// (docs/archive/plans/PLAN_KNOWLEDGE_BASE_v1.0.md, requirement #2). Toggle select mode from +// the function bar, tick messages, "Add to KB" writes a Layer-1 source verbatim. +(function kbCapture() { + var toggle = document.getElementById('fnKbSelect'); + var log = document.getElementById('log'); + if (!toggle || !log) return; + var selecting = false; + var bar = null; + + function selectedMsgs() { return log.querySelectorAll('.msg.kb-sel'); } + function updateCount() { + var n = selectedMsgs().length; + var c = document.getElementById('kbCapCount'); + if (c) c.textContent = n + ' selected'; + var add = document.getElementById('kbCapAdd'); + if (add) add.disabled = n === 0; + } + function clearSel() { + var s = log.querySelectorAll('.msg.kb-sel'); + for (var i = 0; i < s.length; i++) s[i].classList.remove('kb-sel'); + } + function ensureBar() { + if (bar) return bar; + bar = document.createElement('div'); + bar.id = 'kbCaptureBar'; + bar.className = 'kb-capture-bar'; + bar.innerHTML = '' + + '0 selected' + + '' + + ''; + document.body.appendChild(bar); + document.getElementById('kbCapCancel').addEventListener('click', function () { setSelecting(false); }); + document.getElementById('kbCapAdd').addEventListener('click', doAdd); + return bar; + } + function setSelecting(on) { + selecting = on; + log.classList.toggle('kb-selecting', on); + toggle.classList.toggle('active', on); + if (on) { ensureBar().classList.add('open'); updateCount(); } + else { clearSel(); if (bar) bar.classList.remove('open'); } + } + toggle.addEventListener('click', function () { setSelecting(!selecting); }); + log.addEventListener('click', function (e) { + if (!selecting) return; + var msg = e.target && e.target.closest ? e.target.closest('.msg') : null; + if (!msg || !log.contains(msg)) return; + e.preventDefault(); + msg.classList.toggle('kb-sel'); + updateCount(); + }); + function roleOf(msgEl) { + var prev = msgEl.previousElementSibling; + while (prev && !(prev.classList && prev.classList.contains('role'))) prev = prev.previousElementSibling; + return prev ? (prev.textContent || '').trim() : ''; + } + function doAdd() { + var msgs = selectedMsgs(); + if (!msgs.length) return; + var parts = []; + for (var i = 0; i < msgs.length; i++) { + var role = roleOf(msgs[i]) || 'MESSAGE'; + parts.push('**' + role + ':** ' + (msgs[i].textContent || '')); + } + var content = parts.join('\n\n'); + var titleEl = document.getElementById('kbCapTitle'); + var title = titleEl ? titleEl.value.trim() : ''; + var add = document.getElementById('kbCapAdd'); + if (add) { add.disabled = true; add.textContent = 'Saving…'; } + fetch('/api/kb/add', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: title, content: content, origin: 'chat', tags: [] }) }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (add) add.textContent = 'Add to KB'; + if (titleEl) titleEl.value = ''; + setSelecting(false); + if (typeof window.lisaReloadKb === 'function') window.lisaReloadKb(); + kbToast(d && d.ok ? 'Saved to Knowledge Base' : 'Save failed'); + }) + .catch(function () { if (add) { add.disabled = false; add.textContent = 'Add to KB'; } kbToast('Save failed'); }); + } + function kbToast(msg) { + var t = document.createElement('div'); + t.className = 'kb-toast'; + t.textContent = msg; + document.body.appendChild(t); + setTimeout(function () { t.classList.add('show'); }, 10); + setTimeout(function () { t.classList.remove('show'); setTimeout(function () { t.remove(); }, 300); }, 2200); + } + // Shared with the chat-bubble ingest chip (defined outside this closure). + window.lisaKbToast = kbToast; +})(); + +// ── PWA: register service worker + iOS install hint ───────────────── +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(err => { + console.warn('[pwa] sw register failed:', err); + }); +} +(function() { + const isiOS = /iPad|iPhone|iPod/.test(navigator.userAgent); + const isStandalone = window.matchMedia('(display-mode: standalone)').matches + || (window.navigator).standalone === true; + if (!isiOS || isStandalone) return; + if (localStorage.getItem('lisa.pwa.dismissed') === '1') return; + setTimeout(() => { + const banner = document.createElement('div'); + banner.style.cssText = 'position:fixed;bottom:8px;left:8px;right:8px;background:var(--bg-card-strong);border:1px solid var(--border-strong);border-radius:12px;padding:10px 12px;color:var(--fg);font-size:12px;z-index:9999;display:flex;gap:8px;align-items:center;'; + banner.innerHTML = '✦ Add Lisa to Home Screen: Share button → "Add to Home Screen"'; + const dismiss = document.createElement('button'); + dismiss.textContent = '✕'; + dismiss.style.cssText = 'background:transparent;border:none;color:var(--fg-2);cursor:pointer;font-size:14px;margin-left:auto;'; + dismiss.onclick = () => { + localStorage.setItem('lisa.pwa.dismissed', '1'); + banner.remove(); + }; + banner.appendChild(dismiss); + document.body.appendChild(banner); + }, 5000); +})(); + +// ════════════════════════════════════════════════════════════════════ +// ── Sidebar live wiring ───────────────────────────────────────────── +// +// Populates the new sidebar blocks introduced by the redesign: +// - identity card sub-line (born YYYY-MM-DD · NN days) +// - "currently wanting" paragraph (top actionable desire) +// - Claude Code monitor card (active sessions) +// - "last reflection" mini-card (most recent ★ idle message) +// Wires to /api/island/ping + /api/claude/sessions + /api/soul, and +// piggy-backs on the connectEvents() SSE listener above for live +// claude_session_update + idle_message refreshes. +// ════════════════════════════════════════════════════════════════════ +(function setupSidebarLive() { + const sbDesire = document.getElementById('sbDesire'); + const sbClaudeCount = document.getElementById('sbClaudeCount'); + const sbClaudeRows = document.getElementById('sbClaudeRows'); + const sbReflection = document.getElementById('sbReflection'); + const sbReflectionBody = document.getElementById('sbReflectionBody'); + const sbSessionBadge = document.getElementById('sbSessionBadge'); + const identitySub = document.getElementById('identitySub'); + + // Active session window matches the watcher's ACTIVE_WINDOW_MS. + const ACTIVE_WINDOW_MS = 30 * 60 * 1000; + + function relativeTime(iso) { + const ms = Date.now() - new Date(iso).getTime(); + if (ms < 30_000) return 'just now'; + if (ms < 60_000) return Math.round(ms / 1000) + 's'; + if (ms < 3600_000) return Math.round(ms / 60_000) + 'm'; + if (ms < 86400_000) return Math.round(ms / 3600_000) + 'h'; + return Math.round(ms / 86400_000) + 'd'; + } + + function setDesire(text) { + sbDesire.textContent = text || '(nothing actively pursued)'; + sbDesire.title = text || ''; + } + + window.updateReflection = function (text) { + if (!text) { sbReflection.style.display = 'none'; return; } + sbReflection.style.display = ''; + sbReflectionBody.textContent = '"' + text.replace(/^["“”]+|["“”]+$/g, '').trim() + '"'; + }; + + // Compact one-line activity summary — mirrors agent-roster.ts formatActivity + // (kept inline because this client script is a no-interpolation template + // literal; the island uses the source-injected shared version). + function sbActivity(s) { + const a = s.activity; + if (!a || typeof a !== 'object') return ''; + if (a.pendingPermission) return '⚠ wants to run ' + a.pendingPermission; + const bits = []; + if (a.lastError) bits.push('✗ ' + a.lastError); + const prog = []; + if (typeof a.turnCount === 'number' && a.turnCount > 0) prog.push('turn ' + a.turnCount); + if (a.tokens && (a.tokens.input || a.tokens.output)) { + const tot = (a.tokens.input || 0) + (a.tokens.output || 0); + prog.push(tot >= 1000 ? Math.round(tot / 1000) + 'k tok' : tot + ' tok'); + } + if (prog.length) bits.push(prog.join(' ')); + if (a.lastCommandName) bits.push('$ ' + a.lastCommandName); + const tool = a.lastTools && a.lastTools.length ? a.lastTools[a.lastTools.length - 1] : ''; + const file = a.filesTouched && a.filesTouched.length ? (String(a.filesTouched[a.filesTouched.length - 1]).split('/').pop() || '') : ''; + if (tool && file) bits.push(tool + ' ' + file); + else if (tool) bits.push(tool); + else if (file) bits.push(file); + return bits.join(' · '); + } + + // POST a control action to the right agent family (managed|pty), then refresh. + function agentAction(fam, id, action, body) { + fetch('/api/agents/' + fam + '/' + encodeURIComponent(id) + '/' + action, { + method: 'POST', + headers: body ? { 'content-type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }).then(function () { + if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions(); + }).catch(function () {}); + } + + // Show a PTY agent's captured terminal tail in the modal — explicit + on + // demand (it's content, so it's never folded into the structural roster). + function ptyOutput(id) { + fetch('/api/agents/pty/' + encodeURIComponent(id) + '/output').then(function (r) { + return r.ok ? r.json() : null; + }).then(function (d) { + if (!d) return; + openModal('agent output', '
' + escapeHtml(d.output || '(no output yet)') + '
'); + }).catch(function () {}); + } + + // ── Agent roster → tree + inspector (PLAN_UI_SESSION_SHELL_v1.0 §1.3/§3.1) + // The right panel's row list became a single INSPECTOR card for the session + // selected in the sidebar tree; the roster itself now lives in the tree as + // per-agent root groups (LISA / Claude Code / Codex … same-level siblings), + // grouped agent kind → project → session. + let cachedAgents = []; + // {type:'agent', key} | {type:'lisa', id} | null (null → auto: the + // top-ranked live agent, else the active Lisa session). + let selInsp = null; + // Collapse state survives the 60s re-renders (keyed group → closed?). + const collapsedKeys = {}; + function applyCollapsed(el, key) { + if (collapsedKeys[key]) el.classList.add('closed'); + } + function toggleCollapsed(el, key) { + el.classList.toggle('closed'); + collapsedKeys[key] = el.classList.contains('closed'); + } + function agentKey(s) { return s.agent + '/' + s.sessionId; } + function agentByKey(key) { + for (let i = 0; i < cachedAgents.length; i++) { + if (agentKey(cachedAgents[i]) === key) return cachedAgents[i]; + } + return null; + } + // Label by git branch when available (more meaningful than a worktree + // hash), stripping the claude/ prefix; fall back to the project name. + // (String ops, not a regex — a /// here would be mangled by the outer + // template literal that wraps this client script.) + function agentLabel(s) { + let label = s.project; + if (s.activity && s.activity.gitBranch) { + const br = String(s.activity.gitBranch); + label = br.indexOf('claude/') === 0 ? br.slice(7) : br; + } + return label; + } + const AGENT_NAMES = { + 'claude-code': 'Claude Code', codex: 'Codex', opencode: 'OpenCode', + aider: 'Aider', 'github-pr': 'GitHub PR', cursor: 'Cursor', + gemini: 'Gemini', lisa: 'LISA agents', mcp: 'MCP', + }; + function agentGlyphClass(kind) { + if (kind === 'claude-code') return 'cc'; + if (kind === 'codex') return 'codex'; + return 'other'; + } + + function setClaudeSessions(sessions) { + const cutoff = Date.now() - ACTIVE_WINDOW_MS; + const recent = sessions.filter(s => new Date(s.lastMtime).getTime() >= cutoff); + sbClaudeCount.textContent = String(recent.length); + // sort: errors first, then waiting, then working, then by mtime + const rank = { error: 0, waiting: 1, working: 2, unknown: 3 }; + cachedAgents = recent.slice().sort((a, b) => { + const ra = rank[a.state] ?? 9; + const rb = rank[b.state] ?? 9; + if (ra !== rb) return ra - rb; + return new Date(b.lastMtime).getTime() - new Date(a.lastMtime).getTime(); + }); + // First roster snapshot: honor a #agent=kind/id deep link (F5). + if (typeof window.lisaHandleAgentHashOnce === 'function') window.lisaHandleAgentHashOnce(); + renderSessionTree(); + renderNeeds(); + renderInspector(); + // Live refresh for an open stream tab (head/perm/foot reflect the + // newest snapshot; steps repoll on their own timer too). + if (currentAgentTab) renderStreamView(); + } + + // ── "Needs you" rail section (确认轮二) — every agent decision waiting + // on the user, actionable inline: approve/deny a pending permission, or + // jump straight into the read-only stream of a waiting/errored session. + function renderNeeds() { + const rows = document.getElementById('sbNeedsRows'); + const count = document.getElementById('sbNeedsCount'); + if (!rows) return; + rows.innerHTML = ''; + const needs = cachedAgents.filter(function (s) { + return (s.activity && s.activity.pendingPermission) || s.state === 'waiting' || s.state === 'error'; + }); + if (count) { + // The count is an aria-live region: the number plus a visually hidden + // noun so a screen reader hears "2 agents need you", not just "2". + count.textContent = ''; + if (needs.length) { + count.appendChild(document.createTextNode(String(needs.length))); + const sr = document.createElement('span'); + sr.className = 'sr-only'; + // The visible node already carries the number — the hidden part is + // just the unit, so a reader hears "2 agents need you", not "2 2 …". + sr.textContent = ' ' + tr(needs.length === 1 ? 'rail.needs.unit.one' : 'rail.needs.unit.many'); + count.appendChild(sr); + } + } + // UX-5: mirror the count onto the collapsed rail's toggle, and let the + // rail open itself the first time something is actually blocked on the + // user. An "error" agent is reported but does NOT trigger the expand — + // only a decision waiting to be made does. + if (typeof window.lisaRightbarAttention === 'function') { + window.lisaRightbarAttention(needs.length, needs.some(function (s) { + return (s.activity && s.activity.pendingPermission) || s.state === 'waiting'; + })); + } + if (!needs.length) { + const ok = document.createElement('div'); + ok.className = 'session-empty'; + ok.textContent = 'all clear ✓'; + rows.appendChild(ok); + return; + } + needs.slice(0, 6).forEach(function (s) { + const row = document.createElement('div'); + row.className = 'needs-row'; + row.innerHTML = ''; + const st = s.state || 'unknown'; + if (st === 'working' || st === 'waiting' || st === 'error') row.querySelector('.pip').classList.add(st); + row.querySelector('.nr-name').textContent = agentLabel(s); + const pend = s.activity && s.activity.pendingPermission; + row.querySelector('.nr-sub').textContent = pend ? ('⚠ ' + pend) : (s.stateReason || s.state); + row.title = (AGENT_NAMES[s.agent] || s.agent) + ' · ' + s.project + ' · ' + s.sessionId; + row.setAttribute('data-agent', s.agent); + row.setAttribute('data-session', s.sessionId); + const acts = document.createElement('span'); + acts.className = 'session-ctrl nr-acts'; + if (s.controllable === 'managed' && pend) { + const ap = document.createElement('button'); + ap.className = 'mc approve'; ap.textContent = '✓'; ap.title = 'Approve'; + ap.setAttribute('data-act', 'approve'); + const dn = document.createElement('button'); + dn.className = 'mc deny'; dn.textContent = '✕'; dn.title = 'Deny'; + dn.setAttribute('data-act', 'deny'); + acts.appendChild(ap); acts.appendChild(dn); + } else { + const open = document.createElement('button'); + open.className = 'mc'; open.textContent = '▤'; open.title = 'Open the read-only stream'; + open.setAttribute('data-act', 'stream'); + acts.appendChild(open); + } + row.appendChild(acts); + rows.appendChild(row); + }); + } + + // ── Tree view mode (F3): group by agent kind (default) or by project ── + let treeMode = 'agent'; + try { treeMode = localStorage.getItem('lisaTreeMode') === 'project' ? 'project' : 'agent'; } catch (e) {} + const treeModeBtn = document.getElementById('sbTreeMode'); + function syncTreeModeBtn() { + if (!treeModeBtn) return; + treeModeBtn.classList.toggle('on', treeMode === 'project'); + treeModeBtn.title = treeMode === 'project' ? 'Grouped by project — click for agent view' : 'Grouped by agent — click for project view'; + } + syncTreeModeBtn(); + if (treeModeBtn) treeModeBtn.addEventListener('click', function () { + treeMode = treeMode === 'project' ? 'agent' : 'project'; + try { localStorage.setItem('lisaTreeMode', treeMode); } catch (e) {} + syncTreeModeBtn(); + renderSessionTree(); + }); + function lisaProjectOf(s) { + const c = s.cwd || ''; + const base = c.split('/').pop(); + return base || c || '?'; + } + + // Shared leaf builders (both tree modes). withGlyph adds a mini source + // glyph in project view where Lisa and agent sessions sit side by side. + // NOTE (真实E2E修复): tree/needs/inspector rebuild on every roster tick, + // so per-node listeners kept landing real clicks on freshly-replaced dead + // elements. All click handling is DELEGATED to the stable containers — + // nodes only carry data-* attributes. + function makeLisaLeaf(s, withGlyph) { + const isActive = s.id === window.lisaActiveSessionId; + const leaf = document.createElement('button'); + leaf.type = 'button'; + leaf.className = 'tleaf' + (isActive ? ' active' : '') + (window.lisaIsUnread && window.lisaIsUnread(s.id) ? ' unread' : ''); + leaf.innerHTML = '' + (withGlyph ? 'L' : '') + ''; + if (isActive) leaf.querySelector('.pip').classList.add('live'); + leaf.querySelector('.tname').textContent = sessionLabel(s); + leaf.querySelector('.ttime').textContent = relativeTime(s.startedAt); + leaf.title = s.id + ' · ' + (s.messageCount || 0) + ' msgs'; + leaf.setAttribute('data-lisa-id', s.id); + return leaf; + } + function makeAgentLeaf(s, withGlyph) { + const key = agentKey(s); + const leaf = document.createElement('button'); + leaf.type = 'button'; + leaf.className = 'tleaf' + (selInsp && selInsp.type === 'agent' && selInsp.key === key ? ' active' : ''); + leaf.innerHTML = '' + (withGlyph ? '' : '') + ''; + const st = s.state || 'unknown'; + if (st === 'working' || st === 'waiting' || st === 'error') leaf.querySelector('.pip').classList.add(st); + if (withGlyph) { + const g = leaf.querySelector('.agent-glyph'); + const kindName = AGENT_NAMES[s.agent] || s.agent; + g.classList.add(agentGlyphClass(s.agent)); + g.textContent = (kindName.charAt(0) || 'A').toUpperCase(); + } + leaf.querySelector('.tname').textContent = agentLabel(s); + leaf.querySelector('.ttime').textContent = relativeTime(s.lastMtime); + leaf.title = (s.stateReason ? s.state + ' · ' + s.stateReason : s.state) + ' · ' + s.project + ' · ' + s.sessionId; + leaf.setAttribute('data-agent', s.agent); + leaf.setAttribute('data-session', s.sessionId); + return leaf; + } + + // F3 project view: roots = projects, children = that project's Lisa + // sessions + agent sessions side by side (mini glyphs mark the source). + function buildProjectTree(tree) { + const projects = []; + const byProject = {}; + function bucket(p) { + if (!byProject[p]) { byProject[p] = { lisa: [], agents: [] }; projects.push(p); } + return byProject[p]; + } + cachedSessions.slice(0, 30).forEach(function (s) { bucket(lisaProjectOf(s)).lisa.push(s); }); + cachedAgents.forEach(function (s) { bucket(s.project || '?').agents.push(s); }); + projects.sort(function (a, b) { return a.localeCompare(b); }); + projects.forEach(function (p) { + const group = document.createElement('div'); + group.className = 'tgroup'; + const key = 'p:' + p; + applyCollapsed(group, key); + const root = document.createElement('button'); + root.type = 'button'; + root.className = 'tnode'; + root.innerHTML = ''; + root.querySelector('.tlabel').textContent = p; + root.querySelector('.tcount').textContent = String(byProject[p].lisa.length + byProject[p].agents.length); + root.setAttribute('data-toggle', key); + group.appendChild(root); + const kids = document.createElement('div'); + kids.className = 'tchildren'; + byProject[p].lisa.forEach(function (s) { kids.appendChild(makeLisaLeaf(s, true)); }); + byProject[p].agents.forEach(function (s) { kids.appendChild(makeAgentLeaf(s, true)); }); + group.appendChild(kids); + tree.appendChild(group); + }); + } + + // Sidebar tree: one root group per live agent kind, then project sub-groups, + // then session leaves. Idempotent — removes its own groups before rebuilding + // (the LISA group is owned by renderSessionTree below). + function renderAgentTree() { + const tree = document.getElementById('sessionTree'); + if (!tree) return; + const olds = tree.querySelectorAll('.tgroup.agent-group'); + for (let i = 0; i < olds.length; i++) olds[i].remove(); + const kinds = []; + const byKind = {}; + cachedAgents.forEach(function (s) { + if (!byKind[s.agent]) { byKind[s.agent] = []; kinds.push(s.agent); } + byKind[s.agent].push(s); + }); + kinds.sort(); + kinds.forEach(function (kind) { + const group = document.createElement('div'); + group.className = 'tgroup agent-group'; + applyCollapsed(group, 'agent:' + kind); + const root = document.createElement('button'); + root.type = 'button'; + root.className = 'tnode'; + root.innerHTML = ''; + const kindName = AGENT_NAMES[kind] || kind; + const glyph = root.querySelector('.agent-glyph'); + glyph.classList.add(agentGlyphClass(kind)); + glyph.textContent = (kindName.charAt(0) || 'A').toUpperCase(); + root.querySelector('.tlabel').textContent = kindName; + root.querySelector('.tcount').textContent = String(byKind[kind].length); + root.setAttribute('data-toggle', 'agent:' + kind); + group.appendChild(root); + const kids = document.createElement('div'); + kids.className = 'tchildren'; + const projects = []; + const byProject = {}; + byKind[kind].forEach(function (s) { + const p = s.project || '?'; + if (!byProject[p]) { byProject[p] = []; projects.push(p); } + byProject[p].push(s); + }); + projects.forEach(function (p) { + const sub = document.createElement('div'); + sub.className = 'tsub'; + const subKey = 'proj:' + kind + '/' + p; + applyCollapsed(sub, subKey); + const pn = document.createElement('button'); + pn.type = 'button'; + pn.className = 'tnode'; + pn.innerHTML = ''; + pn.querySelector('.tlabel').textContent = p; + pn.setAttribute('data-toggle', subKey); + sub.appendChild(pn); + const pkids = document.createElement('div'); + pkids.className = 'tchildren'; + byProject[p].forEach(function (s) { pkids.appendChild(makeAgentLeaf(s, false)); }); + sub.appendChild(pkids); + kids.appendChild(sub); + }); + group.appendChild(kids); + tree.appendChild(group); + }); + } + + // ── Inspector card builders ───────────────────────────────────────── + function inspRow(label, value, cls) { + const row = document.createElement('div'); + row.className = 'kvrow' + (cls ? ' ' + cls : ''); + const k = document.createElement('span'); + k.textContent = label; + const v = document.createElement('code'); + v.textContent = value; + v.title = value; + row.appendChild(k); + row.appendChild(v); + return row; + } + // Same row, but the value is a filesystem path: shown as ~/…, full path in + // the tooltip, and liftable with one click (UX-11). + function inspPathRow(label, value) { + const row = inspRow(label, abbrevPath(value)); + const code = row.querySelector('code'); + if (code) code.title = value; + const copy = copyButton(function () { return value; }, 'copy-btn kv-copy'); + copy.title = tr('copyPath'); + copy.setAttribute('aria-label', tr('copyPath')); + row.appendChild(copy); + return row; + } + function inspStat(value, label) { + const st = document.createElement('div'); + st.className = 'stat'; + const b = document.createElement('b'); + b.textContent = value; + b.title = value; + const sp = document.createElement('span'); + sp.textContent = label; + st.appendChild(b); + st.appendChild(sp); + return st; + } + function inspHead(glyphCls, glyphText, name, stateCls, stateText, sub) { + const head = document.createElement('div'); + head.className = 'insp-head'; + const g = document.createElement('span'); + g.className = 'agent-glyph ' + glyphCls; + g.textContent = glyphText; + const box = document.createElement('div'); + box.className = 'insp-names'; + const nm = document.createElement('div'); + nm.className = 'insp-name'; + const nmText = document.createElement('span'); + nmText.className = 'nm'; + nmText.textContent = name; + nmText.title = name; + nm.appendChild(nmText); + if (stateText) { + const chip = document.createElement('span'); + chip.className = 'st-chip ' + stateCls; + chip.textContent = stateText; + nm.appendChild(chip); + } + const subEl = document.createElement('div'); + subEl.className = 'insp-sub'; + subEl.textContent = sub || ''; + subEl.title = sub || ''; + box.appendChild(nm); + box.appendChild(subEl); + head.appendChild(g); + head.appendChild(box); + return head; + } + function fmtTokens(t) { + if (!t) return '—'; + const total = (t.input || 0) + (t.output || 0); + return total >= 1000 ? Math.round(total / 1000) + 'k' : String(total); + } + + function renderInspector() { + const box = sbClaudeRows; + while (box.firstChild) box.removeChild(box.firstChild); + let agent = null; + let lisaSession = null; + if (selInsp && selInsp.type === 'agent') agent = agentByKey(selInsp.key); + if (!agent && selInsp && selInsp.type === 'lisa') lisaSession = sessionById(selInsp.id); + // Default context = the ACTIVE Lisa session (确认轮二: no more + // auto-picking a random agent — the Needs-you section carries the + // agent-attention case now). + if (!agent && !lisaSession) { + lisaSession = sessionById(window.lisaActiveSessionId); + } + if (agent) { renderAgentInspector(box, agent); return; } + if (lisaSession) { renderLisaInspector(box, lisaSession); return; } + const empty = document.createElement('div'); + empty.className = 'session-empty'; + empty.textContent = '(idle)'; + box.appendChild(empty); + } + + function renderLisaInspector(box, s) { + const isActive = s.id === window.lisaActiveSessionId; + box.appendChild(inspHead('lisa', 'L', sessionLabel(s), isActive ? 'working' : 'done', isActive ? 'active' : 'idle', s.id + ' · ' + abbrevPath(s.cwd || ''))); + const stats = document.createElement('div'); + stats.className = 'stats'; + stats.appendChild(inspStat(String(s.messageCount || 0), 'msgs')); + stats.appendChild(inspStat(relativeTime(s.startedAt), 'started')); + stats.appendChild(inspStat(String((s.cwd || '—').split('/').pop() || '—'), 'project')); + box.appendChild(stats); + const kv = document.createElement('div'); + kv.className = 'kvrows'; + if (s.model) kv.appendChild(inspRow('model', s.model)); + if (s.cwd) kv.appendChild(inspPathRow('cwd', s.cwd)); + box.appendChild(kv); + if (!isActive) { + const acts = document.createElement('div'); + acts.className = 'session-ctrl insp-actions'; + const openBtn = document.createElement('button'); + openBtn.className = 'mc adopt'; + openBtn.textContent = '⇱ open'; + openBtn.title = 'Switch to this session'; + openBtn.setAttribute('data-act', 'open-lisa'); + openBtn.setAttribute('data-lisa-id', s.id); + acts.appendChild(openBtn); + box.appendChild(acts); + } + } + + function renderAgentInspector(box, s) { + const a = s.activity || {}; + const kindName = AGENT_NAMES[s.agent] || s.agent; + const stateCls = s.state === 'done' ? 'done' : (s.state || 'unknown'); + const sub = kindName + ' · ' + s.project + (a.gitBranch ? ' · ' + a.gitBranch : ''); + box.appendChild(inspHead(agentGlyphClass(s.agent), (kindName.charAt(0) || 'A').toUpperCase(), agentLabel(s), stateCls, s.state || '?', sub)); + const stats = document.createElement('div'); + stats.className = 'stats'; + stats.appendChild(inspStat(a.turnCount != null ? String(a.turnCount) : '—', 'turns')); + stats.appendChild(inspStat(fmtTokens(a.tokens), 'tokens')); + stats.appendChild(inspStat(a.filesTouched ? String(a.filesTouched.length) : '—', 'files')); + box.appendChild(stats); + const kv = document.createElement('div'); + kv.className = 'kvrows'; + if (a.lastCommandName) kv.appendChild(inspRow('last cmd', '$ ' + a.lastCommandName)); + if (a.lastTools && a.lastTools.length) kv.appendChild(inspRow('tools', a.lastTools.join(' · '))); + if (a.filesTouched && a.filesTouched.length) { + const names = a.filesTouched.slice(-3).map(function (f) { return String(f).split('/').pop(); }); + kv.appendChild(inspRow('files', names.join(' · '))); + } + if (a.pendingPermission) kv.appendChild(inspRow('pending', '⚠ ' + a.pendingPermission, 'warn')); + if (a.lastError) kv.appendChild(inspRow('error', a.lastError, 'err')); + if (s.stateReason && !a.pendingPermission && !a.lastError) kv.appendChild(inspRow('state', s.stateReason)); + if (kv.childNodes.length) box.appendChild(kv); + // Controls — same surface the roster rows had, for the selected session: + // managed → approve/deny pending, send, cancel; pty → send, output, + // cancel; idle external claude → adopt. Observe-only agents get nothing. + const fam = s.controllable; + const id = s.sessionId; + const acts = document.createElement('div'); + acts.className = 'session-ctrl insp-actions'; + acts.setAttribute('data-fam', fam || ''); + acts.setAttribute('data-agent', s.agent); + acts.setAttribute('data-session', id); + acts.setAttribute('data-cwd', s.cwd || ''); + // F1 — every agent session gets a read-only step stream tab. + const streamBtn = document.createElement('button'); + streamBtn.className = 'mc'; + streamBtn.textContent = '▤ stream'; + streamBtn.title = 'Open the read-only step stream'; + streamBtn.setAttribute('data-act', 'stream'); + acts.appendChild(streamBtn); + if (fam === 'managed' && a.pendingPermission) { + const ap = document.createElement('button'); + ap.className = 'mc approve'; ap.textContent = '✓ approve'; + ap.setAttribute('data-act', 'approve'); + const dn = document.createElement('button'); + dn.className = 'mc deny'; dn.textContent = '✕ deny'; + dn.setAttribute('data-act', 'deny'); + acts.appendChild(ap); acts.appendChild(dn); + } else if (fam && s.state !== 'done') { + const inp = document.createElement('input'); + inp.className = 'mc-send'; inp.type = 'text'; + inp.placeholder = fam === 'pty' ? 'type into the CLI…' : 'send a follow-up…'; + acts.appendChild(inp); + } + if (fam === 'pty') { + const out = document.createElement('button'); + out.className = 'mc'; out.textContent = '▤ output'; out.title = 'View terminal output'; + out.setAttribute('data-act', 'output'); + acts.appendChild(out); + } + if (fam && s.state !== 'done') { + const cancel = document.createElement('button'); + cancel.className = 'mc cancel'; cancel.textContent = '⏹ cancel'; cancel.title = 'Cancel agent'; + cancel.setAttribute('data-act', 'cancel'); + acts.appendChild(cancel); + } + if (s.resumable) { + const adopt = document.createElement('button'); + adopt.className = 'mc adopt'; adopt.textContent = '⇲ adopt'; + adopt.title = 'Resume this session under LISA — then send / answer / cancel / view it'; + adopt.setAttribute('data-act', 'adopt'); + acts.appendChild(adopt); + } + if (acts.childNodes.length) box.appendChild(acts); + } + + async function refreshPing() { + try { + const r = await fetch('/api/island/ping'); + if (!r.ok) return; + const data = await r.json(); + setDesire(data.current_desire); + // Same for the desire-derived first starter prompt. + if (typeof window.lisaRenderChatEmpty === 'function') window.lisaRenderChatEmpty(); + if (data.last_idle_message_text) { + window.updateReflection(data.last_idle_message_text); + } + } catch {} + } + + // ── Token source + usage (right-rail bottom section) ──────────────── + // Source = the selected coding plan (plan://…) else the configured API + // key; usage = the local billing ledger's today/12h aggregates plus any + // per-plan window stats the plan detector reports. + function fmtUsd(microUSD) { + if (!microUSD) return '$0'; + const usd = microUSD / 1e6; + return usd >= 1 ? '$' + usd.toFixed(2) : '$' + usd.toFixed(3); + } + function fmtCount(n) { + if (!n) return '0'; + return n >= 1000 ? Math.round(n / 1000) + 'k' : String(n); + } + async function refreshTokens() { + const statsEl = document.getElementById('sbTokenStats'); + const rowsEl = document.getElementById('sbTokenRows'); + const modelEl = document.getElementById('sbTokenModel'); + if (!statsEl || !rowsEl || !modelEl) return; + let model = ''; + let usage = null; + let plansData = null; + try { + const r = await fetch('/session'); + if (r.ok) { const d = await r.json(); model = d.model || ''; } + } catch (e) {} + try { + const r = await fetch('/api/billing/usage'); + if (r.ok) usage = await r.json(); + } catch (e) {} + try { + const r = await fetch('/api/plans'); + if (r.ok) plansData = await r.json(); + } catch (e) {} + modelEl.textContent = model; + modelEl.title = model; + statsEl.innerHTML = ''; + rowsEl.innerHTML = ''; + const today = usage && usage.today ? usage.today : null; + const win = usage && usage.window12h ? usage.window12h : null; + if (today) { + statsEl.appendChild(inspStat(fmtCount((today.inputTokens || 0) + (today.outputTokens || 0)), 'tokens')); + statsEl.appendChild(inspStat(String(today.turns || 0), 'turns')); + statsEl.appendChild(inspStat(fmtUsd(today.microUSD), 'today')); + } + let source = 'API key'; + const plans = plansData && Array.isArray(plansData.plans) ? plansData.plans : []; + for (let i = 0; i < plans.length; i++) { + if (plans[i].selected) { source = String(plans[i].label || plans[i].id); break; } + } + rowsEl.appendChild(inspRow('source', source)); + if (win) { + rowsEl.appendChild(inspRow('12h window', fmtCount((win.inputTokens || 0) + (win.outputTokens || 0)) + ' tok · ' + fmtUsd(win.microUSD))); + } + plans.forEach(function (p) { + if (p && p.available && p.usage) rowsEl.appendChild(inspRow(String(p.id || 'plan'), String(p.usage))); + }); + } + window.refreshTokens = refreshTokens; + + // Exposed so the SSE handler above can call this on + // agent_session_update events without redeclaring the helper. D4a — the + // multi-agent snapshot (all agents), not just Claude Code. + window.refreshClaudeSessions = async function () { + try { + const r = await fetch('/api/agents/sessions'); + if (!r.ok) return; + const data = await r.json(); + setClaudeSessions(data.sessions || []); + } catch {} + }; + + // "Delegate a task" → open a modal to pick the agent kind + write the task. + // (A roomy dialog beats the cramped 280px sidebar.) managed = LISA-run + // (controllable); claude/codex = a real CLI under a PTY (needs LISA_PTY_AGENTS=1 + // — a 503/error surfaces inline in the dialog). + function openDelegateModal() { + openModal( + 'Delegate a task', + '
' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '
' + ); + const kindEl = document.getElementById('dmKind'); + const taskEl = document.getElementById('dmTask'); + const startEl = document.getElementById('dmStart'); + const errEl = document.getElementById('dmErr'); + if (taskEl) taskEl.focus(); + function submitDelegate() { + const task = taskEl && taskEl.value.trim(); + if (!task) { if (taskEl) taskEl.focus(); return; } + const kind = kindEl ? kindEl.value : 'managed'; + const url = kind === 'managed' ? '/api/agents/managed/start' : '/api/agents/pty/start'; + const body = kind === 'managed' ? { task: task } : { agent: kind, task: task }; + if (errEl) errEl.textContent = ''; + if (startEl) { startEl.disabled = true; startEl.textContent = 'Starting…'; } + fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }).then(function (r) { + if (!r.ok) { + return r.text().then(function (t) { + if (errEl) errEl.textContent = t || ('failed (' + r.status + ')'); + if (startEl) { startEl.disabled = false; startEl.textContent = 'Start agent →'; } + }); + } + closeModal(); + if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions(); + }).catch(function () { + if (errEl) errEl.textContent = 'network error'; + if (startEl) { startEl.disabled = false; startEl.textContent = 'Start agent →'; } + }); + } + if (startEl) startEl.addEventListener('click', submitDelegate); + if (taskEl) taskEl.addEventListener('keydown', function (e) { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); submitDelegate(); } + }); + } + const sbDelegateBtn = document.getElementById('sbDelegateBtn'); + if (sbDelegateBtn) sbDelegateBtn.addEventListener('click', openDelegateModal); + // Exposed so the console Dashboard / Control views reuse the same dialog. + window.lisaOpenDelegate = openDelegateModal; + + async function refreshIdentity() { + try { + const r = await fetch('/api/soul'); + if (!r.ok) return; + const data = await r.json(); + if (!data.born) return; + const bornAt = data.summary?.seed?.bornAt; + if (!bornAt) return; + const born = new Date(bornAt); + if (Number.isNaN(born.getTime())) return; + const days = Math.max(0, Math.floor((Date.now() - born.getTime()) / 86400000)); + const ymd = born.toISOString().slice(0, 10); + identitySub.textContent = 'born ' + ymd + ' · ' + days + ' day' + (days === 1 ? '' : 's'); + // The empty-chat card quotes this line; it renders before /api/soul + // lands, so repaint it once the identity is known. + if (typeof window.lisaRenderChatEmpty === 'function') window.lisaRenderChatEmpty(); + } catch {} + } + + // ── Session tree + tab strip (PLAN_UI_SESSION_SHELL_v1.0 §3.1) ────── + // Lisa's own sessions as the first root group of the sidebar tree; the + // monitored-agent groups join in the control-tree phase. Open tabs are a + // client-side notion (which sessions you keep at hand), persisted in + // localStorage; the tree is the full on-disk list. + let cachedSessions = []; + // (确认轮二) Multi-tab strip removed: creating/switching lives in the + // sidebar tree only, and the strip renders a single CONTEXT CHIP for + // what the main pane is showing — the active Lisa session, or the + // observed agent while the stream pane is open. + function sessionLabel(s) { + // F2 auto-naming: the FIRST user message is the session's name (the + // opening request describes the task); fall back to the latest one. + const t = (s && (s.firstUserMessage || s.lastUserMessage) ? (s.firstUserMessage || s.lastUserMessage) : '').trim(); + if (t) return t.length > 30 ? t.slice(0, 30) + '…' : t; + // UX-4: a session with nothing in it yet used to be labelled with its raw + // id (20260905-220846-9f7d58) in the tree, the context chip, the title bar + // and the inspector — four places showing a string no human reads. It is + // "New session · 2m" now; the id survives as the tooltip / inspector sub. + if (s && !s.messageCount) return tr('session.new') + ' · ' + relativeTime(s.startedAt); + return s ? s.id : ''; + } + // The title bar is rendered outside this closure (setActiveSessionUI runs + // before /api/sessions lands), so it asks for the label through here and + // falls back to the raw id while the list is still in flight. + window.lisaSessionLabel = function (id) { + const s = sessionById(id); + return s ? sessionLabel(s) : ''; + }; + // ⌘K switcher (UX-11) lives at top level; hand it pre-rendered rows so it + // does not need sessionLabel / relativeTime / cachedSessions itself. + window.lisaSessionsForSwitcher = function () { + return cachedSessions.map(function (s) { + return { + id: s.id, + label: sessionLabel(s), + meta: String(s.messageCount || 0) + ' msgs · ' + relativeTime(s.startedAt), + active: s.id === window.lisaActiveSessionId, + }; + }); + }; + window.lisaSwitchSession = switchSession; + window.lisaNewSession = newSession; + function sessionById(id) { + for (let i = 0; i < cachedSessions.length; i++) { + if (cachedSessions[i].id === id) return cachedSessions[i]; + } + return null; + } + + // e2e 加固: switch/new used to swallow failures (empty catch, no finally) + // — one hung POST left the switching flag stuck forever and every later + // +New / tree click silently no-oped. Now: 8s timeout, finally-reset, a + // visible console error, and the composer LOCKS during the swap so a + // message can never be typed into a session that's about to be switched + // away (the "sent but nothing happened" race). + let switching = false; + // The keyboard hint does not fit a phone composer: at 375px the textarea is + // ~160px wide, so "Talk to Lisa… (Enter to send · Shift+Enter for newline)" + // wraps and gets clipped mid-word. The hint is also useless there — a soft + // keyboard has no Shift+Enter. Narrow screens get the short form; the + // matchMedia listener keeps it right across rotation and window resizes. + const composerNarrow = window.matchMedia('(max-width: 720px)'); + function composerHint() { + return composerNarrow.matches + ? 'Talk to Lisa…' + : 'Talk to Lisa… (Enter to send · Shift+Enter for newline)'; + } + function setComposerLocked(on) { + try { + input.disabled = on; + sendBtn.disabled = on; + input.placeholder = on ? 'switching session…' : composerHint(); + } catch (e) {} + } + try { + input.placeholder = composerHint(); + composerNarrow.addEventListener('change', function () { + if (!input.disabled) input.placeholder = composerHint(); + }); + } catch (e) {} + async function postSessionMutation(url) { + const ctrl = new AbortController(); + const timer = setTimeout(function () { ctrl.abort(); }, 8000); + try { + const r = await fetch(url, { method: 'POST', signal: ctrl.signal }); + return await r.json(); + } finally { + clearTimeout(timer); + } + } + async function switchSession(id) { + if (switching || !id || id === window.lisaActiveSessionId) return; + switching = true; + document.body.classList.add('session-switching'); + setComposerLocked(true); + try { + const data = await postSessionMutation('/api/sessions/' + encodeURIComponent(id) + '/activate'); + if (data && data.ok && typeof window.lisaSetActiveSession === 'function') { + window.lisaSetActiveSession(data.id); + } + } catch (e) { + console.error('[sessions] switch failed:', e); + } finally { + document.body.classList.remove('session-switching'); + setComposerLocked(false); + switching = false; + refreshSessionsBadge(); + } + } + async function newSession() { + if (switching) return; + switching = true; + closeStreamView(); + document.body.classList.add('session-switching'); + setComposerLocked(true); + try { + const data = await postSessionMutation('/api/sessions'); + if (data && data.ok && typeof window.lisaSetActiveSession === 'function') { + window.lisaSetActiveSession(data.id); + } + } catch (e) { + console.error('[sessions] new session failed:', e); + } finally { + document.body.classList.remove('session-switching'); + setComposerLocked(false); + switching = false; + refreshSessionsBadge(); + } + } + + function renderSessionTree() { + const tree = document.getElementById('sessionTree'); + if (!tree) return; + // Rendering with no Lisa sessions means the list fetch hasn't landed or + // failed — kick it (in-flight guard + retry make this loop-safe). + if (!cachedSessions.length) refreshSessionsBadge(); + tree.innerHTML = ''; + if (treeMode === 'project') { buildProjectTree(tree); return; } + const group = document.createElement('div'); + group.className = 'tgroup'; + applyCollapsed(group, 'lisa'); + const root = document.createElement('button'); + root.type = 'button'; + root.className = 'tnode'; + root.innerHTML = 'LLISA'; + root.querySelector('.tcount').textContent = String(cachedSessions.length); + root.setAttribute('data-toggle', 'lisa'); + group.appendChild(root); + const kids = document.createElement('div'); + kids.className = 'tchildren'; + const MAX_LEAVES = 14; + cachedSessions.slice(0, MAX_LEAVES).forEach(function (s) { + kids.appendChild(makeLisaLeaf(s, false)); + }); + group.appendChild(kids); + tree.appendChild(group); + renderAgentTree(); + } + + // Single context chip: what the main pane is showing right now — the + // active Lisa session, or the observed agent while the stream pane is + // open (with an × back to chat). NOT a switcher: creation and switching + // live in the sidebar tree only (确认轮二). + function renderTabs() { + const strip = document.getElementById('tabStrip'); + if (!strip) return; + strip.innerHTML = ''; + const chip = document.createElement('div'); + chip.className = 'ctx-chip'; + if (currentAgentTab) { + chip.classList.add('agent'); + chip.innerHTML = 'observing'; + const s = agentByKey(currentAgentTab.agent + '/' + currentAgentTab.id); + const kindName = AGENT_NAMES[currentAgentTab.agent] || currentAgentTab.agent; + const g = chip.querySelector('.agent-glyph'); + g.classList.add(agentGlyphClass(currentAgentTab.agent)); + g.textContent = (kindName.charAt(0) || 'A').toUpperCase(); + const st = s ? (s.state || 'unknown') : 'unknown'; + if (st === 'working' || st === 'waiting' || st === 'error') chip.querySelector('.pip').classList.add(st); + chip.querySelector('.ctx-name').textContent = s ? agentLabel(s) : (currentAgentTab.label || currentAgentTab.id); + chip.querySelector('.ctx-x').setAttribute('data-act', 'close-stream'); + } else { + chip.innerHTML = ''; + const s = sessionById(window.lisaActiveSessionId); + chip.querySelector('.ctx-name').textContent = s ? sessionLabel(s) : (window.lisaActiveSessionId || '—'); + chip.querySelector('.ctx-meta').textContent = s ? String(s.messageCount || 0) + ' msgs' : ''; + // The raw id is demoted to the tooltip now that the name can be + // "New session · 2m" (UX-4). + chip.title = window.lisaActiveSessionId || ''; + } + strip.appendChild(chip); + } + + // ── F1: read-only agent stream tab ───────────────────────────────── + // An agent tab swaps the chat surface for a structural step timeline + // (GET /api/agents/steps; PTY sessions stream their terminal tail over + // the existing SSE endpoint instead). 4s visibility-gated polling, plus + // refresh on agent_session_update via setClaudeSessions. + let currentAgentTab = null; + let streamTimer = null; + let ptyStream = null; + window.lisaOpenAgentStream = function (s) { + openStreamTab({ t: 'agent', agent: s.agent, id: s.sessionId, label: agentLabel(s) }); + }; + function openStreamTab(entry) { + if (ptyStream) { ptyStream.close(); ptyStream = null; } + currentAgentTab = entry; + document.body.classList.add('agent-tab-active'); + if (window.lisaShowView) window.lisaShowView('chat'); + renderSessionUI(); + renderStreamView(); + if (streamTimer) clearInterval(streamTimer); + streamTimer = setInterval(function () { + if (currentAgentTab && !document.hidden) refreshStreamSteps(); + }, 4000); + } + function closeStreamView() { + if (!currentAgentTab) return; + currentAgentTab = null; + document.body.classList.remove('agent-tab-active'); + if (streamTimer) { clearInterval(streamTimer); streamTimer = null; } + if (ptyStream) { ptyStream.close(); ptyStream = null; } + } + function streamSession() { + return currentAgentTab ? agentByKey(currentAgentTab.agent + '/' + currentAgentTab.id) : null; + } + function renderStreamView() { + if (!currentAgentTab) return; + const head = document.getElementById('asHead'); + const perm = document.getElementById('asPerm'); + const foot = document.getElementById('asFoot'); + if (!head || !perm || !foot) return; + const s = streamSession(); + head.innerHTML = ''; + const kindName = AGENT_NAMES[currentAgentTab.agent] || currentAgentTab.agent; + const g = document.createElement('span'); + g.className = 'agent-glyph ' + agentGlyphClass(currentAgentTab.agent); + g.textContent = (kindName.charAt(0) || 'A').toUpperCase(); + head.appendChild(g); + const nameEl = document.createElement('b'); + nameEl.textContent = s ? agentLabel(s) : (currentAgentTab.label || currentAgentTab.id); + head.appendChild(nameEl); + const meta = document.createElement('span'); + meta.className = 'meta'; + if (s) { + const a = s.activity || {}; + const bits = [kindName, s.project]; + if (a.gitBranch) bits.push(a.gitBranch); + if (a.turnCount != null) bits.push(a.turnCount + ' turns'); + if (a.tokens) bits.push(fmtTokens(a.tokens) + ' tok'); + meta.textContent = bits.join(' · '); + } else { + meta.textContent = kindName + ' · (outside the active window)'; + } + head.appendChild(meta); + const badge = document.createElement('span'); + badge.className = 'ro-badge'; + badge.textContent = s && s.controllable ? s.controllable : 'observing'; + head.appendChild(badge); + perm.style.display = 'none'; + perm.innerHTML = ''; + if (s && s.controllable === 'managed' && s.activity && s.activity.pendingPermission) { + perm.style.display = ''; + const label = document.createElement('span'); + label.textContent = '⚠ pending'; + perm.appendChild(label); + const code = document.createElement('code'); + code.textContent = s.activity.pendingPermission; + perm.appendChild(code); + const grow = document.createElement('span'); + grow.className = 'grow'; + perm.appendChild(grow); + perm.setAttribute('data-session', s.sessionId); + const ap = document.createElement('button'); + ap.className = 'mc approve'; ap.textContent = '✓ approve'; + ap.setAttribute('data-act', 'approve'); + const dn = document.createElement('button'); + dn.className = 'mc deny'; dn.textContent = '✕ deny'; + dn.setAttribute('data-act', 'deny'); + perm.appendChild(ap); + perm.appendChild(dn); + } + foot.innerHTML = ''; + if (s && s.controllable && s.state !== 'done') { + foot.setAttribute('data-fam', s.controllable); + foot.setAttribute('data-session', s.sessionId); + const inp = document.createElement('input'); + inp.type = 'text'; + inp.className = 'mc-send'; + inp.placeholder = s.controllable === 'pty' ? 'type into the CLI…' : 'send a follow-up…'; + foot.appendChild(inp); + const cancel = document.createElement('button'); + cancel.className = 'mc cancel'; cancel.textContent = '⏹ cancel'; + cancel.setAttribute('data-act', 'cancel'); + foot.appendChild(cancel); + } else if (s && s.resumable) { + const note = document.createElement('span'); + note.style.cssText = 'font-size:11.5px;color:var(--fg-3);align-self:center;'; + note.textContent = 'observe-only — adopt it from the inspector to take control'; + foot.appendChild(note); + } + refreshStreamSteps(); + } + function refreshStreamSteps() { + if (!currentAgentTab) return; + const stepsEl = document.getElementById('asSteps'); + if (!stepsEl) return; + const s = streamSession(); + // PTY sessions: live terminal tail over the existing SSE endpoint. + if (s && s.controllable === 'pty') { + if (!ptyStream) { + stepsEl.innerHTML = ''; + const pre = document.createElement('pre'); + pre.className = 'pty-tail'; + stepsEl.appendChild(pre); + ptyStream = new EventSource('/api/agents/pty/' + encodeURIComponent(s.sessionId) + '/stream'); + ptyStream.onmessage = function (e) { + try { + const ev = JSON.parse(e.data); + if (ev.type === 'snapshot') pre.textContent = ev.text || ''; + else if (ev.type === 'chunk') pre.textContent += ev.text || ''; + else if (ev.type === 'end') { if (ptyStream) { ptyStream.close(); ptyStream = null; } } + stepsEl.scrollTop = stepsEl.scrollHeight; + } catch (err) {} + }; + ptyStream.onerror = function () { if (ptyStream) { ptyStream.close(); ptyStream = null; } }; + } + return; + } + // 确认轮三: prefer the full local transcript (user/assistant text + + // structural tool markers — loopback-only endpoint); fall back to the + // structural steps when it comes back empty (remote access, other + // agent kinds, or a quiet tail). + const q = 'agent=' + encodeURIComponent(currentAgentTab.agent) + '&id=' + encodeURIComponent(currentAgentTab.id); + fetch('/api/agents/transcript?' + q) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (!currentAgentTab) return null; + if (d && d.entries && d.entries.length) { + renderTranscript(stepsEl, d.entries); + return null; + } + return fetch('/api/agents/steps?' + q) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (sd) { + if (!sd || !currentAgentTab) return; + renderSteps(stepsEl, sd.steps || []); + }); + }) + .catch(function () {}); + } + function renderTranscript(stepsEl, entries) { + const atBottom = stepsEl.scrollHeight - stepsEl.scrollTop - stepsEl.clientHeight < 60; + stepsEl.innerHTML = ''; + entries.forEach(function (en) { + if (en.kind === 'tool') { + const row = document.createElement('div'); + row.className = 'srow' + (en.isError ? ' err' : ''); + row.innerHTML = ''; + row.querySelector('.sic').textContent = stepIcon(en.tool); + row.children[1].textContent = en.tool || ''; + row.querySelector('.sdetail').textContent = en.target || ''; + if (en.ts) row.querySelector('.stime').textContent = relativeTime(en.ts); + stepsEl.appendChild(row); + return; + } + const msg = document.createElement('div'); + msg.className = 'as-msg ' + (en.kind === 'user' ? 'user' : 'assistant'); + const role = document.createElement('div'); + role.className = 'as-role'; + role.textContent = en.kind === 'user' ? 'USER' : 'AGENT'; + if (en.ts) { + const t = document.createElement('span'); + t.className = 'stime'; + t.textContent = relativeTime(en.ts); + role.appendChild(t); + } + msg.appendChild(role); + const body = document.createElement('div'); + body.className = 'as-text'; + // Agent replies are Markdown → render; the user's own text as-is + // (same rule as Lisa's chat history). + if (en.kind === 'assistant' && typeof renderMarkdown === 'function') body.innerHTML = renderMarkdown(en.text || ''); + else body.textContent = en.text || ''; + msg.appendChild(body); + stepsEl.appendChild(msg); + }); + if (atBottom) stepsEl.scrollTop = stepsEl.scrollHeight; + } + function stepIcon(tool) { + const t = (tool || '').toLowerCase(); + if (t === 'read') return '📖'; + if (t === 'edit' || t === 'write' || t === 'notebookedit') return '✏️'; + if (t === 'bash') return '🖥'; + if (t === 'grep' || t === 'glob') return '🔎'; + return '⚙'; + } + function renderSteps(stepsEl, steps) { + const atBottom = stepsEl.scrollHeight - stepsEl.scrollTop - stepsEl.clientHeight < 60; + stepsEl.innerHTML = ''; + if (!steps.length) { + const empty = document.createElement('div'); + empty.className = 'session-empty'; + empty.textContent = '(no structural steps in the recent tail — metadata-only visibility, or a quiet session)'; + stepsEl.appendChild(empty); + return; + } + steps.forEach(function (st) { + const row = document.createElement('div'); + if (st.kind === 'user') { + row.className = 'srow turn'; + row.innerHTML = '💬'; + row.children[1].textContent = 'turn ' + st.turn; + } else if (st.kind === 'assistant') { + row.className = 'srow'; + row.innerHTML = '💭reply'; + } else { + row.className = 'srow' + (st.isError ? ' err' : ''); + row.innerHTML = ''; + row.querySelector('.sic').textContent = stepIcon(st.tool); + row.children[1].textContent = st.tool || ''; + row.querySelector('.sdetail').textContent = st.target || ''; + } + if (st.ts) row.querySelector('.stime').textContent = relativeTime(st.ts); + stepsEl.appendChild(row); + }); + if (atBottom) stepsEl.scrollTop = stepsEl.scrollHeight; + } + + function renderSessionUI() { + renderSessionTree(); + renderTabs(); + // The label depends on cachedSessions, which lands after the title bar's + // first paint — refresh it on every list update. + if (typeof window.lisaUpdateTitlebar === 'function') window.lisaUpdateTitlebar(); + if (typeof window.lisaRenderChatEmpty === 'function') window.lisaRenderChatEmpty(); + } + window.lisaRenderSessionTree = renderSessionUI; + + // F6 — unread marks for sessions whose reply finished in the background. + const unreadSessions = {}; + window.lisaMarkUnread = function (id) { + if (!id) return; + unreadSessions[id] = true; + renderSessionUI(); + }; + window.lisaClearUnread = function (id) { + if (id && unreadSessions[id]) { + delete unreadSessions[id]; + renderSessionUI(); + } + }; + window.lisaIsUnread = function (id) { return !!unreadSessions[id]; }; + + // ── F5: focus an agent session from outside (island SSE / #agent= hash) ── + window.lisaFocusAgent = function (agent, id) { + if (!agent || !id) return; + selInsp = { type: 'agent', key: agent + '/' + id }; + if (window.lisaShowView) window.lisaShowView('chat'); + renderInspector(); + renderSessionUI(); + }; + let agentHashHandled = false; + function handleAgentHash() { + const h = (location.hash || '').replace('#', ''); + if (h.indexOf('agent=') !== 0) return; + const parts = decodeURIComponent(h.slice(6)).split('/'); + if (parts.length >= 2) window.lisaFocusAgent(parts[0], parts.slice(1).join('/')); + } + window.addEventListener('hashchange', handleAgentHash); + window.lisaHandleAgentHashOnce = function () { + if (agentHashHandled) return; + agentHashHandled = true; + handleAgentHash(); + }; + const sbNewBtn = document.getElementById('sbNewSession'); + if (sbNewBtn) sbNewBtn.addEventListener('click', newSession); + + // e2e 加固: a failed/slow session-list fetch used to stay broken for the + // whole 5-minute interval while the roster SSE kept rebuilding the tree — + // Lisa's sessions "vanished" until the next tick. Now: in-flight guard + + // 5s retry, and renderSessionTree triggers a fetch whenever it finds + // itself rendering with an empty list. + let sessionsFetchInFlight = false; + let sessionsRetryTimer = null; + async function refreshSessionsBadge() { + if (sessionsFetchInFlight) return; + sessionsFetchInFlight = true; + try { + const r = await fetch('/api/sessions'); + if (!r.ok) throw new Error('http ' + r.status); + const data = await r.json(); + cachedSessions = Array.isArray(data.sessions) ? data.sessions : []; + sbSessionBadge.textContent = String(cachedSessions.length); + renderSessionUI(); + // The inspector's default target is the active Lisa session — it can + // only resolve once this list (and /session) has landed, so re-render + // here too or a fresh page shows "(idle)" until the next roster tick. + renderInspector(); + } catch (e) { + if (sessionsRetryTimer) clearTimeout(sessionsRetryTimer); + sessionsRetryTimer = setTimeout(function () { + sessionsRetryTimer = null; + refreshSessionsBadge(); + }, 5000); + } finally { + sessionsFetchInFlight = false; + } + } + + // ── Mail card: connect a mailbox + show the daily classified digest ── + function renderMail(accounts, digest) { + const body = document.getElementById('sbMailBody'); + const count = document.getElementById('sbMailCount'); + const connectBtn = document.getElementById('sbMailConnectBtn'); + if (!body) return; + while (body.firstChild) body.removeChild(body.firstChild); + const hasAccounts = accounts && accounts.length > 0; + // 确认轮二: an unconnected mail section is pure noise — hide the whole + // card until a mailbox exists (the fnbar Mail button stays the entry + // point for connecting one). + const mailCard = document.getElementById('sbMailCard'); + if (mailCard) mailCard.style.display = hasAccounts ? '' : 'none'; + if (connectBtn) connectBtn.textContent = hasAccounts ? '+ add mailbox' : '+ connect mailbox'; + if (!hasAccounts) { + const empty = document.createElement('div'); + empty.className = 'session-empty'; + empty.textContent = '(not connected)'; + body.appendChild(empty); + if (count) count.textContent = ''; + return; + } + const sum = document.createElement('div'); + sum.className = 'mail-summary'; + sum.textContent = digest && digest.summary ? digest.summary : 'No digest yet — sweep to build one.'; + body.appendChild(sum); + const needs = digest && digest.needsYou ? digest.needsYou : []; + if (count) count.textContent = needs.length ? ('✦ ' + needs.length) : ''; + for (const i of needs.slice(0, 5)) { + const row = document.createElement('div'); + row.className = 'mail-row'; + const bang = document.createElement('span'); + bang.className = 'mail-bang' + (i.importance >= 3 ? ' urgent' : ''); + bang.textContent = i.importance >= 3 ? '‼' : '!'; + const subj = document.createElement('span'); + subj.className = 'mail-subj'; + subj.textContent = i.subject || '(no subject)'; + subj.title = (i.from || '') + ' — ' + (i.reason || ''); + row.appendChild(bang); + row.appendChild(subj); + body.appendChild(row); + } + const sweep = document.createElement('button'); + sweep.className = 'mail-sweep'; + sweep.type = 'button'; + sweep.textContent = 'sweep now'; + sweep.addEventListener('click', function () { + sweep.disabled = true; sweep.textContent = 'sweeping…'; + fetch('/api/mail/sweep', { method: 'POST' }).then(function () { + if (window.refreshMail) window.refreshMail(); + }).catch(function () {}).then(function () { sweep.disabled = false; sweep.textContent = 'sweep now'; }); + }); + body.appendChild(sweep); + } + + window.refreshMail = async function () { + try { + const a = await fetch('/api/mail/accounts').then(function (r) { return r.ok ? r.json() : null; }); + const d = await fetch('/api/mail/digest').then(function (r) { return r.ok ? r.json() : null; }); + const accounts = a ? a.accounts : []; + const digest = d ? d.digest : null; + renderMail(accounts, digest); + // Nav 九宫格 Mail tile badge = "needs you" count (blank when none). + const needs = digest && digest.needsYou ? digest.needsYou : []; + const nb = document.getElementById('navMailCount'); + if (nb) nb.textContent = needs.length ? String(needs.length) : ''; + // Keep an open Mail rail view in sync. + if (typeof window.lisaMailViewRender === 'function') window.lisaMailViewRender(accounts, digest); + } catch (e) {} + }; + + // Guided connect: pick a provider → see exactly where to get an app-password + // (a link that opens in the system browser), with labels + host tuned to it. + // Most people do not know an IMAP mailbox needs an app-password, not their + // login password — the steps + link are the whole point of this modal. + const MAIL_PROVIDERS = [ + { key: 'gmail', label: 'Gmail', domains: ['gmail.com', 'googlemail.com'], + emailPh: 'you@gmail.com', hostPh: 'imap.gmail.com', + credLabel: 'App password', credPh: '16-character app password', + linkUrl: 'https://myaccount.google.com/apppasswords', linkText: 'Open Google App Passwords ↗', + steps: [ + 'Turn on 2-Step Verification for your Google account (app passwords need it).', + 'Open Google App Passwords below and create one — name it Lisa.', + 'Paste the 16-character code below. Your Google login password will not work.', + ] }, + { key: 'icloud', label: 'iCloud', domains: ['icloud.com', 'me.com', 'mac.com'], + emailPh: 'you@icloud.com', hostPh: 'imap.mail.me.com', + credLabel: 'App-specific password', credPh: 'xxxx-xxxx-xxxx-xxxx', + linkUrl: 'https://account.apple.com', linkText: 'Open Apple Account ↗', + steps: [ + 'Open Apple Account below and go to Sign-In and Security.', + 'Under App-Specific Passwords, generate one for Lisa.', + 'Paste it below. Your Apple ID login password will not work.', + ] }, + { key: 'qq', label: 'QQ', domains: ['qq.com', 'foxmail.com'], + emailPh: 'you@qq.com', hostPh: 'imap.qq.com', + credLabel: 'Authorization code 授权码', credPh: 'IMAP authorization code', + linkUrl: 'https://mail.qq.com', linkText: 'Open QQ Mail ↗', + steps: [ + 'In QQ Mail open 设置 → 账户 and find POP3/IMAP/SMTP 服务.', + 'Enable IMAP 服务; QQ shows a 16-char authorization code (授权码).', + 'Paste that 授权码 below, not your QQ login password.', + ] }, + { key: 'netease', label: '163 / 126', domains: ['163.com', '126.com', 'yeah.net'], + emailPh: 'you@163.com', hostPh: 'imap.163.com', + credLabel: 'Authorization code 授权码', credPh: 'IMAP authorization code', + linkUrl: 'https://mail.163.com', linkText: 'Open 163 Mail ↗', + steps: [ + 'In 163 Mail open 设置 → POP3/SMTP/IMAP.', + 'Enable IMAP 服务 and set a client authorization code (授权码).', + 'Paste that 授权码 below, not your login password.', + ] }, + { key: 'outlook', label: 'Outlook', domains: ['outlook.com', 'hotmail.com', 'live.com', 'msn.com'], + emailPh: 'you@outlook.com', hostPh: 'outlook.office365.com', + credLabel: 'App password', credPh: 'app password', + linkUrl: 'https://account.microsoft.com/security', linkText: 'Open Microsoft security ↗', + steps: [ + 'Turn on two-step verification for your Microsoft account.', + 'Under Advanced security options, create an app password.', + 'Paste it below. Some Microsoft accounts block IMAP; then this will not work.', + ] }, + { key: 'other', label: 'Other', domains: [], + emailPh: 'you@example.com', hostPh: 'imap.example.com', + credLabel: 'App-password / authorization code', credPh: 'not your login password', + linkUrl: '', linkText: '', + steps: [ + 'Most providers need an app-password or authorization code, not your login password.', + 'Find it in the security or IMAP settings of your mail provider.', + 'If the IMAP host is not auto-detected, fill it in below.', + ] }, + ]; + + function openMailModal() { + function providerByKey(k) { + for (let i = 0; i < MAIL_PROVIDERS.length; i++) if (MAIL_PROVIDERS[i].key === k) return MAIL_PROVIDERS[i]; + return MAIL_PROVIDERS[MAIL_PROVIDERS.length - 1]; + } + let currentKey = 'gmail'; + openModal('Connect a mailbox', + '
' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '
Read-only. Stored locally (mode 0600). Lisa reads only headers and a short preview — never full message bodies, and never sends mail.
' + + '
'); + const emailEl = document.getElementById('mmEmail'); + const passEl = document.getElementById('mmPass'); + const passLabelEl = document.getElementById('mmPassLabel'); + const hostEl = document.getElementById('mmHost'); + const startEl = document.getElementById('mmStart'); + const errEl = document.getElementById('mmErr'); + const provWrap = document.getElementById('mmProviders'); + const helpEl = document.getElementById('mmHelp'); + + function renderHelp(p) { + if (!helpEl) return; + while (helpEl.firstChild) helpEl.removeChild(helpEl.firstChild); + const ol = document.createElement('ol'); + ol.className = 'mm-steps'; + for (let i = 0; i < p.steps.length; i++) { + const li = document.createElement('li'); + li.textContent = p.steps[i]; + ol.appendChild(li); + } + helpEl.appendChild(ol); + if (p.linkUrl) { + const a = document.createElement('a'); + a.className = 'mm-link'; + a.href = p.linkUrl; a.target = '_blank'; a.rel = 'noopener'; + a.textContent = p.linkText; + helpEl.appendChild(a); + } + } + function selectProvider(key) { + const p = providerByKey(key); + currentKey = p.key; + if (provWrap) { + const chips = provWrap.querySelectorAll('.mm-chip'); + for (let i = 0; i < chips.length; i++) { + if (chips[i].getAttribute('data-key') === p.key) chips[i].classList.add('on'); + else chips[i].classList.remove('on'); + } + } + if (emailEl) emailEl.placeholder = p.emailPh; + if (hostEl) hostEl.placeholder = p.hostPh; + if (passLabelEl) passLabelEl.textContent = p.credLabel; + if (passEl) passEl.placeholder = p.credPh; + renderHelp(p); + } + if (provWrap) { + MAIL_PROVIDERS.forEach(function (p) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'mm-chip' + (p.key === currentKey ? ' on' : ''); + b.textContent = p.label; + b.setAttribute('data-key', p.key); + b.addEventListener('click', function () { selectProvider(p.key); if (emailEl) emailEl.focus(); }); + provWrap.appendChild(b); + }); + } + function detectFromEmail() { + const v = (emailEl && emailEl.value ? emailEl.value : '').toLowerCase().trim(); + const at = v.indexOf('@'); + if (at < 0) return; + const dom = v.slice(at + 1); + if (!dom) return; + for (let i = 0; i < MAIL_PROVIDERS.length; i++) { + if (MAIL_PROVIDERS[i].domains.indexOf(dom) >= 0) { + if (MAIL_PROVIDERS[i].key !== currentKey) selectProvider(MAIL_PROVIDERS[i].key); + return; + } + } + } + if (emailEl) emailEl.addEventListener('input', detectFromEmail); + selectProvider(currentKey); + if (emailEl) emailEl.focus(); + + function submitMail() { + const email = emailEl && emailEl.value.trim(); + const pass = passEl && passEl.value; + if (!email || !pass) { if (errEl) errEl.textContent = 'Email and app-password are required.'; return; } + const body = { email: email, password: pass }; + if (hostEl && hostEl.value.trim()) body.host = hostEl.value.trim(); + if (errEl) errEl.textContent = ''; + if (startEl) { startEl.disabled = true; startEl.textContent = 'Checking…'; } + fetch('/api/mail/connect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) + .then(function (r) { + if (!r.ok) { return r.text().then(function (t) { if (errEl) errEl.textContent = t || ('failed (' + r.status + ')'); if (startEl) { startEl.disabled = false; startEl.textContent = 'Connect →'; } }); } + closeModal(); + if (window.refreshMail) window.refreshMail(); + }) + .catch(function () { if (errEl) errEl.textContent = 'Network error.'; if (startEl) { startEl.disabled = false; startEl.textContent = 'Connect →'; } }); + } + if (startEl) startEl.addEventListener('click', submitMail); + if (passEl) passEl.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); submitMail(); } }); + } + // Exposed so the Mail rail view (setupConsole/loadMail) reuses the same + // connect-mailbox modal instead of duplicating it. + window.lisaOpenMailModal = openMailModal; + var sbMailConnectBtn = document.getElementById('sbMailConnectBtn'); + if (sbMailConnectBtn) sbMailConnectBtn.addEventListener('click', openMailModal); + // The Mail nav tile was removed to lock the launcher to a clean 3x3; the + // sidebar Mail card's header is now the entry into the full Mail view. + var sbMailHead = document.querySelector('#sbMailCard .h'); + if (sbMailHead) { + sbMailHead.style.cursor = 'pointer'; + sbMailHead.title = 'Open Mail'; + sbMailHead.addEventListener('click', function () { + if (typeof window.lisaShowView === 'function') window.lisaShowView('mail'); + }); + } + + // ── Delegated wiring (真实E2E修复) ─────────────────────────────────── + // The tree / needs-you / inspector / stream controls REBUILD on every + // roster tick (SSE fires every few seconds while agents are active), so + // per-node listeners kept landing real clicks on freshly-replaced dead + // elements — "I clicked and nothing happened". One listener per STABLE + // container; nodes carry data-* attributes. + function selectAgentAndStream(agent, sid) { + selInsp = { type: 'agent', key: agent + '/' + sid }; + renderInspector(); + const found = agentByKey(agent + '/' + sid); + openStreamTab({ t: 'agent', agent: agent, id: sid, label: found ? agentLabel(found) : sid }); + } + function runDelegatedAction(act, fam, agent, sid, cwd, sendText) { + if (act === 'stream') { selectAgentAndStream(agent, sid); return; } + if (act === 'approve') { agentAction('managed', sid, 'approve', { allow: true }); return; } + if (act === 'deny') { agentAction('managed', sid, 'approve', { allow: false }); return; } + if (act === 'cancel') { agentAction(fam, sid, 'cancel', null); return; } + if (act === 'output') { ptyOutput(sid); return; } + if (act === 'send') { agentAction(fam, sid, 'send', { text: sendText }); return; } + if (act === 'adopt') { + fetch('/api/agents/pty/start', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ agent: 'claude', resumeSessionId: sid, cwd: cwd || '' }), + }).then(function (r) { + if (!r.ok) { return r.text().then(function (t) { openModal('adopt', '
' + escapeHtml(t) + '
'); }); } + if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions(); + }).catch(function () {}); + } + } + const treeEl = document.getElementById('sessionTree'); + if (treeEl) treeEl.addEventListener('click', function (e) { + const t = e.target; + if (!t || !t.closest) return; + const leaf = t.closest('.tleaf'); + if (leaf && treeEl.contains(leaf)) { + const lisaId = leaf.getAttribute('data-lisa-id'); + if (lisaId) { + selInsp = { type: 'lisa', id: lisaId }; + renderInspector(); + closeStreamView(); + if (lisaId === window.lisaActiveSessionId) renderSessionUI(); + else switchSession(lisaId); + return; + } + const agent = leaf.getAttribute('data-agent'); + const sid = leaf.getAttribute('data-session'); + if (agent && sid) { + const all = treeEl.querySelectorAll('.tleaf.active'); + for (let i = 0; i < all.length; i++) all[i].classList.remove('active'); + leaf.classList.add('active'); + selectAgentAndStream(agent, sid); + } + return; + } + const node = t.closest('.tnode'); + if (node && treeEl.contains(node)) { + const tkey = node.getAttribute('data-toggle'); + if (tkey && node.parentElement) toggleCollapsed(node.parentElement, tkey); + } + }); + const stripEl = document.getElementById('tabStrip'); + if (stripEl) stripEl.addEventListener('click', function (e) { + const t = e.target; + if (!t || !t.closest) return; + const btn = t.closest('[data-act]'); + if (btn && btn.getAttribute('data-act') === 'close-stream') { + closeStreamView(); + renderSessionUI(); + } + }); + const needsEl = document.getElementById('sbNeedsRows'); + if (needsEl) needsEl.addEventListener('click', function (e) { + const t = e.target; + if (!t || !t.closest) return; + const row = t.closest('.needs-row'); + if (!row) return; + const agent = row.getAttribute('data-agent'); + const sid = row.getAttribute('data-session'); + const btn = t.closest('[data-act]'); + if (btn) { runDelegatedAction(btn.getAttribute('data-act'), 'managed', agent, sid, ''); return; } + if (agent && sid) selectAgentAndStream(agent, sid); + }); + const inspEl = document.getElementById('sbClaudeRows'); + if (inspEl) { + inspEl.addEventListener('click', function (e) { + const t = e.target; + if (!t || !t.closest) return; + const btn = t.closest('[data-act]'); + if (!btn) return; + const act = btn.getAttribute('data-act'); + if (act === 'open-lisa') { switchSession(btn.getAttribute('data-lisa-id')); return; } + const acts = t.closest('.insp-actions'); + if (!acts) return; + runDelegatedAction(act, acts.getAttribute('data-fam'), acts.getAttribute('data-agent'), acts.getAttribute('data-session'), acts.getAttribute('data-cwd')); + }); + inspEl.addEventListener('keydown', function (e) { + const t = e.target; + if (!t || !t.classList || !t.classList.contains('mc-send')) return; + if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229 && t.value.trim()) { + e.preventDefault(); + const acts = t.closest('.insp-actions'); + if (acts) runDelegatedAction('send', acts.getAttribute('data-fam'), acts.getAttribute('data-agent'), acts.getAttribute('data-session'), '', t.value.trim()); + t.value = ''; + } + }); + } + const permEl = document.getElementById('asPerm'); + if (permEl) permEl.addEventListener('click', function (e) { + const t = e.target; + if (!t || !t.closest) return; + const btn = t.closest('[data-act]'); + if (btn) runDelegatedAction(btn.getAttribute('data-act'), 'managed', '', permEl.getAttribute('data-session'), ''); + }); + const footEl = document.getElementById('asFoot'); + if (footEl) { + footEl.addEventListener('click', function (e) { + const t = e.target; + if (!t || !t.closest) return; + const btn = t.closest('[data-act]'); + if (btn) runDelegatedAction(btn.getAttribute('data-act'), footEl.getAttribute('data-fam'), '', footEl.getAttribute('data-session'), ''); + }); + footEl.addEventListener('keydown', function (e) { + const t = e.target; + if (!t || !t.classList || !t.classList.contains('mc-send')) return; + if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229 && t.value.trim()) { + e.preventDefault(); + runDelegatedAction('send', footEl.getAttribute('data-fam'), '', footEl.getAttribute('data-session'), '', t.value.trim()); + t.value = ''; + } + }); + } + + // Bootstrap + periodic resync. SSE handles the fast-path updates; + // these timers are belt-and-braces in case the stream silently dies. + refreshPing(); + window.refreshClaudeSessions(); + window.refreshMail(); + refreshIdentity(); + refreshSessionsBadge(); + refreshTokens(); + setInterval(refreshPing, 30_000); + // Resolve refreshClaudeSessions at call time (arrow), not now: setupConsole + // later wraps window.refreshClaudeSessions to re-render the active console + // view + nav count, and the wrapper must win on this 60s tick too. + setInterval(() => window.refreshClaudeSessions(), 60_000); + setInterval(window.refreshMail, 5 * 60_000); + setInterval(refreshSessionsBadge, 5 * 60_000); + // Cheap resync for the tokens section; chat_end gives the fast path. + setInterval(refreshTokens, 5 * 60_000); +})(); + +// ════════════════════════════════════════════════════════════════════ +// Console view-switcher — adds Dashboard / Control / Reve / Sense / +// Memory views beside the default Chat. Purely additive: the chat +// pipeline and every existing id stay untouched. Each non-chat view is +// built lazily from the real endpoints on first activation, and the live +// agent stream (refreshClaudeSessions) is wrapped to re-render the active +// console view. No backticks / no template placeholders in this block. +// ════════════════════════════════════════════════════════════════════ +(function setupConsole() { + var navList = document.getElementById('navList'); + if (!navList) return; + var views = { + chat: document.getElementById('viewChat'), + dashboard: document.getElementById('viewDashboard'), + control: document.getElementById('viewControl'), + reve: document.getElementById('viewReve'), + room: document.getElementById('viewRoom'), + sense: document.getElementById('viewSense'), + memory: document.getElementById('viewMemory'), + mail: document.getElementById('viewMail'), + settings: document.getElementById('viewSettings'), + kb: document.getElementById('viewKb'), + }; + var loaded = {}; + var active = 'chat'; + var proactiveOn = true; + + function esc(s) { return (typeof escapeHtml === 'function') ? escapeHtml(s) : String(s == null ? '' : s); } + function getJSON(u) { return fetch(u).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); } + + function statusClass(state) { + if (state === 'working') return 'working'; + if (state === 'waiting') return 'waiting'; + if (state === 'error') return 'error'; + if (state === 'done' || state === 'idle') return 'done'; + return ''; + } + function statusLabel(state) { + if (state === 'working') return 'Working'; + if (state === 'waiting') return 'Waiting'; + if (state === 'error') return 'Error'; + if (state === 'done') return 'Done'; + if (state === 'idle') return 'Idle'; + return 'Unknown'; + } + // Compact activity line (local copy of the sidebar helper — kept decoupled). + function actLine(s) { + var a = s && s.activity; + if (!a || typeof a !== 'object') return ''; + if (a.pendingPermission) return 'wants to run ' + a.pendingPermission; + var bits = []; + if (a.lastError) bits.push(a.lastError); + if (typeof a.turnCount === 'number' && a.turnCount > 0) bits.push('turn ' + a.turnCount); + if (a.tokens && (a.tokens.input || a.tokens.output)) { + var tot = (a.tokens.input || 0) + (a.tokens.output || 0); + bits.push(tot >= 1000 ? Math.round(tot / 1000) + 'k tok' : tot + ' tok'); + } + var tool = a.lastTools && a.lastTools.length ? a.lastTools[a.lastTools.length - 1] : ''; + var file = a.filesTouched && a.filesTouched.length ? (String(a.filesTouched[a.filesTouched.length - 1]).split('/').pop() || '') : ''; + if (tool && file) bits.push(tool + ' ' + file); + else if (tool) bits.push(tool); + else if (file) bits.push(file); + return bits.join(' · '); + } + function updateAgentCount(n) { + var c = document.getElementById('navAgentCount'); + if (c) c.textContent = String(n); + } + + // ── view switching ────────────────────────────────────────────── + function loadView(name) { + // KB builds once, then refreshes its list on every re-show (it changes). + if (name === 'kb') { if (!loaded.kb) { loaded.kb = true; loadKb(); } else { loadKbList(); } return; } + if (loaded[name]) return; + loaded[name] = true; + if (name === 'dashboard') loadDashboard(); + else if (name === 'control') loadControl(); + else if (name === 'reve') loadReve(); + else if (name === 'sense') loadSense(); + else if (name === 'memory') loadMemory(); + else if (name === 'mail') loadMail(); + else if (name === 'settings') loadSettings(); + else if (name === 'room') { var rf = document.getElementById('roomFrame'); if (rf && !rf.getAttribute('src')) rf.setAttribute('src', '/room'); } + } + function showView(name) { + if (!views[name]) name = 'chat'; + active = name; + for (var k in views) { if (views[k]) views[k].classList.toggle('active', k === name); } + var items = navList.querySelectorAll('.nav-item'); + for (var i = 0; i < items.length; i++) { + items[i].classList.toggle('active', items[i].getAttribute('data-view') === name); + } + if (name !== 'chat') loadView(name); + else { try { document.getElementById('input').focus(); } catch (e) {} } + try { history.replaceState(null, '', name === 'chat' ? location.pathname + location.search : '#' + name); } catch (e) {} + } + navList.addEventListener('click', function (e) { + var btn = e.target && e.target.closest ? e.target.closest('.nav-item') : null; + if (btn && btn.getAttribute('data-view')) showView(btn.getAttribute('data-view')); + }); + window.lisaShowView = showView; + + // (The Room iframe → parent "message" bridge lives at module scope near the + // top of this file — it handles the richer {type:'lisa-room', action, prefill} + // protocol the Room now posts, superseding the old room_open_chat listener.) + + // ── proactive autonomy state (the toggle UI now lives in the Settings rail + // view; this owns the state + keeps the Dashboard proactive panel and the + // Settings switch in sync via setProactiveUI) ───────────────────────── + function setProactiveUI(on) { + proactiveOn = !!on; + var sw = document.getElementById('setProactiveToggle'); + if (sw) { sw.classList.toggle('on', proactiveOn); sw.setAttribute('aria-checked', proactiveOn ? 'true' : 'false'); } + var pp = document.getElementById('ppPanel'); + if (pp) { + pp.classList.toggle('off', !proactiveOn); + var st = document.getElementById('ppState'); + if (st) st.textContent = proactiveOn ? 'On · watching' : 'Paused'; + var sd = document.getElementById('ppDesc'); + if (sd) sd.textContent = proactiveOn ? 'Lisa watches your agents, tasks and signals for blockers and next steps.' : 'Resting — she will only act when you talk to her.'; + } + } + function syncProactive() { + getJSON('/api/autonomy/state').then(function (s) { if (s && typeof s.enabled === 'boolean') setProactiveUI(s.enabled); }); + } + function toggleProactive() { + var on = !proactiveOn; + setProactiveUI(on); + fetch('/api/autonomy/state', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ enabled: on }) }) + .then(function (r) { return r && r.ok ? r.json() : null; }) + .then(function (j) { if (j && typeof j.enabled === 'boolean') setProactiveUI(j.enabled); else setProactiveUI(!on); }) + .catch(function () { setProactiveUI(!on); }); + } + syncProactive(); + + // ── Dashboard ─────────────────────────────────────────────────── + function agentCardHTML(s) { + var cls = statusClass(s.state); + var act = actLine(s); + return '
' + + '
' + esc(s.agent || 'agent') + '' + esc(statusLabel(s.state)) + '
' + + '
' + esc(s.project || s.agent || 'agent') + '
' + + '
' + (act ? esc(act) : 'no recent activity') + '
' + + '
'; + } + function taskCardHTML(d) { + return '
' + + '
' + esc(d.agent || 'dispatch') + '' + (d.alive ? 'Running' : 'Done') + '
' + + '
' + esc(String(d.task || 'task').slice(0, 80)) + '
' + + '
' + esc(d.cwd ? String(d.cwd).split('/').pop() : 'dispatch') + '
' + + '
'; + } + function loadDashboard() { + views.dashboard.innerHTML = + '

Dashboard

Operations at a glance
' + + '
' + + '
loading…
'; + var del = document.getElementById('dashDelegate'); + if (del) del.addEventListener('click', function () { if (typeof window.lisaOpenDelegate === 'function') window.lisaOpenDelegate(); }); + renderDashboard(); + } + function renderDashboard() { + var scroll = document.getElementById('dashScroll'); + if (!scroll) return; + Promise.all([ + getJSON('/api/agents/sessions'), getJSON('/api/dispatch/list'), + getJSON('/api/sense/recent'), getJSON('/api/island/ping'), + ]).then(function (res) { + var sessions = (res[0] && res[0].sessions) || []; + var dispatches = (res[1] && res[1].dispatches) || []; + var events = (res[2] && res[2].events) || []; + var ping = res[3] || {}; + updateAgentCount(sessions.length); + var aliveTasks = dispatches.filter(function (d) { return d.alive; }).length; + var waiting = sessions.filter(function (s) { return s.state === 'waiting'; }).length; + var errored = sessions.filter(function (s) { return s.state === 'error'; }).length; + var desire = ping.current_desire || ''; + var html = ''; + html += '
' + + '
' + sessions.length + '
Agents
' + + '
' + aliveTasks + '
Tasks
' + + '
' + dispatches.length + '
Dispatched
' + + '
' + events.length + '
Signals
'; + html += '
' + + '
Proactive mode ' + (proactiveOn ? 'On · watching' : 'Paused') + '
' + + '
' + (proactiveOn ? 'Lisa watches your agents, tasks and signals for blockers and next steps.' : 'Resting — she will only act when you talk to her.') + '
' + + '
' + sessions.length + ' agents' + aliveTasks + ' tasks' + + (waiting ? '' + waiting + ' waiting' : '') + + (errored ? '' + errored + ' errored' : '') + + 'owner-routedquiet on waits
'; + html += '
Focus · currently pursuing
'; + if (desire) { + html += '
' + + '
' + esc(desire) + '
' + + '
A self-driven desire Lisa is pursuing on her own time.
' + + 'Active
' + + '
self-driven' + sessions.length + ' agents live
'; + } else { + html += '
Nothing actively pursued right now.
'; + } + html += '
Agents & tasks
'; + var cards = sessions.map(agentCardHTML).concat(dispatches.slice(0, 6).map(taskCardHTML)); + html += cards.length ? '
' + cards.join('') + '
' + : '
No active agents or tasks. Delegate one to get started.
'; + scroll.innerHTML = html; + }); + } + + // ── Control ───────────────────────────────────────────────────── + // Progress-only activity line (turns/tokens/cmd/tool·file) — the pending + // and error states get their own dedicated inline lines below. + function ctrlProgress(s) { + var a = s.activity; if (!a || typeof a !== 'object') return ''; + var bits = []; + if (typeof a.turnCount === 'number' && a.turnCount > 0) bits.push('turn ' + a.turnCount); + if (a.tokens && (a.tokens.input || a.tokens.output)) { + var tot = (a.tokens.input || 0) + (a.tokens.output || 0); + bits.push(tot >= 1000 ? Math.round(tot / 1000) + 'k tok' : tot + ' tok'); + } + if (a.lastCommandName) bits.push('$ ' + a.lastCommandName); + var tool = a.lastTools && a.lastTools.length ? a.lastTools[a.lastTools.length - 1] : ''; + var file = a.filesTouched && a.filesTouched.length ? (String(a.filesTouched[a.filesTouched.length - 1]).split('/').pop() || '') : ''; + if (tool && file) bits.push(tool + ' ' + file); + else if (tool) bits.push(tool); + else if (file) bits.push(file); + return bits.join(' · '); + } + function ctrlRowHTML(s) { + var id = esc(s.sessionId); + var scls = statusClass(s.state); + var a = s.activity || {}; + var pend = a.pendingPermission; + var err = a.lastError || (s.state === 'error' ? (s.stateReason || 'errored') : ''); + var label = s.project || s.sessionId || 'agent'; + if (a.gitBranch) { var br = String(a.gitBranch); label = br.indexOf('claude/') === 0 ? br.slice(7) : br; } + var badge = (s.agent && s.agent !== 'claude-code') ? '' + esc(s.agent) + '' : ''; + var sub = ctrlProgress(s); + var rowCls = 'ctrl-row' + (err ? ' problem' : (pend ? ' pending' : '')); + var h = '
'; + h += '
'; + h += '
' + badge + '' + esc(label) + '
'; + h += '' + esc(statusLabel(s.state)) + ''; + if (sub) h += '
' + esc(sub) + '
'; + if (pend) { + h += '
⚠ wants to run ' + esc(pend); + if (s.controllable === 'managed') { + h += ''; + h += ''; + } + h += '
'; + } else if (err) { + h += '
✗ ' + esc(err) + '
'; + } + h += '
'; + return h; + } + // POST an action to the right agent family, surfacing the server error text. + function sdAction(fam, id, action, body) { + return fetch('/api/agents/' + fam + '/' + encodeURIComponent(id) + '/' + action, { + method: 'POST', + headers: body ? { 'content-type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }).then(function (r) { + if (!r.ok) return r.text().then(function (t) { throw new Error(String(r.status) + (t ? ' ' + t : '')); }); + return r.json().catch(function () { return {}; }); + }); + } + // Click a Control row → a rich per-session inspector (status + follow-up + // actions + surfaced problem). Re-fetches on open so it always reflects truth. + function openSessionDetail(id) { + getJSON('/api/agents/sessions').then(function (res) { + var sessions = (res && res.sessions) || []; + var s = null; + for (var i = 0; i < sessions.length; i++) { if (sessions[i].sessionId === id) { s = sessions[i]; break; } } + if (!s) { openModal('Session', '
This session is no longer active.
'); return; } + var a = s.activity || {}; + var fam = s.controllable; + var scls = statusClass(s.state); + var title = (s.agent && s.agent !== 'claude-code' ? s.agent + ' · ' : '') + (s.project || 'agent'); + function rel(iso) { var ms = Date.now() - new Date(iso).getTime(); if (ms < 60000) return 'just now'; if (ms < 3600000) return Math.round(ms / 60000) + 'm ago'; if (ms < 86400000) return Math.round(ms / 3600000) + 'h ago'; return Math.round(ms / 86400000) + 'd ago'; } + function row(k, v) { return '
' + k + '
' + v + '
'; } + var h = '
'; + h += '
' + esc(statusLabel(s.state)) + ''; + if (fam) h += '' + esc(fam) + ''; + if (s.resumable) h += 'adoptable'; + h += '' + esc(s.sessionId) + '
'; + if (a.pendingPermission) { + h += '
⚠ Waiting for approval to run ' + esc(a.pendingPermission) + '
'; + if (fam === 'managed') h += '
'; + h += '
'; + } + if (a.lastError || s.state === 'error') { + h += '
✗ ' + esc(a.lastError || s.stateReason || 'errored') + '
'; + } + h += '
'; + h += row('State', esc(statusLabel(s.state)) + (s.stateReason ? ' (' + esc(s.stateReason) + ')' : '')); + if (typeof a.turnCount === 'number' && a.turnCount > 0) h += row('Turns', String(a.turnCount)); + if (a.tokens && (a.tokens.input || a.tokens.output)) h += row('Tokens', String(a.tokens.input || 0) + ' in · ' + String(a.tokens.output || 0) + ' out'); + if (a.gitBranch) h += row('Branch', esc(a.gitBranch)); + if (a.lastCommandName) h += row('Last command', esc(a.lastCommandName)); + if (a.lastTools && a.lastTools.length) h += row('Recent tools', '
' + a.lastTools.slice(-6).map(function (t) { return '' + esc(t) + ''; }).join('') + '
'); + if (a.filesTouched && a.filesTouched.length) h += row('Files', '
' + a.filesTouched.slice(-8).map(function (f) { return '' + esc(String(f).split('/').pop() || f) + ''; }).join('') + '
'); + if (s.cwd) h += row('Path', esc(s.cwd)); + h += row('Last activity', esc(rel(s.lastMtime))); + h += '
'; + h += '
'; + if (fam && s.state !== 'done' && !a.pendingPermission) { + h += ''; + h += ''; + } + if (fam === 'pty') h += ''; + if (fam && s.state !== 'done') h += ''; + if (s.resumable) h += ''; + if (!fam && !s.resumable) h += 'Observe-only — no control channel for this session.'; + h += '
'; + h += ''; + h += '
'; + openModal(title, h); + var body = document.getElementById('modalBody'); + if (!body) return; + function sdErr(err) { body.insertAdjacentHTML('afterbegin', '
✗ ' + esc(err && err.message ? err.message : 'action failed') + '
'); } + function afterAct(p) { p.then(function () { if (window.refreshClaudeSessions) window.refreshClaudeSessions(); openSessionDetail(id); }).catch(sdErr); } + function doSend() { var inp = document.getElementById('sdSend'); var t = inp && inp.value.trim(); if (t) afterAct(sdAction(fam, id, 'send', { text: t })); } + var btns = body.querySelectorAll('[data-sd]'); + for (var j = 0; j < btns.length; j++) { + btns[j].addEventListener('click', function () { + var act = this.getAttribute('data-sd'); + if (act === 'approve') afterAct(sdAction('managed', id, 'approve', { allow: true })); + else if (act === 'deny') afterAct(sdAction('managed', id, 'approve', { allow: false })); + else if (act === 'cancel') afterAct(sdAction(fam, id, 'cancel', null)); + else if (act === 'send') doSend(); + else if (act === 'adopt') afterAct(fetch('/api/agents/pty/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'claude', resumeSessionId: s.sessionId, cwd: s.cwd || '' }) }).then(function (r) { if (!r.ok) return r.text().then(function (t) { throw new Error(t); }); })); + else if (act === 'output') { + var out = document.getElementById('sdOut'); + if (out) { out.style.display = 'block'; out.textContent = 'loading…'; } + getJSON('/api/agents/pty/' + encodeURIComponent(id) + '/output').then(function (d) { if (out) out.textContent = (d && d.output) ? d.output : '(no output yet)'; }); + } + }); + } + var sendInp = document.getElementById('sdSend'); + if (sendInp) { sendInp.focus(); sendInp.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); doSend(); } }); } + }); + } + function loadControl() { + views.control.innerHTML = + '

Control

Live agents & control plane · click a session to inspect
' + + '
' + + '
loading…
'; + var del = document.getElementById('ctrlDelegate'); + if (del) del.addEventListener('click', function () { if (typeof window.lisaOpenDelegate === 'function') window.lisaOpenDelegate(); }); + renderControl(); + } + function renderControl() { + var scroll = document.getElementById('ctrlScroll'); + if (!scroll) return; + Promise.all([getJSON('/api/agents/sessions'), getJSON('/api/control/policy')]).then(function (res) { + var sessions = (res[0] && res[0].sessions) || []; + var policy = res[1] || {}; + updateAgentCount(sessions.length); + // Surface problems first: error → waiting(needs you) → working → rest. + var rank = { error: 0, waiting: 1, working: 2, done: 4, idle: 5 }; + sessions = sessions.slice().sort(function (x, y) { + var rx = rank[x.state]; if (rx === undefined) rx = 3; + var ry = rank[y.state]; if (ry === undefined) ry = 3; + if (rx !== ry) return rx - ry; + return new Date(y.lastMtime).getTime() - new Date(x.lastMtime).getTime(); + }); + var html = '
' + + 'remote control: ' + (policy.remoteControl ? 'on' : 'off') + '' + + 'adopt external: ' + (policy.remoteAdoptExternal ? 'on' : 'off') + '
'; + if (!sessions.length) { scroll.innerHTML = html + '
No agents running. Delegate a task to start one.
'; return; } + html += '
'; + sessions.forEach(function (s) { html += ctrlRowHTML(s); }); + html += '
'; + scroll.innerHTML = html; + // Inline quick approve/deny — stopPropagation so they do not open the detail. + var quicks = scroll.querySelectorAll('.cr-quick'); + for (var q = 0; q < quicks.length; q++) { + quicks[q].addEventListener('click', function (e) { + e.stopPropagation(); + var self = e.currentTarget; + self.disabled = true; + sdAction('managed', self.getAttribute('data-sid'), 'approve', { allow: self.getAttribute('data-q') === 'approve' }) + .then(function () { if (window.refreshClaudeSessions) window.refreshClaudeSessions(); renderControl(); }) + .catch(function (err) { self.disabled = false; scroll.insertAdjacentHTML('afterbegin', '
' + esc(err.message) + '
'); }); + }); + } + // Row → detail inspector. + var rows = scroll.querySelectorAll('.ctrl-row'); + for (var r = 0; r < rows.length; r++) { + rows[r].addEventListener('click', function (ev) { openSessionDetail(ev.currentTarget.getAttribute('data-sid')); }); + rows[r].addEventListener('keydown', function (ev) { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSessionDetail(ev.currentTarget.getAttribute('data-sid')); } }); + } + }); + } + + // ── Reve ──────────────────────────────────────────────────────── + function loadReve() { + views.reve.innerHTML = + '

Rêve

Reflections from her own time
' + + '
' + + '
loading…
'; + var sel = document.getElementById('reveWindow'); + if (sel) sel.addEventListener('change', function () { renderReve(sel.value); }); + renderReve('120'); + } + function renderReve(mins) { + var scroll = document.getElementById('reveScroll'); + if (!scroll) return; + Promise.all([getJSON('/api/island/ping'), getJSON('/api/agents/recap?sinceMinutes=' + encodeURIComponent(mins))]).then(function (res) { + var ping = res[0] || {}; var recap = res[1] || {}; + var html = ''; + if (ping.last_idle_message_text) html += '

' + esc(idleHeaderLabel()) + '

' + esc(ping.last_idle_message_text) + '
'; + if (ping.current_desire) html += '

Currently pursuing

' + esc(ping.current_desire) + '
'; + html += '

Recap

' + esc(recap.text || 'No activity in this window.') + '
'; + scroll.innerHTML = html; + }); + } + + // ── Sense ─────────────────────────────────────────────────────── + function loadSense() { + views.sense.innerHTML = + '

Sense

Ambient signals Lisa may see
' + + '
loading…
'; + renderSense(); + } + function renderSense() { + var scroll = document.getElementById('senseScroll'); + if (!scroll) return; + Promise.all([ + getJSON('/api/consent'), + getJSON('/api/sense/recent'), + getJSON('/api/sense/social/connectors'), + getJSON('/api/sense/social/drafts'), + getJSON('/api/sense/social/status') + ]).then(function (res) { + var grants = (res[0] && res[0].grants) || []; + var events = (res[1] && res[1].events) || []; + var connectors = (res[2] && res[2].connectors) || []; + var drafts = (res[3] && res[3].drafts) || []; + var paused = Boolean(res[4] && res[4].paused); + // UX-11: with no connector installed there is no publishing to be + // active or paused, and offering "Pause publishing" implied there was. + // State line only until something can actually publish. + var html = connectors.length + ? '
Publishing ' + (paused ? 'paused' : 'active') + + '
' + : '
' + esc(tr('sense.noConnector')) + '
'; + html += '
Connected media
'; + if (!connectors.length) { + html += '
No social connector installed. Ask Lisa to help connect a supported account.
'; + } + connectors.forEach(function (item) { + var c = item.manifest; + var accounts = item.accounts || []; + html += '
' + + esc(c ? c.displayName : item.plugin) + '
' + + esc(c ? (accounts.length ? accounts.map(function (a) { return a.handle || a.displayName || a.id; }).join(', ') : c.platform + ' · ready to link') : item.error || 'unavailable') + + '
'; + }); + html += '
Post drafts
'; + if (!drafts.length) { + html += '
No drafts yet. Tell Lisa what you want to publish.
'; + } + drafts.slice().reverse().slice(0, 20).forEach(function (d) { + var targetLabel = (d.targets || []).map(function (t) { return t.platform + ' · ' + t.accountId; }).join(', '); + var body = (d.canonical && (d.canonical.text || d.canonical.title || d.canonical.link)) || ''; + var mediaCount = (d.canonical && d.canonical.media && d.canonical.media.length) || 0; + html += ''; + }); + html += '
Consent
'; + if (!grants.length) html += '
No signals configured.
'; + grants.forEach(function (g) { + html += '
' + esc(g.signal) + '
' + + (g.description ? '
' + esc(g.description) + '
' : '') + '
' + + '
'; + }); + html += '
Recently sensed
'; + if (!events.length) html += '
Nothing captured.
'; + events.slice(0, 30).forEach(function (e) { + html += '
' + esc(e.summary || '') + '
' + + '
' + esc([e.signal, e.kind, e.app].filter(Boolean).join(' · ')) + '
'; + }); + html += '
'; + scroll.innerHTML = html; + var tgs = scroll.querySelectorAll('.v-toggle'); + for (var i = 0; i < tgs.length; i++) { + tgs[i].addEventListener('click', function () { + var sig = this.getAttribute('data-signal'); + var on = this.getAttribute('data-on') === '1'; + fetch(on ? '/api/consent/revoke' : '/api/consent/grant', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ signal: sig }) }) + .then(function () { renderSense(); }).catch(function () {}); + }); + } + var approveButtons = scroll.querySelectorAll('[data-social-approve]'); + for (var a = 0; a < approveButtons.length; a++) { + approveButtons[a].addEventListener('click', function (ev) { + var self = ev.currentTarget; + var id = self.getAttribute('data-social-approve'); + var digest = self.getAttribute('data-social-digest'); + self.disabled = true; + fetch('/api/sense/social/drafts/' + encodeURIComponent(id) + '/approve', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ digest: digest }) + }).then(function (r) { + if (!r.ok) throw new Error('approval failed'); + renderSense(); + }).catch(function () { renderSense(); }); + }); + } + var cancelButtons = scroll.querySelectorAll('[data-social-cancel]'); + for (var c = 0; c < cancelButtons.length; c++) { + cancelButtons[c].addEventListener('click', function (ev) { + var self = ev.currentTarget; + var id = self.getAttribute('data-social-cancel'); + self.disabled = true; + fetch('/api/sense/social/drafts/' + encodeURIComponent(id) + '/cancel', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}' + }).then(function () { renderSense(); }).catch(function () { renderSense(); }); + }); + } + var pauseButton = document.getElementById('socialPauseBtn'); + if (pauseButton) { + pauseButton.addEventListener('click', function () { + this.disabled = true; + fetch('/api/sense/social/' + (paused ? 'resume' : 'pause'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}' + }).then(function () { renderSense(); }).catch(function () { renderSense(); }); + }); + } + }); + } + + // ── Memory (launchers for the existing modal panels) ──────────── + function memBtn(key, ico, title, sub) { + return ''; + } + function loadMemory() { + views.memory.innerHTML = + '

Memory

Soul · skills · memory · tools
' + + '
' + + memBtn('soul', '★', 'Soul', 'identity · values · emotions') + + memBtn('skills', '✦', 'Skills', 'saved workflows') + + memBtn('memory', '▤', 'Memory', 'USER.md · MEMORY.md') + + memBtn('tools', '⚒', 'Tools', 'capabilities') + + memBtn('plans', '◆', 'Coding plans', 'subscription CLIs') + + '
'; + var mbs = views.memory.querySelectorAll('.mem-btn'); + for (var i = 0; i < mbs.length; i++) { + mbs[i].addEventListener('click', function () { + var w = this.getAttribute('data-mem'); + if (w === 'soul' && typeof showSoul === 'function') showSoul(); + else if (w === 'skills' && typeof showSkills === 'function') showSkills(); + else if (w === 'memory' && typeof showMemory === 'function') showMemory(); + else if (w === 'tools' && typeof showTools === 'function') showTools(); + else if (w === 'plans' && typeof showPlans === 'function') showPlans(); + }); + } + } + + // ── Mail (full rail view — reuses the sidebar mail endpoints and adds the + // per-account management the compact sidebar card lacks) ────────────── + function mailNeedsRow(i) { + var urgent = i.importance >= 3; + return '
' + + '' + (urgent ? '‼' : '!') + '' + + '' + esc(i.subject || '(no subject)') + '' + + '
'; + } + function renderMailView(accounts, digest) { + var scroll = document.getElementById('mailScroll'); + if (!scroll) return; + accounts = accounts || []; + var needs = (digest && digest.needsYou) ? digest.needsYou : []; + var html = ''; + if (!accounts.length) { + html += '
No mailbox connected yet. Connect one for a daily classified digest — read-only, stored locally (0600).
'; + } else { + html += '
Digest
'; + html += '
' + esc(digest && digest.summary ? digest.summary : 'No digest yet — sweep to build one.') + '
'; + if (needs.length) { for (var n = 0; n < needs.length; n++) html += mailNeedsRow(needs[n]); } + else html += '
Nothing needs you right now.
'; + html += '
'; + html += '
Mailboxes
'; + for (var a = 0; a < accounts.length; a++) { + var ac = accounts[a]; + var on = ac.enabled !== false; + html += '
' + + '
' + esc(ac.label || ac.email || ac.id) + '
' + + '
' + esc((ac.email || '') + (ac.host ? ' · ' + ac.host : '')) + '
' + + '' + + '
'; + } + html += '
'; + } + scroll.innerHTML = html; + var btns = scroll.querySelectorAll('[data-mail-act]'); + for (var b = 0; b < btns.length; b++) { + btns[b].addEventListener('click', function (ev) { + var self = ev.currentTarget; + var id = self.getAttribute('data-mail-id'); + var act = self.getAttribute('data-mail-act'); + if (act === 'remove' && !window.confirm('Remove this mailbox from Lisa? Access is read-only; nothing is deleted on the server.')) return; + self.disabled = true; + fetch('/api/mail/accounts/' + encodeURIComponent(id) + '/' + act, { method: 'POST' }) + .then(function () { if (window.refreshMail) window.refreshMail(); }) + .catch(function () {}); + }); + } + } + window.lisaMailViewRender = renderMailView; + function loadMail() { + views.mail.innerHTML = + '

Mail

Daily classified digest · read-only
' + + '
' + + '
loading…
' + + '
'; + var cb = document.getElementById('mailConnectBtn'); + if (cb) cb.addEventListener('click', function () { if (window.lisaOpenMailModal) window.lisaOpenMailModal(); }); + var sb = document.getElementById('mailSweepBtn'); + if (sb) sb.addEventListener('click', function () { + sb.disabled = true; sb.textContent = 'Sweeping…'; + fetch('/api/mail/sweep', { method: 'POST' }) + .then(function () { if (window.refreshMail) window.refreshMail(); }) + .catch(function () {}) + .then(function () { sb.disabled = false; sb.textContent = 'Sweep now'; }); + }); + if (window.refreshMail) window.refreshMail(); + } + + // ── Settings (API keys · Proactive · Compact · About) ───────────────── + function renderSettings(status, edition) { + var scroll = document.getElementById('settingsScroll'); + if (!scroll) return; + status = status || {}; + var chip = function (ok) { return '' + (ok ? 'configured' : 'not set') + ''; }; + var compactOn = (typeof window.lisaGetCompact === 'function') ? window.lisaGetCompact() : false; + var edName = (edition && edition.edition) ? edition.edition : '—'; + var sw = function (id, on) { + return '
'; + }; + var html = ''; + // Provider list + one picker, driven by the same table the key gate uses + // (UX-1 wave 2). Was an Anthropic-and-OpenAI-only pair of inputs, which + // made every other provider a config.env edit. + var provs = window.lisaProviderList(status); + html += '
Model provider
'; + for (var pi = 0; pi < provs.length; pi++) { + var pv = provs[pi]; + if (!pv.configured && pv.custom) continue; + html += '
' + esc(pv.label) + + '
' + esc(pv.envKey) + '
' + chip(pv.configured) + '
'; + } + html += '
' + + '' + + '' + + '' + + '' + + '
' + + '
Saved to ~/.lisa/config.env (0600), on this machine. Accepted from localhost only.
'; + html += '
'; + html += '
Automation
'; + html += '
Proactive mode
Let Lisa watch your agents, tasks and signals and act when you are away
' + sw('setProactiveToggle', proactiveOn) + '
'; + html += '
'; + html += '
Display
'; + html += '
Compact mode
Dock Lisa as a narrow stacked panel at any window width
' + sw('setCompactToggle', compactOn) + '
'; + html += '
'; + html += '
About
'; + html += '
Edition
Runtime build
' + esc(edName) + '
'; + // A discoverable entry point for the shortcut list — "press ?" is only + // findable if you already know it is there (UX-11). + html += '
Keyboard shortcuts
⌘K switch · ⌘/ focus · ⌘F find · ? this list
'; + html += '
Anthropic Console
Manage billing & keys
open ↗
'; + html += '
'; + scroll.innerHTML = html; + + var pt = document.getElementById('setProactiveToggle'); + if (pt) { + pt.addEventListener('click', toggleProactive); + pt.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleProactive(); } }); + } + var ct = document.getElementById('setCompactToggle'); + if (ct) { + var flip = function () { if (window.lisaSetCompact) window.lisaSetCompact(!(window.lisaGetCompact && window.lisaGetCompact())); }; + ct.addEventListener('click', flip); + ct.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); flip(); } }); + } + var scBtn = document.getElementById('setShortcuts'); + if (scBtn) scBtn.addEventListener('click', function () { + if (typeof window.lisaShowShortcuts === 'function') window.lisaShowShortcuts(); + }); + var sel = document.getElementById('setProvider'); + var keyEl = document.getElementById('setKey'); + var modelEl = document.getElementById('setModel'); + var baseEl = document.getElementById('setBaseUrl'); + var pick = function () { + for (var i = 0; i < provs.length; i++) if (provs[i].id === sel.value) return provs[i]; + return provs[0]; + }; + if (sel) { + var preselect = ''; + for (var q = 0; q < provs.length; q++) { + var o = document.createElement('option'); + o.value = provs[q].id; + o.textContent = provs[q].label + (provs[q].configured ? ' · configured' : ''); + sel.appendChild(o); + if (provs[q].configured && !preselect) preselect = provs[q].id; + } + sel.value = preselect || (provs[0] ? provs[0].id : ''); + var syncSel = function () { + var pv = pick(); + if (!pv) return; + keyEl.placeholder = pv.envKey + ' — ' + pv.placeholder + (pv.configured ? ' (leave blank to keep)' : ''); + modelEl.placeholder = pv.model ? ('model (optional · ' + pv.model + ')') : 'model (optional)'; + baseEl.style.display = pv.custom ? '' : 'none'; + }; + sel.addEventListener('change', syncSel); + syncSel(); + } + var saveBtn = document.getElementById('setKeySave'); + if (saveBtn) saveBtn.addEventListener('click', function () { + var msg = document.getElementById('setKeyMsg'); + var pv = pick(); + var key = keyEl ? keyEl.value.trim() : ''; + var model = modelEl ? modelEl.value.trim() : ''; + // Same rule as the gate: pin a model only where the server cannot + // infer one from the key. + if (!model && key && pv.needsModel && pv.model) model = pv.model; + var base = (pv && pv.custom && baseEl) ? baseEl.value.trim() : ''; + if (!pv) return; + if (!key && !model && !base) { if (msg) { msg.style.color = ''; msg.textContent = 'Enter a key or a model to update.'; } return; } + if (pv.custom && key && !base) { if (msg) { msg.style.color = ''; msg.textContent = 'A custom endpoint needs its base URL.'; } return; } + if (msg) { msg.style.color = ''; msg.textContent = 'Saving…'; } + saveBtn.disabled = true; + fetch('/api/config/save', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(window.lisaProviderSaveBody(pv, key, model, base)) }) + .then(function (r) { if (!r.ok) return r.text().then(function (t) { throw new Error(t || ('failed (' + r.status + ')')); }); return r.json(); }) + .then(function () { return getJSON('/api/config/status').catch(function () { return null; }); }) + .then(function (after) { + // Same guard as the gate: say so when this backend dropped the key + // instead of reporting a save that did not happen. + if (key && after && window.lisaProviderConfirm(pv, after) === false) { + throw new Error(window.lisaProviderUnsupportedNote(pv)); + } + if (keyEl) keyEl.value = ''; + if (msg) { msg.style.color = 'var(--proactive)'; msg.textContent = 'Saved.'; } + loadSettings(); + }) + .catch(function (err) { if (msg) { msg.style.color = ''; msg.textContent = (err && err.message) ? err.message : 'save failed'; } }) + .then(function () { saveBtn.disabled = false; }); + }); + // Refresh Proactive state from the server so the switch reflects truth. + syncProactive(); + } + function loadSettings() { + views.settings.innerHTML = + '

Settings

Keys · automation · display
' + + '
loading…
'; + Promise.all([getJSON('/api/config/status'), getJSON('/api/edition')]).then(function (res) { renderSettings(res[0], res[1]); }); + } + // ── Knowledge base view (docs/archive/plans/PLAN_KNOWLEDGE_BASE_v1.0.md) ────────── + function kbRenderList(entries, container) { + if (!entries || !entries.length) { + container.innerHTML = '
Nothing here yet. In Chat, select messages and "Add to KB", or ask Lisa to save something.
'; + return; + } + var html = ''; + for (var i = 0; i < entries.length; i++) { + var e = entries[i]; + var tags = (e.tags && e.tags.length) ? e.tags.map(function (t) { return '#' + t; }).join(' ') : ''; + html += ''; + } + container.innerHTML = html; + var items = container.querySelectorAll('.kb-item'); + for (var j = 0; j < items.length; j++) { + items[j].addEventListener('click', function () { + kbOpen(this.getAttribute('data-layer'), this.getAttribute('data-slug')); + }); + } + } + function kbOpen(layer, slug) { + var reader = document.getElementById('kbReader'); + if (!reader) return; + reader.classList.add('open'); + reader.innerHTML = '
loading…
'; + getJSON('/api/kb/entry?layer=' + encodeURIComponent(layer) + '&slug=' + encodeURIComponent(slug)).then(function (d) { + if (!d || !d.entry) { reader.innerHTML = '
not found
'; return; } + var e = d.entry; + var meta = []; + if (e.tags && e.tags.length) meta.push('tags: ' + e.tags.join(', ')); + if (e.sources && e.sources.length) meta.push('sources: ' + e.sources.join(', ')); + if (e.origin) meta.push('origin: ' + e.origin); + reader.innerHTML = + '
' + (e.layer === 'wiki' ? 'wiki' : 'source') + '' + + '

' + esc(e.title) + '

' + + (meta.length ? '
' + esc(meta.join(' · ')) + '
' : '') + + '
' + esc(e.body) + '
'; + var del = reader.querySelector('.kb-del'); + if (del) del.addEventListener('click', function () { + if (window.confirm('Delete "' + e.title + '"? This removes it from your knowledge base.')) kbDelete(e.layer, e.slug); + }); + }); + } + function kbDelete(layer, slug) { + fetch('/api/kb/remove', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ layer: layer, slug: slug }) }) + .then(function () { + var reader = document.getElementById('kbReader'); + if (reader) { reader.classList.remove('open'); reader.innerHTML = ''; } + loadKbList(); + }); + } + var kbSearchTimer = null; + function loadKbList() { + var listEl = document.getElementById('kbList'); + if (!listEl) return; + var box = document.getElementById('kbSearch'); + var q = box ? box.value : ''; + if (q && q.trim()) { + getJSON('/api/kb/search?q=' + encodeURIComponent(q)).then(function (d) { + kbRenderList((d && d.hits) || [], listEl); + }); + } else { + getJSON('/api/kb').then(function (d) { + kbRenderList((d && d.entries) || [], listEl); + }); + } + } + function kbIngestSubmit() { + var urlEl = document.getElementById('kbIngestUrl'); + var go = document.getElementById('kbIngestGo'); + var status = document.getElementById('kbIngestStatus'); + if (!urlEl || !go) return; + var url = (urlEl.value || '').trim(); + if (!url) return; + go.disabled = true; + urlEl.disabled = true; + if (status) { status.className = 'kb-ingest-status'; status.textContent = 'Fetching & extracting…'; } + fetch('/api/kb/ingest', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url: url }) }) + .then(function (r) { return r.json(); }) + .then(function (d) { + go.disabled = false; + urlEl.disabled = false; + if (d && d.ok) { + urlEl.value = ''; + var note = d.deduped ? 'Already saved — opening it' : 'Saved via ' + (d.via || 'generic'); + if (d.transcript && String(d.transcript).indexOf('unavailable') === 0) note += ' (no transcript — you can paste one)'; + if (status) status.textContent = note; + loadKbList(); + if (d.entry) kbOpen(d.entry.layer, d.entry.slug); + } else if (status) { + status.className = 'kb-ingest-status err'; + status.textContent = (d && d.error) ? d.error : 'Ingest failed'; + } + }) + .catch(function () { + go.disabled = false; + urlEl.disabled = false; + if (status) { status.className = 'kb-ingest-status err'; status.textContent = 'Ingest failed (network)'; } + }); + } + function loadKb() { + views.kb.innerHTML = + '

Knowledge Base

Sources + wiki · live search
' + + '
' + + '' + + '
'; + var s = document.getElementById('kbSearch'); + if (s) s.addEventListener('input', function () { + if (kbSearchTimer) clearTimeout(kbSearchTimer); + kbSearchTimer = setTimeout(loadKbList, 200); + }); + var go = document.getElementById('kbIngestGo'); + if (go) go.addEventListener('click', kbIngestSubmit); + var u = document.getElementById('kbIngestUrl'); + if (u) u.addEventListener('keydown', function (e) { if (e.key === 'Enter') kbIngestSubmit(); }); + loadKbList(); + } + window.lisaReloadKb = loadKbList; + + // ── live refresh: wrap the agent-session refresh so the active console + // view + the Control nav count update on SSE agent_session_update. + var origRefresh = window.refreshClaudeSessions; + window.refreshClaudeSessions = function () { + var r = origRefresh ? origRefresh.apply(this, arguments) : undefined; + Promise.resolve(r).then(function () { + if (active === 'dashboard') renderDashboard(); + else if (active === 'control') renderControl(); + else getJSON('/api/agents/sessions').then(function (d) { updateAgentCount((d && d.sessions) ? d.sessions.length : 0); }); + }); + return r; + }; + getJSON('/api/agents/sessions').then(function (d) { updateAgentCount((d && d.sessions) ? d.sessions.length : 0); }); + + // ── init: honor a deep-link hash, else default to chat ────────── + var initial = (location.hash || '').replace('#', ''); + showView(views[initial] ? initial : 'chat'); + window.addEventListener('hashchange', function () { + var h = (location.hash || '').replace('#', ''); + if (views[h] && h !== active) showView(h); + }); +})(); \ No newline at end of file diff --git a/src/web/lisa-client.test.ts b/src/web/lisa-client.test.ts index 3374812..a2021ce 100644 --- a/src/web/lisa-client.test.ts +++ b/src/web/lisa-client.test.ts @@ -1,30 +1,28 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; +import { createContext, runInContext } from "node:vm"; import { MAIN_CLIENT_JS } from "./lisa-client.js"; // Regression guard for the idle "while you were away" sentinel regex. // -// The client source lives inside MAIN_CLIENT_JS, a plain (untagged) template -// literal, so backslashes are consumed once when the literal is evaluated. A -// regex written with SINGLE backslashes cooks down to a character class plus a -// literal "s*" instead of the intended literal "[while you were away]" prefix. -// Because the persisted sentinel starts with "[", and "[" is not inside that -// character class, `.test()` returns false and history-loaded idle notes fall -// through to a plain Lisa bubble showing the raw sentinel text instead of the -// distinct idle card. The fix is to DOUBLE-escape in source so it cooks to the -// correct regex. `npm run typecheck` can't see this — the template literal is -// valid TypeScript either way — so we assert on the cooked bytes here. -// MAIN_CLIENT_JS is imported already-cooked, i.e. exactly what the browser gets. +// Origin: the client used to live inside MAIN_CLIENT_JS, an untagged template +// literal, so every backslash was consumed once when the literal was +// evaluated. A regex written with single backslashes cooked down to a +// character class plus a literal "s*" instead of the intended literal +// "[while you were away]" prefix — and because the persisted sentinel starts +// with "[", which is not inside that character class, history-loaded idle +// notes fell through to a plain Lisa bubble showing the raw sentinel. // -// See lisa-client.ts:~718 (detection + strip) and the correct `\\s+` precedent -// at lisa-client.ts:~1061. +// That whole trap is gone: the client is a real .js file now, so what is +// written is what is served. The check stays because the behaviour it guards +// (idle notes render as the distinct card, ordinary replies do not) is worth +// pinning on its own, and it runs against the exact bytes the browser gets. // // (This test file deliberately keeps the regex out of any block comment: the -// pattern contains the `*` + `/` pair that would prematurely close one — the -// very same "one layer of escaping/quoting eats your metacharacters" trap.) +// pattern contains the `*` + `/` pair that would prematurely close one.) -const CORRECT_LITERAL = "/^\\[while you were away\\]\\s*/i"; // cooked: caret, \[ , text, \] , \s star, /i -const BROKEN_LITERAL = "/^[while you were away]s*/i"; // what single-escaping cooks down to +const CORRECT_LITERAL = "/^\\[while you were away\\]\\s*/i"; // caret, \[ , text, \] , \s star, /i +const BROKEN_LITERAL = "/^[while you were away]s*/i"; // what the old double-escaping bug produced describe("idle-note sentinel regex survives template-literal cooking", () => { test("cooked source carries the correct regex, not the mangled one", () => { @@ -36,8 +34,7 @@ describe("idle-note sentinel regex survives template-literal cooking", () => { ); assert.ok( !MAIN_CLIENT_JS.includes(BROKEN_LITERAL), - "MAIN_CLIENT_JS contains the mangled sentinel regex — a single-backslash " + - "escape was eaten by the template literal", + "MAIN_CLIENT_JS contains the mangled sentinel regex", ); }); @@ -68,3 +65,416 @@ describe("idle-note sentinel regex survives template-literal cooking", () => { ); }); }); + +/** + * Behavioural tests for individual client functions. + * + * The client is one big template literal, so there is no module to import + * and no DOM in `npm test`. These pull a named function's exact served text + * out of MAIN_CLIENT_JS and run it in a `vm` sandbox with hand-made stubs — + * so what is tested is literally what the browser executes. + */ +/** The i18n block, so a sandbox renders the real strings rather than stubs. */ +const I18N_SRC = MAIN_CLIENT_JS.slice( + MAIN_CLIENT_JS.indexOf("const LISA_STRINGS = {"), + MAIN_CLIENT_JS.indexOf("const log = document.getElementById('log');"), +); +function i18nContext(extra: Record = {}) { + return createContext({ + navigator: { language: "en-US" }, + document: { documentElement: {} }, + ...extra, + }); +} + +function extractFunction(src: string, name: string): string { + const head = `function ${name}(`; + const start = src.indexOf(head); + assert.ok(start >= 0, `function ${name} not found in MAIN_CLIENT_JS`); + let depth = 0; + let i = src.indexOf("{", start); + assert.ok(i >= 0, `function ${name} has no body`); + for (let j = i; j < src.length; j++) { + if (src[j] === "{") depth++; + else if (src[j] === "}") { + depth--; + if (depth === 0) return src.slice(start, j + 1); + } + } + throw new Error(`unbalanced braces in ${name}`); +} + +describe("sessionLabel names an empty session instead of showing its raw id (UX-4)", () => { + const src = extractFunction(MAIN_CLIENT_JS, "sessionLabel"); + const ctx = i18nContext({ relativeTime: (iso: string) => (iso ? "2m" : "") }); + runInContext(`${I18N_SRC}\n${src}; globalThis.__label = sessionLabel;`, ctx); + const label = (ctx as { __label: (s: unknown) => string }).__label; + const ID = "20260905-220846-9f7d58"; + + test("a session with no messages reads as a new session, not the id", () => { + assert.equal(label({ id: ID, messageCount: 0, startedAt: "2026-09-05T22:08:46Z" }), "New session · 2m"); + }); + test("the first user message still wins once there is one", () => { + assert.equal(label({ id: ID, messageCount: 1, firstUserMessage: "fix the mail sweep" }), "fix the mail sweep"); + }); + test("long names are ellipsised to 30 chars", () => { + const long = "a".repeat(80); + assert.equal(label({ id: ID, messageCount: 3, firstUserMessage: long }), "a".repeat(30) + "…"); + }); + test("a session with messages but no captured text falls back to the id", () => { + assert.equal(label({ id: ID, messageCount: 4 }), ID); + }); +}); + +describe("collapsed right rail keeps a way in (UX-5)", () => { + test("the manual toggle records that the user has a preference", () => { + assert.match( + MAIN_CLIENT_JS, + /localStorage\.setItem\('lisaRightbarTouched', '1'\)/, + "the #fnPanel click handler must persist lisaRightbarTouched", + ); + assert.match( + MAIN_CLIENT_JS, + /localStorage\.getItem\('lisaRightbarTouched'\) === '1'/, + "the flag must be read back at boot", + ); + }); + + test("the auto-expand nudge never writes the layout preference", () => { + // It is a one-time nudge for someone who has never touched the toggle. + // If it ever persisted 'lisaRightbar', a single blocked agent would + // permanently change a layout the user did not choose. + const start = MAIN_CLIENT_JS.indexOf("window.lisaRightbarAttention = function"); + assert.ok(start >= 0, "lisaRightbarAttention not found"); + const end = MAIN_CLIENT_JS.indexOf("\n };", start); + const body = MAIN_CLIENT_JS.slice(start, end); + assert.ok(!body.includes("setItem"), `auto-expand must not persist: ${body}`); + assert.match(body, /!touched && !autoExpanded/, "must be gated on both flags"); + }); + + test("the needs-you renderer feeds it, and only a decision expands the rail", () => { + assert.match( + MAIN_CLIENT_JS, + /window\.lisaRightbarAttention\(needs\.length, needs\.some\(function \(s\) \{\s*return \(s\.activity && s\.activity\.pendingPermission\) \|\| s\.state === 'waiting';/, + "an errored agent must be counted but must not auto-expand the rail", + ); + }); +}); + +describe("birth errors are classified into human copy (UX-1)", () => { + // The copy comes from the i18n table now, so the sandbox needs that block + // too — which also means these assertions run against the real strings. + const src = + MAIN_CLIENT_JS.slice( + MAIN_CLIENT_JS.indexOf("const BIRTH_ERROR_TEXT = {"), + MAIN_CLIENT_JS.indexOf("function showBirthError("), + ); + const ctx = i18nContext(); + runInContext(`${I18N_SRC}\n${src}; globalThis.__code = birthErrorCode; globalThis.__text = BIRTH_ERROR_TEXT;`, ctx); + const code = (ctx as { __code: (ev: unknown) => string }).__code; + const text = (ctx as { __text: Record }).__text; + + test("a new server's explicit code wins", () => { + assert.equal(code({ kind: "error", code: "rate_limit", message: "whatever" }), "rate_limit"); + }); + test("an unknown code falls back to classification rather than being trusted", () => { + assert.equal(code({ code: "teapot", message: "401 nope" }), "auth"); + }); + test("an old server's raw Anthropic 401 payload classifies as auth", () => { + const raw = + '401 {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"},"request_id":null}'; + assert.equal(code({ kind: "error", message: raw }), "auth"); + }); + test("network, timeout and rate-limit shapes are recognised without a code", () => { + assert.equal(code({ message: "ECONNREFUSED 127.0.0.1:443" }), "network"); + assert.equal(code({ message: "fetch failed" }), "network"); + assert.equal(code({ message: "request timed out after 600000ms" }), "timeout"); + assert.equal(code({ message: "429 rate_limit_error" }), "rate_limit"); + }); + test("anything else is unknown, never rendered raw", () => { + assert.equal(code({ message: "kaboom" }), "unknown"); + assert.equal(code({}), "unknown"); + assert.equal(code(null), "unknown"); + }); + test("every class has copy, and none of it is JSON", () => { + for (const k of ["auth", "timeout", "network", "rate_limit", "unknown"]) { + assert.ok(text[k] && text[k].length > 20, `missing copy for ${k}`); + assert.ok(!text[k]!.includes("{"), `copy for ${k} leaks a payload`); + } + }); + test("the raw payload goes to the title attribute, never to textContent", () => { + const show = MAIN_CLIENT_JS.slice( + MAIN_CLIENT_JS.indexOf("function showBirthError("), + MAIN_CLIENT_JS.indexOf("async function maybeBirth("), + ); + assert.match(show, /birthErrorEl\.textContent = BIRTH_ERROR_TEXT\[code\]/); + assert.match(show, /birthErrorEl\.title = String\(\(ev && ev\.message\)/); + }); +}); + +describe("provider picker works against both server generations (UX-1)", () => { + const src = MAIN_CLIENT_JS.slice( + MAIN_CLIENT_JS.indexOf("const LISA_PROVIDER_FALLBACK = ["), + MAIN_CLIENT_JS.indexOf("// ── API key config gate"), + ); + const ctx = i18nContext({ window: {} }); + runInContext(`${I18N_SRC}\n${src}`, ctx); + const c = ctx as { + lisaProviderList: (s: unknown) => Array>; + lisaProviderSaveBody: (p: unknown, k: string, m: string, b: string) => Record; + lisaProviderConfirm: (p: unknown, s: unknown) => boolean | null; + }; + + test("an old status (no providers block) falls back to the built-in table", () => { + const list = c.lisaProviderList({ configured: true, anthropic: true, openai: false }); + assert.ok(list.length >= 8, `only ${list.length} providers`); + const ids = list.map((p) => p.id); + for (const want of ["anthropic", "openai", "deepseek", "zhipu", "dashscope", "moonshot", "gemini", "custom"]) { + assert.ok(ids.includes(want), `missing ${want}`); + } + assert.equal(list.find((p) => p.id === "anthropic")!.configured, true); + assert.equal(list.find((p) => p.id === "openai")!.configured, false); + }); + + test("a served providers block wins, including providers this client has never heard of", () => { + const list = c.lisaProviderList({ + providers: [ + { id: "zhipu", envKey: "ZHIPU_API_KEY", label: "Zhipu GLM", modelPrefixes: ["glm-"], configured: true }, + { id: "brandnew", envKey: "BRANDNEW_API_KEY", label: "Brand New Co", modelPrefixes: ["bn-"], configured: false }, + ], + }); + assert.equal(JSON.stringify(list.map((p) => p.id)), JSON.stringify(["zhipu", "brandnew"])); + assert.equal(list[0]!.configured, true); + // Local presentation hints still merge in for the ones we know. + assert.equal(list[0]!.model, "glm-4-plus"); + assert.equal(list[1]!.placeholder, "key..."); + }); + + test("the save body carries the new shape plus every legacy field name", () => { + const anthropic = c.lisaProviderList(null).find((p) => p.id === "anthropic")!; + const body = c.lisaProviderSaveBody(anthropic, "sk-ant-x", "", ""); + assert.equal(JSON.stringify(body.keys), JSON.stringify({ ANTHROPIC_API_KEY: "sk-ant-x" })); + assert.equal(body.anthropicKey, "sk-ant-x"); + assert.equal(body.anthropic, "sk-ant-x"); + const openai = c.lisaProviderList(null).find((p) => p.id === "openai")!; + const b2 = c.lisaProviderSaveBody(openai, "sk-o", "gpt-4o", ""); + assert.equal(b2.openaiKey, "sk-o"); + assert.equal(b2.openai, "sk-o"); + assert.equal(b2.model, "gpt-4o"); + // A third-party provider gets no legacy field — there is none to send. + const ds = c.lisaProviderList(null).find((p) => p.id === "deepseek")!; + const b3 = c.lisaProviderSaveBody(ds, "sk-d", "deepseek-chat", ""); + assert.equal(JSON.stringify(Object.keys(b3).sort()), JSON.stringify(["keys", "model"])); + const custom = c.lisaProviderList(null).find((p) => p.id === "custom")!; + assert.equal(c.lisaProviderSaveBody(custom, "k", "m", "https://h/v1").baseUrl, "https://h/v1"); + }); + + test("only providers the server cannot auto-detect pin a model", () => { + const byId = (id: string) => c.lisaProviderList(null).find((p) => p.id === id)!; + // Anthropic and OpenAI are resolved from the key alone by + // providers/registry.resolveDefaultModel; the rest would silently fall + // back to Claude if LISA_MODEL were left unset. + assert.equal(byId("anthropic").needsModel, false); + assert.equal(byId("openai").needsModel, false); + for (const id of ["deepseek", "zhipu", "dashscope", "moonshot", "gemini", "custom"]) { + assert.equal(byId(id).needsModel, true, `${id} must pin a model`); + } + }); + + test("confirm reports false only when we KNOW the server dropped the key", () => { + const ds = c.lisaProviderList(null).find((p) => p.id === "deepseek")!; + // Old server, third-party key: it cannot have kept it. + assert.equal(c.lisaProviderConfirm(ds, { configured: false, anthropic: false }), false); + // Old server, Anthropic key it did keep. + const an = c.lisaProviderList(null).find((p) => p.id === "anthropic")!; + assert.equal(c.lisaProviderConfirm(an, { anthropic: true }), true); + // New server that lists the provider. + const st = { providers: [{ id: "deepseek", envKey: "DEEPSEEK_API_KEY", configured: true }] }; + assert.equal(c.lisaProviderConfirm(ds, st), true); + // New server that does not list it at all — unknowable, never block. + assert.equal(c.lisaProviderConfirm({ envKey: "NOPE" }, st), null); + }); +}); + +describe("interface language table (UX-8)", () => { + const ctxEn = i18nContext(); + runInContext(`${I18N_SRC}; globalThis.__tr = tr; globalThis.__loc = LISA_LOCALE;`, ctxEn); + const ctxZh = createContext({ navigator: { language: "zh-CN" }, document: { documentElement: {} } }); + runInContext(`${I18N_SRC}; globalThis.__tr = tr; globalThis.__loc = LISA_LOCALE;`, ctxZh); + const en = ctxEn as { __tr: (k: string, v?: Record) => string; __loc: string }; + const zh = ctxZh as { __tr: (k: string, v?: Record) => string; __loc: string }; + + test("navigator.language picks the locale, and document.lang follows", () => { + assert.equal(en.__loc, "en"); + assert.equal(zh.__loc, "zh-CN"); + assert.equal((ctxEn as { document: { documentElement: { lang?: string } } }).document.documentElement.lang, "en"); + assert.equal((ctxZh as { document: { documentElement: { lang?: string } } }).document.documentElement.lang, "zh-CN"); + }); + + test("both tables define exactly the same keys", () => { + const keys = (c: object) => { + const ctx = createContext({ navigator: { language: "en" }, document: { documentElement: {} } }); + runInContext(`${I18N_SRC}; globalThis.__k = Object.keys(LISA_STRINGS.en).sort().join(","); globalThis.__z = Object.keys(LISA_STRINGS['zh-CN']).sort().join(",");`, ctx); + return ctx as { __k: string; __z: string }; + }; + const k = keys({}); + assert.equal(k.__k, k.__z, "en and zh-CN tables have drifted apart"); + }); + + test("interpolation and fallback both work", () => { + assert.equal(en.__tr("rail.needs.many", { n: 3 }), "3 agents need you"); + assert.equal(zh.__tr("rail.needs.many", { n: 3 }), "3 个 agent 在等你"); + // An unknown key returns the key rather than "undefined" on screen. + assert.equal(en.__tr("nope.nope"), "nope.nope"); + }); + + test("the client carries no leftover CJK string literals", () => { + // Comments are fine; user-visible literals are not. The QQ/163 mailbox + // help quotes those providers' own Chinese UI labels ("设置", "授权码") + // and must stay as-is, so it is the one allowed island. + const cjk = /[一-鿿぀-ヿ가-힯]/; + const offenders: string[] = []; + for (const line of MAIN_CLIENT_JS.split("\n")) { + const code = line.replace(/\/\/.*$/, ""); + if (!cjk.test(code)) continue; + if (/授权码|设置|服务|账户/.test(code)) continue; // QQ / 163 provider labels + // idleHeaderLabel keeps ja/ko, which predate the table (en + zh-CN only). + if (/indexOf\('(ja|ko)'\)/.test(code)) continue; + if (code.includes("LISA_STRINGS") || code.includes("'zh-CN'")) continue; + offenders.push(line.trim()); + } + // Everything else must live in the zh-CN half of LISA_STRINGS, which sits + // between these two markers. + const zhStart = MAIN_CLIENT_JS.indexOf("'zh-CN': {"); + const zhEnd = MAIN_CLIENT_JS.indexOf("const LISA_LOCALE"); + const inTable = offenders.filter((l) => { + const at = MAIN_CLIENT_JS.indexOf(l); + return at > zhStart && at < zhEnd; + }); + assert.deepEqual( + offenders.filter((l) => !inTable.includes(l)), + [], + ); + }); +}); + +describe("no call site of the i18n helper is left un-renamed (UX-8)", () => { + test("the served source has no bare t(...) call", () => { + // "t" is a local in twenty places in this file, so a t(...) call reaching + // the i18n helper by accident — or a tr(...) call that was missed — is a + // silent TypeError only a browser would show. Scan the cooked bytes. + const offenders = MAIN_CLIENT_JS.split("\n").filter((line) => { + const code = line.replace(/\/\/.*$/, ""); + return /[^A-Za-z0-9_$.]t\(/.test(code); + }); + assert.deepEqual(offenders, []); + }); +}); + +describe("backend liveness is surfaced (UX-10)", () => { + test("every /events frame and the open event count as liveness", () => { + assert.match(MAIN_CLIENT_JS, /es\.addEventListener\('open', noteEventBytes\)/); + assert.match(MAIN_CLIENT_JS, /es\.addEventListener\('message', \(e\) => \{\s*noteEventBytes\(\);/); + assert.match(MAIN_CLIENT_JS, /es\.onerror = \(\) => \{[\s\S]{0,120}setConnPill\(true\)/); + }); + test("the quiet window is 45s and a quiet-but-open socket is probed, not assumed dead", () => { + assert.match(MAIN_CLIENT_JS, /const CONN_QUIET_MS = 45_000;/); + const fn = MAIN_CLIENT_JS.slice( + MAIN_CLIENT_JS.indexOf("async function checkConnection()"), + MAIN_CLIENT_JS.indexOf("setInterval(checkConnection, 5000)"), + ); + // readyState !== OPEN is decided locally; only the half-open case costs a + // request, and that one is rate-limited. + assert.match(fn, /es\.readyState !== 1 \) \{ setConnPill\(true\); return; \}|es\.readyState !== 1\) \{ setConnPill\(true\); return; \}/); + assert.match(fn, /fetch\('\/health'/); + assert.match(fn, /connProbeAt < 30_000/); + }); + test("the 2s chat escalation is armed on send and disarmed on the first frame and in finally", () => { + const fn = MAIN_CLIENT_JS.slice( + MAIN_CLIENT_JS.indexOf("async function runChat("), + MAIN_CLIENT_JS.indexOf("// ── send"), + ); + assert.match(fn, /let waitTimer = setTimeout\(function \(\) \{[\s\S]*?\}, 2000\);/); + assert.match(fn, /noteFrame\(\);/); + // Two clears: the first frame, and the finally that ends the turn. + assert.equal((fn.match(/clearTimeout\(waitTimer\)/g) || []).length, 2); + }); +}); + +describe("small fixes (UX-11)", () => { + const ctx = i18nContext(); + runInContext(`${I18N_SRC}\n${extractFunction(MAIN_CLIENT_JS, "abbrevPath")}; globalThis.__ab = abbrevPath;`, ctx); + const ab = (ctx as { __ab: (p: unknown) => string }).__ab; + + test("home directories abbreviate to ~, everything else is left alone", () => { + assert.equal(ab("/Users/oratis/Projects/LISA"), "~/Projects/LISA"); + assert.equal(ab("/Users/oratis"), "~"); + assert.equal(ab("/home/deploy/app"), "~/app"); + assert.equal(ab("/opt/lisa"), "/opt/lisa"); + assert.equal(ab("/UsersOfSomething/x"), "/UsersOfSomething/x"); + assert.equal(ab(""), ""); + assert.equal(ab(null), ""); + }); + + test("the Sense view only claims publishing state when a connector exists", () => { + // "Publishing active · Pause publishing" on a fresh install implied there + // was something to pause. + assert.match( + MAIN_CLIENT_JS, + /var html = connectors\.length\s*\?\s*'