in the same field stack — give it the
+ platform arrow back and a comfortable hit height. */
+ select.set-input { min-height: 34px; cursor: pointer; }
+ .set-note { font-size: 11.5px; color: var(--fg-3); line-height: 1.5; }
+ .set-note a { color: var(--accent); }
+ .set-err { font-size: 11.5px; color: var(--err-color); min-height: 14px; }
+ /* Small destructive row action (remove mailbox) */
+ .v-del {
+ flex-shrink: 0;
+ font-family: inherit;
+ font-size: 11.5px;
+ font-weight: 600;
+ cursor: pointer;
+ border-radius: 7px;
+ padding: 4px 9px;
+ border: 1px solid var(--border-new);
+ background: var(--bg-3);
+ color: var(--fg-3);
+ }
+ .v-del:hover { color: var(--err-color); border-color: var(--err-color); }
+ /* ── Knowledge base view ── */
+ .kb-searchbar { padding: 12px 24px 0; }
+ .kb-search {
+ width: 100%; box-sizing: border-box; font-family: inherit; font-size: 13px;
+ padding: 9px 12px; border-radius: 9px; border: 1px solid var(--border);
+ background: var(--bg-card); color: var(--fg); outline: none;
+ }
+ .kb-search:focus { border-color: var(--accent); }
+ .kb-body { flex: 1; min-height: 0; display: flex; overflow: hidden; }
+ .kb-list { width: 320px; flex-shrink: 0; overflow-y: auto; padding: 12px 16px 20px; border-right: 1px solid var(--border); }
+ .kb-item {
+ display: block; width: 100%; text-align: left; font-family: inherit; cursor: pointer;
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px;
+ padding: 9px 11px; margin-bottom: 8px; color: var(--fg);
+ }
+ .kb-item:hover { background: var(--bg-card-strong); border-color: var(--border-strong); }
+ .kb-row { display: flex; align-items: center; gap: 7px; }
+ .kb-badge {
+ flex-shrink: 0; font-size: 11.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
+ padding: 2px 6px; border-radius: 5px; background: var(--accent-soft); color: var(--accent);
+ }
+ .kb-badge.sources { background: rgba(255,255,255,0.06); color: var(--fg-3); }
+ .kb-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .kb-tags { display: block; margin-top: 4px; font-size: 11.5px; color: var(--accent); opacity: 0.85; }
+ .kb-excerpt { display: block; margin-top: 4px; font-size: 11.5px; color: var(--fg-3); line-height: 1.45; max-height: 34px; overflow: hidden; }
+ .kb-reader { flex: 1; min-width: 0; overflow-y: auto; padding: 18px 24px 26px; display: none; }
+ .kb-reader.open { display: block; }
+ .kb-reader-head { display: flex; align-items: center; gap: 10px; }
+ .kb-reader-head h3 { margin: 0; font-size: 15px; font-weight: 700; color: var(--fg); flex: 1; }
+ .kb-del {
+ flex-shrink: 0; font-family: inherit; cursor: pointer; font-size: 12px; line-height: 1;
+ background: transparent; border: 1px solid var(--border); border-radius: 7px; color: var(--fg-3); padding: 5px 8px;
+ }
+ .kb-del:hover { border-color: #e0555f; color: #e0555f; }
+ .kb-reader-meta { margin-top: 6px; font-size: 11.5px; color: var(--fg-3); }
+ .kb-reader-body { margin-top: 14px; white-space: pre-wrap; word-wrap: break-word; font-family: inherit; font-size: 13px; line-height: 1.6; color: var(--fg-2); }
+ @media (max-width: 720px) {
+ .kb-body { flex-direction: column; }
+ .kb-list { width: auto; border-right: 0; border-bottom: 1px solid var(--border); max-height: 45%; }
+ }
+
+ /* ── KB link ingest: paste bar (Knowledge view) + chat-bubble chip ── */
+ .kb-ingestbar { display: flex; align-items: center; gap: 8px; padding: 0 0 10px; flex-wrap: wrap; }
+ .kb-ingest-url {
+ flex: 1; min-width: 220px; font-family: inherit; font-size: 12.5px; padding: 8px 11px;
+ border-radius: 9px; border: 1px solid var(--border); background: var(--bg-card); color: var(--fg); outline: none;
+ }
+ .kb-ingest-url:focus { border-color: var(--accent); }
+ .kb-ingest-go {
+ font-family: inherit; cursor: pointer; font-size: 12.5px; font-weight: 700;
+ padding: 8px 14px; border-radius: 9px; border: 0; background: var(--accent); color: #04121f;
+ }
+ .kb-ingest-go:disabled { opacity: 0.45; cursor: default; }
+ .kb-ingest-status { font-size: 11.5px; color: var(--fg-3); }
+ .kb-ingest-status.err { color: #e0555f; }
+ .kb-ingest-chip { text-align: right; margin-top: 2px; }
+ .kb-ingest-btn {
+ font-family: inherit; cursor: pointer; font-size: 11.5px; padding: 4px 10px;
+ border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--fg-3);
+ }
+ .kb-ingest-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
+ .kb-ingest-btn:disabled { cursor: default; color: var(--fg-3); }
+
+ /* ── KB capture: select-mode highlight + floating bar + toast ── */
+ .kb-selecting .msg { cursor: pointer; }
+ .kb-selecting .msg:hover { background: rgba(255,255,255,0.04); border-radius: 6px; }
+ .msg.kb-sel { background: var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent); border-radius: 6px; }
+ .fbtn.active { background: var(--accent-soft); color: var(--accent); }
+ .kb-capture-bar {
+ position: fixed; left: 50%; bottom: 88px; transform: translateX(-50%);
+ z-index: 9998; display: none; align-items: center; gap: 10px;
+ background: var(--bg-card-strong); border: 1px solid var(--border-strong);
+ border-radius: 12px; padding: 8px 10px; box-shadow: 0 18px 44px rgba(0,0,0,0.55);
+ }
+ .kb-capture-bar.open { display: flex; }
+ .kb-cap-title {
+ font-family: inherit; font-size: 12.5px; padding: 6px 9px; width: 200px;
+ border-radius: 8px; border: 1px solid var(--border); background: var(--bg-card); color: var(--fg); outline: none;
+ }
+ .kb-cap-count { font-size: 11.5px; color: var(--fg-3); white-space: nowrap; }
+ .kb-cap-add {
+ font-family: inherit; cursor: pointer; font-size: 12.5px; font-weight: 700;
+ padding: 7px 13px; border-radius: 8px; border: 0; background: var(--accent); color: #04121f;
+ }
+ .kb-cap-add:disabled { opacity: 0.45; cursor: default; }
+ .kb-cap-cancel {
+ font-family: inherit; cursor: pointer; font-size: 12.5px; padding: 7px 10px;
+ border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--fg-3);
+ }
+ .kb-cap-cancel:hover { color: var(--fg); }
+ .kb-toast {
+ position: fixed; left: 50%; bottom: 150px; transform: translateX(-50%) translateY(10px);
+ z-index: 9999; background: var(--bg-card-strong); border: 1px solid var(--border-strong);
+ color: var(--fg); font-size: 12.5px; padding: 9px 15px; border-radius: 10px;
+ opacity: 0; transition: opacity .25s, transform .25s; pointer-events: none;
+ box-shadow: 0 12px 32px rgba(0,0,0,0.5);
+ }
+ .kb-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
+
+ #log {
+ overflow-y: auto;
+ padding: 22px 28px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ scroll-behavior: smooth;
+ }
+
+ /* ── Empty chat card (UX-4) ──────────────────────────────────────
+ The first screen after the birth ritual. Centred in the log, capped
+ so it doesn't read as a wall on a 1440px pane, and it never grows a
+ scrollbar of its own — if the log has content this card is gone. */
+ .chat-empty {
+ margin: auto;
+ max-width: 560px;
+ width: 100%;
+ padding: 22px 20px;
+ border: 1px solid var(--border-new);
+ border-radius: 16px;
+ background: var(--bg-card);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+ .chat-empty .ce-who {
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--accent);
+ }
+ .chat-empty .ce-lead { font-size: 13px; color: var(--fg-2); }
+ .chat-empty .ce-starters { display: flex; flex-direction: column; gap: 6px; }
+ .chat-empty .ce-starter {
+ text-align: left;
+ font-family: inherit;
+ font-size: 12.5px;
+ line-height: 1.45;
+ color: var(--fg);
+ background: var(--bg-3);
+ border: 1px solid var(--border-new);
+ border-radius: 10px;
+ padding: 9px 12px;
+ min-height: 36px;
+ cursor: pointer;
+ transition: border-color 140ms ease, background 140ms ease;
+ }
+ .chat-empty .ce-starter:hover {
+ border-color: var(--accent-glow);
+ background: var(--accent-soft);
+ }
+ .chat-empty .ce-can {
+ margin: 2px 0 0;
+ padding: 12px 0 0;
+ border-top: 1px solid var(--border-new);
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ font-size: 11.5px;
+ line-height: 1.5;
+ color: var(--fg-3);
+ }
+ .chat-empty .ce-can b { color: var(--fg-2); font-weight: 700; }
+
+ /* Chat author label (.role .you/.lisa) */
+ .role {
+ font-size: 11.5px;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ padding: 6px 2px 0;
+ margin-top: 6px;
+ }
+ .role.you { color: var(--accent); text-align: right; }
+ .role.lisa { color: var(--warm); }
+
+ /* Message bubble */
+ .msg {
+ display: block;
+ max-width: 88%;
+ background: var(--bg-card);
+ border: 1px solid var(--border-new);
+ border-radius: 14px;
+ padding: 9px 13px;
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--fg);
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ }
+ /* "you" bubbles sit on the right with cyan tint */
+ .role.you + .msg {
+ margin-left: auto;
+ background: linear-gradient(160deg, rgba(106, 212, 255, 0.12), rgba(106, 212, 255, 0.06));
+ border-color: rgba(106, 212, 255, 0.25);
+ }
+ /* attachment label sits compactly under the user bubble */
+ .msg.attach-label {
+ margin-top: 2px;
+ background: transparent;
+ border: 0;
+ padding: 0 2px;
+ font-size: 11.5px;
+ color: var(--fg-3);
+ max-width: 88%;
+ margin-left: auto;
+ }
+
+ /* ── Rendered Markdown (Lisa's chat bubbles + idle reflection cards) ──
+ .msg / .idle-block carry HTML from renderMarkdown() (source-injected in
+ lisa-html.ts). The base .msg keeps white-space:pre-wrap for the user's
+ plain-text bubble; the block children below reset to normal flow and carry
+ the hierarchy. Scoped to both so idle "★" notes render the same way. */
+ :is(.msg, .idle-block, .as-text) > :first-child { margin-top: 0; }
+ :is(.msg, .idle-block, .as-text) > :last-child { margin-bottom: 0; }
+ :is(.msg, .idle-block, .as-text) :is(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, table, td, th) { white-space: normal; }
+ :is(.msg, .idle-block, .as-text) p { margin: 0 0 0.6em; }
+ :is(.msg, .idle-block, .as-text) :is(h1, h2, h3, h4, h5, h6) {
+ margin: 1em 0 0.5em;
+ line-height: 1.3;
+ font-weight: 650;
+ color: var(--fg);
+ letter-spacing: 0.01em;
+ }
+ :is(.msg, .idle-block, .as-text) h1 { font-size: 18px; }
+ :is(.msg, .idle-block, .as-text) h2 {
+ font-size: 15.5px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid var(--border-new);
+ }
+ :is(.msg, .idle-block, .as-text) h3 { font-size: 13.5px; color: #cfe9ff; }
+ :is(.msg, .idle-block, .as-text) :is(h4, h5, h6) { font-size: 13px; color: var(--fg-2); }
+ :is(.msg, .idle-block, .as-text) strong { font-weight: 680; color: #fff; }
+ :is(.msg, .idle-block, .as-text) em { font-style: italic; color: var(--fg-2); }
+ :is(.msg, .idle-block, .as-text) a {
+ color: var(--accent);
+ text-decoration: none;
+ border-bottom: 1px solid rgba(106, 212, 255, 0.35);
+ }
+ :is(.msg, .idle-block, .as-text) a:hover { border-bottom-color: var(--accent); }
+ :is(.msg, .idle-block, .as-text) :is(ul, ol) { margin: 0 0 0.6em; padding-left: 1.4em; }
+ :is(.msg, .idle-block, .as-text) li { margin: 0.18em 0; }
+ :is(.msg, .idle-block, .as-text) li::marker { color: var(--accent); }
+ :is(.msg, .idle-block, .as-text) :is(ul, ol) :is(ul, ol) { margin: 0.18em 0 0; }
+ :is(.msg, .idle-block, .as-text) blockquote {
+ margin: 0 0 0.6em;
+ padding: 0.35em 0 0.35em 0.9em;
+ border-left: 3px solid var(--accent);
+ border-radius: 0 8px 8px 0;
+ color: var(--fg-2);
+ font-style: italic;
+ background: linear-gradient(90deg, var(--accent-soft), transparent 80%);
+ }
+ :is(.msg, .idle-block, .as-text) hr {
+ border: 0;
+ height: 1px;
+ margin: 0.9em 0;
+ background: linear-gradient(90deg, transparent, var(--border-strong), transparent);
+ }
+ :is(.msg, .idle-block, .as-text) :not(pre) > code {
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
+ font-size: 0.86em;
+ background: rgba(106, 212, 255, 0.10);
+ color: #a9e6ff;
+ border: 1px solid rgba(106, 212, 255, 0.16);
+ border-radius: 5px;
+ padding: 0.05em 0.36em;
+ }
+
+ /* fenced code block: header (lang + copy) over a scrollable */
+ .md-code {
+ margin: 0 0 0.6em;
+ border: 1px solid var(--border-new);
+ border-radius: 10px;
+ overflow: hidden;
+ background: rgba(3, 5, 18, 0.55);
+ }
+ .md-code-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 11px;
+ border-bottom: 1px solid var(--border-new);
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
+ font-size: 11.5px;
+ color: var(--fg-3);
+ letter-spacing: 0.05em;
+ }
+ .md-code-head .md-lang { color: var(--accent); text-transform: lowercase; }
+ .md-code .md-copy {
+ margin-left: auto;
+ font-family: inherit;
+ font-size: 11.5px;
+ color: var(--fg-3);
+ background: transparent;
+ border: 1px solid var(--border-new);
+ border-radius: 6px;
+ padding: 2px 9px;
+ cursor: pointer;
+ }
+ .md-code .md-copy:hover { color: var(--fg); border-color: var(--border-strong); }
+ .md-code pre {
+ margin: 0;
+ padding: 11px 13px;
+ overflow-x: auto;
+ white-space: pre;
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
+ font-size: 12px;
+ line-height: 1.6;
+ color: #d7e0ff;
+ }
+ .md-code pre code {
+ font: inherit;
+ color: inherit;
+ background: none;
+ border: 0;
+ padding: 0;
+ }
+
+ /* table */
+ .md-table {
+ margin: 0 0 0.6em;
+ overflow-x: auto;
+ border: 1px solid var(--border-new);
+ border-radius: 10px;
+ }
+ .md-table table { border-collapse: collapse; width: 100%; font-size: 12px; }
+ .md-table th, .md-table td {
+ text-align: left;
+ padding: 7px 12px;
+ border-bottom: 1px solid var(--border-new);
+ }
+ .md-table th {
+ background: rgba(106, 212, 255, 0.07);
+ color: #bff0ff;
+ font-weight: 650;
+ letter-spacing: 0.03em;
+ }
+ .md-table tr:last-child td { border-bottom: 0; }
+ .md-table tbody tr:nth-child(even) td { background: rgba(255, 255, 255, 0.02); }
+
+ /* Thinking indicator */
+ .thinking {
+ font-size: 12px;
+ color: var(--fg-3);
+ font-style: italic;
+ padding: 4px 2px;
+ letter-spacing: 0.03em;
+ }
+ /* Escalated after 2s of silence from /chat (UX-10) — same line, warmer
+ colour, so a stalled backend reads differently from normal thinking. */
+ .thinking.waiting { color: var(--warm); font-style: normal; }
+
+ /* Tool call card */
+ .tool-block {
+ background: var(--bg-card);
+ border: 1px solid var(--border-new);
+ border-radius: 10px;
+ padding: 8px 12px;
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
+ font-size: 11.5px;
+ color: var(--fg-2);
+ margin: 4px 0;
+ max-width: 92%;
+ }
+ .tool-block.tool-error {
+ border-color: rgba(255, 85, 119, 0.30);
+ background: rgba(255, 85, 119, 0.05);
+ }
+ .tool-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--accent);
+ font-weight: 600;
+ }
+ .tool-block.tool-error .tool-head { color: var(--err-color); }
+ .tool-icon { font-size: 12px; }
+ .tool-spinner { color: var(--fg-3); font-weight: 400; margin-left: auto; font-size: 12px; }
+ .tool-input {
+ color: var(--fg-3);
+ margin-top: 4px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+ .tool-result {
+ color: var(--fg-2);
+ margin-top: 4px;
+ padding-top: 4px;
+ border-top: 1px dashed var(--border-new);
+ white-space: pre-wrap;
+ max-height: 160px;
+ overflow-y: auto;
+ }
+
+ .err {
+ color: var(--err-color);
+ font-size: 12px;
+ padding: 6px 2px;
+ }
+ /* Failed-turn block: error detail + retry button (replaces the old bare
+ [error] line so the user can read what happened and re-run the turn). */
+ .err-block {
+ background: linear-gradient(180deg, rgba(255, 85, 119, 0.10), rgba(255, 85, 119, 0.02));
+ border: 1px solid rgba(255, 85, 119, 0.30);
+ border-radius: 14px;
+ padding: 10px 12px;
+ margin: 6px 0;
+ }
+ .err-block .err-head {
+ color: var(--err-color);
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ margin-bottom: 4px;
+ }
+ .err-block .err-detail {
+ color: var(--err-color);
+ font-size: 12px;
+ line-height: 1.45;
+ word-break: break-word;
+ white-space: pre-wrap;
+ opacity: 0.9;
+ }
+ .err-block .err-retry {
+ margin-top: 8px;
+ padding: 4px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--accent);
+ background: var(--accent-soft);
+ border: 1px solid rgba(106, 212, 255, 0.40);
+ border-radius: 8px;
+ cursor: pointer;
+ }
+ .err-block .err-retry:hover {
+ background: var(--accent-glow);
+ }
+
+ /* Idle pulse + while-you-were-away banner injected into #log */
+ .idle-pulse {
+ color: var(--dream);
+ font-size: 12px;
+ font-style: italic;
+ padding: 4px 2px;
+ letter-spacing: 0.03em;
+ }
+ .idle-block {
+ background: linear-gradient(180deg, rgba(180, 135, 255, 0.10), rgba(180, 135, 255, 0.02));
+ border: 1px solid rgba(180, 135, 255, 0.30);
+ border-radius: 14px;
+ padding: 12px 14px;
+ margin: 6px 0;
+ font-size: 12.5px;
+ line-height: 1.6;
+ color: var(--fg);
+ }
+ .idle-head {
+ font-size: 11.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.10em;
+ color: var(--dream);
+ font-weight: 700;
+ margin-bottom: 6px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+ .idle-time {
+ margin-left: auto;
+ font-weight: 400;
+ letter-spacing: 0.05em;
+ color: var(--fg-3);
+ font-size: 11.5px;
+ }
+
+ /* Composer */
+ #attachPreview {
+ padding: 4px 22px 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ min-height: 0;
+ }
+ #attachPreview:empty { padding: 0; }
+ .attach-chip {
+ background: var(--bg-card-strong);
+ border: 1px solid var(--border-strong);
+ color: var(--fg-2);
+ font-size: 11.5px;
+ padding: 3px 8px;
+ border-radius: 8px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ }
+ .attach-rm {
+ background: transparent;
+ border: 0;
+ color: var(--fg-3);
+ cursor: pointer;
+ font-size: 13px;
+ padding: 0;
+ line-height: 1;
+ min-width: 24px; min-height: 24px;
+ margin: -4px -6px -4px 0;
+ }
+ .attach-rm:hover { color: var(--err-color); }
+
+ #form {
+ padding: 14px 20px 18px;
+ background: rgba(7, 9, 26, 0.6);
+ backdrop-filter: blur(30px);
+ -webkit-backdrop-filter: blur(30px);
+ border-top: 1px solid var(--border-new);
+ display: grid;
+ grid-template-columns: 36px 36px 1fr 96px;
+ gap: 10px;
+ align-items: end;
+ }
+
+ #plusBtn, #recordBtn {
+ align-self: stretch;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ border: 0;
+ color: var(--fg-2);
+ cursor: pointer;
+ border-radius: 10px;
+ transition: background 120ms ease, color 120ms ease;
+ min-height: 44px;
+ padding: 0;
+ /* Identical hit areas: #recordBtn is a grid item (36px column) but
+ #plusBtn sits INSIDE .plus-wrap — without an explicit width it
+ shrank to its 19px icon. 100% = the 36px cell in both cases. */
+ width: 100%;
+ }
+ /* Line-style icons matching the .fbtn function bar above. */
+ #plusBtn svg, #recordBtn svg { width: 19px; height: 19px; display: block; }
+ #plusBtn:hover, #recordBtn:hover { background: var(--bg-card); color: var(--fg); }
+ #plusBtn.flash { background: var(--accent); color: var(--bg-deep); }
+
+ /* + composer menu (merged attach + screenshot) */
+ .plus-wrap { position: relative; align-self: stretch; display: flex; }
+ .plus-menu {
+ display: none;
+ position: absolute;
+ left: 0;
+ bottom: calc(100% + 8px);
+ min-width: 168px;
+ background: var(--bg-deep, #0c1024);
+ border: 1px solid var(--border-new, rgba(255,255,255,.1));
+ border-radius: 11px;
+ padding: 5px;
+ box-shadow: 0 14px 34px rgba(0,0,0,.5);
+ z-index: 30;
+ }
+ .plus-menu.open { display: block; }
+ .plus-menu button {
+ display: flex; align-items: center; gap: 9px; width: 100%;
+ background: transparent; border: 0; color: var(--fg); font-size: 13px;
+ padding: 9px 10px; border-radius: 8px; cursor: pointer; text-align: left;
+ }
+ .plus-menu button:hover { background: var(--bg-card); }
+ .plus-menu .g { width: 18px; text-align: center; }
+
+ /* Top icon function bar (功能区) inside #viewChat */
+ .fnbar {
+ display: flex; align-items: center; gap: 6px;
+ padding: 7px 16px;
+ border-bottom: 1px solid var(--border-new, rgba(255,255,255,.08));
+ background: rgba(255,255,255,.02);
+ /* The bar must fit the pane it sits in (see #viewChat above): it may scroll
+ sideways when the pane is narrower than its buttons, never widen it. */
+ min-width: 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: thin;
+ }
+ .fbtn {
+ /* 36px box (a11y floor); the ≤720px block extends the hit area to 44px. */
+ width: 36px; height: 36px; flex: none;
+ position: relative;
+ display: flex; align-items: center; justify-content: center;
+ background: transparent; border: 1px solid transparent; border-radius: 9px;
+ color: var(--fg-2); cursor: pointer;
+ transition: background 120ms ease, color 120ms ease;
+ }
+ .fbtn:hover { background: var(--bg-card, rgba(255,255,255,.06)); color: var(--fg); }
+ .fbtn svg { width: 19px; height: 19px; display: block; }
+ /* Attention badge on the collapsed right-rail toggle (UX-5). Uses the
+ "needs you" warm tone, not the accent, so it reads as "something is
+ waiting on you" rather than "this control is active". */
+ .fbtn-badge {
+ position: absolute;
+ top: -2px; right: -2px;
+ min-width: 15px; height: 15px;
+ padding: 0 3px;
+ border-radius: 999px;
+ background: var(--warm);
+ color: #1a1206;
+ font-size: 11.5px;
+ font-weight: 700;
+ line-height: 15px;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+ pointer-events: none;
+ }
+ .fbar-spacer { flex: 1; }
+ /* Theme toggle: the moon shows in Nebula (dark), the sun in Calm. */
+ body[data-theme="calm"] #fnThemeMoon { display: none; }
+ body:not([data-theme="calm"]) #fnThemeSun { display: none; }
+ .fn-find {
+ height: 28px; width: 150px; font-size: 12px; padding: 0 9px;
+ border-radius: 8px; border: 1px solid var(--border-strong, rgba(255,255,255,.14));
+ background: rgba(0,0,0,.3); color: var(--fg);
+ }
+ /* Pulsing red while recording. */
+ #recordBtn.recording {
+ background: var(--err-color);
+ color: #fff;
+ animation: rec-pulse 1.1s ease-in-out infinite;
+ }
+ @keyframes rec-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.55; } }
+ /* Off-screen the file input instead of display:none. WKWebView's
+ implicit → click forward doesn't fire on a
+ fully display:none input — the OS file picker silently no-ops.
+ Off-screening keeps the input "live" while invisible. */
+ #fileInput {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ pointer-events: none;
+ overflow: hidden;
+ }
+
+ #input {
+ background: var(--bg-card-strong);
+ border: 1px solid var(--border-strong);
+ border-radius: 14px;
+ color: var(--fg);
+ font-family: inherit;
+ font-size: 13.5px;
+ line-height: 1.45;
+ padding: 11px 13px;
+ resize: none;
+ outline: none;
+ width: 100%;
+ min-height: 44px;
+ max-height: 200px;
+ transition: border-color 120ms ease, box-shadow 120ms ease;
+ }
+ #input:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+ }
+ #input::placeholder { color: var(--fg-3); }
+
+ #sendBtn {
+ background: linear-gradient(180deg, var(--accent) 0%, #4eb8e5 100%);
+ color: #0a1024;
+ border: 0;
+ border-radius: 14px;
+ height: 100%;
+ font-family: inherit;
+ font-size: 12.5px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 10px 14px;
+ transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
+ box-shadow: 0 4px 14px rgba(106, 212, 255, 0.25);
+ }
+ #sendBtn:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 6px 18px rgba(106, 212, 255, 0.35);
+ }
+ #sendBtn:active:not(:disabled) { transform: translateY(0); }
+ #sendBtn:disabled { opacity: 0.5; cursor: wait; }
+ /* Hide the legacy pixel send icon; the text label is enough in the new theme. */
+ #sendBtn img { display: none; }
+
+ /* ── Manual right-panel collapse (F4, persisted client-side) ────
+ Collapsed is the DEFAULT state; the class is only absent once the user
+ has explicitly opened the panel (lisaRightbar === "open").
+ Wide screens only: this selector (0,1,1) outranks the bare .frame
+ rules inside the breakpoints below (0,0,1), so unscoped it forced the
+ two-column desktop grid onto phones — a 375px viewport got
+ "300px 75px" and a 75px-wide chat (UX-2). The ≤720px block owns the
+ single-column layout; the panel itself stays hidden at every width. */
+ @media (min-width: 721px) {
+ body.rb-collapsed .frame {
+ grid-template-columns: 300px 1fr;
+ grid-template-areas:
+ "titlebar titlebar"
+ "sidebar main";
+ }
+ }
+ body.rb-collapsed .rightbar { display: none; }
+ #fnPanel.active { background: var(--accent-soft); color: var(--accent); }
+
+ /* ── Narrow desktop: drop the right panel, keep two columns ──── */
+ @media (max-width: 1180px) {
+ .frame {
+ grid-template-columns: 300px 1fr;
+ grid-template-areas:
+ "titlebar titlebar"
+ "sidebar main";
+ }
+ .rightbar { display: none; }
+ }
+
+ /* ── Mobile / narrow ───────────────────────────────────────── */
+ @media (max-width: 720px) {
+ body {
+ padding-top: env(safe-area-inset-top);
+ padding-bottom: env(safe-area-inset-bottom);
+ }
+ .frame {
+ grid-template-columns: 1fr;
+ /* titlebar · sidebar (capped, scrolls) · main view-stack (fills rest) */
+ grid-template-rows: auto auto 1fr;
+ grid-template-areas:
+ "titlebar"
+ "sidebar"
+ "main";
+ /* Mobile browsers shrink the viewport as their toolbars come and go;
+ dvh tracks that so the composer never hides behind the toolbar.
+ Older engines ignore the unknown unit and keep the 100vh above. */
+ height: 100dvh;
+ }
+ /* No traffic lights to clear on a phone. */
+ .titlebar { padding: 0 14px; }
+ .rightbar { display: none; }
+ .sidebar {
+ max-height: 38vh;
+ padding: 14px 14px 12px;
+ gap: 14px;
+ }
+ /* Function bar: fewer buttons, tighter spacing. The five quick-panel
+ buttons (soul/skills/tools/plans/pair) are all reachable from the
+ Memory view, and pairing is a Mac-side (loopback-only) action anyway;
+ the right-panel toggle has nothing to toggle here (the rail is hidden
+ at this width). What remains — session chip · KB · mail · find ·
+ theme — fits a 375px row without scrolling. */
+ .fnbar { padding: 6px 10px; gap: 4px; }
+ .fnbar [data-panel], #fnPanel { display: none; }
+ /* 44px touch targets without growing the visible controls: an invisible
+ pseudo-element pads each small button's hit area (WCAG 2.5.8). The
+ buttons are position:relative above; ::after is otherwise unused. */
+ .fbtn::after, .tree-mode-btn::after, .new-btn::after, .session-ctrl .mc::after, .ctx-chip .ctx-x::after {
+ content: ""; position: absolute; inset: -4px;
+ }
+ .tree-mode-btn::after, .new-btn::after, .session-ctrl .mc::after, .ctx-chip .ctx-x::after { inset: -8px -4px; }
+ .fn-find { width: 120px; }
+ #log { padding: 14px 12px 16px; }
+ .msg { max-width: 94%; }
+ #attachPreview { padding: 4px 12px 0; }
+ #form {
+ grid-template-columns: 36px 36px 1fr 84px;
+ padding: 10px 14px 14px;
+ }
+ #input { font-size: 16px; /* prevents iOS Safari auto-zoom */ }
+ }
+
+ /* ── Forced compact / "sidebar mode" ─────────────────────────────
+ Same stacked layout as the narrow breakpoint, but toggled on at ANY
+ width (persisted client-side) so Lisa can be docked as a skinny panel. */
+ body.force-compact .frame {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto 1fr;
+ grid-template-areas:
+ "titlebar"
+ "sidebar"
+ "main";
+ }
+ body.force-compact .rightbar { display: none; }
+ body.force-compact .sidebar {
+ max-height: 38vh;
+ padding: 14px 14px 12px;
+ gap: 14px;
+ }
+ body.force-compact #form { grid-template-columns: 36px 36px 1fr 84px; }
+
+ /* ===================================================================
+ Modal panel (skills / memory / tools / soul) — unchanged from the
+ legacy pixel-art shell. Uses the legacy CSS vars declared above.
+ =================================================================== */
+ .modal-bg {
+ position: fixed; inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: none;
+ align-items: center; justify-content: center;
+ z-index: 100;
+ }
+ .modal-bg.open { display: flex; }
+ .modal {
+ background: var(--bg-card-strong);
+ border: 1px solid var(--border-strong);
+ border-radius: 16px;
+ backdrop-filter: blur(30px);
+ -webkit-backdrop-filter: blur(30px);
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
+ max-width: 720px;
+ width: 90vw;
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+ }
+ .modal-head {
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--border-new);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+ .modal-title {
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.10em;
+ text-transform: uppercase;
+ color: var(--accent);
+ }
+ .modal-close {
+ background: transparent;
+ border: 1px solid var(--border-new);
+ color: var(--fg-2);
+ font-family: inherit;
+ font-size: 11.5px;
+ letter-spacing: 0.06em;
+ padding: 5px 10px;
+ border-radius: 8px;
+ cursor: pointer;
+ }
+ .modal-close:hover { color: var(--err-color); border-color: rgba(255, 85, 119, 0.40); }
+ .modal-body {
+ padding: 16px 18px;
+ overflow-y: auto;
+ font-size: 12.5px;
+ line-height: 1.55;
+ color: var(--fg-2);
+ }
+ .modal-body h3 {
+ font-size: 11.5px;
+ font-weight: 700;
+ letter-spacing: 0.10em;
+ text-transform: uppercase;
+ color: var(--accent);
+ margin: 14px 0 6px 0;
+ border-bottom: 1px dashed var(--border-new);
+ padding-bottom: 5px;
+ }
+ .modal-body h3:first-child { margin-top: 0; }
+ .modal-body .item {
+ padding: 7px 0;
+ border-bottom: 1px dotted var(--border-new);
+ }
+ .modal-body .item:last-child { border: none; }
+ .modal-body .name {
+ color: var(--warm);
+ font-weight: 600;
+ font-size: 12px;
+ }
+ .modal-body .desc { color: var(--fg-3); margin-top: 2px; }
+ .modal-body .pair-qr {
+ display: flex;
+ justify-content: center;
+ margin: 4px 0 14px;
+ }
+ .modal-body .pair-qr svg {
+ width: 200px;
+ height: 200px;
+ background: #fff;
+ padding: 10px;
+ border-radius: 12px;
+ }
+ .modal-body .pair-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 0;
+ border-bottom: 1px solid var(--line, rgba(255,255,255,0.07));
+ }
+ .modal-body .pair-row:last-child { border: none; }
+ .modal-body .pair-label {
+ flex: 0 0 52px;
+ color: var(--fg-3);
+ font-size: 11.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ }
+ .modal-body .pair-val {
+ flex: 1 1 auto;
+ min-width: 0;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 11.5px;
+ color: var(--fg-1, #e8e8ea);
+ overflow-wrap: anywhere;
+ user-select: all;
+ }
+ /* Lifetime of the pairing token — it has none, and the panel has to say so. */
+ .modal-body .pair-note {
+ margin-top: 12px;
+ padding: 9px 12px;
+ border-radius: 9px;
+ border: 1px solid rgba(255, 208, 102, 0.32);
+ background: rgba(255, 208, 102, 0.09);
+ color: var(--fg-2);
+ font-size: 11.5px;
+ line-height: 1.5;
+ }
+ .modal-body .pair-copy {
+ flex: 0 0 auto;
+ font-family: inherit;
+ font-size: 11.5px;
+ font-weight: 700;
+ color: var(--fg-2);
+ background: var(--btn-bg, rgba(255,255,255,0.08));
+ border: 1px solid var(--line, rgba(255,255,255,0.12));
+ border-radius: 6px;
+ padding: 3px 9px;
+ cursor: pointer;
+ }
+ .modal-body .pair-copy:hover { filter: brightness(1.12); }
+ .modal-body .plan-select {
+ margin-top: 6px;
+ font-family: inherit;
+ font-size: 11.5px;
+ font-weight: 700;
+ padding: 4px 12px;
+ border: 0;
+ border-radius: 8px;
+ cursor: pointer;
+ background: var(--accent);
+ color: #07091a;
+ }
+ .modal-body .plan-select:hover { filter: brightness(1.08); }
+ .modal-body .plan-select[disabled] {
+ background: var(--fg-faint);
+ color: var(--fg-3);
+ cursor: default;
+ }
+ .modal-body pre {
+ background: rgba(0, 0, 0, 0.30);
+ padding: 10px 12px;
+ border-left: 2px solid var(--accent);
+ border-radius: 4px;
+ white-space: pre-wrap;
+ margin: 6px 0;
+ color: var(--fg);
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
+ font-size: 11.5px;
+ line-height: 1.5;
+ }
+ .modal-body .empty { color: var(--fg-3); font-style: italic; }
+
+ /* ── ⌘K session switcher (UX-11) ─────────────────────────────────
+ A minimal palette: filter field + list, anchored near the top so the
+ eye does not have to travel. Uses [hidden] rather than a class so it is
+ inert for assistive tech when closed. */
+ .kbd-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 900;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding-top: 14vh;
+ background: rgba(0, 0, 0, 0.42);
+ backdrop-filter: blur(3px);
+ -webkit-backdrop-filter: blur(3px);
+ }
+ .kbd-overlay[hidden] { display: none; }
+ .kbd-panel {
+ width: min(520px, 92vw);
+ max-height: 62vh;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-1);
+ border: 1px solid var(--border-strong);
+ border-radius: 14px;
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.5);
+ overflow: hidden;
+ }
+ .kbd-input {
+ flex: none;
+ border: 0;
+ border-bottom: 1px solid var(--border-new);
+ background: transparent;
+ color: var(--fg);
+ font-family: inherit;
+ font-size: 14px;
+ padding: 13px 15px;
+ }
+ .kbd-input:focus { outline: none; }
+ .kbd-list { overflow-y: auto; padding: 6px; }
+ .kbd-empty { color: var(--fg-3); font-size: 12.5px; font-style: italic; padding: 12px 9px; }
+ .kbd-row {
+ width: 100%;
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ text-align: left;
+ font-family: inherit;
+ font-size: 12.5px;
+ color: var(--fg-2);
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 9px;
+ padding: 8px 10px;
+ min-height: 36px;
+ cursor: pointer;
+ }
+ .kbd-row .kbd-row-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .kbd-row .kbd-row-meta { flex: none; font-size: 11.5px; color: var(--fg-3); font-variant-numeric: tabular-nums; }
+ .kbd-row.current .kbd-row-name { color: var(--accent); }
+ .kbd-row.sel {
+ background: var(--accent-soft);
+ border-color: var(--accent-glow);
+ color: var(--fg);
+ }
+
+ /* ===================================================================
+ Birth ritual overlay — full-screen, one-time. Uses legacy palette
+ intentionally (it's a separate ceremonial moment).
+ =================================================================== */
+ .birth-overlay {
+ position: fixed;
+ inset: 0;
+ background:
+ radial-gradient(ellipse at center, rgba(40, 30, 80, 0.95) 0%, rgba(5, 5, 20, 1) 70%);
+ z-index: 9999;
+ display: none;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 32px;
+ }
+ .birth-overlay.open { display: flex; }
+ .birth-content {
+ position: relative;
+ z-index: 2;
+ width: min(800px, 95vw);
+ max-height: 90vh;
+ overflow-y: auto;
+ }
+ .birth-stars {
+ text-align: center;
+ color: var(--accent);
+ font-size: 14px;
+ letter-spacing: 8px;
+ text-shadow: 0 0 8px var(--accent-glow);
+ animation: starBlink 1.5s steps(3) infinite;
+ }
+ @keyframes starBlink {
+ 0%, 30%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+ }
+ .birth-title {
+ text-align: center;
+ color: var(--warm);
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: 6px;
+ margin: 24px 0 32px;
+ text-shadow: 0 0 12px rgba(255, 208, 102, 0.45);
+ }
+ .birth-step {
+ margin: 14px 0;
+ padding: 10px 16px;
+ border-left: 3px solid var(--border-new);
+ background: rgba(20, 26, 64, 0.55);
+ border-radius: 0 8px 8px 0;
+ opacity: 0;
+ transform: translateY(8px);
+ transition: opacity 0.4s ease-out, transform 0.4s ease-out;
+ }
+ .birth-step.shown { opacity: 1; transform: translateY(0); }
+ .birth-step.active {
+ border-left-color: var(--warm);
+ background: rgba(255, 208, 102, 0.08);
+ }
+ .birth-step.done { border-left-color: var(--accent); }
+ .birth-step .step-name {
+ font-size: 11.5px;
+ font-weight: 700;
+ letter-spacing: 0.10em;
+ color: var(--fg-3);
+ text-transform: uppercase;
+ margin-bottom: 4px;
+ }
+ .birth-step.active .step-name { color: var(--warm); }
+ .birth-step.done .step-name { color: var(--accent); }
+ .birth-step .step-detail {
+ color: var(--fg);
+ font-size: 13px;
+ line-height: 1.55;
+ word-break: break-word;
+ }
+ .birth-step .step-cursor {
+ display: inline-block;
+ width: 6px;
+ background: var(--warm);
+ height: 0.9em;
+ vertical-align: middle;
+ animation: blink 0.8s steps(2) infinite;
+ }
+ @keyframes blink { 50% { opacity: 0; } }
+ .birth-final {
+ margin-top: 36px;
+ text-align: center;
+ color: var(--warm);
+ font-size: 15px;
+ font-weight: 700;
+ letter-spacing: 0.10em;
+ text-shadow: 0 0 10px rgba(255, 208, 102, 0.4);
+ opacity: 0;
+ transition: opacity 0.6s ease-in;
+ }
+ .birth-final.shown { opacity: 1; }
+ .birth-enter {
+ margin: 24px auto 0;
+ display: block;
+ background: linear-gradient(180deg, var(--accent) 0%, #4eb8e5 100%);
+ border: 0;
+ color: #0a1024;
+ font-family: inherit;
+ font-weight: 700;
+ font-size: 13px;
+ padding: 12px 32px;
+ border-radius: 12px;
+ cursor: pointer;
+ letter-spacing: 0.10em;
+ opacity: 0;
+ transition: opacity 0.6s ease-in, transform 120ms ease;
+ box-shadow: 0 6px 18px rgba(106, 212, 255, 0.35);
+ }
+ .birth-enter.shown { opacity: 1; }
+ .birth-enter:hover { transform: translateY(-1px); }
+ .birth-error {
+ color: var(--err-color);
+ text-align: center;
+ margin-top: 24px;
+ font-size: 12.5px;
+ line-height: 1.55;
+ max-width: 44ch;
+ margin-left: auto;
+ margin-right: auto;
+ }
+ /* Cancel during the ritual; Change key / Try again after it fails (UX-1). */
+ .birth-actions {
+ display: flex;
+ justify-content: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-top: 18px;
+ }
+ .birth-action {
+ font-family: inherit;
+ font-size: 11.5px;
+ font-weight: 700;
+ letter-spacing: 0.10em;
+ text-transform: uppercase;
+ min-height: 36px;
+ padding: 0 18px;
+ border-radius: 999px;
+ cursor: pointer;
+ color: var(--fg-2);
+ background: transparent;
+ border: 1px solid var(--border-strong);
+ }
+ .birth-action:hover { color: var(--fg); border-color: var(--accent-glow); }
+ .birth-action.primary {
+ color: var(--accent);
+ border-color: var(--accent-glow);
+ background: var(--accent-soft);
+ }
+
+ /* ===================================================================
+ API-key config overlay — same modernized treatment.
+ =================================================================== */
+ .cfg-overlay {
+ position: fixed;
+ inset: 0;
+ background:
+ radial-gradient(ellipse at center, rgba(40, 30, 80, 0.95) 0%, rgba(5, 5, 20, 1) 70%);
+ z-index: 9998;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ padding: 32px;
+ }
+ .cfg-overlay.open { display: flex; }
+ .cfg-card {
+ position: relative;
+ z-index: 2;
+ width: min(520px, 95vw);
+ background: var(--bg-card-strong);
+ border: 1px solid var(--border-strong);
+ border-radius: 16px;
+ backdrop-filter: blur(30px);
+ -webkit-backdrop-filter: blur(30px);
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
+ padding: 28px 32px;
+ }
+ .cfg-stars {
+ text-align: center;
+ color: var(--accent);
+ font-size: 12px;
+ letter-spacing: 6px;
+ text-shadow: 0 0 8px var(--accent-glow);
+ animation: starBlink 1.5s steps(3) infinite;
+ }
+ .cfg-title {
+ text-align: center;
+ color: var(--warm);
+ font-size: 16px;
+ font-weight: 700;
+ letter-spacing: 6px;
+ margin: 16px 0 6px;
+ }
+ .cfg-sub {
+ text-align: center;
+ color: var(--fg-3);
+ font-size: 12.5px;
+ margin-bottom: 22px;
+ line-height: 1.5;
+ }
+ .cfg-sub a { color: var(--accent); text-decoration: underline; }
+ /* Why you are looking at this form again (UX-1) — only set when the gate
+ is reopened after a failed or cancelled ritual. */
+ .cfg-reason {
+ margin: -8px auto 18px;
+ max-width: 44ch;
+ padding: 9px 13px;
+ border-radius: 10px;
+ border: 1px solid rgba(255, 85, 119, 0.35);
+ background: rgba(255, 85, 119, 0.10);
+ color: var(--fg-2);
+ font-size: 12.5px;
+ line-height: 1.5;
+ text-align: center;
+ }
+ .cfg-field { display: block; margin: 14px 0; }
+ .cfg-label {
+ display: block;
+ font-size: 11.5px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--accent);
+ margin-bottom: 6px;
+ }
+ .cfg-label .opt {
+ color: var(--fg-3);
+ font-size: 11.5px;
+ margin-left: 6px;
+ text-transform: none;
+ letter-spacing: 0;
+ font-weight: 400;
+ }
+ .cfg-input {
+ width: 100%;
+ background: var(--bg-card);
+ border: 1px solid var(--border-strong);
+ border-radius: 10px;
+ color: var(--fg);
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
+ font-size: 13px;
+ padding: 10px 12px;
+ }
+ .cfg-input:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+ }
+ select.cfg-input { font-family: inherit; min-height: 40px; cursor: pointer; }
+ .cfg-help {
+ color: var(--fg-3);
+ font-size: 11.5px;
+ margin-top: 6px;
+ }
+ .cfg-help code {
+ color: var(--fg-2);
+ background: rgba(0, 0, 0, 0.3);
+ padding: 1px 5px;
+ border-radius: 3px;
+ font-size: 11.5px;
+ }
+ .cfg-actions {
+ margin-top: 22px;
+ display: flex;
+ justify-content: center;
+ }
+ .cfg-save {
+ background: linear-gradient(180deg, var(--accent) 0%, #4eb8e5 100%);
+ border: 0;
+ color: #0a1024;
+ font-family: inherit;
+ font-size: 12.5px;
+ font-weight: 700;
+ padding: 11px 28px;
+ border-radius: 12px;
+ cursor: pointer;
+ letter-spacing: 0.10em;
+ box-shadow: 0 4px 14px rgba(106, 212, 255, 0.25);
+ transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
+ }
+ .cfg-save:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba(106, 212, 255, 0.35); }
+ .cfg-save:active { transform: translateY(0); }
+ .cfg-save:disabled { opacity: 0.55; cursor: wait; }
+ .cfg-error {
+ color: var(--err-color);
+ font-size: 11.5px;
+ text-align: center;
+ margin-top: 14px;
+ min-height: 14px;
+ }
+
+ /* ── Reduced motion (UX-3) ───────────────────────────────────────
+ One override block rather than wrapping each animation where it is
+ declared, so every looping effect (breathing/halo pips, star blink,
+ record pulse, typewriter cursor) and the reveal transitions are
+ silenced together and a future animation only needs a selector added
+ here. State stays legible without motion: pips keep their colour, the
+ record button stays red, revealed birth steps are simply visible. */
+ @media (prefers-reduced-motion: reduce) {
+ .session-row .pip, .ctrl-row .cr-pip, .tleaf .pip, .ctx-chip .pip, .needs-row .pip,
+ #recordBtn.recording, .birth-stars, .cfg-stars, .birth-step .step-cursor { animation: none; }
+ .birth-step, .birth-final, .birth-enter, .kb-toast, .identity .avatar-wrap img,
+ .ctrl-row, .nav-item, .fbtn, .badge, #input, #sendBtn, .cfg-save,
+ .chat-empty .ce-starter, .copy-btn { transition: none; }
+ #log { scroll-behavior: auto; }
+ }
\ No newline at end of file
diff --git a/src/web/assets/client/main.js b/src/web/assets/client/main.js
new file mode 100644
index 0000000..d386c76
--- /dev/null
+++ b/src/web/assets/client/main.js
@@ -0,0 +1,5054 @@
+// ── First-run safety net: never leave the user with a silent dead UI. Any
+// uncaught error / unreachable backend surfaces as a banner instead of nothing.
+function lisaBanner(msg) {
+ var el = document.getElementById('lisaBanner');
+ if (!el) {
+ el = document.createElement('div');
+ el.id = 'lisaBanner';
+ el.style.cssText = 'position:fixed;left:50%;top:12px;transform:translateX(-50%);z-index:99999;max-width:min(700px,92vw);padding:11px 16px;border-radius:12px;background:rgba(255,85,119,.14);border:1px solid rgba(255,85,119,.5);color:#ffc2cf;font:13px/1.5 -apple-system,system-ui,sans-serif;box-shadow:0 8px 30px rgba(0,0,0,.5);user-select:text';
+ document.body.appendChild(el);
+ }
+ el.textContent = msg;
+ el.hidden = false;
+}
+function lisaClearBanner() { var el = document.getElementById('lisaBanner'); if (el) el.hidden = true; }
+function lisaSleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
+window.addEventListener('error', function (e) {
+ if (e && e.message) lisaBanner('Lisa UI error: ' + e.message + ' — reload (Cmd-R); restart the backend if it persists.');
+});
+window.addEventListener('unhandledrejection', function (e) {
+ var r = e && e.reason;
+ lisaBanner('Lisa task failed: ' + ((r && r.message) ? r.message : String(r)));
+});
+
+// ── Interface language (UX-8) ────────────────────────────────────
+// The shell was English with ~30 Chinese literals mixed into it (the KB
+// button, the error block, the chat status). One table, two locales, picked
+// from navigator.language; anything missing falls back to English rather than
+// rendering a key. Only the strings this pass touches are in here — it is a
+// seam for the rest, not a claim that the whole UI is translated.
+const LISA_STRINGS = {
+ en: {
+ 'session.new': 'New session',
+ 'chat.thinking': 'Lisa is thinking',
+ 'chat.replying': 'Lisa is replying',
+ 'chat.done': 'Lisa finished replying',
+ 'chat.failed': 'The request failed',
+ 'idle.header': 'WHILE YOU WERE AWAY',
+ 'empty.lead': 'Say anything — or start here:',
+ 'empty.starter.desire': 'How is "{desire}" going?',
+ 'empty.starter.open': 'What is on your mind right now?',
+ 'empty.starter.memory': 'What do you remember about me?',
+ 'empty.starter.today': 'What should we work on today?',
+ 'empty.can.tools.k': 'Tools',
+ 'empty.can.tools.v': 'ask her to read a file, run a command, or look something up on the web.',
+ 'empty.can.kb.k': 'Knowledge',
+ 'empty.can.kb.v': 'select any message and save it — she will recall it in later sessions.',
+ 'empty.can.mail.k': 'Mail',
+ 'empty.can.mail.v': 'connect a mailbox in the right rail and she triages it for you daily.',
+ 'rail.toggle': 'Collapse / expand the right panel',
+ 'rail.toggleAria': 'Toggle right panel',
+ 'rail.needs.one': '1 agent needs you',
+ 'rail.needs.many': '{n} agents need you',
+ 'rail.needs.unit.one': 'agent needs you',
+ 'rail.needs.unit.many': 'agents need you',
+ 'rail.needs.open': '{noun} — open the right panel',
+ 'rail.needs.openAria': '{noun}, open the right panel',
+ 'birth.err.auth': 'That API key was rejected by the provider. Enter a different one and Lisa will try again.',
+ 'birth.err.timeout': 'The provider took too long to answer. Nothing was lost — try again.',
+ 'birth.err.network': 'Could not reach the provider. Check the network on this machine, then try again.',
+ 'birth.err.rate_limit': 'The provider is rate-limiting this key right now. Wait a minute, then try again.',
+ 'birth.err.unknown': 'Something went wrong while she was waking up.',
+ 'birth.cancel': 'Cancel',
+ 'birth.cancelled': 'Cancelled. Set a key and Lisa will start again.',
+ 'birth.changeKey': 'Change key',
+ 'birth.retry': 'Try again',
+ 'gate.title.set': 'SET · API · KEY',
+ 'gate.title.change': 'CHANGE · API · KEY',
+ 'gate.pickProvider': 'Pick a provider.',
+ 'gate.keyRequired': '{env} is required.',
+ 'gate.baseUrlRequired': 'A custom endpoint needs its base URL.',
+ 'gate.saveFailed': 'Save failed: ',
+ 'gate.alreadySet': ' (configured — a new value replaces it)',
+ 'gate.getKey': 'Get a key for {label} ↗',
+ 'gate.providerDefault': 'provider default',
+ 'gate.unsupported': 'This Lisa did not keep the {label} key — it only accepts Anthropic and OpenAI keys. Update Lisa (npm i -g @oratis/lisa), or add {env}=... to ~/.lisa/config.env and restart.',
+ 'kb.save': '💾 Save to knowledge base',
+ 'kb.saving': 'Saving…',
+ 'kb.saved': 'Saved to knowledge base ✓',
+ 'kb.exists': 'Already in the knowledge base ✓',
+ 'kb.failed': 'Save failed',
+ 'err.request': '⚠ Request failed',
+ 'err.retry': '↻ Retry',
+ 'conn.reconnecting': 'reconnecting…',
+ 'chat.waiting': '⋯ waiting for the backend',
+ 'copy': 'Copy',
+ 'copied': 'Copied',
+ 'copyPath': 'Copy the full path',
+ 'pair.noExpiry': 'This pairing link carries a device token that does not expire — anyone who gets it can reach this Lisa. Revoke the device in Settings when you are done with it.',
+ 'sense.noConnector': 'Nothing is published — no social connector is installed.',
+ 'kbd.switchPlaceholder': 'Switch session…',
+ 'kbd.noSessions': 'No sessions match.',
+ 'kbd.title': 'KEYBOARD',
+ 'kbd.switch': 'Switch session',
+ 'kbd.focus': 'Focus the composer',
+ 'kbd.find': 'Find in this conversation',
+ 'kbd.close': 'Close the switcher, find bar or a panel',
+ 'kbd.send': 'Send · newline',
+ 'kbd.help': 'This list',
+ },
+ 'zh-CN': {
+ 'session.new': '新会话',
+ 'chat.thinking': 'Lisa 正在思考',
+ 'chat.replying': 'Lisa 正在回复',
+ 'chat.done': 'Lisa 回复完成',
+ 'chat.failed': '请求失败',
+ 'idle.header': '你不在的时候',
+ 'empty.lead': '随便说点什么 —— 或者从这里开始:',
+ 'empty.starter.desire': '“{desire}”进展如何?',
+ 'empty.starter.open': '你现在在想什么?',
+ 'empty.starter.memory': '你还记得我的什么?',
+ 'empty.starter.today': '我们今天做点什么?',
+ 'empty.can.tools.k': '工具',
+ 'empty.can.tools.v': '可以让她读文件、执行命令,或者上网查东西。',
+ 'empty.can.kb.k': '知识库',
+ 'empty.can.kb.v': '选中任意消息存进去,之后的会话她还会记得。',
+ 'empty.can.mail.k': '邮件',
+ 'empty.can.mail.v': '在右栏连接一个邮箱,她每天帮你分拣。',
+ 'rail.toggle': '收起 / 展开右侧面板',
+ 'rail.toggleAria': '切换右侧面板',
+ 'rail.needs.one': '1 个 agent 在等你',
+ 'rail.needs.many': '{n} 个 agent 在等你',
+ 'rail.needs.unit.one': '个 agent 在等你',
+ 'rail.needs.unit.many': '个 agent 在等你',
+ 'rail.needs.open': '{noun} —— 打开右侧面板',
+ 'rail.needs.openAria': '{noun},打开右侧面板',
+ 'birth.err.auth': '这个 API key 被服务商拒绝了。换一个再试一次。',
+ 'birth.err.timeout': '服务商响应太慢。什么都没丢,再试一次。',
+ 'birth.err.network': '连不上服务商。检查这台机器的网络后再试。',
+ 'birth.err.rate_limit': '这个 key 正在被限流。等一分钟再试。',
+ 'birth.err.unknown': '她醒来的过程中出了点问题。',
+ 'birth.cancel': '取消',
+ 'birth.cancelled': '已取消。填一个 key,Lisa 会重新开始。',
+ 'birth.changeKey': '更换 key',
+ 'birth.retry': '重试',
+ 'gate.title.set': '设 · 置 · KEY',
+ 'gate.title.change': '更 · 换 · KEY',
+ 'gate.pickProvider': '先选一个服务商。',
+ 'gate.keyRequired': '需要填写 {env}。',
+ 'gate.baseUrlRequired': '自定义端点需要填 base URL。',
+ 'gate.saveFailed': '保存失败:',
+ 'gate.alreadySet': '(已配置 —— 填新值会覆盖)',
+ 'gate.getKey': '去申请 {label} 的 key ↗',
+ 'gate.providerDefault': '服务商默认',
+ 'gate.unsupported': '这个 Lisa 没有保存 {label} 的 key —— 它只接受 Anthropic 和 OpenAI 的 key。请升级 Lisa(npm i -g @oratis/lisa),或者把 {env}=... 写进 ~/.lisa/config.env 后重启。',
+ 'kb.save': '💾 存入知识库',
+ 'kb.saving': '保存中…',
+ 'kb.saved': '已存入知识库 ✓',
+ 'kb.exists': '已在知识库 ✓',
+ 'kb.failed': '保存失败',
+ 'err.request': '⚠ 请求出错',
+ 'err.retry': '↻ 重试',
+ 'conn.reconnecting': '重新连接…',
+ 'chat.waiting': '⋯ 正在等待后端',
+ 'copy': '复制',
+ 'copied': '已复制',
+ 'copyPath': '复制完整路径',
+ 'pair.noExpiry': '这个配对链接里的设备令牌不会过期 —— 拿到它的人就能连上这个 Lisa。用完之后请在 Settings 里吊销该设备。',
+ 'sense.noConnector': '没有发布任何内容 —— 尚未安装社交连接器。',
+ 'kbd.switchPlaceholder': '切换会话…',
+ 'kbd.noSessions': '没有匹配的会话。',
+ 'kbd.title': '快捷键',
+ 'kbd.switch': '切换会话',
+ 'kbd.focus': '聚焦输入框',
+ 'kbd.find': '在当前对话中查找',
+ 'kbd.close': '关闭切换器、查找栏或面板',
+ 'kbd.send': '发送 · 换行',
+ 'kbd.help': '这个列表',
+ },
+};
+const LISA_LOCALE = (function () {
+ var l = String((navigator && navigator.language) || 'en').toLowerCase();
+ return l.indexOf('zh') === 0 ? 'zh-CN' : 'en';
+})();
+// The document ships as lang="en"; correct it so screen readers and hyphenation
+// follow the language actually rendered.
+try { document.documentElement.lang = LISA_LOCALE; } catch (e) {}
+// Named tr(), not t(): "t" is already a local variable in twenty places in
+// this file (const t = e.target, const t = await res.text(), …) and one of
+// them — sessionLabel — shadowed the helper into a string.
+function tr(key, vars) {
+ var table = LISA_STRINGS[LISA_LOCALE] || LISA_STRINGS.en;
+ var s = table[key];
+ if (s == null) s = LISA_STRINGS.en[key];
+ if (s == null) return key;
+ if (vars) {
+ for (var k in vars) s = s.split('{' + k + '}').join(String(vars[k]));
+ }
+ return s;
+}
+
+const log = document.getElementById('log');
+const input = document.getElementById('input');
+const form = document.getElementById('form');
+const sendBtn = document.getElementById('sendBtn');
+const sessionEl = document.getElementById('sessionId');
+const fileInput = document.getElementById('fileInput');
+const attachPreview = document.getElementById('attachPreview');
+
+// ── Attached files state ──────────────────────────────────────────
+let pendingFiles = []; // Array of {name, mediaType, data (base64)}
+
+function readFileAsBase64(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ // readAsDataURL always yields a string; FileReader.result is typed
+ // string|ArrayBuffer because readAsArrayBuffer shares the field.
+ const dataUrl = String(reader.result);
+ const base64 = dataUrl.split(',')[1];
+ resolve(base64);
+ };
+ reader.onerror = reject;
+ reader.readAsDataURL(file);
+ });
+}
+
+function inferMediaType(file) {
+ if (file.type) return file.type;
+ const ext = file.name.split('.').pop().toLowerCase();
+ const map = { pdf: 'application/pdf', txt: 'text/plain', md: 'text/plain', csv: 'text/csv', json: 'application/json' };
+ return map[ext] || 'application/octet-stream';
+}
+
+// ── Vision: screenshot → composer ──────────────────────────────────
+// lisaAttachImage adds an already-encoded {name,mediaType,data} object to
+// the pending attachments — used by both the in-page 📷 button and the
+// native global hotkey (Lisa.app calls lisaCaptureAndAttach via JS bridge).
+window.lisaAttachImage = function (file) {
+ if (!file || !file.data) return;
+ pendingFiles.push({
+ name: file.name || 'screenshot.png',
+ mediaType: file.mediaType || 'image/png',
+ data: file.data,
+ });
+ renderAttachPreview();
+ try { input.focus(); } catch (_) {}
+};
+
+// lisaPrefillComposer drops text into the composer WITHOUT sending — the user
+// reads it and hits Enter to send (that send is the confirmation). Used by the
+// island's screen-advisor "Optimize ▸" card (via the Swift bridge) and by the
+// ?prefill= URL param (plain browser tabs).
+window.lisaPrefillComposer = function (text) {
+ if (!text || !input) return;
+ input.value = String(text);
+ try { input.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {}
+ try { input.focus(); input.setSelectionRange(input.value.length, input.value.length); } catch (_) {}
+};
+
+// On load, honour ?prefill=… (the island opens /?prefill= in a browser
+// tab). Strip it from the URL afterwards so a refresh doesn't re-fill.
+try {
+ var _pf = new URLSearchParams(location.search).get('prefill');
+ if (_pf) {
+ window.lisaPrefillComposer(_pf);
+ history.replaceState(null, '', location.pathname);
+ }
+} catch (_) {}
+
+// Room (#viewRoom iframe) → parent GUI actions. Keeps every room interaction
+// in-client (this WKWebView) — never a browser window. Same-origin guarded so
+// only our own room frame can drive view switches / composer prefill.
+window.addEventListener('message', function (e) {
+ if (e.origin !== location.origin) return;
+ var d = e && e.data;
+ if (!d || d.type !== 'lisa-room') return;
+ switch (d.action) {
+ case 'open-chat':
+ if (d.prefill) { try { window.lisaPrefillComposer(d.prefill); } catch (_) {} }
+ try { window.lisaShowView('chat'); } catch (_) {}
+ break;
+ case 'switch-view':
+ if (d.view) { try { window.lisaShowView(d.view); } catch (_) {} }
+ break;
+ }
+});
+
+// lisaCaptureAndAttach asks the server to run a screen capture, then
+// attaches the result. mode: 'interactive' (crosshair, default) | 'full'.
+// Returns true if an image was attached, false if cancelled/failed.
+// Exposed on window so the native app's global hotkey can invoke it.
+let capturing = false;
+window.lisaCaptureAndAttach = async function (mode) {
+ if (capturing) return false;
+ capturing = true;
+ const btn = document.getElementById('plusBtn');
+ if (btn) btn.classList.add('flash');
+ try {
+ const res = await fetch('/api/vision/capture', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ mode: mode || 'interactive' }),
+ });
+ if (!res.ok) {
+ console.warn('[vision] capture failed: HTTP ' + res.status);
+ return false;
+ }
+ const data = await res.json();
+ if (data.cancelled || !data.file) return false;
+ window.lisaAttachImage(data.file);
+ return true;
+ } catch (err) {
+ console.warn('[vision] capture error:', err);
+ return false;
+ } finally {
+ capturing = false;
+ if (btn) setTimeout(() => btn.classList.remove('flash'), 200);
+ }
+};
+
+function renderAttachPreview() {
+ attachPreview.innerHTML = '';
+ pendingFiles.forEach((f, i) => {
+ const chip = document.createElement('span');
+ chip.className = 'attach-chip';
+ chip.textContent = f.name;
+ const rm = document.createElement('button');
+ rm.type = 'button';
+ rm.className = 'attach-rm';
+ rm.textContent = '×';
+ rm.onclick = () => { pendingFiles.splice(i, 1); renderAttachPreview(); };
+ chip.appendChild(rm);
+ attachPreview.appendChild(chip);
+ });
+}
+
+fileInput.addEventListener('change', async () => {
+ for (const file of fileInput.files) {
+ const data = await readFileAsBase64(file);
+ pendingFiles.push({ name: file.name, mediaType: inferMediaType(file), data });
+ }
+ fileInput.value = '';
+ renderAttachPreview();
+});
+
+// The natural → click-forwarding is supposed
+// to open the picker without any JS. But in WKWebView under our
+// layout it never reaches runOpenPanel. We add an explicit click
+// listener that forwards the click synchronously (preserving the
+// user-gesture context) and logs to console so we can verify in the
+// inspector if it ever silently no-ops again.
+// ── + composer menu: merges 📎 attach + 📷 screenshot into one button ──
+const plusBtnEl = document.getElementById('plusBtn');
+const plusMenuEl = document.getElementById('plusMenu');
+function closePlusMenu() { if (plusMenuEl) plusMenuEl.classList.remove('open'); }
+if (plusBtnEl && plusMenuEl) {
+ plusBtnEl.addEventListener('click', (e) => { e.stopPropagation(); plusMenuEl.classList.toggle('open'); });
+ document.addEventListener('click', (e) => {
+ if (plusMenuEl.classList.contains('open') && !plusMenuEl.contains(e.target) && e.target !== plusBtnEl) closePlusMenu();
+ });
+}
+const pmAttachEl = document.getElementById('pmAttach');
+if (pmAttachEl) {
+ pmAttachEl.addEventListener('click', () => {
+ closePlusMenu();
+ try { fileInput.click(); } catch (err) { console.error('[attach] fileInput.click failed:', err); }
+ });
+}
+const pmShotEl = document.getElementById('pmShot');
+if (pmShotEl) {
+ pmShotEl.addEventListener('click', () => { closePlusMenu(); void window.lisaCaptureAndAttach('interactive'); });
+}
+
+// ── Audio recording → transcribe → Lisa summarizes ─────────────────
+// 🎙 toggles a MediaRecorder. On stop: POST the clip to /api/voice/transcribe
+// (server-side Whisper), then send the transcript into the normal chat with a
+// "summarize this" framing — so Lisa produces the summary in her own voice and
+// it's persisted + discussable like any turn. First click prompts mic
+// permission (browser-native).
+const recordBtnEl = document.getElementById('recordBtn');
+let mediaRecorder = null;
+let recordedChunks = [];
+let recordStream = null;
+// 'dictation' (default): polish speech → composer for you to edit + send.
+// 'summary' (long-press 🎙): the original record→Lisa-summarizes flow.
+let recordMode = 'dictation';
+
+function pickAudioMime() {
+ const prefs = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg'];
+ for (const m of prefs) {
+ try { if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m; } catch (_) {}
+ }
+ return '';
+}
+
+async function startRecording() {
+ if (!navigator.mediaDevices || !window.MediaRecorder) {
+ el('div', 'err', '[voice] recording not supported in this browser');
+ return;
+ }
+ try {
+ recordStream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ } catch (err) {
+ el('div', 'err', '[voice] microphone access denied or unavailable');
+ return;
+ }
+ const mimeType = pickAudioMime();
+ recordedChunks = [];
+ mediaRecorder = mimeType ? new MediaRecorder(recordStream, { mimeType }) : new MediaRecorder(recordStream);
+ mediaRecorder.addEventListener('dataavailable', (e) => { if (e.data && e.data.size > 0) recordedChunks.push(e.data); });
+ mediaRecorder.addEventListener('stop', () => { void finishRecording(); });
+ mediaRecorder.start();
+ if (recordBtnEl) { recordBtnEl.classList.add('recording'); recordBtnEl.textContent = '⏹'; recordBtnEl.title = 'Stop recording'; }
+}
+
+function stopRecordingTracks() {
+ if (recordStream) { recordStream.getTracks().forEach((t) => t.stop()); recordStream = null; }
+ if (recordBtnEl) { recordBtnEl.classList.remove('recording'); recordBtnEl.textContent = '🎙'; recordBtnEl.title = 'Dictate — speak and Lisa drops polished text in the box (hold to record a summary)'; }
+}
+
+async function finishRecording() {
+ const mime = (mediaRecorder && mediaRecorder.mimeType) || 'audio/webm';
+ stopRecordingTracks();
+ const mode = recordMode;
+ const blob = new Blob(recordedChunks, { type: mime });
+ recordedChunks = [];
+ if (blob.size === 0) return;
+ // Transient status line: dictation polishes; summary transcribes.
+ const pending = el('div', 'thinking', mode === 'dictation' ? '⋯ transcribing + polishing' : '⋯ transcribing recording');
+ try {
+ const data = await blobToBase64(blob);
+ const res = await fetch('/api/voice/transcribe', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ data, mediaType: mime, mode }),
+ });
+ const out = await res.json();
+ if (pending) pending.remove();
+ if (!res.ok || out.error) {
+ el('div', 'err', '[voice] ' + (out.error || ('HTTP ' + res.status)));
+ return;
+ }
+ if (mode === 'dictation') {
+ // Typeless-style: drop the polished text into the composer (cursor at end)
+ // for you to review and send. Never auto-sends. Appends if you'd already
+ // started typing.
+ const text = (out.text || out.transcript || '').trim();
+ if (!text) { el('div', 'err', '[voice] (nothing dictated)'); return; }
+ const existing = input.value.trimEnd();
+ input.value = existing ? existing + '\n' + text : text;
+ try { input.dispatchEvent(new Event('input', { bubbles: true })); } catch (_) {}
+ try { input.focus(); input.setSelectionRange(input.value.length, input.value.length); } catch (_) {}
+ return;
+ }
+ // summary mode: hand the transcript to Lisa with a summarize framing.
+ const transcript = (out.transcript || '').trim();
+ if (!transcript) { el('div', 'err', '[voice] (empty transcript)'); return; }
+ const framed =
+ "I just recorded some audio. Here's the transcript — please give me a clear, " +
+ "useful summary (key points, decisions, action items if any), then I might ask follow-ups.\n\n" +
+ "--- transcript ---\n" + transcript;
+ send(framed);
+ } catch (err) {
+ if (pending) pending.remove();
+ el('div', 'err', '[voice] ' + (err && err.message ? err.message : 'transcription failed'));
+ }
+}
+
+function blobToBase64(blob) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result).split(',')[1] || '');
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+}
+
+if (recordBtnEl) {
+ // Short click → dictation (polished text into the composer). Press-and-hold
+ // (≥500ms) → summary (record → Lisa summarizes). A click always follows
+ // pointerup, so we just flag long-presses and read the flag on click.
+ let longPress = false;
+ let holdTimer = null;
+ const clearHold = () => { if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; } };
+ recordBtnEl.addEventListener('pointerdown', () => {
+ longPress = false;
+ clearHold();
+ holdTimer = setTimeout(() => { longPress = true; }, 500);
+ });
+ recordBtnEl.addEventListener('pointerup', clearHold);
+ recordBtnEl.addEventListener('pointercancel', clearHold);
+ recordBtnEl.addEventListener('pointerleave', clearHold);
+ recordBtnEl.addEventListener('click', () => {
+ if (mediaRecorder && mediaRecorder.state === 'recording') {
+ mediaRecorder.stop();
+ longPress = false;
+ return;
+ }
+ recordMode = longPress ? 'summary' : 'dictation';
+ longPress = false;
+ if (recordBtnEl) recordBtnEl.title = recordMode === 'summary' ? 'Recording for summary — click to stop' : 'Dictating — click to stop';
+ void startRecording();
+ });
+}
+
+// (📎 attach is now an item in the + composer menu — see pmAttach above.)
+
+// Paste-to-attach: when the user has copied an image (screenshot,
+// image from a webpage, etc.) and presses ⌘V, pull the image off the
+// clipboard and add it to pendingFiles — same path the file picker
+// uses. Plain-text paste falls through to default behavior.
+//
+// We listen at the DOCUMENT level so paste works whether the textarea
+// is focused, the chat log is focused, or focus is on a tool-block.
+// (Some users press ⌘V right after launching the app, before clicking
+// anywhere — element-scoped handlers miss that.)
+//
+// For text paste we MUST NOT preventDefault — let the browser route
+// it to the focused element. Only intercept when we detect image
+// items.
+async function handlePastedClipboard(ev) {
+ const cb = ev.clipboardData;
+ if (!cb) return;
+
+ // Two clipboard surfaces — items (modern, exposes pasteboard files)
+ // and files (older, doesn't include MIME type for some types). We
+ // dedupe via the underlying File reference.
+ const fileMap = new Map(); // File → mediaType (string)
+ if (cb.items && cb.items.length) {
+ for (const item of cb.items) {
+ if (item.kind !== 'file') continue;
+ if (!item.type || !item.type.startsWith('image/')) continue;
+ const f = item.getAsFile();
+ if (f) fileMap.set(f, item.type);
+ }
+ }
+ if (cb.files && cb.files.length) {
+ for (const f of cb.files) {
+ if (f.type && f.type.startsWith('image/')) {
+ if (!fileMap.has(f)) fileMap.set(f, f.type || 'image/png');
+ }
+ }
+ }
+
+ if (fileMap.size === 0) return; // text paste — leave default behavior alone
+
+ ev.preventDefault();
+ // Keep focus on the textarea so the user can keep typing right after.
+ try { input.focus(); } catch (_) {}
+
+ for (const [file, mediaType] of fileMap) {
+ try {
+ const data = await readFileAsBase64(file);
+ const ext = (mediaType.split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '');
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ const name = file.name && file.name !== 'image.png'
+ ? file.name
+ : 'pasted-' + ts + '.' + ext;
+ pendingFiles.push({ name, mediaType, data });
+ } catch (err) {
+ console.warn('[paste] failed to read image:', err);
+ }
+ }
+ renderAttachPreview();
+}
+
+input.addEventListener('paste', (ev) => { void handlePastedClipboard(ev); });
+// Document-level fallback. If focus is somewhere other than the
+// textarea (e.g. just-launched, no focus yet), the textarea-level
+// paste listener never fires; this one catches it.
+document.addEventListener('paste', (ev) => {
+ // Avoid double-firing when the textarea ALREADY handled it: if
+ // ev.defaultPrevented is set, the textarea listener already
+ // consumed the paste and added the file.
+ if (ev.defaultPrevented) return;
+ void handlePastedClipboard(ev);
+});
+
+// Surface session id from server header on first request. The titlebar
+// shows the same id with a leading "·" separator after the "Lisa" label.
+// Session switching (PLAN_UI_SESSION_SHELL_v1.0): the active id is tracked
+// here; lisaSetActiveSession is the one entry point every switch path uses —
+// tree clicks, tab clicks, +New, and the session_switched SSE frame from
+// another window — so they all converge on the same reload behavior.
+let activeSessionId = null;
+function setActiveSessionUI(id) {
+ activeSessionId = id;
+ window.lisaActiveSessionId = id;
+ sessionEl.textContent = id;
+ updateTitlebarSession();
+}
+// UX-4: the title bar used to read "Lisa · 20260905-220846-9f7d58". It shows
+// the session's human label now ("New session · just now" until the first
+// message names it) and keeps the id in the tooltip, so the id is still one
+// hover away for anyone matching it against ~/.lisa/sessions.
+function updateTitlebarSession() {
+ const tag = document.getElementById('titlebarSession');
+ if (!tag) return;
+ const id = window.lisaActiveSessionId;
+ if (!id) { tag.textContent = ''; tag.title = ''; return; }
+ const label = (typeof window.lisaSessionLabel === 'function' && window.lisaSessionLabel(id)) || id;
+ tag.textContent = '· ' + label;
+ tag.title = id;
+}
+window.lisaUpdateTitlebar = updateTitlebarSession;
+window.lisaSetActiveSession = function (id) {
+ if (!id || id === activeSessionId) return;
+ setActiveSessionUI(id);
+ if (typeof window.lisaClearUnread === 'function') window.lisaClearUnread(id);
+ if (typeof window.lisaResetChatLog === 'function') window.lisaResetChatLog();
+ if (typeof window.lisaRenderSessionTree === 'function') window.lisaRenderSessionTree();
+};
+fetch('/session').then(r => r.json()).then(s => {
+ setActiveSessionUI(s.id);
+ if (typeof window.lisaRenderSessionTree === 'function') window.lisaRenderSessionTree();
+});
+
+// ── While-you-were-away idle note: shared card builder + localized label ──
+// Idle reflections render as a distinct card — never a plain Lisa bubble — so
+// they don't blend into her actual replies, both live (SSE) and on history
+// reload. The label follows the UI language; the note body is already written
+// in the user's language by the idle runner.
+function idleHeaderLabel() {
+ // ja/ko are not in the string table (this pass only added en + zh-CN) but
+ // the idle card already had them — keep them rather than regress.
+ var l = String((navigator && navigator.language) || 'en').toLowerCase();
+ if (l.indexOf('ja') === 0) return '不在のあいだに';
+ if (l.indexOf('ko') === 0) return '자리를 비운 사이';
+ return tr('idle.header');
+}
+function buildIdleBlock(text, at) {
+ const block = document.createElement('div');
+ block.className = 'idle-block';
+ const head = document.createElement('div');
+ head.className = 'idle-head';
+ head.textContent = '★ ' + idleHeaderLabel();
+ if (at) {
+ const time = document.createElement('span');
+ time.className = 'idle-time';
+ try { time.textContent = new Date(at).toLocaleTimeString(); } catch (e) {}
+ head.appendChild(time);
+ }
+ block.appendChild(head);
+ const bodyEl = document.createElement('div');
+ // Idle notes are Lisa's own reflections → render their Markdown too.
+ bodyEl.innerHTML = renderMarkdown(text);
+ block.appendChild(bodyEl);
+ return block;
+}
+
+// ── Connection state (UX-10) ──────────────────────────────────────
+// A slow or wedged backend used to be completely invisible: the UI only said
+// anything once a request failed outright, so the in-between (5s+ stalls seen
+// on the daily driver) looked like nothing happening. This surfaces it.
+//
+// Note on the heartbeat contract: the server sends ": ping" SSE comments, and
+// EventSource does NOT surface comments to JS at all — they keep the socket
+// warm but fire no event. So liveness here is: readyState (authoritative for
+// a closed/reconnecting stream) plus, for the half-open case where the socket
+// still claims OPEN but nothing has arrived for 45s, one direct /health probe
+// rather than a guess.
+const CONN_QUIET_MS = 45_000;
+let connLastBytesAt = Date.now();
+let connProbeAt = 0;
+let connCurrentEs = null;
+function setConnPill(on) {
+ const pill = document.getElementById('connPill');
+ if (!pill) return;
+ if (on && pill.hidden) pill.textContent = tr('conn.reconnecting');
+ pill.hidden = !on;
+}
+function noteEventBytes() {
+ connLastBytesAt = Date.now();
+ setConnPill(false);
+}
+async function checkConnection() {
+ const es = connCurrentEs;
+ // 0 = CONNECTING (the browser is retrying), 2 = CLOSED.
+ if (!es || es.readyState !== 1) { setConnPill(true); return; }
+ if (Date.now() - connLastBytesAt < CONN_QUIET_MS) return;
+ if (Date.now() - connProbeAt < 30_000) return;
+ connProbeAt = Date.now();
+ try {
+ const ctl = new AbortController();
+ const to = setTimeout(function () { ctl.abort(); }, 4000);
+ const r = await fetch('/health', { signal: ctl.signal });
+ clearTimeout(to);
+ if (r && r.ok) { noteEventBytes(); return; }
+ } catch (e) {}
+ setConnPill(true);
+}
+setInterval(checkConnection, 5000);
+
+// ── Persistent /events SSE: mood updates + idle messages + Claude
+// activity, lifetime of page.
+function connectEvents() {
+ const es = new EventSource('/events');
+ connCurrentEs = es;
+ es.addEventListener('open', noteEventBytes);
+ let idlePulseEl = null;
+ es.addEventListener('message', (e) => {
+ noteEventBytes();
+ const ev = JSON.parse(e.data);
+ if (ev.type === 'mood') {
+ setMood(ev.slug);
+ } else if (ev.type === 'idle_start') {
+ if (!idlePulseEl) {
+ idlePulseEl = document.createElement('div');
+ idlePulseEl.className = 'idle-pulse';
+ idlePulseEl.textContent = '⋯ Lisa is thinking on her own time ⋯';
+ removeChatEmpty();
+ log.appendChild(idlePulseEl);
+ log.scrollTop = log.scrollHeight;
+ }
+ } else if (ev.type === 'idle_message') {
+ if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }
+ removeChatEmpty();
+ log.appendChild(buildIdleBlock(ev.text, ev.at));
+ log.scrollTop = log.scrollHeight;
+ // sidebar reflection card mirrors the latest while-you-were-away
+ if (typeof updateReflection === 'function') updateReflection(ev.text);
+ } else if (ev.type === 'idle_done') {
+ if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }
+ } else if (ev.type === 'idle_error') {
+ if (idlePulseEl) { idlePulseEl.remove(); idlePulseEl = null; }
+ const e2 = document.createElement('div');
+ e2.className = 'err';
+ e2.textContent = '[idle error] ' + ev.message;
+ removeChatEmpty();
+ log.appendChild(e2);
+ } else if (ev.type === 'agent_session_update') {
+ // D4a — sidebar multi-agent monitor refresh (defined later in the
+ // "sidebar live wiring" block). Generalized from claude_session_update
+ // so codex / opencode / git / … sessions update the sidebar too.
+ if (typeof refreshClaudeSessions === 'function') refreshClaudeSessions();
+ } else if (ev.type === 'session_switched') {
+ // Another window (or this one) moved the active web session — converge.
+ if (typeof window.lisaSetActiveSession === 'function') window.lisaSetActiveSession(ev.session);
+ } else if (ev.type === 'focus_session') {
+ // F5 island deep link — select an agent session in the tree/inspector.
+ if (typeof window.lisaFocusAgent === 'function') window.lisaFocusAgent(ev.agent, ev.session);
+ } else if (ev.type === 'chat_end') {
+ // A turn just finished (any window/session) — the ledger moved.
+ if (typeof window.refreshTokens === 'function') window.refreshTokens();
+ } else if (ev.type === 'mail_digest_update' || ev.type === 'mail_accounts_update') {
+ if (typeof window.refreshMail === 'function') window.refreshMail();
+ }
+ });
+ es.onerror = () => {
+ es.close();
+ setConnPill(true);
+ setTimeout(connectEvents, 3000); // reconnect
+ };
+}
+connectEvents();
+
+// ── Providers ────────────────────────────────────────────────────
+// The gate and the Settings view both drive off one table. A new server
+// reports it on /api/config/status as
+// providers: [{id, envKey, label, modelPrefixes, configured}]
+// plus a top-level "model"; this list is the fallback for servers that do not,
+// and it also supplies the parts the server has no opinion on (placeholder,
+// console link, a suggested model). Entries mirror src/providers/registry.ts.
+const LISA_PROVIDER_FALLBACK = [
+ { id: 'anthropic', envKey: 'ANTHROPIC_API_KEY', label: 'Anthropic (Claude)',
+ placeholder: 'sk-ant-...', model: 'claude-sonnet-4-6',
+ consoleUrl: 'https://console.anthropic.com/' },
+ { id: 'openai', envKey: 'OPENAI_API_KEY', label: 'OpenAI (GPT)',
+ placeholder: 'sk-...', model: 'gpt-4o',
+ consoleUrl: 'https://platform.openai.com/api-keys' },
+ { id: 'deepseek', envKey: 'DEEPSEEK_API_KEY', label: 'DeepSeek',
+ placeholder: 'sk-...', model: 'deepseek-chat',
+ consoleUrl: 'https://platform.deepseek.com/api_keys', needsModel: true },
+ { id: 'zhipu', envKey: 'ZHIPU_API_KEY', label: 'Zhipu GLM',
+ placeholder: 'key...', model: 'glm-4-plus',
+ consoleUrl: 'https://open.bigmodel.cn/usercenter/apikeys', needsModel: true },
+ { id: 'dashscope', envKey: 'DASHSCOPE_API_KEY', label: 'Aliyun Qwen',
+ placeholder: 'sk-...', model: 'qwen-plus',
+ consoleUrl: 'https://bailian.console.aliyun.com/', needsModel: true },
+ { id: 'moonshot', envKey: 'MOONSHOT_API_KEY', label: 'Moonshot Kimi',
+ placeholder: 'sk-...', model: 'moonshot-v1-32k',
+ consoleUrl: 'https://platform.moonshot.cn/console/api-keys', needsModel: true },
+ { id: 'gemini', envKey: 'GEMINI_API_KEY', label: 'Google Gemini',
+ placeholder: 'AIza...', model: 'gemini-2.5-pro',
+ consoleUrl: 'https://aistudio.google.com/apikey', needsModel: true },
+ { id: 'custom', envKey: 'LISA_API_KEY', label: 'Custom (OpenAI-compatible)',
+ placeholder: 'key...', model: '', custom: true, consoleUrl: '', needsModel: true },
+];
+// Merge what the server knows (which keys exist, which are configured) with
+// the local presentation hints. Matching on envKey first: ids are the server's
+// to choose, the environment variable name is the stable identity.
+// Shape of a fallback-table row, for providers the server reports that this
+// client has no hints for. Spelling the keys out (rather than {}) keeps the
+// merge below readable and type-checkable.
+const EMPTY_PROVIDER_HINT = {
+ id: '', envKey: '', label: '', placeholder: '', model: '',
+ consoleUrl: '', custom: false, needsModel: false,
+};
+function lisaProviderList(status) {
+ const hintOf = function (envKey, id) {
+ for (let i = 0; i < LISA_PROVIDER_FALLBACK.length; i++) {
+ const h = LISA_PROVIDER_FALLBACK[i];
+ if (h.envKey === envKey || (id && h.id === id)) return h;
+ }
+ return null;
+ };
+ const served = status && Array.isArray(status.providers) ? status.providers : null;
+ if (!served || !served.length) {
+ // No providers block: an older server. Mark configured from the two
+ // booleans it does report.
+ return LISA_PROVIDER_FALLBACK.map(function (h) {
+ const conf = h.envKey === 'ANTHROPIC_API_KEY' ? !!(status && status.anthropic)
+ : h.envKey === 'OPENAI_API_KEY' ? !!(status && status.openai) : false;
+ return { id: h.id, envKey: h.envKey, label: h.label, placeholder: h.placeholder,
+ model: h.model, consoleUrl: h.consoleUrl, custom: !!h.custom,
+ needsModel: !!h.needsModel, configured: conf, served: false };
+ });
+ }
+ return served.map(function (p) {
+ const h = hintOf(p.envKey, p.id) || EMPTY_PROVIDER_HINT;
+ return {
+ id: p.id || h.id || p.envKey,
+ envKey: p.envKey || h.envKey || '',
+ label: p.label || h.label || p.id || p.envKey,
+ placeholder: h.placeholder || 'key...',
+ model: h.model || '',
+ consoleUrl: h.consoleUrl || '',
+ custom: !!h.custom,
+ needsModel: !!h.needsModel,
+ configured: !!p.configured,
+ served: true,
+ };
+ });
+}
+// One body understood by both generations of the endpoint: the new
+// {keys, model, baseUrl} shape plus every legacy field name.
+function lisaProviderSaveBody(provider, key, model, baseUrl) {
+ const body = { keys: {} };
+ if (key && provider.envKey) body.keys[provider.envKey] = key;
+ if (model) body.model = model;
+ if (baseUrl) body.baseUrl = baseUrl;
+ if (key && provider.envKey === 'ANTHROPIC_API_KEY') { body.anthropicKey = key; body.anthropic = key; }
+ if (key && provider.envKey === 'OPENAI_API_KEY') { body.openaiKey = key; body.openai = key; }
+ return body;
+}
+// After a save: did this server actually keep the key? true / false / null
+// when there is no way to tell. An older server silently drops anything that
+// is not an Anthropic or OpenAI key, which is exactly the case worth naming.
+function lisaProviderConfirm(provider, status) {
+ const served = status && Array.isArray(status.providers) ? status.providers : null;
+ if (served && served.length) {
+ for (let i = 0; i < served.length; i++) {
+ if (served[i].envKey === provider.envKey) return !!served[i].configured;
+ }
+ return null;
+ }
+ if (provider.envKey === 'ANTHROPIC_API_KEY') return !!(status && status.anthropic);
+ if (provider.envKey === 'OPENAI_API_KEY') return !!(status && status.openai);
+ return false;
+}
+function lisaProviderUnsupportedNote(provider) {
+ return tr('gate.unsupported', { label: provider.label, env: provider.envKey });
+}
+// The Settings view lives in the console closure at the bottom of this file.
+window.lisaProviderList = lisaProviderList;
+window.lisaProviderSaveBody = lisaProviderSaveBody;
+window.lisaProviderConfirm = lisaProviderConfirm;
+window.lisaProviderUnsupportedNote = lisaProviderUnsupportedNote;
+
+// ── API key config gate: show overlay if no key is configured ─────
+const cfgOverlay = document.getElementById('cfgOverlay');
+const cfgForm = document.getElementById('cfgForm');
+const cfgProvider = document.getElementById('cfgProvider');
+const cfgKey = document.getElementById('cfgKey');
+const cfgKeyLabel = document.getElementById('cfgKeyLabel');
+const cfgModel = document.getElementById('cfgModel');
+const cfgBaseUrl = document.getElementById('cfgBaseUrl');
+const cfgBaseUrlField = document.getElementById('cfgBaseUrlField');
+const cfgConsole = document.getElementById('cfgConsole');
+const cfgSaveBtn = document.getElementById('cfgSave');
+const cfgError = document.getElementById('cfgError');
+
+// Populated by startupGate / openKeyGate from /api/config/status.
+let cfgProviders = lisaProviderList(null);
+let cfgStatus = null;
+function cfgSelected() {
+ for (let i = 0; i < cfgProviders.length; i++) {
+ if (cfgProviders[i].id === cfgProvider.value) return cfgProviders[i];
+ }
+ return cfgProviders[0];
+}
+function cfgSyncProvider() {
+ const p = cfgSelected();
+ if (!p) return;
+ cfgKeyLabel.textContent = p.envKey + (p.configured ? tr('gate.alreadySet') : '');
+ cfgKey.placeholder = p.placeholder;
+ cfgModel.placeholder = p.model || tr('gate.providerDefault');
+ // Only the custom endpoint needs a base URL, and it needs a model too.
+ cfgBaseUrlField.style.display = p.custom ? '' : 'none';
+ if (cfgConsole) {
+ if (p.consoleUrl) {
+ cfgConsole.href = p.consoleUrl;
+ cfgConsole.textContent = tr('gate.getKey', { label: p.label });
+ cfgConsole.style.display = '';
+ } else {
+ cfgConsole.textContent = '';
+ cfgConsole.style.display = 'none';
+ }
+ }
+}
+function cfgRenderProviders(status) {
+ cfgStatus = status || cfgStatus;
+ cfgProviders = lisaProviderList(cfgStatus);
+ const keep = cfgProvider.value;
+ cfgProvider.innerHTML = '';
+ cfgProviders.forEach(function (p) {
+ const o = document.createElement('option');
+ o.value = p.id;
+ o.textContent = p.label + (p.configured ? ' · configured' : '');
+ cfgProvider.appendChild(o);
+ });
+ // Preselect: whatever is already picked (so a repair keeps the provider
+ // that just failed), else the one this Lisa is actually configured with,
+ // else Anthropic.
+ let firstConfigured = '';
+ for (let i = 0; i < cfgProviders.length && !firstConfigured; i++) {
+ if (cfgProviders[i].configured) firstConfigured = cfgProviders[i].id;
+ }
+ const wanted = keep || firstConfigured || 'anthropic';
+ cfgProvider.value = wanted;
+ if (!cfgProvider.value) cfgProvider.value = cfgProviders[0] ? cfgProviders[0].id : '';
+ cfgSyncProvider();
+}
+cfgProvider.addEventListener('change', cfgSyncProvider);
+
+// UX-1: the gate used to be one-shot. Once a key was written to config.env
+// /api/config/status reported "configured" forever, so a REJECTED key left no
+// way back — the form never returned and the Settings view sat behind the
+// birth overlay. It is reopenable now, and remembers whether this visit is the
+// first run or a repair, so the copy and the post-save path can differ.
+let cfgReconfigure = false;
+function openKeyGate(opts) {
+ cfgReconfigure = !!(opts && opts.reconfigure);
+ const title = document.getElementById('cfgTitle');
+ if (title) title.textContent = tr(cfgReconfigure ? 'gate.title.change' : 'gate.title.set');
+ const reason = document.getElementById('cfgReason');
+ if (reason) {
+ reason.textContent = (opts && opts.reason) ? opts.reason : '';
+ reason.style.display = reason.textContent ? '' : 'none';
+ }
+ // Never prefill the rejected key — retyping is the point. The PROVIDER is
+ // preselected (opts.provider, else whatever is already chosen), so a repair
+ // starts on the provider that just failed rather than back at Anthropic.
+ cfgKey.value = '';
+ cfgError.textContent = '';
+ cfgSaveBtn.disabled = false;
+ if (opts && opts.provider) cfgProvider.value = opts.provider;
+ cfgRenderProviders(cfgStatus);
+ birthOverlay.classList.remove('open');
+ cfgOverlay.classList.add('open');
+ setTimeout(() => cfgKey.focus(), 50);
+}
+
+cfgForm.addEventListener('submit', async (ev) => {
+ ev.preventDefault();
+ cfgError.textContent = '';
+ const provider = cfgSelected();
+ const key = cfgKey.value.trim();
+ // Anthropic and OpenAI are auto-detected server-side from the key alone;
+ // every other provider must pin LISA_MODEL or the run falls back to Claude.
+ const model = cfgModel.value.trim() ||
+ (provider && provider.needsModel && provider.model ? provider.model : '');
+ const baseUrl = provider && provider.custom ? cfgBaseUrl.value.trim() : '';
+ if (!provider) { cfgError.textContent = tr('gate.pickProvider'); return; }
+ if (!key) { cfgError.textContent = tr('gate.keyRequired', { env: provider.envKey }); return; }
+ if (provider.custom && !baseUrl) { cfgError.textContent = tr('gate.baseUrlRequired'); return; }
+ cfgSaveBtn.disabled = true;
+ try {
+ const res = await fetch('/api/config/save', {
+ method: 'POST',
+ headers: {'content-type': 'application/json'},
+ body: JSON.stringify(lisaProviderSaveBody(provider, key, model, baseUrl)),
+ });
+ if (!res.ok) {
+ const txt = await res.text().catch(() => '');
+ cfgError.textContent = tr('gate.saveFailed') + 'HTTP ' + res.status + (txt ? ' — ' + txt.slice(0, 120) : '');
+ cfgSaveBtn.disabled = false;
+ return;
+ }
+ // Confirm the server actually kept it. An older backend silently drops
+ // anything that is not an Anthropic/OpenAI key, which would otherwise send
+ // the user into a ritual that cannot possibly succeed.
+ const after = await fetch('/api/config/status').then(r => r.json()).catch(() => null);
+ if (after) { cfgStatus = after; cfgRenderProviders(after); }
+ if (after && lisaProviderConfirm(provider, after) === false) {
+ cfgError.textContent = lisaProviderUnsupportedNote(provider);
+ cfgSaveBtn.disabled = false;
+ return;
+ }
+ cfgKey.value = '';
+ cfgOverlay.classList.remove('open');
+ // A repair restarts the ritual in place — no location.reload(), so the
+ // page keeps its SSE connection, its log and its scroll position.
+ if (cfgReconfigure) { cfgReconfigure = false; beginBirth(); }
+ else maybeBirth();
+ } catch (err) {
+ cfgError.textContent = tr('gate.saveFailed') + err.message;
+ cfgSaveBtn.disabled = false;
+ }
+});
+
+// ── Birth ritual: show overlay if Lisa hasn't been born yet ───────
+const birthOverlay = document.getElementById('birthOverlay');
+const birthStepsEl = document.getElementById('birthSteps');
+const birthFinalEl = document.getElementById('birthFinal');
+const birthEnterBtn = document.getElementById('birthEnter');
+const birthErrorEl = document.getElementById('birthError');
+const birthActionsEl = document.getElementById('birthActions');
+
+birthEnterBtn.addEventListener('click', () => {
+ birthOverlay.classList.remove('open');
+ setTimeout(() => location.reload(), 300);
+});
+
+// The ritual can now run more than once per page load (a repaired key, a
+// retry after a timeout), so its UI needs a clean slate between runs.
+function resetBirthUI() {
+ birthStepsEl.innerHTML = '';
+ birthFinalEl.textContent = '';
+ birthFinalEl.classList.remove('shown');
+ birthEnterBtn.classList.remove('shown');
+ birthErrorEl.textContent = '';
+ birthErrorEl.title = '';
+ clearBirthActions();
+}
+function clearBirthActions() {
+ birthActionsEl.innerHTML = '';
+ birthActionsEl.style.display = 'none';
+}
+function birthAction(label, onClick, primary) {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'birth-action' + (primary ? ' primary' : '');
+ b.textContent = label;
+ b.addEventListener('click', onClick);
+ birthActionsEl.appendChild(b);
+ birthActionsEl.style.display = '';
+ return b;
+}
+function beginBirth() {
+ resetBirthUI();
+ birthOverlay.classList.add('open');
+ startBirthStream();
+}
+
+// Live stream control (UX-1): the ritual is a long POST that used to be
+// un-cancellable — closing the tab was the only exit, and the server kept
+// inferring. Cancel aborts the fetch and hands the user back to the gate.
+let birthAbort = null;
+function abortBirth() {
+ if (!birthAbort) return;
+ try { birthAbort.abort(); } catch (e) {}
+ birthAbort = null;
+}
+
+// Human text for every failure class. The old client printed the provider's
+// raw payload — users saw 401 {"type":"error","error":{"type":
+// "authentication_error",...}} and had nothing to do about it.
+const BIRTH_ERROR_TEXT = {
+ auth: tr('birth.err.auth'),
+ timeout: tr('birth.err.timeout'),
+ network: tr('birth.err.network'),
+ rate_limit: tr('birth.err.rate_limit'),
+ unknown: tr('birth.err.unknown'),
+};
+// New servers send {kind:"error", code, message, retryable}. Older ones send
+// {kind:"error", message} with the raw provider text — classify those by hand
+// so the same human copy shows either way. Substring tests, not regexes: this
+// file is a template literal and every backslash here would need doubling.
+function birthErrorCode(ev) {
+ const code = ev && typeof ev.code === 'string' ? ev.code : '';
+ if (code && BIRTH_ERROR_TEXT[code]) return code;
+ const raw = String((ev && ev.message) || '');
+ const low = raw.toLowerCase();
+ if (raw.indexOf('401') === 0 || raw.indexOf('403') === 0 ||
+ low.indexOf('authentication_error') >= 0 || low.indexOf('invalid_api_key') >= 0 ||
+ low.indexOf('invalid api key') >= 0 || low.indexOf('unauthorized') >= 0) return 'auth';
+ if (raw.indexOf('429') === 0 || low.indexOf('rate_limit') >= 0 || low.indexOf('rate limit') >= 0) return 'rate_limit';
+ if (low.indexOf('timeout') >= 0 || low.indexOf('timed out') >= 0 || low.indexOf('etimedout') >= 0) return 'timeout';
+ if (low.indexOf('enotfound') >= 0 || low.indexOf('econnrefused') >= 0 ||
+ low.indexOf('econnreset') >= 0 || low.indexOf('eai_again') >= 0 ||
+ low.indexOf('fetch failed') >= 0 || low.indexOf('network') >= 0) return 'network';
+ return 'unknown';
+}
+function showBirthError(ev) {
+ abortBirth();
+ const code = birthErrorCode(ev);
+ birthErrorEl.textContent = BIRTH_ERROR_TEXT[code] || BIRTH_ERROR_TEXT.unknown;
+ // The provider payload is diagnostics, not copy — reachable on hover and in
+ // the DOM for a bug report, never rendered as the message.
+ birthErrorEl.title = String((ev && ev.message) || '');
+ clearBirthActions();
+ const changeKey = function () {
+ openKeyGate({ reconfigure: true, reason: BIRTH_ERROR_TEXT[code] || BIRTH_ERROR_TEXT.unknown });
+ };
+ if (code === 'auth') {
+ birthAction(tr('birth.changeKey'), changeKey, true);
+ return;
+ }
+ // Anything the server marks non-retryable gets the key path instead.
+ if (ev && ev.retryable === false) { birthAction(tr('birth.changeKey'), changeKey, true); return; }
+ birthAction(tr('birth.retry'), beginBirth, true);
+ birthAction(tr('birth.changeKey'), changeKey);
+}
+
+async function maybeBirth() {
+ const status = await fetch('/api/soul').then(r => r.json());
+ if (status.born) return;
+ beginBirth();
+}
+
+function appendBirthStep(step) {
+ const prevActive = birthStepsEl.querySelector('.birth-step.active');
+ if (prevActive) {
+ prevActive.classList.remove('active');
+ prevActive.classList.add('done');
+ }
+ const div = document.createElement('div');
+ div.className = 'birth-step active';
+ const name = document.createElement('div');
+ name.className = 'step-name';
+ name.textContent = step;
+ div.appendChild(name);
+ const detail = document.createElement('div');
+ detail.className = 'step-detail';
+ const cursor = document.createElement('span');
+ cursor.className = 'step-cursor';
+ detail.appendChild(cursor);
+ div.appendChild(detail);
+ birthStepsEl.appendChild(div);
+ setTimeout(() => div.classList.add('shown'), 50);
+ return detail;
+}
+
+function typewriter(el, text, done) {
+ const cursor = el.querySelector('.step-cursor');
+ if (cursor) cursor.remove();
+ let i = 0;
+ const speed = Math.max(8, Math.min(28, 600 / text.length));
+ function tick() {
+ if (i >= text.length) {
+ const c = document.createElement('span');
+ c.className = 'step-cursor';
+ el.appendChild(c);
+ done && done();
+ return;
+ }
+ el.appendChild(document.createTextNode(text[i]));
+ i++;
+ el.parentElement.parentElement.scrollTop = el.parentElement.parentElement.scrollHeight;
+ setTimeout(tick, speed);
+ }
+ tick();
+}
+
+async function startBirthStream() {
+ birthErrorEl.textContent = '';
+ let currentDetail = null;
+ let queue = [];
+ let processing = false;
+
+ function processQueue() {
+ if (processing) return;
+ if (queue.length === 0) return;
+ processing = true;
+ const ev = queue.shift();
+ if (ev.kind === 'step') {
+ currentDetail = appendBirthStep(ev.name);
+ typewriter(currentDetail, ev.detail || '', () => {
+ processing = false;
+ processQueue();
+ });
+ } else if (ev.kind === 'done') {
+ const last = birthStepsEl.querySelector('.birth-step.active');
+ if (last) { last.classList.remove('active'); last.classList.add('done'); }
+ birthFinalEl.textContent = ev.message;
+ birthFinalEl.classList.add('shown');
+ birthEnterBtn.classList.add('shown');
+ processing = false;
+ } else if (ev.kind === 'error') {
+ showBirthError(ev);
+ processing = false;
+ }
+ }
+
+ // Cancel is offered for the whole run and withdrawn the moment the stream
+ // ends — it aborts the fetch and returns to the gate rather than leaving the
+ // user staring at a ritual they cannot stop.
+ abortBirth();
+ const ctrl = new AbortController();
+ birthAbort = ctrl;
+ let cancelled = false;
+ birthAction(tr('birth.cancel'), function () {
+ cancelled = true;
+ abortBirth();
+ clearBirthActions();
+ openKeyGate({ reconfigure: true, reason: tr('birth.cancelled') });
+ });
+
+ try {
+ const res = await fetch('/api/birth', { method: 'POST', signal: ctrl.signal });
+ if (!res.ok) {
+ // An HTTP-level refusal carries no SSE frame — classify it the same way.
+ showBirthError({ code: res.status === 401 || res.status === 403 ? 'auth' : 'unknown',
+ message: 'HTTP ' + res.status });
+ return;
+ }
+ 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 event = buf.slice(0, idx);
+ buf = buf.slice(idx + 2);
+ const m = event.match(/^data: (.*)$/m);
+ if (!m) continue;
+ const ev = JSON.parse(m[1]);
+ queue.push(ev);
+ processQueue();
+ }
+ }
+ // The stream ended without an error frame: the run is over, drop Cancel
+ // (processQueue has already revealed ENTER on a 'done' frame).
+ if (birthAbort === ctrl) { birthAbort = null; clearBirthActions(); }
+ } catch (err) {
+ // An abort is the user's own Cancel — the gate is already up, say nothing.
+ if (cancelled || (err && err.name === 'AbortError')) return;
+ showBirthError({ message: (err && err.message) ? err.message : String(err) });
+ }
+}
+
+async function startupGate() {
+ // Retry: the page can render a hair before the backend's API routes answer,
+ // and a transient miss must not silently skip onboarding (the old bug — a
+ // bare catch+return left a dead UI with no key prompt, no birth, no message).
+ let cfg = null;
+ for (let i = 0; i < 6 && !cfg; i++) {
+ try { cfg = await fetch('/api/config/status').then(r => r.json()); }
+ catch (e) { if (i < 5) await lisaSleep(700); }
+ }
+ if (!cfg) {
+ lisaBanner('Cannot reach Lisa backend on localhost:5757. Start it: lisa serve --web (install once: npm i -g @oratis/lisa). Retrying…');
+ setTimeout(startupGate, 3000);
+ return;
+ }
+ lisaClearBanner();
+ cfgStatus = cfg;
+ cfgRenderProviders(cfg);
+ if (!cfg.configured) {
+ openKeyGate();
+ return;
+ }
+ try {
+ await maybeBirth();
+ } catch (e) {
+ lisaBanner('Birth check failed: ' + ((e && e.message) ? e.message : e) + ' — reload to retry.');
+ }
+}
+startupGate();
+
+// ── history load & infinite-scroll ──────────────────────────────────
+let historyPage = 0;
+let historyLoading = false;
+let historyExhausted = false;
+// The empty-state card may only be drawn once we KNOW the log is empty — i.e.
+// after the first /api/history answer. Before that an empty #log just means
+// the fetch is still in flight.
+let historyFetched = false;
+
+function textOfMessage(msg) {
+ if (typeof msg.content === 'string') return msg.content.trim();
+ if (!Array.isArray(msg.content)) return '';
+ return msg.content
+ .filter(b => b.type === 'text')
+ .map(b => b.text)
+ .join('')
+ .trim();
+}
+
+function prependHistoryMessages(messages) {
+ const fragment = document.createDocumentFragment();
+ for (const msg of messages) {
+ const text = textOfMessage(msg);
+ if (!text) continue;
+ // Idle "while you were away" notes are persisted as assistant messages with
+ // a [while you were away] sentinel — render them as the distinct idle card
+ // (not a plain Lisa bubble) so they don't blend into her real replies.
+ if (msg.role !== 'user' && /^\[while you were away\]\s*/i.test(text)) {
+ const clean = text.replace(/^\[while you were away\]\s*/i, '');
+ fragment.appendChild(buildIdleBlock(clean, msg.at || msg.ts || null));
+ continue;
+ }
+ const roleDiv = document.createElement('div');
+ roleDiv.className = 'role ' + (msg.role === 'user' ? 'you' : 'lisa');
+ roleDiv.textContent = msg.role === 'user' ? 'YOU' : 'LISA';
+ const span = document.createElement('span');
+ span.className = 'msg';
+ // Lisa's replies are Markdown → render; the user's own text is shown as-is.
+ if (msg.role === 'user') span.textContent = text;
+ else span.innerHTML = renderMarkdown(text);
+ fragment.appendChild(roleDiv);
+ fragment.appendChild(span);
+ }
+ removeChatEmpty();
+ log.insertBefore(fragment, log.firstChild);
+}
+
+async function loadHistoryPage() {
+ if (historyLoading || historyExhausted) return;
+ historyLoading = true;
+ const prevScrollHeight = log.scrollHeight;
+ try {
+ // F6 — pin the read to the session this log is showing, so a reply
+ // streaming in another session's ctx can never bleed into this page.
+ const sidQ = window.lisaActiveSessionId
+ ? '&sessionId=' + encodeURIComponent(window.lisaActiveSessionId)
+ : '';
+ const res = await fetch('/api/history?page=' + historyPage + sidQ);
+ const data = await res.json();
+ if (data.messages && data.messages.length) {
+ prependHistoryMessages(data.messages);
+ log.scrollTop = log.scrollHeight - prevScrollHeight;
+ historyPage++;
+ }
+ if (!data.hasMore) {
+ historyExhausted = true;
+ if (historyPage > 1) {
+ const marker = document.createElement('div');
+ marker.style.cssText = 'text-align:center;color:var(--fg-3);font-size:11.5px;padding:8px 0;letter-spacing:0.06em;';
+ marker.textContent = '— end of history —';
+ log.insertBefore(marker, log.firstChild);
+ }
+ }
+ } finally {
+ historyLoading = false;
+ historyFetched = true;
+ renderChatEmpty();
+ }
+}
+
+loadHistoryPage();
+log.addEventListener('scroll', () => {
+ if (log.scrollTop < 80) loadHistoryPage();
+});
+
+// Session switch support: wipe the log and reload page 0 for the (new) active
+// session. Bumps the chat generation so an in-flight reply stream from the
+// previous session stops touching the DOM (it keeps draining silently and the
+// server persists it into the session it started in).
+window.lisaResetChatLog = function () {
+ chatGeneration++;
+ historyPage = 0;
+ historyLoading = false;
+ historyExhausted = false;
+ currentLisaSpan = null;
+ pendingTools.clear();
+ if (thinkingEl) { thinkingEl.remove(); thinkingEl = null; }
+ log.innerHTML = '';
+ historyFetched = false;
+ loadHistoryPage();
+};
+
+// ── Empty chat: who she is · three ways to start · three things she does ──
+// UX-4: straight out of the birth ritual #log had zero children and the only
+// hint was the composer placeholder "Talk to Lisa…". This card is the first
+// screen a new user actually sees. It reuses what the sidebar already
+// fetched (identity line, current desire) rather than issuing its own calls,
+// and it disappears the moment the log holds anything real.
+function chatLogHasContent() {
+ for (let i = 0; i < log.children.length; i++) {
+ if (log.children[i].id !== 'chatEmpty') return true;
+ }
+ return false;
+}
+function removeChatEmpty() {
+ const card = document.getElementById('chatEmpty');
+ if (card) card.remove();
+}
+// Three openers. The first one names what she is actually pursuing when the
+// soul has a desire, so the card is about THIS Lisa and not a generic tour.
+function chatStarters() {
+ let desire = '';
+ const d = document.getElementById('sbDesire');
+ if (d && d.title) desire = d.title.trim();
+ if (desire.length > 64) desire = desire.slice(0, 64).trim() + '…';
+ return [
+ desire ? tr('empty.starter.desire', { desire: desire }) : tr('empty.starter.open'),
+ tr('empty.starter.memory'),
+ tr('empty.starter.today'),
+ ];
+}
+const CHAT_ABILITY_KEYS = ['tools', 'kb', 'mail'];
+function renderChatEmpty() {
+ if (!historyFetched || chatLogHasContent()) { removeChatEmpty(); return; }
+ let card = document.getElementById('chatEmpty');
+ if (!card) {
+ card = document.createElement('div');
+ card.id = 'chatEmpty';
+ card.className = 'chat-empty';
+ }
+ card.innerHTML = '';
+ const who = document.createElement('div');
+ who.className = 'ce-who';
+ const sub = document.getElementById('identitySub');
+ const subTxt = sub && sub.textContent && sub.textContent !== '—' ? sub.textContent : '';
+ who.textContent = subTxt ? 'Lisa · ' + subTxt : 'Lisa';
+ card.appendChild(who);
+ const lead = document.createElement('div');
+ lead.className = 'ce-lead';
+ lead.textContent = tr('empty.lead');
+ card.appendChild(lead);
+ const starters = document.createElement('div');
+ starters.className = 'ce-starters';
+ chatStarters().forEach(function (text) {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'ce-starter';
+ b.textContent = text;
+ // Fill, don't send: the user still edits and presses Enter, so a
+ // mis-click never spends a model call.
+ b.addEventListener('click', function () {
+ input.value = text;
+ input.focus();
+ try { input.setSelectionRange(text.length, text.length); } catch (e) {}
+ // Let the composer's own 'input' listener re-measure its height.
+ input.dispatchEvent(new Event('input'));
+ });
+ starters.appendChild(b);
+ });
+ card.appendChild(starters);
+ const can = document.createElement('ul');
+ can.className = 'ce-can';
+ CHAT_ABILITY_KEYS.forEach(function (k) {
+ const li = document.createElement('li');
+ const b = document.createElement('b');
+ b.textContent = tr('empty.can.' + k + '.k');
+ li.appendChild(b);
+ li.appendChild(document.createTextNode(' — ' + tr('empty.can.' + k + '.v')));
+ can.appendChild(li);
+ });
+ card.appendChild(can);
+ if (!card.parentNode) log.appendChild(card);
+}
+window.lisaRenderChatEmpty = renderChatEmpty;
+
+// ── mascot crossfade on mood event ──────────────────────────────────
+const mascotEl = document.getElementById('mascot');
+const mascotTagEl = document.getElementById('mascotTag');
+let currentMood = 'neutral';
+function setMood(slug) {
+ if (!slug || slug === currentMood) return;
+ const url = '/assets/lisa/' + encodeURIComponent(slug) + '.png';
+ const probe = new Image();
+ probe.onload = () => {
+ mascotEl.classList.add('fading');
+ setTimeout(() => {
+ mascotEl.src = url;
+ mascotTagEl.textContent = 'mood: ' + slug;
+ mascotEl.classList.remove('fading');
+ currentMood = slug;
+ }, 250);
+ };
+ probe.onerror = () => { /* asset not generated yet — keep current */ };
+ probe.src = url;
+}
+
+// ── modal panel: SOUL / SKILLS / MEMORY / TOOLS ──────────────────────
+const modalBg = document.getElementById('modalBg');
+const modalTitle = document.getElementById('modalTitle');
+const modalBody = document.getElementById('modalBody');
+const modalClose = document.getElementById('modalClose');
+
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
+}
+function openModal(title, html) {
+ modalTitle.textContent = title;
+ modalBody.innerHTML = html;
+ modalBg.classList.add('open');
+}
+function closeModal() { modalBg.classList.remove('open'); }
+modalClose.addEventListener('click', closeModal);
+modalBg.addEventListener('click', (e) => { if (e.target === modalBg) closeModal(); });
+document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
+
+async function showSkills() {
+ openModal('SKILLS', 'loading…
');
+ const data = await fetch('/api/skills').then(r => r.json());
+ if (!data.skills.length) {
+ modalBody.innerHTML = 'No skills saved yet. Lisa will start saving useful workflows as you use her.
';
+ return;
+ }
+ modalBody.innerHTML = data.skills.map(s =>
+ '' + escapeHtml(s.name) + '
' + escapeHtml(s.description) + '
'
+ ).join('');
+}
+
+async function showMemory() {
+ openModal('MEMORY', 'loading…
');
+ const data = await fetch('/api/memory').then(r => r.json());
+ modalBody.innerHTML =
+ 'USER.md — what Lisa remembers about you ' +
+ '' + escapeHtml(data.user || '(empty)') + ' ' +
+ 'MEMORY.md — Lisa\'s working notes ' +
+ '' + escapeHtml(data.memory || '(empty)') + ' ';
+}
+
+async function showTools() {
+ openModal('TOOLS', 'loading…
');
+ const data = await fetch('/api/tools').then(r => r.json());
+ modalBody.innerHTML = data.tools.map(t =>
+ '' + escapeHtml(t.name) + '
' + escapeHtml(t.description) + '
'
+ ).join('');
+}
+
+async function showPlans() {
+ openModal('CODING PLANS', 'loading…
');
+ let data;
+ try { data = await fetch('/api/plans').then(r => r.json()); }
+ catch (e) { modalBody.innerHTML = 'failed to load plans
'; return; }
+ const intro = 'Run coding work on a subscription you already pay for (Claude Pro/Max · ChatGPT/Codex · Copilot) instead of a metered API key. LISA drives the vendor CLI you are logged into, so it bills your plan, not a key. This does not change her own model.
';
+ const rows = (data.plans || []).map(function (p) {
+ const star = p.selected ? ' — selected' : '';
+ const usage = p.usage ? 'usage: ' + escapeHtml(p.usage) + '
' : '';
+ let btn;
+ if (p.selected) btn = 'selected ';
+ else if (p.available) btn = 'use this ';
+ else btn = 'not installed ';
+ return '' + escapeHtml(p.mark + ' ' + p.label + star) + '
' + escapeHtml(p.detail) + '
' + usage + btn + '
';
+ }).join('');
+ const clear = 'clear selection
';
+ 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) + ''
+ + '' + escapeHtml(tr('copy')) + '
';
+}
+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 '
+ + 'Add to KB '
+ + 'Cancel ';
+ 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',
+ '' +
+ '
Agent ' +
+ '
' +
+ 'managed — LISA runs it (approve each step) ' +
+ 'claude — real CLI under a PTY ' +
+ 'codex — real CLI under a PTY ' +
+ ' ' +
+ '
Task ' +
+ '
' +
+ '
Start agent →
' +
+ '
' +
+ '
'
+ );
+ 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 = '▾ L LISA ';
+ 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',
+ '' +
+ '
' +
+ '
' +
+ '
Email ' +
+ '
' +
+ '
App password ' +
+ '
' +
+ '
IMAP host (optional, auto-detected) ' +
+ '
' +
+ '
Connect →
' +
+ '
' +
+ '
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
' +
+ '
+ Delegate a task ' +
+ '';
+ 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
' +
+ '
' +
+ '
' + 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-routed quiet 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 += 'approve ';
+ h += 'deny ';
+ }
+ 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 += '
Approve Deny
';
+ 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 += 'Send ';
+ }
+ if (fam === 'pty') h += 'View output ';
+ if (fam && s.state !== 'done') h += 'Cancel ';
+ if (s.resumable) h += '⇲ Adopt ';
+ 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
' +
+ '
+ Delegate a task ' +
+ '';
+ 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
' +
+ '
last 2h last 8h last 24h ' +
+ '';
+ 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
' +
+ '';
+ 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') +
+ ' ' + (paused ? 'Resume publishing' : 'Pause publishing') +
+ '
'
+ : '' + 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') +
+ '
' +
+ (c ? (accounts.length ? 'linked' : 'available') : 'error') + ' ';
+ });
+ 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 += '
' + esc(d.state) + ' revision ' +
+ esc(d.revision) + '
' + esc(body || 'Media post') +
+ '
' + esc(targetLabel) +
+ (mediaCount ? ' · ' + mediaCount + ' media' : '') + '
';
+ if (d.state === 'awaiting-approval' && d.approvalDigest) {
+ var approvalSnapshot = JSON.stringify({
+ targets: d.targets || [],
+ canonical: d.canonical || {},
+ variants: d.variants || {}
+ }, null, 2);
+ html += '
Confirm this immutable snapshot. Any edit will require a new confirmation.
' +
+ '
' + esc(approvalSnapshot) + ' ' +
+ '
Approve snapshot ';
+ }
+ if (['draft', 'awaiting-approval', 'approved', 'failed', 'expired'].indexOf(d.state) >= 0) {
+ html += '
Cancel ';
+ }
+ html += '
';
+ });
+ html += '
Consent
';
+ if (!grants.length) html += '
No signals configured.
';
+ grants.forEach(function (g) {
+ html += '
' + esc(g.signal) + '
' +
+ (g.description ? '
' + esc(g.description) + '
' : '') + '
' +
+ '
' + (g.granted ? 'on' : 'off') + ' ';
+ });
+ 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 '' + ico + ' ' + title + '' + sub + ' ';
+ }
+ 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 : '')) + '
' +
+ '
' + (on ? 'on' : 'off') + ' ' +
+ '
remove ';
+ }
+ 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
' +
+ '
+ Connect mailbox ' +
+ '';
+ 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 += '
';
+ 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 += '
';
+ // 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
show ';
+ 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
' +
+ '';
+ 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 += ''
+ + '' + (e.layer === 'wiki' ? 'wiki' : 'source') + ' '
+ + '' + esc(e.title) + ' '
+ + (tags ? '' + esc(tags) + ' ' : '')
+ + (e.excerpt ? '' + esc(e.excerpt) + ' ' : '')
+ + ' ';
+ }
+ 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
'
+ + 'Save
'
+ + '
'
+ + '';
+ 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*'/,
+ "the policy row must be gated on connectors.length",
+ );
+ assert.match(MAIN_CLIENT_JS, /social-policy neutral[\s\S]{0,80}sense\.noConnector/);
+ });
+
+ test("the pairing panel states the token's lifetime", () => {
+ assert.match(MAIN_CLIENT_JS, /class="pair-note"/);
+ const ctx2 = i18nContext();
+ runInContext(`${I18N_SRC}; globalThis.__tr = tr;`, ctx2);
+ const note = (ctx2 as { __tr: (k: string) => string }).__tr("pair.noExpiry");
+ assert.match(note, /does not expire/);
+ });
+
+ test("the inspector's cwd row is abbreviated, tooltipped and copyable", () => {
+ const fn = extractFunction(MAIN_CLIENT_JS, "inspPathRow");
+ assert.match(fn, /inspRow\(label, abbrevPath\(value\)\)/);
+ assert.match(fn, /code\.title = value/);
+ assert.match(fn, /copyButton\(function \(\) \{ return value; \}/);
+ assert.match(MAIN_CLIENT_JS, /kv\.appendChild\(inspPathRow\('cwd', s\.cwd\)\)/);
+ });
+});
+
+describe("keyboard shortcuts (UX-11)", () => {
+ test("the four bindings exist and are modifier-gated", () => {
+ const fn = MAIN_CLIENT_JS.slice(
+ MAIN_CLIENT_JS.indexOf("document.addEventListener('keydown', function (e) {\n const mod ="),
+ ).slice(0, 1200);
+ assert.match(fn, /const mod = e\.metaKey \|\| e\.ctrlKey;/);
+ assert.match(fn, /e\.key === 'k' \|\| e\.key === 'K'[\s\S]{0,40}openSwitcher\(\)/);
+ assert.match(fn, /e\.key === '\/'[\s\S]{0,40}input\.focus\(\)/);
+ assert.match(fn, /e\.key === 'f' \|\| e\.key === 'F'[\s\S]{0,40}openFind\(\)/);
+ assert.match(fn, /e\.key === '\?' && !mod && !isTypingTarget\(e\.target\)/);
+ });
+
+ test("Escape closes the switcher and the find bar, never the first-run gates", () => {
+ const fn = MAIN_CLIENT_JS.slice(
+ MAIN_CLIENT_JS.indexOf("if (e.key === 'Escape') {\n // Most-nested first."),
+ ).slice(0, 500);
+ assert.match(fn, /switcherIsOpen\(\)[\s\S]{0,40}closeSwitcher\(\)/);
+ assert.match(fn, /findIsOpen\(\)[\s\S]{0,40}closeFind\(\)/);
+ // Dismissing the key gate or the birth overlay would drop the user into a
+ // shell that cannot work — those must stay out of this handler.
+ assert.ok(!fn.includes("cfgOverlay"), "Esc must not close the key gate");
+ assert.ok(!fn.includes("birthOverlay"), "Esc must not close the birth ritual");
+ });
+
+ test("every shortcut in the help list has copy in both locales", () => {
+ const ctx = i18nContext();
+ runInContext(`${I18N_SRC}; globalThis.__tr = tr; globalThis.__zh = LISA_STRINGS['zh-CN'];`, ctx);
+ const c = ctx as { __tr: (k: string) => string; __zh: Record };
+ for (const key of ["kbd.switch", "kbd.focus", "kbd.find", "kbd.close", "kbd.send", "kbd.help"]) {
+ assert.ok(c.__tr(key) !== key, `missing en copy for ${key}`);
+ assert.ok(c.__zh[key], `missing zh-CN copy for ${key}`);
+ }
+ });
+});
diff --git a/src/web/lisa-client.ts b/src/web/lisa-client.ts
index bc3faef..f68409f 100644
--- a/src/web/lisa-client.ts
+++ b/src/web/lisa-client.ts
@@ -1,4004 +1,26 @@
/**
* Inline client `.
+ * The full browser-side runtime — attachments, vision capture, voice
+ * dictation, SSE, the key gate, the birth ritual, history infinite-scroll,
+ * modal panels, send + streaming, the sidebar wiring, the ⌘K switcher — lives
+ * at `assets/client/main.js`, a real .js file.
*
- * IMPORTANT: this is the literal text served to the browser, NOT executed
- * as part of the build. Backslash escapes (e.g. `\\n`) are written here
- * exactly as they must appear in the emitted {
+ assert.equal(MAIN_CSS, readFileSync(new URL("main.css", clientDir), "utf8"));
+ assert.equal(MAIN_CLIENT_JS, readFileSync(new URL("main.js", clientDir), "utf8"));
+ });
});
-test("MAIN_HTML sha256 is byte-identical to the pre-split snapshot", () => {
- const sha = createHash("sha256").update(MAIN_HTML).digest("hex");
- assert.equal(sha, EXPECTED_SHA256);
+describe("the served page inlines every client source verbatim", () => {
+ test("MAIN_HTML contains the stylesheet, once, inside ");
+ const at = MAIN_HTML.indexOf(MAIN_CSS);
+ assert.ok(styleOpen >= 0 && styleClose > styleOpen, "no