From a13f69a55d6062c8c159d2eeb78431b459a45f1c Mon Sep 17 00:00:00 2001 From: setkyar Date: Fri, 5 Jun 2026 16:52:47 +0700 Subject: [PATCH 1/3] refactor(export): build static snapshot from shared live modules The Gist export runtime was a ~2000-line hand-maintained parallel copy of the live session renderer (internal/ui/live_templates/export/app/*.js), kept in sync by discipline alone and prone to silent drift. Replace it with a Vite-built bundle from a new web/src/export/export-entry.js that imports the exact same web/src/session/ rendering modules as the live app (data, tree, filter, format, render, navigation, ui), omitting all live-only code (SSE, chat, artifacts, annotations). vite.config.export.js emits a single self-contained IIFE that reads window.marked/window.hljs from the inlined vendor scripts; export.go now embeds that one built file instead of concatenating nine hand-written ones. - Delete export/app/*.js; export.go shrinks to a single //go:embed. - Add TestExportBundleIsSelfContained to fail the build if a live-only symbol (EventSource/runLiveReload) ever leaks into the export graph. - Repoint the tests that grepped the old unminified bundle at the canonical web/src source files (the minified bundle is no longer greppable). - export.js + dist-export are generated; gitignore them. npm run build now runs build:export after the live build, so make build/check stay green. --- .gitignore | 2 + AGENTS.md | 15 +- internal/ui/ask_user_question_render_test.go | 39 +- internal/ui/export.go | 79 +-- .../ui/live_templates/export/app/00-data.js | 60 -- .../ui/live_templates/export/app/10-tree.js | 222 ------- .../ui/live_templates/export/app/20-filter.js | 249 -------- .../ui/live_templates/export/app/30-format.js | 125 ---- .../export/app/40-render-tree.js | 91 --- .../export/app/50-render-entry.js | 572 ------------------ .../ui/live_templates/export/app/60-header.js | 138 ----- .../export/app/70-navigation.js | 83 --- .../ui/live_templates/export/app/80-ui.js | 495 --------------- internal/ui/mobile_sidebar_test.go | 28 +- internal/ui/templates_embed_test.go | 37 +- internal/ui/toggle_state_test.go | 103 ++-- web/package.json | 5 +- web/src/export/export-entry.js | 214 +++++++ web/vite.config.export.js | 21 + 19 files changed, 391 insertions(+), 2187 deletions(-) delete mode 100644 internal/ui/live_templates/export/app/00-data.js delete mode 100644 internal/ui/live_templates/export/app/10-tree.js delete mode 100644 internal/ui/live_templates/export/app/20-filter.js delete mode 100644 internal/ui/live_templates/export/app/30-format.js delete mode 100644 internal/ui/live_templates/export/app/40-render-tree.js delete mode 100644 internal/ui/live_templates/export/app/50-render-entry.js delete mode 100644 internal/ui/live_templates/export/app/60-header.js delete mode 100644 internal/ui/live_templates/export/app/70-navigation.js delete mode 100644 internal/ui/live_templates/export/app/80-ui.js create mode 100644 web/src/export/export-entry.js create mode 100644 web/vite.config.export.js diff --git a/.gitignore b/.gitignore index d46a6b81..987e1d31 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ pi-sessions-viewer /pi-web web/node_modules/ web/dist/ +web/dist-export/ +internal/ui/live_templates/export/export.js .pi/extensions/node_modules/ .pi/APPEND_SYSTEM.md diff --git a/AGENTS.md b/AGENTS.md index ab4ecf90..1e544902 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Before making structural changes, read the relevant doc in `docs/`: | Working on export/share | `docs/sequence-flows/share.md` | | Writing or debugging E2E / browser tests | `docs/dev/e2e-testing.md` | -The most important doc for frontend work is **`docs/dev/templates-vs-web.md`** — it explains the unified rendering where `web/` provides the live Vite app, and `internal/ui/live_templates/` provides the Go-embedded shells and consolidated `export/` JS. +The most important doc for frontend work is **`docs/dev/templates-vs-web.md`** — it explains the unified rendering where `web/` provides the live Vite app, and `internal/ui/live_templates/` provides the Go-embedded shells. The static export snapshot bundle is built by Vite from `web/src/export/export-entry.js`, which **reuses the live session modules** in `web/src/session/` — there is no longer a hand-maintained parallel copy. ## Tech Stack @@ -46,14 +46,15 @@ The most important doc for frontend work is **`docs/dev/templates-vs-web.md`** | `web/src/session/annotations/` | Inline review annotations (offset-anchored highlights, Annotations tab, send-to-pi); synced via the `annotations` SSE event | | `web/src/shared/` | API helpers, escape, storage, status events | | `internal/ui/live_templates/` | Go-embedded HTML shells for index/session pages | -| `internal/ui/live_templates/export/` | Self-contained JS/vendor scripts for static Gist snapshots (no server, no chat, no SSE) | +| `web/src/export/` | Static-snapshot Vite entry (`export-entry.js`) — reuses `web/src/session/` rendering modules, omits all live/chat/SSE code | +| `internal/ui/live_templates/export/` | Built snapshot bundle (`export.js`, generated by `npm run build:export`) + inlined `vendor/` scripts for Gist snapshots (no server, no chat, no SSE) | ### Key Files - `cmd/pi-web/main.go` — tiny CLI entrypoint and build-time version variable - `internal/app/app.go` — CLI flags, Tailscale auto-detect, dependency wiring - `internal/frontend/assets.go` + `web/assets_embed.go` — Vite output embedding, manifest parsing, static asset serving - `internal/ui/session_page.go` — **Live session page** rendering (`internal/ui/live_templates/session.html`, chat composer) -- `internal/ui/export.go` — **Export/share snapshot** rendering (using `internal/ui/live_templates/session.html`, inlined JS, no server deps) +- `internal/ui/export.go` — **Export/share snapshot** rendering (using `internal/ui/live_templates/session.html`, inlines the built `export.js` + `vendor/`, no server deps) - `internal/ui/live_templates/styles/session.css` — Live session & export CSS - `.pi/extensions/pi-web.ts` — Pi extension with `/pi-web`, `/pi-web token`, `/pi-web set-token`, `/remote`, `/refresh` commands @@ -63,7 +64,7 @@ The most important doc for frontend work is **`docs/dev/templates-vs-web.md`** |---|---|---| | Go file | `internal/ui/session_page.go` | `internal/ui/export.go` | | HTML shell | `internal/ui/live_templates/session.html` (`IsLive: true`) | `internal/ui/live_templates/session.html` (`IsLive: false`) | -| JS source | `web/src/session/` (Vite) | `internal/ui/live_templates/export/app/*.js` + `vendor/` | +| JS source | `web/src/session/` (Vite) | `web/src/export/export-entry.js` (reuses `web/src/session/`), built → `internal/ui/live_templates/export/export.js` + `vendor/` | | CSS | `internal/ui/live_templates/styles/session.css` | `internal/ui/live_templates/styles/session.css` | | Chat composer | Yes (`internal/ui/live_templates/chat_composer.html`) | No | | Action buttons | Yes (baked into `internal/ui/live_templates/session.html`) | No | @@ -84,7 +85,7 @@ make e2e-setup # one-time: install e2e deps + Playwright browsers make e2e # build binary + run Playwright E2E (not part of test/check) ``` -**Critical:** `go build ./cmd/pi-web` requires `web/dist` to exist first because of `//go:embed`. Always run `make build`, never `go build` alone. +**Critical:** `go build ./cmd/pi-web` requires `web/dist` **and** `internal/ui/live_templates/export/export.js` to exist first because of `//go:embed`. Both are generated by the frontend build (`npm run build` runs the live build then `build:export`). Always run `make build`, never `go build` alone. ### Local development — do NOT rely on a launchd agent @@ -99,13 +100,13 @@ For development, start pi-web with `make dev` (Vite watcher + Go hot-reload) or ## Coding Standards - **Go:** Small focused packages; `internal/server` is the HTTP glue exception. Avoid global state — `internal/app/app.go` wires `server.New(server.Deps{...})`. Use sentinel errors. `WriteTimeout` stays 0 for SSE. -- **JS:** ES modules. Explicit DI (`documentImpl`, `windowImpl`) over globals. Keep `internal/ui/live_templates/` manually in sync with `web/src/session/live/` changes. +- **JS:** ES modules. Explicit DI (`documentImpl`, `windowImpl`) over globals. The export snapshot reuses the live `web/src/session/` modules via `web/src/export/export-entry.js` and is rebuilt by Vite — no manual copy to keep in sync. Keep rendering modules side-effect-free on import and DI-pure so they stay safe to bundle into the server-less export. - **CSS:** both live styling and export styling are in `internal/ui/live_templates/styles/session.css`. Keep visual changes clean and unified. ## Critical Rules 1. **Live and export use a unified template.** `internal/ui/live_templates/session.html` serves both the live app and Gist snapshots. Do not split them. -2. **Always keep `internal/ui/live_templates/` in sync** with `web/src/session/ui/` changes when styling or structures shift. +2. **Export reuses the live source.** The static snapshot is built from `web/src/export/export-entry.js`, which imports the same `web/src/session/` rendering modules as the live app — fix rendering bugs once. Do not reintroduce a hand-maintained `export/app/*.js` copy. A guard test (`TestExportBundleIsSelfContained`) fails if a live-only module (SSE/chat) leaks into the export bundle. 3. **Existing session data is append-only for `session_info`.** Browser chat goes to a `pi --mode rpc` worker, which writes conversation entries. pi-web otherwise watches and broadcasts; its only direct writes to existing session files are appending `session_info` — for browser rename, and for auto-titling (marked `autoTitle:true`, see `internal/server/auto_title.go`). New-session creation may write initial implicit `model_change` / `thinking_level_change` entries in the fresh file. 4. **One worker per session.** Reused for subsequent messages. Crashed = evicted + replaced. Idle workers reaped after 10 min. 5. **SSE topics:** `globalSessID = "__all__"` for index-wide events; session ID for per-session events. diff --git a/internal/ui/ask_user_question_render_test.go b/internal/ui/ask_user_question_render_test.go index fcee7679..bedf6346 100644 --- a/internal/ui/ask_user_question_render_test.go +++ b/internal/ui/ask_user_question_render_test.go @@ -1,26 +1,47 @@ package ui import ( + "os" "strings" "testing" ) +// These tests assert that the shared session entry renderer +// (web/src/session/render/session-entry-renderer.js) implements the +// ask_user_question behavior. Live and static export both bundle this module, +// so the source is the single source of truth — the export bundle is minified, +// so we check the source rather than grepping the built artifact. +func readEntryRendererSrc(t *testing.T) string { + t.Helper() + data, err := os.ReadFile(repoPath("web/src/session/render/session-entry-renderer.js")) + if err != nil { + t.Fatalf("read session-entry-renderer.js: %v", err) + } + return string(data) +} + func TestAskUserQuestionToolHasDedicatedRenderer(t *testing.T) { - checks := []string{ + src := readEntryRendererSrc(t) + jsChecks := []string{ "case 'ask_user_question':", "case 'pi_web_ask_user_question':", "renderAskUserQuestionTool(args, result)", - "ask-question-card", - "ask-question-option", } - for _, check := range checks { - if !strings.Contains(exportJs+liveSessionCss, check) { + for _, check := range jsChecks { + if !strings.Contains(src, check) { t.Fatalf("missing %q; ask_user_question should not render as raw JSON", check) } } + // The card/option chrome is styled in the shared session CSS. + for _, check := range []string{"ask-question-card", "ask-question-option"} { + if !strings.Contains(liveSessionCss, check) { + t.Fatalf("missing %q in session CSS", check) + } + } } func TestAskUserQuestionHonorsMultiSelect(t *testing.T) { + src := readEntryRendererSrc(t) checks := []string{ "const anyMultiSelect = questions.some(q => q && q.multiSelect === true);", "const needsSubmit = isMulti || anyMultiSelect;", @@ -28,25 +49,27 @@ func TestAskUserQuestionHonorsMultiSelect(t *testing.T) { "data-multi-select=", } for _, check := range checks { - if !strings.Contains(exportJs, check) { + if !strings.Contains(src, check) { t.Fatalf("missing %q; multi-select questions must be answerable via collect-then-submit", check) } } } func TestAskUserQuestionAwaitingChatReplyStaysClickable(t *testing.T) { + src := readEntryRendererSrc(t) checks := []string{ "const awaitingChatReply = result?.details?.awaitingChatReply === true;", "|| awaitingChatReply", } for _, check := range checks { - if !strings.Contains(exportJs, check) { + if !strings.Contains(src, check) { t.Fatalf("missing %q; pi-ask awaitingChatReply results must render as pending/clickable, not answered", check) } } } func TestErroredAskUserQuestionKeepsFallbackOptionsClickable(t *testing.T) { + src := readEntryRendererSrc(t) checks := []string{ "const questionToolFailed = result?.isError === true;", "question UI failed", @@ -54,7 +77,7 @@ func TestErroredAskUserQuestionKeepsFallbackOptionsClickable(t *testing.T) { "Use these options as a fallback", } for _, check := range checks { - if !strings.Contains(exportJs, check) { + if !strings.Contains(src, check) { t.Fatalf("missing %q; errored multi-question cards should remain answerable", check) } } diff --git a/internal/ui/export.go b/internal/ui/export.go index a93b8999..3ae38f06 100644 --- a/internal/ui/export.go +++ b/internal/ui/export.go @@ -2,90 +2,29 @@ package ui import ( "bytes" - "embed" - "fmt" + _ "embed" "html/template" "log" "net/http" - "sort" "strings" "pi-web/internal/sessions" ) -//go:embed live_templates/export/app/*.js -var appJsFS embed.FS - //go:embed live_templates/export/vendor/marked.min.js var markedJs string //go:embed live_templates/export/vendor/highlight.min.js var hljsJs string -// Explicit export app JS manifest. The static export runtime is intentionally -// not bundled by Vite, so keep load order here instead of relying on filename -// sorting to silently do the right thing. -var exportAppJSFiles = []string{ - "00-data.js", - "10-tree.js", - "20-filter.js", - "30-format.js", - "40-render-tree.js", - "50-render-entry.js", - "60-header.js", - "70-navigation.js", - "80-ui.js", -} - -// Concatenated export app JS bundle, wrapped in a single IIFE so all modules -// share closure scope. -var exportJs = buildExportJsBundle() - -func buildExportJsBundle() string { - verifyExportAppManifest() - - var b strings.Builder - b.WriteString("(function() {\n'use strict';\n") - for _, name := range exportAppJSFiles { - body, err := appJsFS.ReadFile("live_templates/export/app/" + name) - if err != nil { - panic(fmt.Errorf("read %s: %w", name, err)) - } - b.WriteString("\n// ===== " + name + " =====\n") - b.Write(body) - b.WriteString("\n") - } - b.WriteString("})();\n") - return b.String() -} - -func verifyExportAppManifest() { - entries, err := appJsFS.ReadDir("live_templates/export/app") - if err != nil { - panic(fmt.Errorf("read live_templates/export/app: %w", err)) - } - - seen := make(map[string]bool, len(entries)) - for _, e := range entries { - if !e.IsDir() && strings.HasSuffix(e.Name(), ".js") { - seen[e.Name()] = true - } - } - for _, name := range exportAppJSFiles { - if !seen[name] { - panic(fmt.Errorf("export app manifest references missing file %s", name)) - } - delete(seen, name) - } - if len(seen) > 0 { - extra := make([]string, 0, len(seen)) - for name := range seen { - extra = append(extra, name) - } - sort.Strings(extra) - panic(fmt.Errorf("export app files missing from manifest: %s", strings.Join(extra, ", "))) - } -} +// Static export runtime, built by Vite from web/src/export/export-entry.js +// (a single self-contained IIFE that reuses the live app's rendering modules). +// Generated by `npm run build:export`; see web/vite.config.export.js. The +// frontend build must run before `go build` so this embed target exists — +// the same constraint as web/dist. +// +//go:embed live_templates/export/export.js +var exportJs string // renderExportSessionPage renders a self-contained HTML snapshot suitable for // GitHub Gist sharing. All JS is inlined and server-dependent chrome (buttons, diff --git a/internal/ui/live_templates/export/app/00-data.js b/internal/ui/live_templates/export/app/00-data.js deleted file mode 100644 index 72f8d0c1..00000000 --- a/internal/ui/live_templates/export/app/00-data.js +++ /dev/null @@ -1,60 +0,0 @@ -// ============================================================ -// DATA LOADING -// ============================================================ - -const base64 = document.getElementById('session-data').textContent; -const binary = atob(base64); -const bytes = new Uint8Array(binary.length); -for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); -} -const data = JSON.parse(new TextDecoder('utf-8').decode(bytes)); -const { header, entries, leafId: defaultLeafId, systemPrompt, tools, renderedTools } = data; - -// ============================================================ -// URL PARAMETER HANDLING -// ============================================================ - -// Parse URL parameters for deep linking: leafId and targetId -// Check for injected params (when loaded in iframe via srcdoc) or use window.location -const injectedParams = document.querySelector('meta[name="pi-url-params"]'); -const searchString = injectedParams ? injectedParams.content : window.location.search.substring(1); -const urlParams = new URLSearchParams(searchString); -const urlLeafId = urlParams.get('leafId'); -const urlTargetId = urlParams.get('targetId'); -// Use URL leafId if provided, otherwise fall back to session default -const leafId = urlLeafId || defaultLeafId; - -// ============================================================ -// DATA STRUCTURES -// ============================================================ - -// Entry lookup by ID -const byId = new Map(); -for (const entry of entries) { - byId.set(entry.id, entry); -} - -// Tool call lookup (toolCallId -> {name, arguments}) -const toolCallMap = new Map(); -for (const entry of entries) { - if (entry.type === 'message' && entry.message.role === 'assistant') { - const content = entry.message.content; - if (Array.isArray(content)) { - for (const block of content) { - if (block.type === 'toolCall') { - toolCallMap.set(block.id, { name: block.name, arguments: block.arguments }); - } - } - } - } -} - -// Label lookup (entryId -> label string) -// Labels are stored in 'label' entries that reference their target via targetId -const labelMap = new Map(); -for (const entry of entries) { - if (entry.type === 'label' && entry.targetId && entry.label) { - labelMap.set(entry.targetId, entry.label); - } -} diff --git a/internal/ui/live_templates/export/app/10-tree.js b/internal/ui/live_templates/export/app/10-tree.js deleted file mode 100644 index e07d2556..00000000 --- a/internal/ui/live_templates/export/app/10-tree.js +++ /dev/null @@ -1,222 +0,0 @@ -// ============================================================ -// TREE DATA PREPARATION (no DOM, pure data) -// ============================================================ - -/** - * Build tree structure from flat entries. - * Returns array of root nodes, each with { entry, children, label }. - */ -function buildTree() { - const nodeMap = new Map(); - const roots = []; - - // Create nodes - for (const entry of entries) { - nodeMap.set(entry.id, { - entry, - children: [], - label: labelMap.get(entry.id) - }); - } - - // Build parent-child relationships - for (const entry of entries) { - const node = nodeMap.get(entry.id); - if (entry.parentId === null || entry.parentId === undefined || entry.parentId === entry.id) { - roots.push(node); - } else { - const parent = nodeMap.get(entry.parentId); - if (parent) { - parent.children.push(node); - } else { - roots.push(node); - } - } - } - - // Sort children by timestamp - function sortChildren(node) { - node.children.sort((a, b) => - new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime() - ); - node.children.forEach(sortChildren); - } - roots.forEach(sortChildren); - - return roots; -} - -/** - * Build set of entry IDs on path from root to target. - */ -function buildActivePathIds(targetId) { - const ids = new Set(); - let current = byId.get(targetId); - while (current) { - ids.add(current.id); - // Stop if no parent or self-referencing (root) - if (!current.parentId || current.parentId === current.id) { - break; - } - current = byId.get(current.parentId); - } - return ids; -} - -/** - * Get array of entries from root to target (the conversation path). - */ -function getPath(targetId) { - const path = []; - let current = byId.get(targetId); - while (current) { - path.unshift(current); - // Stop if no parent or self-referencing (root) - if (!current.parentId || current.parentId === current.id) { - break; - } - current = byId.get(current.parentId); - } - return path; -} - -// Tree node lookup for finding leaves -let treeNodeMap = null; - -/** - * Find the newest leaf node reachable from a given node. - * This allows clicking any node in a branch to show the full branch. - * Children are sorted by timestamp, so the newest is always last. - */ -function findNewestLeaf(nodeId) { - // Build tree node map lazily - if (!treeNodeMap) { - treeNodeMap = new Map(); - const tree = buildTree(); - function mapNodes(node) { - treeNodeMap.set(node.entry.id, node); - node.children.forEach(mapNodes); - } - tree.forEach(mapNodes); - } - - const node = treeNodeMap.get(nodeId); - if (!node) return nodeId; - - // Follow the newest (last) child at each level - let current = node; - while (current.children.length > 0) { - current = current.children[current.children.length - 1]; - } - return current.entry.id; -} - -/** - * Flatten tree into list with indentation and connector info. - * Returns array of { node, indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots }. - * Matches tree-selector.ts logic exactly. - */ -function flattenTree(roots, activePathIds) { - const result = []; - const multipleRoots = roots.length > 1; - - // Mark which subtrees contain the active leaf - const containsActive = new Map(); - function markActive(node) { - let has = activePathIds.has(node.entry.id); - for (const child of node.children) { - if (markActive(child)) has = true; - } - containsActive.set(node, has); - return has; - } - roots.forEach(markActive); - - // Stack: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] - const stack = []; - - // Add roots (prioritize branch containing active leaf) - const orderedRoots = [...roots].sort((a, b) => - Number(containsActive.get(b)) - Number(containsActive.get(a)) - ); - for (let i = orderedRoots.length - 1; i >= 0; i--) { - const isLast = i === orderedRoots.length - 1; - stack.push([orderedRoots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]); - } - - while (stack.length > 0) { - const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop(); - - result.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots }); - - const children = node.children; - const multipleChildren = children.length > 1; - - // Order children (active branch first) - const orderedChildren = [...children].sort((a, b) => - Number(containsActive.get(b)) - Number(containsActive.get(a)) - ); - - // Calculate child indent (matches tree-selector.ts) - let childIndent; - if (multipleChildren) { - // Parent branches: children get +1 - childIndent = indent + 1; - } else if (justBranched && indent > 0) { - // First generation after a branch: +1 for visual grouping - childIndent = indent + 1; - } else { - // Single-child chain: stay flat - childIndent = indent; - } - - // Build gutters for children - const connectorDisplayed = showConnector && !isVirtualRootChild; - const currentDisplayIndent = multipleRoots ? Math.max(0, indent - 1) : indent; - const connectorPosition = Math.max(0, currentDisplayIndent - 1); - const childGutters = connectorDisplayed - ? [...gutters, { position: connectorPosition, show: !isLast }] - : gutters; - - // Add children in reverse order for stack - for (let i = orderedChildren.length - 1; i >= 0; i--) { - const childIsLast = i === orderedChildren.length - 1; - stack.push([orderedChildren[i], childIndent, multipleChildren, multipleChildren, childIsLast, childGutters, false]); - } - } - - return result; -} - -/** - * Build ASCII prefix string for tree node. - */ -function buildTreePrefix(flatNode) { - const { indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots } = flatNode; - const displayIndent = multipleRoots ? Math.max(0, indent - 1) : indent; - const connector = showConnector && !isVirtualRootChild ? (isLast ? '└─ ' : '├─ ') : ''; - const connectorPosition = connector ? displayIndent - 1 : -1; - - const totalChars = displayIndent * 3; - const prefixChars = []; - for (let i = 0; i < totalChars; i++) { - const level = Math.floor(i / 3); - const posInLevel = i % 3; - - const gutter = gutters.find(g => g.position === level); - if (gutter) { - prefixChars.push(posInLevel === 0 ? (gutter.show ? '│' : ' ') : ' '); - } else if (connector && level === connectorPosition) { - if (posInLevel === 0) { - prefixChars.push(isLast ? '└' : '├'); - } else if (posInLevel === 1) { - prefixChars.push('─'); - } else { - prefixChars.push(' '); - } - } else { - prefixChars.push(' '); - } - } - return prefixChars.join(''); -} diff --git a/internal/ui/live_templates/export/app/20-filter.js b/internal/ui/live_templates/export/app/20-filter.js deleted file mode 100644 index 28550e4f..00000000 --- a/internal/ui/live_templates/export/app/20-filter.js +++ /dev/null @@ -1,249 +0,0 @@ -// ============================================================ -// FILTERING (pure data) -// ============================================================ - -let filterMode = 'default'; -let searchQuery = ''; - -function hasTextContent(content) { - if (typeof content === 'string') return content.trim().length > 0; - if (Array.isArray(content)) { - for (const c of content) { - if (c.type === 'text' && c.text && c.text.trim().length > 0) return true; - } - } - return false; -} - -function extractContent(content) { - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - return content - .filter(c => c.type === 'text' && c.text) - .map(c => c.text) - .join(''); - } - return ''; -} - -function getSearchableText(entry, label) { - const parts = []; - if (label) parts.push(label); - - switch (entry.type) { - case 'message': { - const msg = entry.message; - parts.push(msg.role); - if (msg.content) parts.push(extractContent(msg.content)); - if (msg.role === 'bashExecution' && msg.command) parts.push(msg.command); - break; - } - case 'custom_message': - parts.push(entry.customType); - parts.push(typeof entry.content === 'string' ? entry.content : extractContent(entry.content)); - break; - case 'compaction': - parts.push('compaction'); - break; - case 'branch_summary': - parts.push('branch summary', entry.summary); - break; - case 'model_change': - parts.push('model', entry.modelId); - break; - case 'thinking_level_change': - parts.push('thinking', entry.thinkingLevel); - break; - } - - return parts.join(' ').toLowerCase(); -} - -/** - * Filter flat nodes based on current filterMode and searchQuery. - */ -function filterNodes(flatNodes, currentLeafId) { - const searchTokens = searchQuery.toLowerCase().split(/\s+/).filter(Boolean); - - const filtered = flatNodes.filter(flatNode => { - const entry = flatNode.node.entry; - const label = flatNode.node.label; - const isCurrentLeaf = entry.id === currentLeafId; - - // Always show current leaf - if (isCurrentLeaf) return true; - - // Hide assistant messages with only tool calls (no text) unless error/aborted - if (entry.type === 'message' && entry.message.role === 'assistant') { - const msg = entry.message; - const hasText = hasTextContent(msg.content); - const isErrorOrAborted = msg.stopReason && msg.stopReason !== 'stop' && msg.stopReason !== 'toolUse'; - if (!hasText && !isErrorOrAborted) return false; - } - - // Apply filter mode - const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change'].includes(entry.type); - let passesFilter = true; - - switch (filterMode) { - case 'user-only': - passesFilter = entry.type === 'message' && entry.message.role === 'user'; - break; - case 'no-tools': - passesFilter = !isSettingsEntry && !(entry.type === 'message' && entry.message.role === 'toolResult'); - break; - case 'labeled-only': - passesFilter = label !== undefined; - break; - case 'all': - passesFilter = true; - break; - default: // 'default' - passesFilter = !isSettingsEntry; - break; - } - - if (!passesFilter) return false; - - // Apply search filter - if (searchTokens.length > 0) { - const nodeText = getSearchableText(entry, label); - if (!searchTokens.every(t => nodeText.includes(t))) return false; - } - - return true; - }); - - // Recalculate visual structure based on visible tree - recalculateVisualStructure(filtered, flatNodes); - - return filtered; -} - -/** - * Recompute indentation/connectors for the filtered view - * - * Filtering can hide intermediate entries; descendants attach to the nearest visible ancestor. - * Keep indentation semantics aligned with flattenTree() so single-child chains don't drift right. - */ -function recalculateVisualStructure(filteredNodes, allFlatNodes) { - if (filteredNodes.length === 0) return; - - const visibleIds = new Set(filteredNodes.map(n => n.node.entry.id)); - - // Build entry map for parent lookup (using full tree) - const entryMap = new Map(); - for (const flatNode of allFlatNodes) { - entryMap.set(flatNode.node.entry.id, flatNode); - } - - // Find nearest visible ancestor for a node - function findVisibleAncestor(nodeId) { - let currentId = entryMap.get(nodeId)?.node.entry.parentId; - while (currentId != null) { - if (visibleIds.has(currentId)) { - return currentId; - } - currentId = entryMap.get(currentId)?.node.entry.parentId; - } - return null; - } - - // Build visible tree structure - const visibleParent = new Map(); - const visibleChildren = new Map(); - visibleChildren.set(null, []); // root-level nodes - - for (const flatNode of filteredNodes) { - const nodeId = flatNode.node.entry.id; - const ancestorId = findVisibleAncestor(nodeId); - visibleParent.set(nodeId, ancestorId); - - if (!visibleChildren.has(ancestorId)) { - visibleChildren.set(ancestorId, []); - } - visibleChildren.get(ancestorId).push(nodeId); - } - - // Update multipleRoots based on visible roots - const visibleRootIds = visibleChildren.get(null); - const multipleRoots = visibleRootIds.length > 1; - - // Build a map for quick lookup: nodeId → FlatNode - const filteredNodeMap = new Map(); - for (const flatNode of filteredNodes) { - filteredNodeMap.set(flatNode.node.entry.id, flatNode); - } - - // DFS traversal of visible tree, applying same indentation rules as flattenTree() - // Stack items: [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] - const stack = []; - - // Add visible roots in reverse order (to process in forward order via stack) - for (let i = visibleRootIds.length - 1; i >= 0; i--) { - const isLast = i === visibleRootIds.length - 1; - stack.push([ - visibleRootIds[i], - multipleRoots ? 1 : 0, - multipleRoots, - multipleRoots, - isLast, - [], - multipleRoots - ]); - } - - while (stack.length > 0) { - const [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop(); - - const flatNode = filteredNodeMap.get(nodeId); - if (!flatNode) continue; - - // Update this node's visual properties - flatNode.indent = indent; - flatNode.showConnector = showConnector; - flatNode.isLast = isLast; - flatNode.gutters = gutters; - flatNode.isVirtualRootChild = isVirtualRootChild; - flatNode.multipleRoots = multipleRoots; - - // Get visible children of this node - const children = visibleChildren.get(nodeId) || []; - const multipleChildren = children.length > 1; - - // Calculate child indent using same rules as flattenTree(): - // - Parent branches (multiple children): children get +1 - // - Just branched and indent > 0: children get +1 for visual grouping - // - Single-child chain: stay flat - let childIndent; - if (multipleChildren) { - childIndent = indent + 1; - } else if (justBranched && indent > 0) { - childIndent = indent + 1; - } else { - childIndent = indent; - } - - // Build gutters for children (same logic as flattenTree) - const connectorDisplayed = showConnector && !isVirtualRootChild; - const currentDisplayIndent = multipleRoots ? Math.max(0, indent - 1) : indent; - const connectorPosition = Math.max(0, currentDisplayIndent - 1); - const childGutters = connectorDisplayed - ? [...gutters, { position: connectorPosition, show: !isLast }] - : gutters; - - // Add children in reverse order (to process in forward order via stack) - for (let i = children.length - 1; i >= 0; i--) { - const childIsLast = i === children.length - 1; - stack.push([ - children[i], - childIndent, - multipleChildren, - multipleChildren, - childIsLast, - childGutters, - false - ]); - } - } -} diff --git a/internal/ui/live_templates/export/app/30-format.js b/internal/ui/live_templates/export/app/30-format.js deleted file mode 100644 index 6c8c78e8..00000000 --- a/internal/ui/live_templates/export/app/30-format.js +++ /dev/null @@ -1,125 +0,0 @@ -// ============================================================ -// TREE DISPLAY TEXT (pure data -> string) -// ============================================================ - -function shortenPath(p) { - if (typeof p !== 'string') return ''; - if (p.startsWith('/Users/')) { - const parts = p.split('/'); - if (parts.length > 2) return '~' + p.slice(('/Users/' + parts[2]).length); - } - if (p.startsWith('/home/')) { - const parts = p.split('/'); - if (parts.length > 2) return '~' + p.slice(('/home/' + parts[2]).length); - } - return p; -} - -function formatToolCall(name, args) { - switch (name) { - case 'read': { - const path = shortenPath(String(args.path || args.file_path || '')); - const offset = args.offset; - const limit = args.limit; - let display = path; - if (offset !== undefined || limit !== undefined) { - const start = offset ?? 1; - const end = limit !== undefined ? start + limit - 1 : ''; - display += `:${start}${end ? `-${end}` : ''}`; - } - return `[read: ${display}]`; - } - case 'write': - return `[write: ${shortenPath(String(args.path || args.file_path || ''))}]`; - case 'edit': - return `[edit: ${shortenPath(String(args.path || args.file_path || ''))}]`; - case 'bash': { - const rawCmd = String(args.command || ''); - const cmd = rawCmd.replace(/[\n\t]/g, ' ').trim().slice(0, 50); - return `[bash: ${cmd}${rawCmd.length > 50 ? '...' : ''}]`; - } - case 'grep': - return `[grep: /${args.pattern || ''}/ in ${shortenPath(String(args.path || '.'))}]`; - case 'find': - return `[find: ${args.pattern || ''} in ${shortenPath(String(args.path || '.'))}]`; - case 'ls': - return `[ls: ${shortenPath(String(args.path || '.'))}]`; - default: { - const argsStr = JSON.stringify(args).slice(0, 40); - return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? '...' : ''}]`; - } - } -} - -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -/** - * Truncate string to maxLen chars, append "..." if truncated. - */ -function truncate(s, maxLen = 100) { - if (s.length <= maxLen) return s; - return s.slice(0, maxLen) + '...'; -} - -/** - * Get display text for tree node (returns HTML string). - */ -function getTreeNodeDisplayHtml(entry, label) { - const normalize = s => s.replace(/[\n\t]/g, ' ').trim(); - const labelHtml = label ? `[${escapeHtml(label)}] ` : ''; - - switch (entry.type) { - case 'message': { - const msg = entry.message; - if (msg.role === 'user') { - const content = truncate(normalize(extractContent(msg.content))); - return labelHtml + `user: ${escapeHtml(content)}`; - } - if (msg.role === 'assistant') { - const textContent = truncate(normalize(extractContent(msg.content))); - if (textContent) { - return labelHtml + `assistant: ${escapeHtml(textContent)}`; - } - if (msg.stopReason === 'aborted') { - return labelHtml + `assistant: (aborted)`; - } - if (msg.errorMessage) { - return labelHtml + `assistant: ${escapeHtml(truncate(msg.errorMessage))}`; - } - return labelHtml + `assistant: (no text)`; - } - if (msg.role === 'toolResult') { - const toolCall = msg.toolCallId ? toolCallMap.get(msg.toolCallId) : null; - if (toolCall) { - return labelHtml + `${escapeHtml(formatToolCall(toolCall.name, toolCall.arguments))}`; - } - return labelHtml + `[${msg.toolName || 'tool'}]`; - } - if (msg.role === 'bashExecution') { - const cmd = truncate(normalize(msg.command || '')); - return labelHtml + `[bash]: ${escapeHtml(cmd)}`; - } - return labelHtml + `[${msg.role}]`; - } - case 'compaction': - return labelHtml + `[compaction: ${Math.round(entry.tokensBefore/1000)}k tokens]`; - case 'branch_summary': { - const summary = truncate(normalize(entry.summary || '')); - return labelHtml + `[branch summary]: ${escapeHtml(summary)}`; - } - case 'custom_message': { - const content = typeof entry.content === 'string' ? entry.content : extractContent(entry.content); - return labelHtml + `[${escapeHtml(entry.customType)}]: ${escapeHtml(truncate(normalize(content)))}`; - } - case 'model_change': - return labelHtml + `[model: ${entry.modelId}]`; - case 'thinking_level_change': - return labelHtml + `[thinking: ${entry.thinkingLevel}]`; - default: - return labelHtml + `[${entry.type}]`; - } -} diff --git a/internal/ui/live_templates/export/app/40-render-tree.js b/internal/ui/live_templates/export/app/40-render-tree.js deleted file mode 100644 index c93cdce9..00000000 --- a/internal/ui/live_templates/export/app/40-render-tree.js +++ /dev/null @@ -1,91 +0,0 @@ -// ============================================================ -// TREE RENDERING (DOM manipulation) -// ============================================================ - -let currentLeafId = leafId; -let currentTargetId = urlTargetId || leafId; -let treeRendered = false; - -function renderTree() { - const tree = buildTree(); - const activePathIds = buildActivePathIds(currentLeafId); - const flatNodes = flattenTree(tree, activePathIds); - const filtered = filterNodes(flatNodes, currentLeafId); - const container = document.getElementById('tree-container'); - - // Full render only on first call or when filter/search changes - if (!treeRendered) { - container.innerHTML = ''; - - for (const flatNode of filtered) { - const entry = flatNode.node.entry; - const isOnPath = activePathIds.has(entry.id); - const isTarget = entry.id === currentTargetId; - - const div = document.createElement('div'); - div.className = 'tree-node'; - if (isOnPath) div.classList.add('in-path'); - if (isTarget) div.classList.add('active'); - div.dataset.id = entry.id; - - const prefix = buildTreePrefix(flatNode); - const prefixSpan = document.createElement('span'); - prefixSpan.className = 'tree-prefix'; - prefixSpan.textContent = prefix; - - const marker = document.createElement('span'); - marker.className = 'tree-marker'; - marker.textContent = isOnPath ? '•' : ' '; - - const content = document.createElement('span'); - content.className = 'tree-content'; - content.innerHTML = getTreeNodeDisplayHtml(entry, flatNode.node.label); - - div.appendChild(prefixSpan); - div.appendChild(marker); - div.appendChild(content); - // Navigate to the newest leaf through this node, but scroll to the clicked node - div.addEventListener('click', () => { - if (window.getSelection().toString()) return; - const leafId = findNewestLeaf(entry.id); - navigateTo(leafId, 'target', entry.id); - if (isMobileLayout()) closeSidebar(); - }); - - container.appendChild(div); - } - - treeRendered = true; - } else { - // Just update markers and classes - const nodes = container.querySelectorAll('.tree-node'); - for (const node of nodes) { - const id = node.dataset.id; - const isOnPath = activePathIds.has(id); - const isTarget = id === currentTargetId; - - node.classList.toggle('in-path', isOnPath); - node.classList.toggle('active', isTarget); - - const marker = node.querySelector('.tree-marker'); - if (marker) { - marker.textContent = isOnPath ? '•' : ' '; - } - } - } - - document.getElementById('tree-status').textContent = `${filtered.length} / ${flatNodes.length} entries`; - - // Scroll active node into view after layout - setTimeout(() => { - const activeNode = container.querySelector('.tree-node.active'); - if (activeNode) { - activeNode.scrollIntoView({ block: 'nearest' }); - } - }, 0); -} - -function forceTreeRerender() { - treeRendered = false; - renderTree(); -} diff --git a/internal/ui/live_templates/export/app/50-render-entry.js b/internal/ui/live_templates/export/app/50-render-entry.js deleted file mode 100644 index dbf09295..00000000 --- a/internal/ui/live_templates/export/app/50-render-entry.js +++ /dev/null @@ -1,572 +0,0 @@ -// ============================================================ -// MESSAGE RENDERING -// ============================================================ - -function formatTokens(count) { - if (count < 1000) return count.toString(); - if (count < 10000) return (count / 1000).toFixed(1) + 'k'; - if (count < 1000000) return Math.round(count / 1000) + 'k'; - return (count / 1000000).toFixed(1) + 'M'; -} - -function formatTimestamp(ts) { - if (!ts) return ''; - const date = new Date(ts); - return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); -} - -function replaceTabs(text) { - return text.replace(/\t/g, ' '); -} - -/** Safely coerce value to string for display. Returns null if invalid type. */ -function str(value) { - if (typeof value === 'string') return value; - if (value == null) return ''; - return null; -} - -function getLanguageFromPath(filePath) { - const ext = filePath.split('.').pop()?.toLowerCase(); - const extToLang = { - ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', - py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', - c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp', - php: 'php', sh: 'bash', bash: 'bash', zsh: 'bash', - sql: 'sql', html: 'html', css: 'css', scss: 'scss', - json: 'json', yaml: 'yaml', yml: 'yaml', xml: 'xml', - md: 'markdown', dockerfile: 'dockerfile' - }; - return extToLang[ext]; -} - -function findToolResult(toolCallId) { - for (const entry of entries) { - if (entry.type === 'message' && entry.message.role === 'toolResult') { - if (entry.message.toolCallId === toolCallId) { - return entry; - } - } - } - return null; -} - -function formatExpandableOutput(text, maxLines, lang) { - text = replaceTabs(text); - const lines = text.split('\n'); - const displayLines = lines.slice(0, maxLines); - const remaining = lines.length - maxLines; - - if (lang) { - let highlighted; - try { - highlighted = hljs.highlight(text, { language: lang }).value; - } catch { - highlighted = escapeHtml(text); - } - - if (remaining > 0) { - const previewCode = displayLines.join('\n'); - let previewHighlighted; - try { - previewHighlighted = hljs.highlight(previewCode, { language: lang }).value; - } catch { - previewHighlighted = escapeHtml(previewCode); - } - - return ``; - } - - return `
${highlighted}
`; - } - - // Plain text output - if (remaining > 0) { - let out = ''; - return out; - } - - let out = '
'; - for (const line of displayLines) { - out += `
${escapeHtml(replaceTabs(line))}
`; - } - out += '
'; - return out; -} - -function renderAskUserQuestionTool(args, result) { - const questions = Array.isArray(args.questions) ? args.questions : []; - const answers = result?.details?.answers || {}; - const cancelled = result?.details?.cancelled === true; - const awaitingChatReply = result?.details?.awaitingChatReply === true; - const questionToolFailed = result?.isError === true; - const canClick = !result || questionToolFailed || awaitingChatReply; - const isInteractive = canClick || cancelled; - const isMulti = questions.length > 1; - const anyMultiSelect = questions.some(q => q && q.multiSelect === true); - const needsSubmit = isMulti || anyMultiSelect; - - let html = `
`; - html += '
Question for you
'; - if (questionToolFailed) { - html += '
question UI failed
'; - } else if (cancelled) { - html += '
cancelled
'; - } else if (awaitingChatReply) { - html += '
waiting for response
'; - } else if (result) { - html += '
answered
'; - } else { - html += '
waiting for response
'; - } - - if (questions.length === 0) { - html += '
No question payload provided.
'; - } - - questions.forEach((q, qIndex) => { - const questionText = typeof q.question === 'string' ? q.question : `Question ${qIndex + 1}`; - const answer = answers[questionText]; - const options = Array.isArray(q.options) ? q.options : []; - const multiSelect = q && q.multiSelect === true; - html += `
`; - if (q.header) html += `
${escapeHtml(String(q.header))}
`; - html += `
${escapeHtml(questionText)}
`; - if (options.length > 0) { - html += '
'; - options.forEach((option) => { - const label = typeof option?.label === 'string' ? option.label : String(option || ''); - const description = typeof option?.description === 'string' ? option.description : ''; - const selected = answer === label || (typeof answer === 'string' && answer.split(', ').includes(label)); - const tag = isInteractive ? 'button' : 'div'; - const actionClass = isInteractive ? ' ask-question-option-action' : ''; - const dataAttrs = isInteractive ? ` type="button" data-question="${escapeHtml(questionText)}" data-answer="${escapeHtml(label)}"` : ''; - html += `<${tag} class="ask-question-option${selected ? ' selected' : ''}${actionClass}"${dataAttrs}>`; - html += `
${selected ? '✓ ' : ''}${escapeHtml(label)}
`; - if (description) html += `
${escapeHtml(description)}
`; - html += ``; - }); - html += '
'; - } - if (answer) { - html += `
Answer: ${escapeHtml(String(answer))}
`; - } - html += '
'; - }); - - if (isInteractive) { - if (needsSubmit) { - html += ''; - } else if (questionToolFailed) { - html += '
Use these options as a fallback — click an option to send your answer to pi.
'; - } else if (cancelled) { - html += '
Click an option to send your answer to pi.
'; - } else if (!result || awaitingChatReply) { - html += '
Click an option, or use the chat composer below, to answer this question.
'; - } - } - - html += '
'; - return html; -} - -function renderToolCall(call) { - const resultEntry = findToolResult(call.id); - const result = resultEntry?.message; - const isError = result?.isError || false; - const statusClass = result ? (isError ? 'error' : 'success') : 'pending'; - - const getResultText = () => { - if (!result) return ''; - const textBlocks = result.content.filter(c => c.type === 'text'); - return textBlocks.map(c => c.text).join('\n'); - }; - - const getResultImages = () => { - if (!result) return []; - return result.content.filter(c => c.type === 'image'); - }; - - const renderResultImages = () => { - const images = getResultImages(); - if (images.length === 0) return ''; - return '
' + - images.map(img => ``).join('') + - '
'; - }; - - const containerId = resultEntry ? ` id="entry-${resultEntry.id}"` : ''; - let html = `
`; - const args = call.arguments || {}; - const name = call.name; - - const invalidArg = '[invalid arg]'; - - switch (name) { - case 'bash': { - const command = str(args.command); - const cmdDisplay = command === null ? invalidArg : escapeHtml(command || '...'); - html += `
$ ${cmdDisplay}
`; - if (result) { - const output = getResultText().trim(); - if (output) html += formatExpandableOutput(output, 5); - } - break; - } - case 'read': { - const filePath = str(args.file_path ?? args.path); - const offset = args.offset; - const limit = args.limit; - - let pathHtml = filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || '')); - if (filePath !== null && (offset !== undefined || limit !== undefined)) { - const startLine = offset ?? 1; - const endLine = limit !== undefined ? startLine + limit - 1 : ''; - pathHtml += `:${startLine}${endLine ? '-' + endLine : ''}`; - } - - html += `
read ${pathHtml}
`; - if (result) { - html += renderResultImages(); - const output = getResultText(); - const lang = filePath ? getLanguageFromPath(filePath) : null; - if (output) html += formatExpandableOutput(output, 10, lang); - } - break; - } - case 'write': { - const filePath = str(args.file_path ?? args.path); - const content = str(args.content); - - html += `
write ${filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || ''))}`; - if (content !== null && content) { - const lines = content.split('\n'); - if (lines.length > 10) html += ` (${lines.length} lines)`; - } - html += '
'; - - if (content === null) { - html += `
[invalid content arg - expected string]
`; - } else if (content) { - const lang = filePath ? getLanguageFromPath(filePath) : null; - html += formatExpandableOutput(content, 10, lang); - } - if (result) { - const output = getResultText().trim(); - if (output) html += `
${escapeHtml(output)}
`; - } - break; - } - case 'edit': { - const filePath = str(args.file_path ?? args.path); - html += `
edit ${filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || ''))}
`; - - if (result?.details?.diff) { - const diffLines = result.details.diff.split('\n'); - html += '
'; - for (const line of diffLines) { - const cls = line.match(/^\+/) ? 'diff-added' : line.match(/^-/) ? 'diff-removed' : 'diff-context'; - html += `
${escapeHtml(replaceTabs(line))}
`; - } - html += '
'; - } else if (result) { - const output = getResultText().trim(); - if (output) html += `
${escapeHtml(output)}
`; - } - break; - } - case 'ls': { - const dirPath = str(args.path); - const limit = args.limit; - - let pathHtml = dirPath === null ? invalidArg : escapeHtml(shortenPath(dirPath || '.')); - if (limit !== undefined) { - pathHtml += ` (limit ${escapeHtml(String(limit))})`; - } - - html += `
ls ${pathHtml}
`; - if (result) { - const output = getResultText().trim(); - if (output) html += formatExpandableOutput(output, 20); - } - break; - } - case 'ask_user_question': - case 'pi_web_ask_user_question': { - html += renderAskUserQuestionTool(args, result); - break; - } - default: { - // Check for pre-rendered custom tool HTML - const rendered = renderedTools?.[call.id]; - if (rendered?.callHtml || rendered?.resultHtmlCollapsed || rendered?.resultHtmlExpanded) { - // Custom tool with pre-rendered HTML from TUI renderer - if (rendered.callHtml) { - html += `
${rendered.callHtml}
`; - } else { - html += `
${escapeHtml(name)}
`; - } - - if (rendered.resultHtmlCollapsed && rendered.resultHtmlExpanded && rendered.resultHtmlCollapsed !== rendered.resultHtmlExpanded) { - // Both collapsed and expanded differ - render expandable section - html += ``; - } else if (rendered.resultHtmlExpanded) { - // Only expanded exists (or collapsed is identical) - show directly - html += `
${rendered.resultHtmlExpanded}
`; - } else if (result) { - // No pre-rendered result HTML - fallback to JSON - const output = getResultText(); - if (output) html += formatExpandableOutput(output, 10); - } - } else { - // Fallback to JSON display (existing behavior) - html += `
${escapeHtml(name)}
`; - html += `
${escapeHtml(JSON.stringify(args, null, 2))}
`; - if (result) { - const output = getResultText(); - if (output) html += formatExpandableOutput(output, 10); - } - } - } - } - - html += '
'; - return html; -} - -/** - * Download the session data as a JSONL file. - * Reconstructs the original format: header line + entry lines. - */ -window.downloadSessionJson = function() { - // Build JSONL content: header first, then all entries - const lines = []; - if (header) { - lines.push(JSON.stringify({ type: 'header', ...header })); - } - for (const entry of entries) { - lines.push(JSON.stringify(entry)); - } - const jsonlContent = lines.join('\n'); - - // Create download - const blob = new Blob([jsonlContent], { type: 'application/x-ndjson' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${header?.id || 'session'}.jsonl`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -/** - * Build a shareable URL for a specific message. - * URL format: base?gistId&leafId=&targetId= - */ -function buildShareUrl(entryId) { - // Check for injected base URL (used when loaded in iframe via srcdoc) - const baseUrlMeta = document.querySelector('meta[name="pi-share-base-url"]'); - const baseUrl = baseUrlMeta ? baseUrlMeta.content : window.location.href.split('?')[0]; - - const url = new URL(window.location.href); - // Find the gist ID (first query param without value, e.g., ?abc123) - const gistId = Array.from(url.searchParams.keys()).find(k => !url.searchParams.get(k)); - - // Build the share URL - const params = new URLSearchParams(); - const sessionId = url.searchParams.get('id'); - if (sessionId) params.set('id', sessionId); - params.set('leafId', currentLeafId); - params.set('targetId', entryId); - - // If we have an injected base URL (iframe context), use it directly - if (baseUrlMeta) { - return `${baseUrl}&${params.toString()}`; - } - - // Otherwise build from current location (direct file access) - url.search = gistId ? `?${gistId}&${params.toString()}` : `?${params.toString()}`; - return url.toString(); -} - -/** - * Copy text to clipboard with visual feedback. - * Uses navigator.clipboard with fallback to execCommand for HTTP contexts. - */ -async function copyToClipboard(text, button) { - let success = false; - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - success = true; - } - } catch (err) { - // Clipboard API failed, try fallback - } - - // Fallback for HTTP or when Clipboard API is unavailable - if (!success) { - try { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.select(); - success = document.execCommand('copy'); - document.body.removeChild(textarea); - } catch (err) { - console.error('Failed to copy:', err); - } - } - - if (success && button) { - const originalHtml = button.innerHTML; - button.innerHTML = '✓'; - button.classList.add('copied'); - setTimeout(() => { - button.innerHTML = originalHtml; - button.classList.remove('copied'); - }, 1500); - } -} - -/** - * Render the copy-link button HTML for a message. - */ -function renderCopyLinkButton(entryId) { - return ``; -} - -function renderEntry(entry) { - const ts = formatTimestamp(entry.timestamp); - const tsHtml = ts ? `
${ts}
` : ''; - const entryId = `entry-${entry.id}`; - const copyBtnHtml = renderCopyLinkButton(entry.id); - - if (entry.type === 'message') { - const msg = entry.message; - - if (msg.role === 'user') { - let html = `
${copyBtnHtml}${tsHtml}`; - const content = msg.content; - - if (Array.isArray(content)) { - const images = content.filter(c => c.type === 'image'); - if (images.length > 0) { - html += '
'; - for (const img of images) { - html += ``; - } - html += '
'; - } - } - - const text = typeof content === 'string' ? content : - content.filter(c => c.type === 'text').map(c => c.text).join('\n'); - if (text.trim()) { - html += `
${safeMarkedParse(text)}
`; - } - html += '
'; - return html; - } - - if (msg.role === 'assistant') { - let html = `
${copyBtnHtml}${tsHtml}`; - - for (const block of msg.content) { - if (block.type === 'text' && block.text.trim()) { - html += `
${safeMarkedParse(block.text)}
`; - } else if (block.type === 'thinking' && block.thinking.trim()) { - html += `
-
${escapeHtml(block.thinking)}
-
Thinking ...
-
`; - } - } - - for (const block of msg.content) { - if (block.type === 'toolCall') { - html += renderToolCall(block); - } - } - - if (msg.stopReason === 'aborted') { - html += '
Aborted
'; - } else if (msg.stopReason === 'error') { - html += `
Error: ${escapeHtml(msg.errorMessage || 'Unknown error')}
`; - } - - html += '
'; - return html; - } - - if (msg.role === 'bashExecution') { - const isError = msg.cancelled || (msg.exitCode !== 0 && msg.exitCode !== null); - let html = `
${tsHtml}`; - html += `
$ ${escapeHtml(msg.command)}
`; - if (msg.output) html += formatExpandableOutput(msg.output, 10); - if (msg.cancelled) { - html += '
(cancelled)
'; - } else if (msg.exitCode !== 0 && msg.exitCode !== null) { - html += `
(exit ${msg.exitCode})
`; - } - html += '
'; - return html; - } - - if (msg.role === 'toolResult') return ''; - } - - if (entry.type === 'model_change') { - return `
${tsHtml}Switched to model: ${escapeHtml(entry.provider)}/${escapeHtml(entry.modelId)}
`; - } - - if (entry.type === 'compaction') { - return `
-
[compaction]
-
Compacted from ${entry.tokensBefore.toLocaleString()} tokens
-
Compacted from ${entry.tokensBefore.toLocaleString()} tokens\n\n${escapeHtml(entry.summary)}
-
`; - } - - if (entry.type === 'branch_summary') { - return `
${tsHtml} -
Branch Summary
-
${safeMarkedParse(entry.summary)}
-
`; - } - - if (entry.type === 'custom_message' && entry.display) { - return `
${tsHtml} -
[${escapeHtml(entry.customType)}]
-
${safeMarkedParse(typeof entry.content === 'string' ? entry.content : JSON.stringify(entry.content))}
-
`; - } - - return ''; -} - diff --git a/internal/ui/live_templates/export/app/60-header.js b/internal/ui/live_templates/export/app/60-header.js deleted file mode 100644 index 1f134b35..00000000 --- a/internal/ui/live_templates/export/app/60-header.js +++ /dev/null @@ -1,138 +0,0 @@ -// ============================================================ -// HEADER / STATS -// ============================================================ - -function computeStats(entryList) { - let userMessages = 0, assistantMessages = 0, toolResults = 0; - let customMessages = 0, compactions = 0, branchSummaries = 0, toolCalls = 0; - const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; - const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; - const models = new Set(); - - for (const entry of entryList) { - if (entry.type === 'message') { - const msg = entry.message; - if (msg.role === 'user') userMessages++; - if (msg.role === 'assistant') { - assistantMessages++; - if (msg.model) models.add(msg.provider ? `${msg.provider}/${msg.model}` : msg.model); - if (msg.usage) { - tokens.input += msg.usage.input || 0; - tokens.output += msg.usage.output || 0; - tokens.cacheRead += msg.usage.cacheRead || 0; - tokens.cacheWrite += msg.usage.cacheWrite || 0; - if (msg.usage.cost) { - cost.input += msg.usage.cost.input || 0; - cost.output += msg.usage.cost.output || 0; - cost.cacheRead += msg.usage.cost.cacheRead || 0; - cost.cacheWrite += msg.usage.cost.cacheWrite || 0; - } - } - toolCalls += msg.content.filter(c => c.type === 'toolCall').length; - } - if (msg.role === 'toolResult') toolResults++; - } else if (entry.type === 'compaction') { - compactions++; - } else if (entry.type === 'branch_summary') { - branchSummaries++; - } else if (entry.type === 'custom_message') { - customMessages++; - } - } - - return { userMessages, assistantMessages, toolResults, customMessages, compactions, branchSummaries, toolCalls, tokens, cost, models: Array.from(models) }; -} - -const globalStats = computeStats(entries); - -function renderHeader() { - const totalCost = globalStats.cost.input + globalStats.cost.output + globalStats.cost.cacheRead + globalStats.cost.cacheWrite; - - const tokenParts = []; - if (globalStats.tokens.input) tokenParts.push(`↑${formatTokens(globalStats.tokens.input)}`); - if (globalStats.tokens.output) tokenParts.push(`↓${formatTokens(globalStats.tokens.output)}`); - if (globalStats.tokens.cacheRead) tokenParts.push(`R${formatTokens(globalStats.tokens.cacheRead)}`); - if (globalStats.tokens.cacheWrite) tokenParts.push(`W${formatTokens(globalStats.tokens.cacheWrite)}`); - - const msgParts = []; - if (globalStats.userMessages) msgParts.push(`${globalStats.userMessages} user`); - if (globalStats.assistantMessages) msgParts.push(`${globalStats.assistantMessages} assistant`); - if (globalStats.toolResults) msgParts.push(`${globalStats.toolResults} tool results`); - if (globalStats.customMessages) msgParts.push(`${globalStats.customMessages} custom`); - if (globalStats.compactions) msgParts.push(`${globalStats.compactions} compactions`); - if (globalStats.branchSummaries) msgParts.push(`${globalStats.branchSummaries} branch summaries`); - - let html = ` -
-

Session: ${escapeHtml(header?.id || 'unknown')}

-
- T show/hide thinking · O show/hide tools · P expand/collapse tool output -
- - - - -
-
-
-
Date:${header?.timestamp ? new Date(header.timestamp).toLocaleString() : 'unknown'}
-
Models:${globalStats.models.join(', ') || 'unknown'}
-
Messages:${msgParts.join(', ') || '0'}
-
Tool Calls:${globalStats.toolCalls}
-
Tokens:${tokenParts.join(' ') || '0'}
-
Cost:$${totalCost.toFixed(3)}
-
-
`; - - // Render system prompt (user's base prompt, applies to all providers) - if (systemPrompt) { - const lines = systemPrompt.split('\n'); - const previewLines = 10; - if (lines.length > previewLines) { - const preview = lines.slice(0, previewLines).join('\n'); - const remaining = lines.length - previewLines; - html += ``; - } else { - html += `
-
System Prompt
-
${escapeHtml(systemPrompt)}
-
`; - } - } - - if (tools && tools.length > 0) { - html += `
-
Available Tools
-
- ${tools.map(t => { - const hasParams = t.parameters && typeof t.parameters === 'object' && t.parameters.properties && Object.keys(t.parameters.properties).length > 0; - if (!hasParams) { - return `
${escapeHtml(t.name)} - ${escapeHtml(t.description)}
`; - } - const params = t.parameters; - const properties = params.properties; - const required = params.required || []; - let paramsHtml = ''; - for (const [name, prop] of Object.entries(properties)) { - const isRequired = required.includes(name); - const typeStr = prop.type || 'any'; - const reqLabel = isRequired ? 'required' : 'optional'; - paramsHtml += `
${escapeHtml(name)} ${escapeHtml(typeStr)} ${reqLabel}`; - if (prop.description) { - paramsHtml += `
${escapeHtml(prop.description)}
`; - } - paramsHtml += `
`; - } - return `
${escapeHtml(t.name)} - ${escapeHtml(t.description)}
${paramsHtml}
`; - }).join('')} -
-
`; - } - - return html; -} diff --git a/internal/ui/live_templates/export/app/70-navigation.js b/internal/ui/live_templates/export/app/70-navigation.js deleted file mode 100644 index 9ce777bd..00000000 --- a/internal/ui/live_templates/export/app/70-navigation.js +++ /dev/null @@ -1,83 +0,0 @@ -// ============================================================ -// NAVIGATION -// ============================================================ - -// Cache for rendered entry DOM nodes -const entryCache = new Map(); - -function renderEntryToNode(entry) { - // Check cache first - if (entryCache.has(entry.id)) { - return entryCache.get(entry.id).cloneNode(true); - } - - // Render to HTML string, then parse to node - const html = renderEntry(entry); - if (!html) return null; - - const template = document.createElement('template'); - template.innerHTML = html; - const node = template.content.firstElementChild; - - // Cache the node - if (node) { - entryCache.set(entry.id, node.cloneNode(true)); - } - return node; -} - -function navigateTo(targetId, scrollMode = 'target', scrollToEntryId = null) { - currentLeafId = targetId; - currentTargetId = scrollToEntryId || targetId; - const path = getPath(targetId); - - renderTree(); - - document.getElementById('header-container').innerHTML = renderHeader(); - attachHeaderHandlers(); - - // Build messages using cached DOM nodes - const messagesEl = document.getElementById('messages'); - const fragment = document.createDocumentFragment(); - - for (const entry of path) { - const node = renderEntryToNode(entry); - if (node) { - fragment.appendChild(node); - } - } - - messagesEl.innerHTML = ''; - messagesEl.appendChild(fragment); - window.sessionToggleState?.applyToNode(messagesEl); - - // Attach click handlers for copy-link buttons - messagesEl.querySelectorAll('.copy-link-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const entryId = btn.dataset.entryId; - const shareUrl = buildShareUrl(entryId); - copyToClipboard(shareUrl, btn); - }); - }); - - // Use setTimeout(0) to ensure DOM is fully laid out before scrolling - setTimeout(() => { - const content = document.getElementById('content'); - if (scrollMode === 'bottom') { - content.scrollTop = content.scrollHeight; - } else if (scrollMode === 'target') { - // If scrollToEntryId is provided, scroll to that specific entry - const scrollTargetId = scrollToEntryId || targetId; - const targetEl = document.getElementById(`entry-${scrollTargetId}`); - if (targetEl) { - targetEl.scrollIntoView({ block: 'center' }); - // Briefly highlight the target message - if (scrollToEntryId) { - targetEl.classList.add('highlight'); - setTimeout(() => targetEl.classList.remove('highlight'), 2000); - } - } - } - }, 0); -} diff --git a/internal/ui/live_templates/export/app/80-ui.js b/internal/ui/live_templates/export/app/80-ui.js deleted file mode 100644 index d7d36d28..00000000 --- a/internal/ui/live_templates/export/app/80-ui.js +++ /dev/null @@ -1,495 +0,0 @@ -// ============================================================ -// INITIALIZATION -// ============================================================ - -// Configure marked with syntax highlighting and TUI-compatible HTML handling -const strictStrikethroughRegex = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/; - -marked.use({ - breaks: true, - gfm: true, - tokenizer: { - // Treat HTML-like input as plain text so tags are shown verbatim, - // matching the TUI markdown renderer. - html() { - return undefined; - }, - tag() { - return undefined; - }, - del(src) { - const match = strictStrikethroughRegex.exec(src); - if (!match) return undefined; - return { - type: 'del', - raw: match[0], - text: match[2], - tokens: this.lexer.inlineTokens(match[2]) - }; - } - }, - renderer: { - // Sanitize link URLs to prevent javascript:/vbscript:/data: XSS - link(token) { - const href = (token.href || '').trim(); - if (/^\s*(javascript|vbscript|data):/i.test(href)) { - return this.parser.parseInline(token.tokens); - } - let out = ''; - return out; - }, - // Sanitize image src URLs - image(token) { - const href = (token.href || '').trim(); - if (/^\s*(javascript|vbscript|data):/i.test(href)) { - return escapeHtml(token.text || ''); - } - let out = '' + escapeHtml(token.text || '') + '${highlighted}`; - }, - // Inline code: escape HTML - codespan(token) { - return `${escapeHtml(token.text)}`; - } - } -}); - -// Simple marked parse (escaping handled in renderers) -function safeMarkedParse(text) { - return marked.parse(text); -} - -// Search input -const searchInput = document.getElementById('tree-search'); -searchInput.addEventListener('input', (e) => { - searchQuery = e.target.value; - forceTreeRerender(); -}); - -// Filter buttons -document.querySelectorAll('.filter-btn').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - filterMode = btn.dataset.filter; - forceTreeRerender(); - }); -}); - -// Sidebar toggle -const sidebar = document.getElementById('sidebar'); -const overlay = document.getElementById('sidebar-overlay'); -const hamburger = document.getElementById('hamburger'); -const sidebarResizer = document.getElementById('sidebar-resizer'); -const SIDEBAR_WIDTH_STORAGE_KEY = 'pi-share:v1:sidebar-width'; -const MIN_CONTENT_WIDTH = 320; - -function isMobileLayout() { - if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { - return false; - } - return window.matchMedia('(max-width: 900px)').matches; -} - -function getSidebarBounds() { - const rootStyles = getComputedStyle(document.documentElement); - const minWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-min-width')) || 240; - const maxWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-max-width')) || 720; - const viewportMaxWidth = window.innerWidth - MIN_CONTENT_WIDTH; - return { - minWidth, - maxWidth: Math.max(minWidth, Math.min(maxWidth, viewportMaxWidth)) - }; -} - -function clampSidebarWidth(width) { - const { minWidth, maxWidth } = getSidebarBounds(); - return Math.max(minWidth, Math.min(maxWidth, width)); -} - -function applySidebarWidth(width) { - document.documentElement.style.setProperty('--sidebar-width', `${Math.round(clampSidebarWidth(width))}px`); -} - -function loadSidebarWidth() { - try { - const raw = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY); - if (raw === null) return null; - const width = Number(raw); - return Number.isFinite(width) ? width : null; - } catch { - return null; - } -} - -function saveSidebarWidth(width) { - try { - localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(clampSidebarWidth(width)))); - } catch { - // Ignore storage failures (e.g. private browsing restrictions) - } -} - -function setupSidebarResize() { - const savedWidth = loadSidebarWidth(); - if (savedWidth !== null) { - applySidebarWidth(savedWidth); - } - - if (!sidebarResizer) return; - - let cleanupDrag = null; - - const stopDrag = (pointerId) => { - if (cleanupDrag) { - cleanupDrag(pointerId); - cleanupDrag = null; - } - }; - - sidebarResizer.addEventListener('pointerdown', (e) => { - if (isMobileLayout()) return; - - e.preventDefault(); - const startX = e.clientX; - const startWidth = sidebar.getBoundingClientRect().width; - document.body.classList.add('sidebar-resizing'); - sidebarResizer.setPointerCapture?.(e.pointerId); - - const onPointerMove = (event) => { - applySidebarWidth(startWidth + (event.clientX - startX)); - }; - - cleanupDrag = (pointerIdToRelease) => { - document.body.classList.remove('sidebar-resizing'); - sidebarResizer.releasePointerCapture?.(pointerIdToRelease); - window.removeEventListener('pointermove', onPointerMove); - window.removeEventListener('pointerup', onPointerUp); - window.removeEventListener('pointercancel', onPointerCancel); - saveSidebarWidth(sidebar.getBoundingClientRect().width); - }; - - const onPointerUp = (event) => stopDrag(event.pointerId); - const onPointerCancel = (event) => stopDrag(event.pointerId); - - window.addEventListener('pointermove', onPointerMove); - window.addEventListener('pointerup', onPointerUp); - window.addEventListener('pointercancel', onPointerCancel); - }); - - sidebarResizer.addEventListener('dblclick', () => { - if (isMobileLayout()) return; - applySidebarWidth(400); - saveSidebarWidth(400); - }); - - window.addEventListener('resize', () => { - if (isMobileLayout()) { - document.body.classList.remove('sidebar-collapsed'); - hamburger.style.display = ''; - return; - } - applySidebarWidth(sidebar.getBoundingClientRect().width); - const collapsed = loadSidebarCollapsed(); - setSidebarCollapsed(collapsed); - }); -} - -setupSidebarResize(); - -const SIDEBAR_COLLAPSED_STORAGE_KEY = 'pi-share:v1:sidebar-collapsed'; - -function loadSidebarCollapsed() { - try { - return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true'; - } catch { - return false; - } -} - -function saveSidebarCollapsed(collapsed) { - try { - localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed)); - } catch {} -} - -function setSidebarCollapsed(collapsed) { - document.body.classList.toggle('sidebar-collapsed', collapsed); - if (isMobileLayout()) { - hamburger.style.display = ''; - } else { - hamburger.style.display = collapsed ? '' : 'none'; - } -} - -function setSidebarOpen(open) { - sidebar.classList.toggle('open', open); - overlay.classList.toggle('open', open); - document.body.classList.toggle('sidebar-open', open); - hamburger.style.display = open ? 'none' : ''; -} - -function setupSidebarCollapse() { - const collapsed = loadSidebarCollapsed(); - if (!isMobileLayout()) { - setSidebarCollapsed(collapsed); - } - - hamburger.addEventListener('click', () => { - if (isMobileLayout()) { - setSidebarOpen(true); - return; - } - setSidebarCollapsed(false); - saveSidebarCollapsed(false); - }); - - const closeSidebar = () => { - if (isMobileLayout()) { - setSidebarOpen(false); - return; - } - setSidebarCollapsed(true); - saveSidebarCollapsed(true); - }; - - overlay.addEventListener('click', closeSidebar); - overlay.addEventListener('touchstart', (e) => { - e.preventDefault(); - closeSidebar(); - }, { passive: false }); - - const hideSidebarBtn = document.getElementById('hide-sidebar'); - if (hideSidebarBtn) { - hideSidebarBtn.addEventListener('click', closeSidebar); - } - - const sidebarCloseBtn = document.getElementById('sidebar-close'); - if (sidebarCloseBtn) { - sidebarCloseBtn.addEventListener('click', () => setSidebarOpen(false)); - } -} - -setupSidebarCollapse(); - -// Toggle states -const TOGGLE_STATE_STORAGE_KEY = 'pi.sessionDetail.toggleState'; -const toggleStateDefaults = { thinkingExpanded: true, toolsVisible: true, toolOutputsExpanded: false }; -let toggleState = { ...toggleStateDefaults }; - -try { - const savedToggleState = JSON.parse(localStorage.getItem(TOGGLE_STATE_STORAGE_KEY) || '{}'); - if (typeof savedToggleState.thinkingExpanded === 'boolean') toggleState.thinkingExpanded = savedToggleState.thinkingExpanded; - if (typeof savedToggleState.toolsVisible === 'boolean') toggleState.toolsVisible = savedToggleState.toolsVisible; - if (typeof savedToggleState.toolOutputsExpanded === 'boolean') toggleState.toolOutputsExpanded = savedToggleState.toolOutputsExpanded; -} catch (_) {} - -const saveToggleState = () => { - try { - localStorage.setItem(TOGGLE_STATE_STORAGE_KEY, JSON.stringify(toggleState)); - } catch (_) {} -}; - -const applyThinkingState = (root) => { - root.querySelectorAll('.thinking-text').forEach(el => { - el.style.display = toggleState.thinkingExpanded ? '' : 'none'; - }); - root.querySelectorAll('.thinking-collapsed').forEach(el => { - el.style.display = toggleState.thinkingExpanded ? 'none' : 'block'; - }); -}; - -const applyToolsVisibilityState = (root) => { - root.querySelectorAll('.tool-execution, .compaction').forEach(el => { - el.style.display = toggleState.toolsVisible ? '' : 'none'; - }); -}; - -const applyToolOutputState = (root) => { - root.querySelectorAll('.tool-output.expandable').forEach(el => { - el.classList.toggle('expanded', toggleState.toolOutputsExpanded); - }); - root.querySelectorAll('.compaction').forEach(el => { - el.classList.toggle('expanded', toggleState.toolOutputsExpanded); - }); -}; - -const syncToggleButtons = () => { - const buttons = [ - [document.querySelector('[data-action="toggle-thinking"]'), toggleState.thinkingExpanded], - [document.querySelector('[data-action="toggle-tools"]'), toggleState.toolsVisible], - [document.querySelector('[data-action="toggle-tool-output"]'), toggleState.toolOutputsExpanded], - ]; - buttons.forEach(([btn, isActive]) => { - if (!btn) return; - btn.classList.toggle('active', isActive); - btn.setAttribute('aria-pressed', isActive ? 'true' : 'false'); - }); -}; - -window.sessionToggleState = { - get thinkingExpanded() { return toggleState.thinkingExpanded; }, - get toolsVisible() { return toggleState.toolsVisible; }, - get toolOutputsExpanded() { return toggleState.toolOutputsExpanded; }, - applyToNode(node) { - if (!node) return; - applyThinkingState(node); - applyToolsVisibilityState(node); - applyToolOutputState(node); - }, - syncButtons: syncToggleButtons, -}; - -const toggleThinking = () => { - toggleState.thinkingExpanded = !toggleState.thinkingExpanded; - saveToggleState(); - window.sessionToggleState.applyToNode(document); - syncToggleButtons(); -}; - -const toggleToolsVisibility = () => { - toggleState.toolsVisible = !toggleState.toolsVisible; - saveToggleState(); - window.sessionToggleState.applyToNode(document); - syncToggleButtons(); -}; - -const toggleToolOutputs = () => { - toggleState.toolOutputsExpanded = !toggleState.toolOutputsExpanded; - saveToggleState(); - window.sessionToggleState.applyToNode(document); - syncToggleButtons(); -}; - -window.applyToggleStateToNode = (node) => window.sessionToggleState.applyToNode(node); - -const attachHeaderHandlers = () => { - document.querySelector('[data-action="toggle-thinking"]')?.addEventListener('click', toggleThinking); - document.querySelector('[data-action="toggle-tools"]')?.addEventListener('click', toggleToolsVisibility); - document.querySelector('[data-action="toggle-tool-output"]')?.addEventListener('click', toggleToolOutputs); - syncToggleButtons(); -}; - -const isEditableTarget = (element) => { - if (!element) return false; - const tagName = element.tagName; - if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT' || tagName === 'BUTTON') { - return true; - } - return element.isContentEditable || Boolean(element.closest?.('[contenteditable="true"]')); -}; - -// Keyboard shortcuts -const SCROLL_AMOUNT = 300; -const GG_TIMEOUT = 500; -let ggTimer = null; - -document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - searchInput.value = ''; - searchQuery = ''; - navigateTo(leafId, 'bottom'); - } - - if (isEditableTarget(document.activeElement)) { - return; - } - - if (e.key === 'j') { - e.preventDefault(); - const content = document.getElementById('content'); - if (content) { - content.scrollBy({ top: SCROLL_AMOUNT, behavior: 'instant' }); - } else { - window.scrollBy({ top: SCROLL_AMOUNT, behavior: 'instant' }); - } - } else if (e.key === 'k') { - e.preventDefault(); - const content = document.getElementById('content'); - if (content) { - content.scrollBy({ top: -SCROLL_AMOUNT, behavior: 'instant' }); - } else { - window.scrollBy({ top: -SCROLL_AMOUNT, behavior: 'instant' }); - } - } else if (e.key === 'g') { - e.preventDefault(); - if (ggTimer) { - clearTimeout(ggTimer); - ggTimer = null; - const content = document.getElementById('content'); - if (content) { - content.scrollTo({ top: 0, behavior: 'instant' }); - } else { - window.scrollTo({ top: 0, behavior: 'instant' }); - } - } else { - ggTimer = setTimeout(() => { ggTimer = null; }, GG_TIMEOUT); - } - } else if (e.key === 'G') { - e.preventDefault(); - const content = document.getElementById('content'); - if (content) { - content.scrollTo({ top: content.scrollHeight, behavior: 'instant' }); - } else { - window.scrollTo({ - top: document.documentElement.scrollHeight, - behavior: 'instant', - }); - } - } else if (e.key.toLowerCase() === 't') { - e.preventDefault(); - toggleThinking(); - } else if (e.key.toLowerCase() === 'o') { - e.preventDefault(); - toggleToolsVisibility(); - } else if (e.key.toLowerCase() === 'p') { - e.preventDefault(); - toggleToolOutputs(); - } -}); - -// ============================================================ -// INITIAL RENDER -// ============================================================ - -// If URL has targetId, scroll to that specific message; otherwise stay at top -if (leafId) { - if (urlTargetId && byId.has(urlTargetId)) { - // Deep link: navigate to leaf and scroll to target message - navigateTo(leafId, 'target', urlTargetId); - } else { - navigateTo(leafId, 'none'); - } -} else if (entries.length > 0) { - // Fallback: use last entry if no leafId - navigateTo(entries[entries.length - 1].id, 'none'); -} diff --git a/internal/ui/mobile_sidebar_test.go b/internal/ui/mobile_sidebar_test.go index 9ba82d78..ced908c2 100644 --- a/internal/ui/mobile_sidebar_test.go +++ b/internal/ui/mobile_sidebar_test.go @@ -1,23 +1,37 @@ package ui import ( + "os" "strings" "testing" "pi-web/internal/sessions" ) +// Mobile sidebar close-on-navigate is implemented in the shared sidebar and +// tree-renderer modules (used by both live and static export). Assert against +// the source rather than the minified export bundle. func TestMobileSidebarClosesWhenNavigatingTree(t *testing.T) { - checks := []string{ - "function setSidebarOpen(open)", - "document.body.classList.toggle('sidebar-open', open);", - "if (isMobileLayout()) closeSidebar();", + sidebarSrc, err := os.ReadFile(repoPath("web/src/session/ui/sidebar.js")) + if err != nil { + t.Fatalf("read sidebar.js: %v", err) } - for _, check := range checks { - if !strings.Contains(exportJs, check) { - t.Fatalf("template JS missing %q; mobile sidebar can remain stuck over chat", check) + treeSrc, err := os.ReadFile(repoPath("web/src/session/tree/tree-renderer.js")) + if err != nil { + t.Fatalf("read tree-renderer.js: %v", err) + } + sidebarChecks := []string{ + "export function setSidebarOpen(open, { documentImpl = document } = {}) {", + "documentImpl.body?.classList.toggle('sidebar-open', open);", + } + for _, check := range sidebarChecks { + if !strings.Contains(string(sidebarSrc), check) { + t.Fatalf("sidebar.js missing %q; mobile sidebar can remain stuck over chat", check) } } + if !strings.Contains(string(treeSrc), "if (isMobileLayout()) closeSidebar();") { + t.Fatal("tree-renderer.js missing mobile close-on-navigate; sidebar can remain stuck over chat") + } } func TestMobileSessionActionsStayAtTopAndHideBehindSidebar(t *testing.T) { diff --git a/internal/ui/templates_embed_test.go b/internal/ui/templates_embed_test.go index 4e801cf2..cc0db5b2 100644 --- a/internal/ui/templates_embed_test.go +++ b/internal/ui/templates_embed_test.go @@ -164,26 +164,21 @@ func assertCSSCustomPropertiesDefined(t *testing.T, name, html string) { } } -func TestExportAppJSManifestMatchesEmbeddedFiles(t *testing.T) { - entries, err := appJsFS.ReadDir("live_templates/export/app") - if err != nil { - t.Fatalf("read live_templates/export/app: %v", err) - } - files := map[string]bool{} - for _, entry := range entries { - if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".js") { - files[entry.Name()] = true +// TestExportBundleIsSelfContained guards the static export runtime built by +// Vite (web/src/export/export-entry.js). The snapshot must run from a single +// inlined