From 4724e5cb606c59bce869ebddba063cac5f06633b Mon Sep 17 00:00:00 2001 From: QuinnWan <144975606+somewan820@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:55:32 +0800 Subject: [PATCH 1/2] feat(web): restore polished workbench UI and conversation rendering The session-admission hardening in #326 (d1118f8) rewrote web/ui and dropped most of the polished design: the settings dialog (language/theme), landing logo animation, rich thinking/tool-call rendering, subagent/workflow status cards, turn rail, activity bar, favicon, and the engineering-grid background. Restore the 4421944 design baseline while keeping the admission and recovery hardening intact. - Settings dialog with language/theme pickers persisted browser-locally - Thinking rows with live timers, tool lines with icons/summaries and right-edge outcome glyphs, 4+ run grouping, per-turn copy/time actions - Subagent/workflow activity cards and composer activity chips adapted to the current { items, omitted } capability projections - Turn rail, favicon.svg + static route, grid/sidebar/paper-lane backdrop - Tests: restore positive assertions for the revived UI surface Refs #76, relates to #345, #346, #350 --- tests/web/app-render.test.ts | 32 +- tests/web/web-host.test.ts | 70 ++- web/host/web-host.ts | 7 +- web/ui/app.js | 840 ++++++++++++++++++++++++++++++++--- web/ui/favicon.svg | 21 + web/ui/index.html | 50 ++- web/ui/styles.css | 671 +++++++++++++++++++++++++--- 7 files changed, 1529 insertions(+), 162 deletions(-) create mode 100644 web/ui/favicon.svg diff --git a/tests/web/app-render.test.ts b/tests/web/app-render.test.ts index c35a3f88..1b12543e 100644 --- a/tests/web/app-render.test.ts +++ b/tests/web/app-render.test.ts @@ -89,8 +89,8 @@ const SNAPSHOT = { id: "sa-1", title: "explore", status: "done", - createdAt: "2026-09-01T10:00:00Z", - settledAt: "2026-09-01T10:00:30Z", + createdAt: Date.parse("2026-09-01T10:00:00Z"), + settledAt: Date.parse("2026-09-01T10:00:30Z"), }, ], omitted: 2, @@ -102,8 +102,8 @@ const SNAPSHOT = { runId: "wf-1", name: "delivery", status: "completed", - startedAt: "2026-09-01T10:00:00Z", - finishedAt: "2026-09-01T10:01:00Z", + startedAt: Date.parse("2026-09-01T10:00:00Z"), + finishedAt: Date.parse("2026-09-01T10:01:00Z"), agents: { total: 1, running: 0, done: 1, error: 0, uncertain: 0 }, }, ], @@ -438,14 +438,28 @@ test("app.js renders a full session without runtime errors", async () => { const conversation = elements.get("conversation"); assert.ok(conversation, "conversation element exists"); assert.match(conversation.innerHTML, /message-row user/); - assert.match(conversation.innerHTML, /assistant-detail/); - assert.match(conversation.innerHTML, /tool-details/); - assert.match(conversation.innerHTML, /runtime-activity/); + assert.match(conversation.innerHTML, /thinking-line/); + assert.match(conversation.innerHTML, /tool-line/); + // Settled tool calls carry an outcome glyph at the row's right edge. + assert.match(conversation.innerHTML, /tool-status done/); + assert.match(conversation.innerHTML, /activity-card subagent/); + assert.match(conversation.innerHTML, /activity-card workflow/); assert.match(conversation.innerHTML, /explore/); - assert.match(conversation.innerHTML, /\+2 omitted/); - assert.match(conversation.innerHTML, /delivery/); assert.match(conversation.innerHTML, /file1/); assert.match(conversation.innerHTML, /最终总结/); + // Copy/time shows on user messages and each turn's final assistant answer + // (this fixture is a single turn: one user row + one final answer). + const actionBars = conversation.innerHTML.match(/message-actions/g) || []; + assert.equal(actionBars.length, 2); + const activityBar = elements.get("activity-bar"); + assert.ok(activityBar, "activity bar element exists"); + assert.equal(activityBar.hidden, false); + assert.match(activityBar.innerHTML, /activity-chip workflow done/); + assert.match(activityBar.innerHTML, /activity-chip subagent done/); + assert.match(activityBar.innerHTML, /delivery/); + assert.match(activityBar.innerHTML, /\+2/); + const turnRail = elements.get("turn-rail"); + assert.ok(turnRail, "turn rail element exists"); }); test("app.js moves the bootstrap token into tab storage and clears the URL", async () => { diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index d80df4fa..db859f89 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -128,8 +128,11 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn assert.match(pageHtml, /id="workspace-menu"/); assert.match(pageHtml, /Rename workspace/); assert.match(pageHtml, /Remove from sidebar/); - assert.doesNotMatch(pageHtml, /theme-picker-trigger|data-theme-value/); - assert.doesNotMatch(pageHtml, /settings-dialog|language-picker/u); + assert.match(pageHtml, /id="theme-picker-trigger"/); + assert.match(pageHtml, /data-theme-value="dark"/); + assert.match(pageHtml, /id="settings-dialog"/); + assert.match(pageHtml, /id="language-picker-trigger"/); + assert.match(pageHtml, /id="open-settings"/); const app = await fetch(`${launched.origin}/app.js`); assert.equal(app.status, 200); @@ -163,23 +166,48 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn assert.match(appSource, /visibleUngroupedSessions/); assert.match(appSource, /workspaceDeleteConfirm/); assert.match(appSource, /workspace-delete-dialog/); - assert.match(appSource, /runtime-activity/); + assert.match(appSource, /message-copy/); + assert.match(appSource, /messageActionsMarkup/); + assert.match(appSource, /turn-tick/); + assert.match(appSource, /turn-rail/); + assert.match(appSource, /scrollIntoView/); + assert.match(appSource, /activity-card/); + assert.match(appSource, /familyToolCallCard/); + assert.match(appSource, /toolLineMarkup/); + assert.match(appSource, /toolCallSummary/); + assert.match(appSource, /groupRows/); + assert.match(appSource, /tool-group/); + assert.match(appSource, /thinkingLineMarkup/); + assert.match(appSource, /data-thinking-start/); + assert.match(appSource, /message-edit-input/); + assert.match(appSource, /enterMessageEdit/); + assert.match(appSource, /renderActivityBar/); + assert.match(appSource, /activity-chip/); assert.match(appSource, /runtime_changed/); + assert.match(appSource, /resultsByCallId/); + assert.match(appSource, /pinnedToBottom/); + assert.match(appSource, /behavior: "instant"/); + assert.match(appSource, /customType/); + assert.match(appSource, /subagent-result/); + assert.match(appSource, /workflow-result/); assert.match(appSource, /sessionStorage\.setItem/); assert.match(appSource, /history\.replaceState/); assert.match(appSource, /\/events\?cursor=/); - assert.doesNotMatch(appSource, /localStorage|openpi\.archived-sessions/); - assert.doesNotMatch( - appSource, - /applyTheme|message-edit-input|enterMessageEdit/, - ); - assert.doesNotMatch(appSource, /language-picker|open-settings/u); + assert.doesNotMatch(appSource, /openpi\.archived-sessions/); + assert.match(appSource, /applyTheme/); + assert.match(appSource, /data-theme-value/); + assert.match(appSource, /language-picker|open-settings/u); const marked = await fetch(`${launched.origin}/marked.js`); assert.equal(marked.status, 200); assert.match(marked.headers.get("content-type") || "", /javascript/); assert.match(await marked.text(), /marked v18/); + const favicon = await fetch(`${launched.origin}/favicon.svg`); + assert.equal(favicon.status, 200); + assert.match(favicon.headers.get("content-type") || "", /image\/svg\+xml/); + assert.match(await favicon.text(), / { + try { return localStorage.getItem("openpi.language") === "zh" ? "zh" : "en"; } catch { return "en"; } + })(), + theme: (() => { + try { + const saved = localStorage.getItem("openpi.web.theme"); + return ["pi", "white", "dark"].includes(saved) ? saved : "pi"; + } catch { return "pi"; } + })(), }; const translations = { @@ -41,6 +51,11 @@ const translations = { untitledSession: "New session", workspaces: "Workspaces", addWorkspace: "Add workspace", + settings: "Settings", + close: "Close", + closeSettings: "Close settings", + generalSettings: "General settings", + language: "Language", selectWorkspace: "Select workspace", describeTask: "Describe a task", chooseWorkspaceHint: "Choose a workspace and describe the work", @@ -71,12 +86,30 @@ const translations = { modelPreparing: "Preparing task...", modelRetrying: "Retrying model request...", switchingSession: "Switching session...", + copyMessage: "Copy message", + copiedMessage: "Copied", + conversationTurns: "Conversation turns", + stepsLabel: "steps", + thinkingActive: "Thinking...", + thinkingDone: "Thinking", + noOutput: "no output", + editMessage: "Edit message", + confirmEdit: "OK", + theme: "Theme", + themePi: "PI Grid", + themeWhite: "Clean White", + themeDark: "Midnight", }, zh: { newSession: "新建会话", untitledSession: "新会话", workspaces: "工作区", addWorkspace: "添加工作区", + settings: "设置", + close: "关闭", + closeSettings: "关闭设置", + generalSettings: "通用设置", + language: "语言", selectWorkspace: "选择工作区", describeTask: "描述任务", chooseWorkspaceHint: "选择工作区并描述任务", @@ -107,6 +140,19 @@ const translations = { modelPreparing: "正在准备任务...", modelRetrying: "模型请求重试中...", switchingSession: "正在切换会话...", + copyMessage: "复制消息", + copiedMessage: "已复制", + conversationTurns: "会话轮次", + stepsLabel: "个步骤", + thinkingActive: "思考中...", + thinkingDone: "思考过程", + noOutput: "无输出", + editMessage: "编辑消息", + confirmEdit: "确定", + theme: "主题", + themePi: "PI 网格", + themeWhite: "简洁白", + themeDark: "暗夜黑", }, }; const t = (key) => translations[state.language][key] || translations.en[key] || key; @@ -118,6 +164,11 @@ function applyLanguage() { document.querySelectorAll("[data-i18n-title]").forEach((element) => { element.title = t(element.dataset.i18nTitle); }); const search = $("search"); if (search) search.placeholder = t("searchPlaceholder"); + const picker = $("language-picker-value"); + if (picker) picker.textContent = state.language === "zh" ? "中文" : "English"; + document.querySelectorAll("#language-menu [data-language]").forEach((option) => { + option.setAttribute("aria-selected", option.dataset.language === state.language ? "true" : "false"); + }); renderWorkspacePicker(); if (state.snapshot) { renderWorkspaces(); @@ -125,6 +176,17 @@ function applyLanguage() { } } +const THEME_KEYS = { pi: "themePi", white: "themeWhite", dark: "themeDark" }; + +function applyTheme() { + document.documentElement.dataset.theme = state.theme; + const value = $("theme-picker-value"); + if (value) value.textContent = t(THEME_KEYS[state.theme] || "themePi"); + document.querySelectorAll("#theme-menu [data-theme-value]").forEach((option) => { + option.setAttribute("aria-selected", option.dataset.themeValue === state.theme ? "true" : "false"); + }); +} + const $ = (id) => document.getElementById(id); const tokenStorageKey = "openpi.web.token"; const fragmentToken = new URLSearchParams(location.hash.slice(1)).get("token"); @@ -345,83 +407,487 @@ function renderWorkspaces() { }); } -function messageMarkup(entry) { +function formatTurnTime(timestamp) { + if (!timestamp) return ""; + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return ""; + return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +} + +function formatElapsedMs(start, end) { + if (typeof start !== "number") return ""; + const totalSeconds = Math.max(0, Math.round(((typeof end === "number" ? end : Date.now()) - start) / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, "0")}s` : `${seconds}s`; +} + +const ACTIVITY_ICONS = { + subagent: ``, + workflow: ``, +}; +const ACTIVITY_STATUS_GLYPHS = { done: "✓", error: "✗", warn: "?" }; + +const TOOL_ICONS = { + bash: ``, + read: ``, + write: ``, + edit: ``, + grep: ``, + glob: ``, + ls: ``, + webfetch: ``, + websearch: ``, + thinking: ``, + default: ``, +}; + +function toolIcon(name) { + return TOOL_ICONS[name] || TOOL_ICONS.default; +} + +/** Pull the one argument that identifies what the tool call actually did. */ +function toolCallSummary(name, args) { + if (!args) return ""; + const value = + name === "bash" ? args.command : + name === "read" || name === "write" || name === "edit" || name === "ls" ? args.path : + name === "grep" || name === "glob" ? args.pattern : + name === "webfetch" ? args.url : + name === "websearch" ? args.query : + ""; + if (typeof value !== "string" || !value) return ""; + const line = value.split("\n").find((part) => part.trim()) || ""; + return line.length > 90 ? `${line.slice(0, 90)}…` : line; +} + +/** Thinking row: "思考中..." with a live timer while active, "思考过程 · 1m03s" once settled. */ +function thinkingLineMarkup(body, thinking) { + const active = Boolean(thinking?.active); + const label = active ? t("thinkingActive") : t("thinkingDone"); + let meta = ""; + if (active) { + meta = `${formatElapsedMs(thinking.startedAt)}`; + } else if (thinking?.elapsedMs) { + meta = `· ${formatElapsedMs(0, thinking.elapsedMs)}`; + } + return `
+ + + + ${escapeHtml(label)}${meta ? `${meta}` : ""} + +
${escapeHtml(body || "")}
+
`; +} + +let thinkingTimerInterval = null; + +function syncThinkingTimer(hasActive) { + if (hasActive && thinkingTimerInterval === null) { + thinkingTimerInterval = window.setInterval(() => { + document.querySelectorAll("[data-thinking-start]").forEach((element) => { + element.textContent = formatElapsedMs(Number(element.dataset.thinkingStart)); + }); + }, 1000); + } else if (!hasActive && thinkingTimerInterval !== null) { + window.clearInterval(thinkingTimerInterval); + thinkingTimerInterval = null; + } +} + +/** Compact collapsible row for ordinary tool calls/results, with an icon. + The right edge carries the outcome: ✓/✗ once settled, a pulsing dot while running. */ +function toolLineMarkup(name, summary, body, status, extraClass) { + const statusMarkup = + status === "done" || status === "error" + ? `${ACTIVITY_STATUS_GLYPHS[status]}` + : status === "running" + ? `` + : ""; + return `
+ + + + ${escapeHtml(name)}${summary ? `${escapeHtml(summary)}` : ""} + ${statusMarkup} + +
${escapeHtml(body || "")}
+
`; +} + +function activityCardMarkup(family, title, meta, body, status) { + const glyph = ACTIVITY_STATUS_GLYPHS[status]; + const indicator = + status === "running" + ? `` + : glyph + ? `` + : ""; + return `
+ + + ${escapeHtml(title)}${meta ? `${escapeHtml(meta)}` : ""} + ${indicator} + + +
${escapeHtml(body || "")}
+
`; +} + +function parseToolArguments(raw) { + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +/** Card status: merged tool result wins; a family call without one is running. */ +function canonicalActivityStatus(value) { + if (value === "running") return "running"; + if (value === "done" || value === "completed") return "done"; + if (value === "error" || value === "failed" || value === "aborted" || value === "killed" || value === "timed_out") return "error"; + if (value === "uncertain") return "warn"; + return "unknown"; +} + +function cardStatus(result) { + if (!result) return "running"; + if (result.isError === true) return "error"; + const details = result.details && typeof result.details === "object" ? result.details : null; + const status = canonicalActivityStatus(details?.status); + if (status !== "unknown") return status; + if (result.isError === false) return "done"; + return "unknown"; +} + +/** Rich cards for the two headline capabilities instead of a generic tool row. */ +function familyToolCallCard(part, result) { + const name = part.name || ""; + const args = parseToolArguments(part.arguments) || {}; + const details = result?.details && typeof result.details === "object" ? result.details : undefined; + const status = cardStatus(result); + if (name === "subagent_spawn") { + const meta = [args.agent_type, args.model, args.working_dir].filter(Boolean).join(" · "); + const title = `Spawn Subagent · ${details?.title || args.name || "subagent"}`; + return activityCardMarkup("subagent", title, meta || details?.cwd, result?.content || args.prompt || part.arguments, status); + } + if (name === "subagent_wait") { + const results = Array.isArray(details?.results) ? details.results : []; + const failed = results.filter((item) => item && item.status !== "done").length; + const meta = results.length > 0 ? `${results.length} settled${failed ? ` · ${failed} failed` : ""}` : (args.ids || []).join(" · ") || undefined; + return activityCardMarkup("subagent", "Wait for Subagents", meta, result?.content || part.arguments, status); + } + if (name === "subagent_cancel") return activityCardMarkup("subagent", "Cancel Subagents", (args.ids || []).join(" · ") || undefined, result?.content || part.arguments, status); + if (name === "subagent_send") return activityCardMarkup("subagent", `Send to Subagent · ${args.id || ""}`.trim(), undefined, result?.content || args.text || part.arguments, status); + if (name === "subagent_check") return activityCardMarkup("subagent", `Check Subagent · ${args.id || ""}`.trim(), undefined, result?.content || part.arguments, status); + if (name === "subagent_list") return activityCardMarkup("subagent", "List Subagents", undefined, result?.content || part.arguments, status); + if (name === "workflow") { + const script = typeof args.script === "string" ? args.script : ""; + const workflowName = details?.name || script.match(/\bname:\s*["'`]([^"'`]+)["'`]/)?.[1]; + const description = script.match(/\bdescription:\s*["'`]([^"'`]+)["'`]/)?.[1]; + const agents = Array.isArray(details?.agents) ? details.agents : []; + const settled = agents.filter((agent) => agent && agent.state !== "running").length; + const meta = details + ? [details.runId, details.status, agents.length > 0 ? `${settled}/${agents.length} agents` : ""].filter(Boolean).join(" · ") + : description; + return activityCardMarkup("workflow", `Workflow · ${workflowName || "unnamed"}`, meta, result?.content || script || part.arguments, status); + } + if (name === "workflow_stop") return activityCardMarkup("workflow", `Stop Workflow · ${args.runId || ""}`.trim(), undefined, result?.content || part.arguments, status); + if (name === "workflow_status") return activityCardMarkup("workflow", "Workflow Status", args.runId, result?.content || part.arguments, status); + return ""; +} + +function familyToolResultCard(message) { + const toolName = message.toolName || ""; + const family = toolName.startsWith("subagent") ? "subagent" : toolName.startsWith("workflow") ? "workflow" : ""; + if (!family) return ""; + const content = message.content || ""; + const status = canonicalActivityStatus(message.details?.status); + const resolvedStatus = status === "unknown" + ? (message.isError === true ? "error" : message.isError === false ? "done" : "unknown") + : status; + const title = `${toolName.replace(/_/g, " ")}${content ? ` · ${compactSummary(content)}` : ""}`; + return activityCardMarkup(family, title, undefined, content, resolvedStatus); +} + +/** Background delivery messages (subagent-result / workflow-result). */ +function customMessageMarkup(message) { + const details = message.details && typeof message.details === "object" ? message.details : {}; + if (message.customType === "subagent-result") { + const status = canonicalActivityStatus(details.status); + const meta = [details.id, details.outcome, details.elapsed].filter(Boolean).join(" · "); + const card = activityCardMarkup("subagent", `Subagent ${details.id || ""} · ${details.title || "result"}`, meta || undefined, message.content, status); + return `
+
${card}
+
`; + } + if (message.customType === "workflow-result") { + const entries = Array.isArray(details.entries) ? details.entries : []; + const entryStatuses = entries.map((item) => canonicalActivityStatus(item?.status)); + const status = entryStatuses.some((item) => item === "error") + ? "error" + : entryStatuses.some((item) => item === "warn") + ? "warn" + : entryStatuses.length > 0 && entryStatuses.every((item) => item === "done") + ? "done" + : entryStatuses.some((item) => item === "running") + ? "running" + : "unknown"; + const title = entries.length > 1 + ? `Workflow results · ${entries.length} runs` + : `Workflow ${entries[0]?.runId || "result"} · ${entries[0]?.status || "delivered"}`; + const body = entries.length > 0 + ? entries + .map((item) => { + const glyph = item.status === "completed" ? "✓" : "✗"; + const alerts = Array.isArray(item.alerts) && item.alerts.length > 0 ? ` (${item.alerts.join("; ")})` : ""; + const preview = item.resultPreview ? `\nResult: ${item.resultPreview}` : ""; + return `${glyph} ${item.summary || item.runId || "run"}${alerts}${preview}`; + }) + .join("\n") + : message.content; + const card = activityCardMarkup("workflow", title, undefined, body, status); + return `
+
${card}
+
`; + } + return ""; +} + +function turnTitle(content) { + const line = String(content || "").split("\n").map((part) => part.trim()).find(Boolean) || ""; + return line.length > 60 ? `${line.slice(0, 60)}…` : line; +} + +/** Empty tool outputs like "", "[]", "{}" carry no information. */ +function isEmptyToolOutput(content) { + const text = String(content ?? "").trim(); + return text === "" || text === "[]" || text === "{}" || text === "null"; +} + +function messageActionsMarkup(entry, canEdit = false) { + const time = formatTurnTime(entry.timestamp); + const editButton = canEdit + ? `` + : ""; + return `
${time ? `` : ""}${editButton}
`; +} + +let messageEditActive = false; + +function enterMessageEdit(row) { + const body = row.querySelector(".message-body"); + const content = row.querySelector(".message-content"); + if (!body || !content || row.classList.contains("editing")) return; + const original = body.textContent || ""; + row.classList.add("editing"); + messageEditActive = true; + content.innerHTML = ` +
+ + +
`; + const textarea = content.querySelector(".message-edit-input"); + if (!textarea) return; + const grow = () => { + textarea.style.height = "auto"; + textarea.style.height = `${Math.min(textarea.scrollHeight, 220)}px`; + }; + grow(); + textarea.focus(); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.addEventListener("input", grow); + textarea.addEventListener("keydown", (event) => { + if (event.isComposing || event.keyCode === 229) return; + if (event.key === "Escape") { + event.preventDefault(); + messageEditActive = false; + renderConversation(); + } + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + void confirmMessageEdit(textarea.value); + } + }); +} + +/** Confirming an edit resends the text as a new prompt (queued if running). */ +async function confirmMessageEdit(value) { + messageEditActive = false; + const content = value.trim(); + if (!content) { + renderConversation(); + return; + } + await sendPrompt(content); +} + +/** Render one entry to a list of row objects: { kind: "user"|"assistant"|"detail", icon?, error?, activeThinking?, html }. */ +function messageMarkup(entry, turn, resultsByCallId, familyCallIds, context, showActions = true) { const message = entry.message; - if (!message) return ""; + if (!message) return []; + if (message.role === "custom") { + const html = customMessageMarkup(message); + if (!html) return []; + const icon = message.customType === "workflow-result" ? ACTIVITY_ICONS.workflow : ACTIVITY_ICONS.subagent; + return [{ kind: "detail", icon, html }]; + } if (message.role === "user") { - return `
+ return [{ kind: "user", html: `
${escapeHtml(message.content)}
-
`; + ${messageActionsMarkup(entry, Boolean(context?.showEdit))} +
` }]; } if (message.role === "assistant") { const parts = Array.isArray(message.parts) ? message.parts : []; - const detailItems = parts - .filter((part) => part.type === "thinking" || part.type === "toolCall") - .map((part) => { - const title = part.type === "thinking" ? "Thinking" : `${part.name} · tool call`; - const body = part.type === "thinking" ? part.text : part.arguments; - return `
- ${escapeHtml(title)} -
${escapeHtml(body)}
-
`; - }); - const detailRows = detailItems - .map( - (detail) => `
+ const rows = []; + for (const part of parts) { + if (part.type !== "thinking" && part.type !== "toolCall") continue; + let detail; + let icon; + let groupable = false; + if (part.type === "toolCall") { + const result = part.id ? resultsByCallId?.get(part.id) : undefined; + const card = familyToolCallCard(part, result); + if (card) { + detail = card; + icon = /^workflow/.test(part.name || "") ? ACTIVITY_ICONS.workflow : ACTIVITY_ICONS.subagent; + } else { + const args = parseToolArguments(part.arguments); + const body = part.name === "bash" && typeof args?.command === "string" ? args.command : part.arguments; + const status = result ? (result.isError ? "error" : "done") : "running"; + detail = toolLineMarkup(part.name || "tool", toolCallSummary(part.name, args), body, status); + icon = toolIcon(part.name || "tool"); + groupable = true; + } + } else { + detail = thinkingLineMarkup(part.text, context?.thinking); + icon = TOOL_ICONS.thinking; + } + rows.push({ kind: "detail", icon, groupable, activeThinking: part.type === "thinking" && Boolean(context?.thinking?.active), html: `
${detail}
-
`, - ) - .join(""); +
` }); + } const content = typeof message.content === "string" ? message.content.trim() : ""; - const contentRow = content - ? `
+ if (content) { + rows.push({ kind: "assistant", html: `
${renderMarkdown(content)}
-
` - : ""; - return detailRows + contentRow; + ${showActions ? messageActionsMarkup(entry) : ""} +
` }); + } + return rows; } if (message.role === "toolResult") { + // Family results merged into their call card do not get a separate row. + if (message.toolCallId && familyCallIds?.has(message.toolCallId)) return []; + const family = (message.toolName || "").startsWith("subagent") ? "subagent" : (message.toolName || "").startsWith("workflow") ? "workflow" : ""; + const card = family ? familyToolResultCard(message) : ""; const toolName = message.toolName || "tool"; - const summary = compactSummary(message.content || "completed"); - return `
-
- ${escapeHtml(toolName)} · ${escapeHtml(summary)} -
${escapeHtml(message.content || "completed")}
-
-
`; + const icon = family ? ACTIVITY_ICONS[family] : toolIcon(toolName); + if (!card && isEmptyToolOutput(message.content)) { + const emptyStatus = `${ACTIVITY_STATUS_GLYPHS[message.isError ? "error" : "done"]}`; + return [{ kind: "detail", icon, groupable: true, html: `
+
${escapeHtml(toolName)}${escapeHtml(t("noOutput"))}${emptyStatus}
+
` }]; + } + const detail = card || toolLineMarkup(toolName, compactSummary(message.content || "completed"), message.content || "completed", message.isError ? "error" : "done"); + return [{ kind: "detail", icon, groupable: !family, error: Boolean(message.isError), html: `
+
${detail}
+
` }]; } - return ""; + return []; +} + +/** Collapse runs of 4+ consecutive ordinary tool rows into one expandable + group. Thinking, subagent, and workflow rows always stay visible. */ +function groupRows(rows) { + const blocks = []; + for (const row of rows) { + const groupable = row.kind === "detail" && row.groupable; + const last = blocks[blocks.length - 1]; + if (groupable && last?.kind === "group") last.rows.push(row); + else if (groupable) blocks.push({ kind: "group", rows: [row] }); + else blocks.push({ kind: "row", rows: [row] }); + } + return blocks + .map((block) => { + if (block.kind !== "group" || block.rows.length < 4) { + return block.rows.map((row) => row.html).join(""); + } + const icons = [...new Set(block.rows.map((row) => row.icon))].filter(Boolean).slice(0, 4).join(""); + const hasError = block.rows.some((row) => row.error); + return `
+ ${block.rows.length} ${escapeHtml(t("stepsLabel"))} +
${block.rows.map((row) => row.html).join("")}
+
`; + }) + .join(""); } function landingMarkup() { return `
-
Open
+
Open
`; } -function runtimeActivityMarkup(capabilities) { - const labels = { - subagents: "Subagent", - workflows: "Workflow", - "background-terminals": "Terminal", - }; - const rows = Object.entries(capabilities || {}).flatMap(([kind, projection]) => { - const items = Array.isArray(projection?.items) ? projection.items : []; - const projected = items.map((item) => { - const title = item.title || item.name || item.id || item.runId || labels[kind] || kind; - const status = typeof item.status === "string" ? item.status : "unknown"; - return `
  • ${escapeHtml(labels[kind] || kind)}${escapeHtml(title)}${escapeHtml(status)}
  • `; - }); - if (Number.isSafeInteger(projection?.omitted) && projection.omitted > 0) { - projected.push(`
  • ${escapeHtml(labels[kind] || kind)}+${projection.omitted} omitted
  • `); - } - return projected; - }); - return rows.length > 0 - ? `` - : ""; +const ACTIVITY_CHIP_LIMIT = 5; + +function activityChipMarkup(kind, status, text) { + const glyph = ACTIVITY_STATUS_GLYPHS[status === "completed" || status === "done" ? "done" : status] || "✓"; + const indicator = + status === "running" + ? `` + : ``; + return `${indicator}${escapeHtml(text)}`; +} + +function activityBarMarkup() { + const capabilities = state.snapshot?.runtime?.capabilities || {}; + // The current protocol projects each capability as { items, omitted }. + const projectionItems = (value) => (Array.isArray(value) ? value : Array.isArray(value?.items) ? value.items : []); + const projectionOmitted = (value) => (Number.isSafeInteger(value?.omitted) ? value.omitted : 0); + const subagents = projectionItems(capabilities.subagents); + const workflows = projectionItems(capabilities.workflows); + const chips = []; + for (const run of workflows) { + // Current protocol projects agent counts; older snapshots list agents. + const agents = Array.isArray(run.agents) ? run.agents : null; + const total = agents ? agents.length : Number(run.agents?.total) || 0; + const settled = agents + ? agents.filter((agent) => agent.state !== "running").length + : total - (Number(run.agents?.running) || 0); + const progress = total > 0 ? ` · ${settled}/${total} agents` : ""; + const phase = run.status === "running" && run.currentPhase ? ` · ${run.currentPhase}` : ""; + const elapsed = run.status === "running" ? formatElapsedMs(run.startedAt) : formatElapsedMs(run.startedAt, run.finishedAt); + const status = canonicalActivityStatus(run.status); + const label = `${run.name || run.runId || "workflow"}${run.status === "running" ? phase + progress : progress}${elapsed ? ` · ${elapsed}` : ""}`; + chips.push({ status, markup: activityChipMarkup("workflow", status, label) }); + } + for (const snap of subagents) { + const elapsed = formatElapsedMs(snap.createdAt, snap.settledAt); + const status = canonicalActivityStatus(snap.status); + chips.push({ status, markup: activityChipMarkup("subagent", status, `${snap.title || snap.id}${elapsed ? ` · ${elapsed}` : ""}`) }); + } + chips.sort((a, b) => (a.status === "running" ? 0 : 1) - (b.status === "running" ? 0 : 1)); + const visible = chips.slice(0, ACTIVITY_CHIP_LIMIT).map((chip) => chip.markup); + const overflow = chips.length - visible.length + projectionOmitted(capabilities.subagents) + projectionOmitted(capabilities.workflows); + if (overflow > 0) visible.push(`+${overflow}`); + return visible.join(""); +} + +function renderActivityBar() { + const bar = $("activity-bar"); + if (!bar) return; + const markup = activityBarMarkup(); + bar.hidden = !markup; + bar.innerHTML = markup; } +let lastRenderedSessionPath = null; +let scrollToBottomOnNextRender = false; + function renderConversation() { const snapshot = state.snapshot; const selected = snapshot?.selectedSession; @@ -433,11 +899,18 @@ function renderConversation() { const summary = snapshot?.sessions.find((session) => session.path === state.selectedPath); if (state.sessionSwitching) { shell.classList.remove("landing"); + const switchingRail = $("turn-rail"); + if (switchingRail) { + switchingRail.hidden = true; + switchingRail.innerHTML = ""; + } + renderActivityBar(); $("session-header").innerHTML = `${escapeHtml(summary ? sessionTitle(summary) : t("newSession"))}${escapeHtml(t("switchingSession"))}`; $("conversation").innerHTML = `
    ${escapeHtml(t("switchingSession"))}
    `; updateComposer(); return; } + const isCurrentSession = Boolean(selected && snapshot && selected.id === snapshot.currentSessionId); const persistedEntries = selected?.entries || []; const persistedMessageKeys = new Set( persistedEntries.map((entry) => `${entry.message?.role || ""}:${entry.message?.content || ""}`), @@ -445,32 +918,127 @@ function renderConversation() { const liveEntries = selected ? state.liveMessages .filter(({ message }) => !persistedMessageKeys.has(`${message.role || ""}:${message.content || ""}`)) - .map(({ message }) => messageMarkup({ type: "message", message })) + .map(({ key, message }) => ({ type: "message", key, message })) : []; - const messages = selected - ? [...persistedEntries.map(messageMarkup).filter(Boolean), ...liveEntries].join("") - : ""; + const allEntries = selected ? [...persistedEntries, ...liveEntries] : []; + const resultsByCallId = new Map(); + const familyCallIds = new Set(); + for (const entry of allEntries) { + const message = entry.message; + if (!message) continue; + if (message.role === "toolResult" && message.toolCallId) { + resultsByCallId.set(message.toolCallId, message); + } + if (Array.isArray(message.parts)) { + for (const part of message.parts) { + if (part.type === "toolCall" && part.id && /^(subagent|workflow)/.test(part.name || "")) { + familyCallIds.add(part.id); + } + } + } + } + const turns = []; + let turnCounter = 0; + let prevEntryTime = 0; + const lastEntryIndex = allEntries.length - 1; + // Each turn's final assistant answer gets the copy/time action bar. + const turnLastAssistant = new Set(); + let turnAssistantCandidate = -1; + let lastUserIndex = -1; + allEntries.forEach((entry, index) => { + const message = entry.message; + if (message?.role === "user") { + lastUserIndex = index; + if (turnAssistantCandidate >= 0) turnLastAssistant.add(turnAssistantCandidate); + turnAssistantCandidate = -1; + return; + } + if (message?.role === "assistant" && typeof message.content === "string" && message.content.trim()) { + turnAssistantCandidate = index; + } + }); + if (turnAssistantCandidate >= 0) turnLastAssistant.add(turnAssistantCandidate); + const rows = allEntries.flatMap((entry, index) => { + let turn = 0; + if (entry.message?.role === "user") { + turn = ++turnCounter; + turns.push({ turn, title: turnTitle(entry.message.content) }); + } + // Thinking duration: live entries time it precisely; persisted entries + // fall back to the gap since the previous entry's timestamp. + const context = {}; + if (isCurrentSession && index === lastUserIndex) context.showEdit = true; + const hasThinking = Array.isArray(entry.message?.parts) && entry.message.parts.some((part) => part.type === "thinking"); + if (hasThinking) { + const startMs = entry.key ? state.thinkingStarts[entry.key] : undefined; + const storedMs = entry.key ? state.thinkingDurations[entry.key] : undefined; + if (startMs && state.liveRunning && index === lastEntryIndex) { + context.thinking = { active: true, startedAt: startMs }; + } else if (storedMs) { + context.thinking = { elapsedMs: storedMs }; + } else { + const endMs = new Date(entry.timestamp).getTime(); + const gap = endMs - prevEntryTime; + if (endMs && gap > 999 && gap < 30 * 60 * 1000) context.thinking = { elapsedMs: gap }; + } + } + const entryTime = new Date(entry.timestamp).getTime(); + if (entryTime) prevEntryTime = entryTime; + return messageMarkup(entry, turn, resultsByCallId, familyCallIds, context, turnLastAssistant.has(index) || entry.message?.role === "user"); + }); + const messages = groupRows(rows); + syncThinkingTimer(rows.some((row) => row.activeThinking)); const landing = !selected || !summary || !messages; shell.classList.toggle("landing", landing); + renderActivityBar(); if (landing) { $("conversation").innerHTML = landingMarkup(); + const landingRail = $("turn-rail"); + if (landingRail) { + landingRail.hidden = true; + landingRail.innerHTML = ""; + } $("session-header").innerHTML = `${escapeHtml(t("newSession"))}${escapeHtml(t("chooseWorkspaceHint"))}`; updateComposer(); return; } $("session-header").innerHTML = `${escapeHtml(sessionTitle(summary))}${escapeHtml(selected.cwd)}`; - const isCurrentSession = selected.id === snapshot.currentSessionId; const isRunning = isCurrentSession && (snapshot.runtime.status === "running" || state.liveRunning); const runningLabel = state.liveRetry ? `${t("modelRetrying")} (${state.liveRetry.attempt}/${state.liveRetry.maxAttempts})` : state.livePhase === "preparing" ? t("modelPreparing") : t("modelRunning"); - const activity = isCurrentSession - ? runtimeActivityMarkup(snapshot.runtime.capabilities) - : ""; - $("conversation").innerHTML = `${messages}${activity}${isRunning ? `
    ${escapeHtml(runningLabel)}
    ` : ""}`; - $("conversation").scrollTop = $("conversation").scrollHeight; + const conversation = $("conversation"); + // While a sent message is being edited inline, keep the editor intact: + // streaming updates resume on the next render after confirm/cancel. + if (messageEditActive) { + updateComposer(); + return; + } + // Stick-to-bottom: only follow the stream when the user is already near the + // bottom; anyone scrolling up to read keeps their position. Session switches + // and first loads always land at the bottom. + const sessionPath = selected?.path || null; + const forceBottom = sessionPath !== lastRenderedSessionPath || scrollToBottomOnNextRender; + scrollToBottomOnNextRender = false; + const pinnedToBottom = + conversation.scrollTop + conversation.clientHeight >= conversation.scrollHeight - 48; + conversation.innerHTML = `${messages}${isRunning ? `
    ${escapeHtml(runningLabel)}
    ` : ""}`; + const rail = $("turn-rail"); + if (rail) { + rail.hidden = turns.length < 2; + rail.setAttribute("aria-label", t("conversationTurns")); + rail.innerHTML = turns + .map( + ({ turn, title }) => ``, + ) + .join(""); + } + if (forceBottom || pinnedToBottom) { + conversation.scrollTo({ top: conversation.scrollHeight, behavior: "instant" }); + } + lastRenderedSessionPath = sessionPath; updateComposer(); } @@ -713,11 +1281,11 @@ function scheduleSnapshotRefresh(delay = 160) { }, delay); } -async function sendPrompt() { +async function sendPrompt(overrideContent) { if (!state.selectedWorkspace) { await chooseWorkspace(); } - const content = $("prompt-input").value.trim(); + const content = (overrideContent ?? $("prompt-input").value).trim(); if ( !content || !state.selectedWorkspace || @@ -738,6 +1306,7 @@ async function sendPrompt() { ].slice(-8); state.promptAdmissionPending = true; state.promptAdmissionToken = admissionToken; + scrollToBottomOnNextRender = true; renderConversation(); $("composer-hint").classList.remove("error"); try { @@ -749,8 +1318,10 @@ async function sendPrompt() { const alreadySettled = state.terminalPromptIds.has(receipt.id); state.liveRunning = !alreadySettled; state.livePhase = alreadySettled ? "idle" : "preparing"; - $("prompt-input").value = ""; - resizePrompt(); + if (overrideContent === undefined) { + $("prompt-input").value = ""; + resizePrompt(); + } $("composer-hint").textContent = t("acceptedHint"); scheduleSnapshotRefresh(120); } catch (error) { @@ -1094,6 +1665,12 @@ function applyRuntimeEvent(event) { const live = { key, message: event.detail.message }; if (index >= 0) state.liveMessages[index] = live; else state.liveMessages = [...state.liveMessages, live].slice(-8); + if (event.detail.message.parts?.some((part) => part.type === "thinking")) { + if (!state.thinkingStarts[key]) state.thinkingStarts[key] = Date.now(); + if (event.type === "message_end") { + state.thinkingDurations[key] = Date.now() - state.thinkingStarts[key]; + } + } renderConversation(); } if ( @@ -1165,6 +1742,8 @@ function resetLiveState() { state.liveRunning = false; state.livePhase = "idle"; state.liveRetry = null; + state.thinkingStarts = {}; + state.thinkingDurations = {}; } async function connectEvents() { @@ -1373,6 +1952,74 @@ $("model-picker")?.addEventListener("click", () => { picker.setAttribute("aria-expanded", String(!menu.hidden)); }); $("mobile-sidebar")?.addEventListener("click", () => document.body.classList.toggle("sidebar-open")); +async function copyText(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + const area = document.createElement("textarea"); + area.value = text; + area.style.position = "fixed"; + area.style.opacity = "0"; + document.body.appendChild(area); + area.select(); + let ok = false; + try { + ok = document.execCommand("copy"); + } catch { + ok = false; + } + area.remove(); + return ok; + } +} + +$("conversation")?.addEventListener("click", async (event) => { + const target = event.target instanceof Element ? event.target : null; + if (!target) return; + const editButton = target.closest(".message-edit"); + if (editButton) { + const row = editButton.closest(".message-row"); + if (row) enterMessageEdit(row); + return; + } + const confirmButton = target.closest(".message-edit-confirm"); + if (confirmButton) { + const input = confirmButton.closest(".message-content")?.querySelector(".message-edit-input"); + if (input) await confirmMessageEdit(input.value); + return; + } + if (target.closest(".message-edit-cancel")) { + messageEditActive = false; + renderConversation(); + return; + } + const copyButton = target.closest(".message-copy"); + if (copyButton) { + const body = copyButton.closest(".message-row")?.querySelector(".message-body"); + const text = body?.textContent?.trim() || ""; + if (text && (await copyText(text))) { + copyButton.classList.add("copied"); + copyButton.title = t("copiedMessage"); + setTimeout(() => { + copyButton.classList.remove("copied"); + copyButton.title = t("copyMessage"); + }, 1200); + } + return; + } + // Replay the landing brand animation when the logo is clicked: swapping in a + // fresh clone restarts its CSS animations. + const brand = target.closest(".landing-brand"); + if (brand) brand.replaceWith(brand.cloneNode(true)); +}); +$("turn-rail")?.addEventListener("click", (event) => { + const tick = event.target instanceof Element ? event.target.closest(".turn-tick") : null; + if (!tick) return; + document.querySelectorAll(".turn-tick.active").forEach((el) => el.classList.remove("active")); + tick.classList.add("active"); + document.querySelector(`.message-row[data-turn="${tick.dataset.turn}"]`)?.scrollIntoView({ behavior: "smooth", block: "start" }); +}); document.querySelector(".sidebar-scrim")?.addEventListener("click", () => document.body.classList.remove("sidebar-open")); $("composer")?.addEventListener("pointerdown", (event) => { if (state.selectedWorkspace) return; @@ -1394,5 +2041,58 @@ $("prompt-input")?.addEventListener("keydown", (event) => { } }); +$("open-settings")?.addEventListener("click", () => $("settings-dialog")?.showModal()); +$("close-settings")?.addEventListener("click", () => $("settings-dialog")?.close()); +$("settings-dialog")?.addEventListener("click", (event) => { + if (event.target === event.currentTarget) event.currentTarget.close(); +}); +$("language-picker-trigger")?.addEventListener("click", () => { + const menu = $("language-menu"); + const trigger = $("language-picker-trigger"); + if (!menu || !trigger) return; + menu.hidden = !menu.hidden; + trigger.setAttribute("aria-expanded", String(!menu.hidden)); +}); +document.querySelectorAll("#language-menu [data-language]").forEach((option) => { + option.addEventListener("click", () => { + state.language = option.dataset.language === "zh" ? "zh" : "en"; + try { localStorage.setItem("openpi.language", state.language); } catch {} + applyLanguage(); + renderConversation(); + $("language-menu").hidden = true; + $("language-picker-trigger")?.setAttribute("aria-expanded", "false"); + }); +}); +$("theme-picker-trigger")?.addEventListener("click", () => { + const menu = $("theme-menu"); + const trigger = $("theme-picker-trigger"); + if (!menu || !trigger) return; + menu.hidden = !menu.hidden; + trigger.setAttribute("aria-expanded", String(!menu.hidden)); +}); +document.querySelectorAll("#theme-menu [data-theme-value]").forEach((option) => { + option.addEventListener("click", () => { + state.theme = option.dataset.themeValue in THEME_KEYS ? option.dataset.themeValue : "pi"; + try { localStorage.setItem("openpi.web.theme", state.theme); } catch {} + applyTheme(); + $("theme-menu").hidden = true; + $("theme-picker-trigger")?.setAttribute("aria-expanded", "false"); + }); +}); +document.addEventListener("pointerdown", (event) => { + const picker = document.querySelector(".language-picker"); + if (picker && !picker.contains(event.target)) { + $("language-menu").hidden = true; + $("language-picker-trigger")?.setAttribute("aria-expanded", "false"); + } + const themeTrigger = $("theme-picker-trigger"); + const themeMenu = $("theme-menu"); + if (themeMenu && !themeMenu.hidden && event.target instanceof Element && !themeMenu.contains(event.target) && event.target !== themeTrigger && !themeTrigger?.contains(event.target)) { + themeMenu.hidden = true; + themeTrigger?.setAttribute("aria-expanded", "false"); + } +}); + applyLanguage(); +applyTheme(); void connectEvents(); diff --git a/web/ui/favicon.svg b/web/ui/favicon.svg new file mode 100644 index 00000000..c28d6242 --- /dev/null +++ b/web/ui/favicon.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/web/ui/index.html b/web/ui/index.html index 7aa60047..cd123e79 100644 --- a/web/ui/index.html +++ b/web/ui/index.html @@ -5,6 +5,7 @@ OpenPI + @@ -74,6 +75,10 @@ +
    @@ -91,12 +96,15 @@
    - Open + Open
    + +
    +
    + +
    + Settings + +
    +
    + +
    +
    +
    Language
    +
    + + +
    +
    +
    +
    Theme
    +
    + + +
    +
    +
    +
    +
    diff --git a/web/ui/styles.css b/web/ui/styles.css index 0270ed9a..3233867e 100644 --- a/web/ui/styles.css +++ b/web/ui/styles.css @@ -2,8 +2,8 @@ color-scheme: light; --page: #ffffff; --sidebar: #faf7ed; - --sidebar-hover: #f3ead0; - --sidebar-active: #eee9dc; + --sidebar-hover: rgba(243, 234, 208, .65); + --sidebar-active: rgba(238, 233, 220, .82); --border: #e3e7eb; --border-strong: #d2d8de; --text: #1f252c; @@ -16,6 +16,9 @@ --focus: #4c88cf; --healthy: #28835a; --error: #b42318; + --grid-minor: #252f3d08; + --grid-major: #252f3d12; + --grid-cross: #252f3d1d; } * { box-sizing: border-box; } @@ -23,14 +26,43 @@ html, body { width: 100%; height: 100%; overflow: hidden; } body { margin: 0; background-color: #faf7ed; + /* pi.dev-style engineering grid: 4px minor lines, 20px major lines, small + crosses punched at major intersections, plus a soft vertical vignette. */ background-image: - linear-gradient(rgba(182, 165, 119, .2) 1px, transparent 1px), - linear-gradient(90deg, rgba(182, 165, 119, .2) 1px, transparent 1px); - background-size: 24px 24px; + linear-gradient(to bottom, rgba(146, 128, 86, .06), rgba(250, 247, 237, 0) 40%, rgba(250, 247, 237, 0) 72%, rgba(250, 247, 237, .5)), + linear-gradient(var(--grid-minor) 0 1px, transparent 1px 4px), + linear-gradient(to right, var(--grid-minor) 0 1px, transparent 1px 4px), + linear-gradient(var(--grid-major) 0 1px, transparent 1px 20px), + linear-gradient(to right, var(--grid-major) 0 1px, transparent 1px 20px), + radial-gradient(circle at center, transparent 2px, #faf7ed 2px 100%), + linear-gradient(var(--grid-cross) 0 1px, transparent 1px 20px), + linear-gradient(to right, var(--grid-cross) 0 1px, transparent 1px 20px); + background-size: + 100% 100%, + 4px 4px, + 4px 4px, + 20px 20px, + 20px 20px, + 20px 20px, + 20px 20px, + 20px 20px; + background-position: + 0 0, + 0 0, + 0 0, + 0 0, + 0 0, + -10px -10px, + 0 0, + 0 0; color: var(--text); font: 14px/1.5 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } +::selection { background: rgba(40, 103, 178, .18); } button, input, textarea { font: inherit; letter-spacing: 0; } +input, textarea { caret-color: var(--accent); } button { color: inherit; cursor: pointer; } button:disabled { cursor: default; } .icon { @@ -75,8 +107,39 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); flex-direction: column; padding: 8px 10px; overflow: hidden; - border-right: 1px solid #e8ebee; - background: var(--sidebar); + border-right: 1px solid rgba(210, 216, 222, .5); + background-color: rgba(250, 247, 237, .3); + background-image: linear-gradient(to bottom, rgba(255, 255, 255, .42), rgba(255, 255, 255, .08) 36%, rgba(255, 255, 255, 0)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, .6), + inset -1px 0 0 rgba(255, 255, 255, .3), + inset 0 -1px 0 rgba(146, 128, 86, .1); + backdrop-filter: blur(30px) saturate(1.8) brightness(1.04); + -webkit-backdrop-filter: blur(30px) saturate(1.8) brightness(1.04); +} +/* Frosted glass smears whatever sits behind it, so redraw the page grid + inside the panel as a soft-focus ghost: the same 4px minor / 20px major + pattern, blurred like an object seen through thick glass. Ghost lines run + slightly stronger than the page grid because the blur eats half of their + contrast. Intersection crosses are intentionally left out — under the blur + they bloom into distracting blobs. */ +.session-sidebar::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; + filter: blur(1.6px); + background-image: + linear-gradient(rgba(37, 47, 61, .07) 0 1px, transparent 1px 4px), + linear-gradient(to right, rgba(37, 47, 61, .07) 0 1px, transparent 1px 4px), + linear-gradient(rgba(37, 47, 61, .09) 0 1px, transparent 1px 20px), + linear-gradient(to right, rgba(37, 47, 61, .09) 0 1px, transparent 1px 20px); + background-size: + 4px 4px, + 4px 4px, + 20px 20px, + 20px 20px; } .sidebar-brand { display: flex; @@ -113,6 +176,7 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); border: 0; background: transparent; color: var(--muted); + transition: background-color 140ms ease, color 140ms ease; } .collapse-button { width: 30px; height: 30px; border-radius: 6px; } .collapse-button:hover, .icon-button:hover, .mobile-sidebar-button:hover { background: var(--sidebar-hover); color: var(--text); } @@ -134,6 +198,7 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); background: var(--page); color: var(--text); font-weight: 550; + transition: background-color 140ms ease, border-color 140ms ease; } .new-session-button:hover { background: #fafbfc; border-color: #b9c2ca; } .new-session-button svg { width: 17px; height: 17px; flex: 0 0 auto; stroke-width: 1.7; } @@ -164,12 +229,13 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); background: rgba(255, 255, 255, .48); color: var(--text); font-size: 12px; + transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease; } .session-search input::placeholder { color: #7f8993; } .session-search input:hover { border-color: #cfd5db; } -.session-search input:focus { border-color: #cfd5db; background: var(--page); box-shadow: none; } +.session-search input:focus { border-color: #cfd5db; background: var(--page); box-shadow: 0 0 0 3px rgba(76, 136, 207, .16); } .session-search-icon { position: absolute; top: 9px; left: 9px; z-index: 1; width: 14px; height: 14px; color: #9aa2aa; pointer-events: none; } -.session-search-clear { position: absolute; top: 4px; right: 4px; display: grid; width: 24px; height: 24px; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #737b83; } +.session-search-clear { position: absolute; top: 4px; right: 4px; display: grid; width: 24px; height: 24px; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: #737b83; transition: background-color 120ms ease, color 120ms ease; } .session-search-clear .icon { width: 14px; height: 14px; stroke-width: 2; } .session-search-clear:hover { background: #e9ecef; color: var(--text); } @@ -185,7 +251,7 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); scrollbar-color: rgba(125, 132, 138, .45) transparent; } .workspace-group + .workspace-group { margin-top: 5px; } -.workspace-button, .session { width: 100%; min-width: 0; border: 0; border-radius: 6px; background: transparent; text-align: left; } +.workspace-button, .session { width: 100%; min-width: 0; border: 0; border-radius: 6px; background: transparent; text-align: left; transition: background-color 120ms ease, color 120ms ease; } .workspace-button { display: grid; height: 34px; @@ -214,6 +280,7 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); border-radius: 4px; background: transparent; color: var(--muted); + transition: background-color 120ms ease, color 120ms ease; } .workspace-toggle { width: 20px; height: 28px; } .workspace-action { width: 24px; height: 28px; } @@ -257,7 +324,7 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); color: var(--muted); opacity: 0; pointer-events: none; - transition: opacity 120ms ease; + transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease; } .session-action .icon { width: 16px; height: 16px; } .session-row:hover .session-action, @@ -266,6 +333,25 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); .session-row:focus-within .session-time { visibility: hidden; } .session-action:hover { background: var(--sidebar-active); color: var(--text); } .workspace-sessions .empty, .workspace-tree > .empty { min-height: 32px; padding: 7px 10px 7px 29px; color: var(--subtle); font-size: 12px; } +.settings-button { + display: flex; + width: 100%; + height: 38px; + flex: 0 0 auto; + align-items: center; + gap: 8px; + padding: 0 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: #46515c; + font-size: 12px; + text-align: left; + margin-top: auto; + transition: background-color 120ms ease, color 120ms ease; +} +.settings-button:hover { background: var(--sidebar-hover); } +.settings-button svg { width: 18px; height: 18px; } .workspace-menu { position: fixed; z-index: 40; @@ -287,6 +373,7 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); border-radius: 4px; background: transparent; text-align: left; + transition: background-color 120ms ease, color 120ms ease; } .workspace-menu button:hover { background: #f1f3f5; } .workspace-menu .danger-action { color: var(--error); } @@ -314,11 +401,51 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; } .dialog-actions button { height: 32px; padding: 0 12px; border: 1px solid var(--border-strong); border-radius: 5px; background: var(--page); } .dialog-actions #save-workspace-name { border-color: var(--text); background: var(--text); color: #fff; } +.settings-dialog { + width: min(760px, calc(100% - 32px)); + min-height: 460px; + padding: 0; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--page); + box-shadow: 0 24px 70px rgba(31, 37, 44, .22); + color: var(--text); +} +.settings-dialog::backdrop { background: rgba(20, 26, 32, .28); backdrop-filter: blur(2px); } +.settings-header { display: flex; height: 58px; align-items: center; justify-content: space-between; padding: 0 20px 0 24px; border-bottom: 1px solid var(--border); } +.settings-header strong { font-size: 16px; font-weight: 650; } +.settings-close { display: grid; width: 30px; height: 30px; place-items: center; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: 22px; line-height: 1; transition: background-color 120ms ease, color 120ms ease; } +.settings-close:hover { background: var(--sidebar-hover); color: var(--text); } +.settings-body { display: grid; min-height: 402px; grid-template-columns: 190px minmax(0, 1fr); } +.settings-nav { padding: 18px 12px; border-right: 1px solid var(--border); } +.settings-nav-item { display: flex; width: 100%; height: 40px; align-items: center; gap: 9px; padding: 0 12px; border: 0; border-radius: 9px; background: transparent; color: var(--text); font-size: 13px; text-align: left; transition: background-color 120ms ease; } +.settings-nav-item.active, .settings-nav-item:hover { background: #eef1f4; } +.settings-nav-icon { display: grid; width: 18px; height: 18px; place-items: center; color: var(--muted); font-size: 15px; } +.settings-content { padding: 28px 30px; } +.settings-content h2 { margin: 0 0 22px; font-size: 15px; font-weight: 650; } +.settings-row { display: flex; min-height: 72px; align-items: center; justify-content: space-between; gap: 24px; padding: 16px 0; border-top: 1px solid var(--border); } +.settings-row-first { padding-top: 4px; border-top: 0; } +.settings-row strong, .settings-row small { display: block; } +.settings-row strong { font-size: 13px; font-weight: 550; } +.settings-row small { margin-top: 4px; color: var(--subtle); font-size: 11px; } +.language-picker { position: relative; flex: 0 0 auto; } +.language-picker-trigger { display: inline-flex; min-width: 112px; height: 36px; align-items: center; justify-content: space-between; gap: 12px; padding: 0 12px 0 14px; border: 0; border-radius: 18px; background: #f4f5f6; color: var(--text); font-size: 12px; transition: background-color 120ms ease; } +.language-picker-trigger:hover, .language-picker-trigger[aria-expanded="true"] { background: #eceff1; } +.language-picker-chevron { width: 14px; height: 14px; color: var(--muted); transition: transform 120ms ease; } +.language-picker-trigger[aria-expanded="true"] .language-picker-chevron { transform: rotate(180deg); } +.language-menu { position: absolute; top: calc(100% + 5px); right: 0; z-index: 3; width: 150px; padding: 4px; border: 1px solid var(--border); border-radius: 10px; background: var(--page); box-shadow: 0 10px 24px rgba(31, 37, 44, .14); } +.language-menu[hidden] { display: none; } +.language-menu button { display: flex; width: 100%; height: 34px; align-items: center; justify-content: space-between; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: var(--text); font-size: 12px; text-align: left; transition: background-color 120ms ease; } +.language-menu button:hover { background: #f1f3f5; } +.language-menu button[aria-selected="true"] { background: #f4f5f6; } +.language-check { visibility: hidden; font-size: 16px; } +.language-menu button[aria-selected="true"] .language-check { visibility: visible; } + body.sidebar-collapsed .session-sidebar { align-items: center; padding: 8px; } body.sidebar-collapsed .sidebar-brand { position: relative; width: 40px; justify-content: center; padding: 0; } -body.sidebar-collapsed .brand-word, body.sidebar-collapsed .new-session-button span, +body.sidebar-collapsed .sidebar-brand .brand-word, body.sidebar-collapsed .new-session-button span, body.sidebar-collapsed .workspace-heading-label, body.sidebar-collapsed .workspace-tree, -body.sidebar-collapsed .session-search { display: none; } +body.sidebar-collapsed .session-search, body.sidebar-collapsed .settings-button span { display: none; } body.sidebar-collapsed .sidebar-brand .collapse-button { position: absolute; inset: 7px 5px auto; z-index: 2; } body.sidebar-collapsed .sidebar-brand .collapse-button .icon { display: none; } body.sidebar-collapsed .sidebar-brand .collapse-button:hover .icon { display: block; } @@ -330,6 +457,7 @@ body.sidebar-collapsed .new-session-button:hover { background: var(--sidebar-hov body.sidebar-collapsed .workspace-heading { width: 36px; min-height: 36px; justify-content: center; padding: 0; } body.sidebar-collapsed .workspace-actions { flex-direction: column; gap: 4px; } body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } +body.sidebar-collapsed .settings-button { width: 36px; justify-content: center; padding: 0; } .conversation-shell { position: relative; display: grid; min-width: 0; min-height: 0; grid-template-rows: minmax(0, 1fr) auto; background: transparent; } .conversation-shell.landing { grid-template-rows: minmax(0, 1fr); } @@ -369,32 +497,153 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .connection-state::before { width: 6px; height: 6px; border-radius: 50%; background: currentColor; content: ""; } .connection-state.reconnecting { color: #9a6700; } -.conversation { min-height: 0; overflow-y: auto; scroll-behavior: smooth; } +.conversation { min-height: 0; overflow-y: auto; scroll-behavior: smooth; scrollbar-width: thin; scrollbar-color: rgba(125, 132, 138, .45) transparent; } +/* Keep the engineering grid at the page margins, but fade it out across the + whole work area — message column, composer surroundings included. The + paper lane is centered on the 840px message column and feathers into the + grid on both sides; it stays fixed while messages scroll above it. */ +.conversation-shell:not(.landing) { + background-image: linear-gradient(to right, rgba(250, 247, 237, 0), rgba(250, 247, 237, .88) 90px, rgba(250, 247, 237, .88) calc(100% - 90px), rgba(250, 247, 237, 0)); + background-position: center; + background-repeat: no-repeat; + background-size: min(1020px, 100%) 100%; +} .landing .conversation { display: grid; place-items: center; } -.landing-welcome { padding: 0 24px; text-align: center; transform: translateY(-104px); } -.landing-brand { display: flex; align-items: center; justify-content: center; } -.landing-brand strong { align-items: flex-end; font-size: 34px; } -.landing-brand .pixel-mark { width: 36px; height: 36px; margin-left: 3px; } -.landing-brand .pixel-mark i { width: 9px; height: 9px; } -.landing-brand .pixel-mark i:nth-child(2) { left: 9px; } -.landing-brand .pixel-mark i:nth-child(3) { left: 18px; } -.landing-brand .pixel-mark i:nth-child(4) { top: 9px; } -.landing-brand .pixel-mark i:nth-child(5) { top: 9px; left: 18px; } -.landing-brand .pixel-mark i:nth-child(6) { top: 18px; } -.landing-brand .pixel-mark i:nth-child(7) { top: 18px; left: 9px; } -.landing-brand .pixel-mark i:nth-child(8) { top: 18px; left: 27px; } -.landing-brand .pixel-mark i:nth-child(9) { top: 27px; } -.landing-brand .pixel-mark i:nth-child(10) { top: 27px; left: 27px; } -.message-row { width: min(760px, calc(100% - 48px)); margin: 0 auto; padding: 18px 0; } +/* The landing conversation layer is click-through, but the brand stays + interactive so clicking the logo replays the animation. */ +.landing .landing-brand { pointer-events: auto; } +.landing-welcome { padding: 0 24px; text-align: center; transform: translateY(-110px); } +.landing-brand { display: flex; align-items: center; justify-content: center; cursor: pointer; } +.landing-brand strong { align-items: flex-end; font-size: 44px; animation: brand-lockup 2100ms cubic-bezier(.22, 1, .36, 1) both; } +.landing-brand .pixel-mark { width: 66px; height: 55px; margin-left: 4px; animation: pixel-nudge 2100ms cubic-bezier(.22, 1, .36, 1) both; } +.landing-brand .pixel-mark i { + width: 11px; + height: 11px; + border-radius: 2px; + background-color: var(--piece, currentColor); + animation: pixel-drop 350ms cubic-bezier(.22, 1, .36, 1) both, piece-to-ink 2100ms ease both; +} +/* Block texture lives on a compositable pseudo-element: it fades out with + opacity instead of animating box-shadow, which would repaint every frame + and read as jitter on these tiny cells. */ +.landing-brand .pixel-mark i::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 2px; + box-shadow: + inset 0 2px 0 rgba(255, 255, 255, .3), + inset 0 -2px 0 rgba(0, 0, 0, .16), + inset 2px 0 0 rgba(255, 255, 255, .12), + inset -2px 0 0 rgba(0, 0, 0, .08); + animation: piece-texture-fade 2100ms ease both; +} +/* Slate-blue down-facing L: top bar with a tail hanging at its right end. */ +.landing-brand .pixel-mark i:nth-child(1) { top: 0; left: 11px; } +.landing-brand .pixel-mark i:nth-child(2) { top: 0; left: 22px; } +.landing-brand .pixel-mark i:nth-child(3) { top: 0; left: 33px; } +.landing-brand .pixel-mark i:nth-child(4) { top: 11px; left: 33px; } +/* Terracotta right-pointing arrow: vertical three with a middle bump. */ +.landing-brand .pixel-mark i:nth-child(5) { top: 11px; left: 11px; } +.landing-brand .pixel-mark i:nth-child(6) { top: 22px; left: 11px; } +.landing-brand .pixel-mark i:nth-child(7) { top: 22px; left: 22px; } +.landing-brand .pixel-mark i:nth-child(8) { top: 33px; left: 11px; } +/* Olive right-facing L: vertical three at column four, foot onto the base. */ +.landing-brand .pixel-mark i:nth-child(9) { top: 22px; left: 44px; } +.landing-brand .pixel-mark i:nth-child(10) { top: 33px; left: 44px; } +.landing-brand .pixel-mark i:nth-child(11) { top: 44px; left: 44px; } +.landing-brand .pixel-mark i:nth-child(12) { top: 44px; left: 55px; } +/* Amber four-cell base bar. */ +.landing-brand .pixel-mark i:nth-child(13) { top: 44px; left: 0; } +.landing-brand .pixel-mark i:nth-child(14) { top: 44px; left: 11px; } +.landing-brand .pixel-mark i:nth-child(15) { top: 44px; left: 22px; } +.landing-brand .pixel-mark i:nth-child(16) { top: 44px; left: 33px; } + +/* Landing brand animation: the π assembles at center screen from four + colored tetromino pieces dropping one by one — the amber base bar, the + terracotta arrow, the slate down-L, the olive right-L — then "Open" rams + it from the left, shoves the lockup to the right, and knocks off the + whole six-cell bottom row; the π sinks one cell into the vacated row. + The lockup then slides back to center while the pieces settle into flat + ink. The mark box is five cells tall, so neither the assembly nor the + sink ever shifts the layout. */ +.landing-brand .brand-word { display: inline-block; opacity: 0; animation: brand-word-slide 2100ms cubic-bezier(.22, 1, .36, 1) both; } +/* Drop order and piece colors: base first, then arrow, down-L, right-L. */ +.landing-brand .pixel-mark i:nth-child(5), .landing-brand .pixel-mark i:nth-child(6), +.landing-brand .pixel-mark i:nth-child(7), .landing-brand .pixel-mark i:nth-child(8) { --piece: #a65b41; animation-delay: 250ms, 0ms; } +.landing-brand .pixel-mark i:nth-child(1), .landing-brand .pixel-mark i:nth-child(2), +.landing-brand .pixel-mark i:nth-child(3), .landing-brand .pixel-mark i:nth-child(4) { --piece: #4f6f95; animation-delay: 500ms, 0ms; } +.landing-brand .pixel-mark i:nth-child(9), .landing-brand .pixel-mark i:nth-child(10), +.landing-brand .pixel-mark i:nth-child(11), .landing-brand .pixel-mark i:nth-child(12) { --piece: #97a05e; animation-delay: 750ms, 0ms; } +.landing-brand .pixel-mark i:nth-child(13), .landing-brand .pixel-mark i:nth-child(14), +.landing-brand .pixel-mark i:nth-child(15), .landing-brand .pixel-mark i:nth-child(16) { --piece: #d29a48; } +/* Doomed bottom row (six cells), scattered on impact, keeping piece colors. + The amber bar drops first; the olive foot arrives with the last piece. */ +.landing-brand .pixel-mark i:nth-child(11), .landing-brand .pixel-mark i:nth-child(12), +.landing-brand .pixel-mark i:nth-child(13), .landing-brand .pixel-mark i:nth-child(14), +.landing-brand .pixel-mark i:nth-child(15), .landing-brand .pixel-mark i:nth-child(16) { opacity: 0; animation: pixel-doomed 2100ms ease both; } +.landing-brand .pixel-mark i:nth-child(11), .landing-brand .pixel-mark i:nth-child(12) { animation-name: pixel-doomed-late; } +.landing-brand .pixel-mark i:nth-child(13) { --dx: -12px; --rot: -22deg; } +.landing-brand .pixel-mark i:nth-child(14) { --dx: -8px; --rot: -12deg; } +.landing-brand .pixel-mark i:nth-child(15) { --dx: -3px; --rot: -5deg; } +.landing-brand .pixel-mark i:nth-child(16) { --dx: 3px; --rot: 6deg; } +.landing-brand .pixel-mark i:nth-child(11) { --dx: 8px; --rot: 14deg; } +.landing-brand .pixel-mark i:nth-child(12) { --dx: 13px; --rot: 24deg; } + +@keyframes pixel-drop { + 0% { opacity: 0; transform: translateY(-40px); } + 100% { opacity: 1; transform: none; } +} +@keyframes piece-to-ink { + 0%, 72% { background-color: var(--piece, currentColor); } + 86%, 100% { background-color: currentColor; } +} +@keyframes piece-texture-fade { + 0%, 72% { opacity: 1; } + 86%, 100% { opacity: 0; } +} +@keyframes pixel-doomed { + 0% { opacity: 0; transform: translateY(-40px); } + 13%, 60% { opacity: 1; transform: none; } + 74% { opacity: .9; transform: translate(var(--dx), 14px) rotate(var(--rot)) scale(.85); } + 100% { opacity: 0; transform: translate(calc(var(--dx) * 2.5), 44px) rotate(calc(var(--rot) * 1.5)) scale(.5); } +} +@keyframes pixel-doomed-late { + 0%, 33% { opacity: 0; transform: translateY(-40px); } + 44%, 60% { opacity: 1; transform: none; } + 74% { opacity: .9; transform: translate(var(--dx), 14px) rotate(var(--rot)) scale(.85); } + 100% { opacity: 0; transform: translate(calc(var(--dx) * 2.5), 44px) rotate(calc(var(--rot) * 1.5)) scale(.5); } +} +@keyframes brand-word-slide { + 0%, 55% { opacity: 0; transform: translateX(-64px); } + 62% { opacity: 1; transform: translateX(2px); } + 70%, 100% { opacity: 1; transform: none; } +} +@keyframes brand-lockup { + 0%, 56% { transform: translateX(-52px); } + 64% { transform: translateX(-38px); } + 90%, 100% { transform: none; } +} +/* On impact the π loses its bottom layer and sinks one cell into the vacated + row (with a slight overshoot), then slides left so it sits flush against + "Open". The mark box is five cells tall, so both the assembled π and the + sunken final mark end flush with the bottom of the text. */ +@keyframes pixel-nudge { + 0%, 60% { transform: none; } + 65% { transform: translateY(13px); } + 71% { transform: translateY(11px); } + 82%, 100% { transform: translate(-11px, 11px); } +} +.message-row { position: relative; width: min(820px, calc(100% - 48px)); margin: 0 auto; padding: 18px 0 26px; } .message-row.user { display: flex; justify-content: flex-end; padding-top: 26px; } -.message-row.assistant { width: min(780px, calc(100% - 48px)); } -.message-row.detail-only { padding: 12px 0; } +.message-row.assistant { width: min(840px, calc(100% - 48px)); padding: 6px 0; } +.message-row.detail-only { padding: 6px 0; } .message-row.detail-only .message-details { margin: 0 auto; } -.message-content { min-width: 0; max-width: min(78%, 560px); } +.message-content { min-width: 0; max-width: min(78%, 620px); } .message-row.assistant .message-content { width: 100%; max-width: none; } -.message-row.assistant .message-details { width: min(760px, calc(100% - 4px)); margin-right: auto; margin-left: auto; } +.message-row.assistant .message-details { width: min(820px, calc(100% - 4px)); margin-right: auto; margin-left: auto; } .message-body { color: #252c33; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; white-space: pre-wrap; } -.message-row.user .message-body { padding: 10px 14px; border-radius: 18px 18px 4px 18px; background: var(--sidebar-active); color: var(--text); } +.message-row.user .message-body { padding: 9px 15px; border-radius: 18px; background: var(--sidebar-active); box-shadow: inset 0 0 0 1px rgba(182, 165, 119, .2); color: var(--text); } .message-body.markdown { white-space: normal; } .message-body.markdown > :first-child { margin-top: 0; } .message-body.markdown > :last-child { margin-bottom: 0; } @@ -414,37 +663,188 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .message-body.markdown blockquote { margin: 0 0 12px; padding: 2px 14px; border-left: 3px solid var(--border-strong); color: var(--muted); } .message-body.markdown hr { margin: 18px 0; border: 0; border-top: 1px solid var(--border); } .message-body.markdown a { color: var(--accent); text-decoration: underline; text-decoration-color: rgba(40, 103, 178, .35); text-underline-offset: 2px; } -.message-body.markdown code { padding: 2px 5px; border-radius: 4px; background: #eef1f4; color: #39434d; font: .9em ui-monospace, SFMono-Regular, Menlo, monospace; } -.message-body.markdown pre { margin: 0 0 14px; overflow-x: auto; padding: 12px 14px; border: 1px solid var(--border); border-radius: 8px; background: #f5f6f7; } -.message-body.markdown pre code { padding: 0; background: transparent; color: var(--text); font-size: 12px; white-space: pre; } +.message-body.markdown code { padding: 2px 5px; border: 1px solid rgba(210, 216, 222, .55); border-radius: 5px; background: #eef1f4; color: #39434d; font: .9em ui-monospace, SFMono-Regular, Menlo, monospace; } +.message-body.markdown pre { margin: 0 0 14px; overflow-x: auto; padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; background: rgba(245, 246, 247, .9); } +.message-body.markdown pre code { padding: 0; border: 0; background: transparent; color: var(--text); font-size: 12px; white-space: pre; } .message-body.markdown table { width: 100%; margin: 0 0 14px; border-collapse: collapse; font-size: .95em; } .message-body.markdown th, .message-body.markdown td { padding: 7px 9px; border: 1px solid var(--border); text-align: left; vertical-align: top; } .message-body.markdown th { background: #f4f5f6; font-weight: 650; } .message-body.markdown input[type="checkbox"] { margin-right: 7px; accent-color: var(--accent); } -.message-details { width: min(760px, calc(100% - 48px)); margin: 4px auto; color: var(--muted); } -.message-details summary { display: flex; min-height: 34px; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 7px; cursor: pointer; list-style: none; font-size: 13px; } +.message-details { width: min(820px, calc(100% - 48px)); margin: 4px auto; color: var(--muted); } +.message-details summary { display: flex; min-height: 34px; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 7px; cursor: pointer; list-style: none; color: var(--muted); font-size: 12.5px; transition: background-color 120ms ease, color 120ms ease; } .message-details summary::-webkit-details-marker { display: none; } -.message-details summary:hover { background: rgba(238, 241, 244, .72); } +.message-details summary:hover { background: rgba(238, 241, 244, .72); color: var(--text); } .details-mark { width: 8px; height: 8px; flex: 0 0 auto; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); transition: transform 140ms ease; } .message-details[open] .details-mark { transform: rotate(45deg); } +/* Disclosure chevrons follow the title text; the far right is reserved for + the outcome status (✓/✗, or a pulsing dot while running). */ +.message-details summary .details-mark, .tool-group > summary .details-mark { order: 1; } +.tool-status { order: 2; margin-left: auto; flex: 0 0 auto; display: inline-flex; align-items: center; font-style: normal; font-weight: 650; } +.tool-status.done { color: var(--healthy); } +.tool-status.error { color: var(--error); } +.tool-status.running i { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 0 rgba(40, 103, 178, .35); animation: conversation-pulse 1.5s ease-out infinite; } .details-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.assistant-detail { margin: 2px 0 4px; } +.assistant-detail { margin: 1px 0; } .assistant-detail .details-body { max-height: 280px; } -.details-body { max-height: 360px; margin: 2px 0 0 24px; overflow: auto; padding: 12px 14px; border: 1px solid var(--border); border-radius: 8px; background: rgba(247, 248, 249, .82); } +.details-body { max-height: 360px; margin: 4px 0 6px 24px; overflow: auto; padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; background: rgba(246, 247, 248, .88); } .tool-evidence { color: var(--muted); font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; white-space: pre-wrap; } +/* Hover actions: plain timestamp + copy icon, no pill chrome. */ +.message-actions { + position: absolute; + bottom: 0; + z-index: 2; + display: flex; + align-items: center; + gap: 6px; + color: var(--subtle); + font-size: 11px; + opacity: 0; + pointer-events: none; + transform: translateY(2px); + transition: opacity 120ms ease, transform 120ms ease; +} +.message-row.user .message-actions { right: 0; } +.message-row.assistant .message-actions { left: 0; } +/* The hover actions strip is absolutely positioned at the row's bottom edge; + rows carrying one need the extra bottom room the uniform 6px rhythm drops. */ +.message-row.assistant:has(.message-actions) { padding-bottom: 26px; } +.message-row:hover .message-actions, +.message-row:focus-within .message-actions { opacity: 1; pointer-events: auto; transform: none; } +.message-actions time { font-variant-numeric: tabular-nums; } +.message-copy { display: grid; width: 20px; height: 20px; place-items: center; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--subtle); transition: background-color 120ms ease, color 120ms ease; } +.message-copy:hover { background: rgba(238, 241, 244, .85); color: var(--text); } +.message-copy .icon { width: 13px; height: 13px; } +.message-copy .icon-check { display: none; } +.message-copy.copied { color: var(--healthy); } +.message-copy.copied .icon-copy { display: none; } +.message-copy.copied .icon-check { display: block; } +.message-edit { display: grid; width: 20px; height: 20px; place-items: center; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--subtle); transition: background-color 120ms ease, color 120ms ease; } +.message-edit:hover { background: rgba(238, 241, 244, .85); color: var(--text); } +.message-edit .icon { width: 13px; height: 13px; } + +/* Inline editing of the last sent message; resend on confirm. */ +.message-row.editing .message-content { width: 100%; max-width: none; } +.message-row.editing .message-actions { display: none; } +.message-edit-input { width: 100%; min-height: 44px; max-height: 220px; padding: 10px 14px; border: 1px solid var(--border-strong); border-radius: 14px; outline: none; background: var(--page); color: var(--text); font: inherit; font-size: 14px; line-height: 1.55; resize: none; } +.message-edit-input:focus { border-color: var(--focus); } +.message-edit-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } +.message-edit-actions button { height: 30px; padding: 0 14px; border: 1px solid var(--border-strong); border-radius: 15px; background: var(--page); color: var(--text); font-size: 12.5px; transition: background-color 120ms ease; } +.message-edit-actions button:hover { background: #f4f5f6; } +.message-edit-actions .message-edit-confirm { border-color: var(--text); background: var(--text); color: var(--page); } + +/* Turn rail: collapsed to a strip of ticks hugging the right edge of the + message column; hovering it expands into a list of user turns that + scrolls to the selected turn. */ +.turn-rail { + position: absolute; + top: 50%; + left: min(calc(50% + 430px), calc(100% - 256px)); + z-index: 6; + display: flex; + width: 16px; + max-height: 70%; + flex-direction: column; + gap: 2px; + padding: 4px 2px; + overflow-y: auto; + border: 1px solid transparent; + border-radius: 10px; + background: transparent; + scrollbar-width: thin; + scrollbar-color: rgba(125, 132, 138, .45) transparent; + transform: translateY(-50%); + transition: width 160ms ease, padding 160ms ease, background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease; +} +.turn-rail:hover, .turn-rail:focus-within { + width: 240px; + padding: 6px; + border-color: var(--border); + background: rgba(255, 255, 255, .92); + box-shadow: 0 12px 28px rgba(31, 37, 44, .14); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.turn-tick { display: flex; width: 100%; align-items: center; gap: 8px; padding: 3px 4px; border: 0; border-radius: 6px; background: transparent; } +.turn-rail:hover .turn-tick, .turn-rail:focus-within .turn-tick { padding: 5px 8px; } +.turn-tick:hover { background: #f1f3f5; } +.turn-tick-mark { width: 8px; height: 2px; flex: 0 0 auto; border-radius: 1px; background: var(--subtle); opacity: .55; transition: background-color 120ms ease, opacity 120ms ease; } +.turn-tick:hover .turn-tick-mark, .turn-tick.active .turn-tick-mark { background: var(--accent); opacity: 1; } +.turn-tick-label { display: none; min-width: 0; overflow: hidden; color: var(--muted); font-size: 12px; text-align: left; text-overflow: ellipsis; white-space: nowrap; } +.turn-rail:hover .turn-tick-label, .turn-rail:focus-within .turn-tick-label { display: block; } +.turn-tick.active .turn-tick-label { color: var(--text); font-weight: 550; } + +/* Compact tool lines: icon chip + tool name + a meaningful one-line summary. */ +.tool-line summary { gap: 8px; } +.tool-icon { display: grid; width: 22px; height: 22px; flex: 0 0 auto; place-items: center; border-radius: 6px; background: rgba(105, 115, 125, .1); color: var(--muted); } +.tool-icon .icon { width: 13px; height: 13px; } +.tool-line .details-title { display: flex; min-width: 0; align-items: baseline; gap: 8px; } +.tool-name { flex: 0 0 auto; color: var(--text); font-size: 12.5px; font-weight: 600; } +.tool-summary { min-width: 0; overflow: hidden; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; } +.thinking-line .tool-name { color: var(--muted); font-weight: 550; } +.thinking-line.active .tool-name { color: var(--text); } +.thinking-timer { font-variant-numeric: tabular-nums; } +.tool-line.error .tool-icon { background: rgba(180, 35, 24, .09); color: var(--error); } +.tool-line.error .tool-name { color: var(--error); } +.tool-line-empty { display: flex; min-height: 30px; align-items: center; gap: 8px; padding: 3px 8px; color: var(--muted); } + +/* Runs of consecutive tool steps collapse into one expandable group. */ +.tool-group { width: min(820px, calc(100% - 48px)); margin: 6px auto; border: 1px solid var(--border); border-radius: 10px; background: rgba(255, 255, 255, .5); color: var(--muted); overflow: hidden; } +.tool-group > summary { display: flex; min-height: 36px; align-items: center; gap: 8px; padding: 5px 10px; cursor: pointer; list-style: none; font-size: 12.5px; transition: background-color 120ms ease; } +.tool-group > summary::-webkit-details-marker { display: none; } +.tool-group > summary:hover { background: rgba(238, 241, 244, .72); } +.tool-group[open] > summary .details-mark { transform: rotate(45deg); } +.tool-group-icons { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); } +.tool-group-icons .icon { width: 13px; height: 13px; } +.tool-group-title { font-weight: 550; } +.tool-group.error > summary { color: var(--error); } +.tool-group-body { border-top: 1px solid var(--border); padding: 4px 10px; } +.tool-group-body .message-row { width: 100%; padding: 0; } +.tool-group-body .message-details { width: 100%; margin: 0; } +.tool-group-body .activity-card { background: transparent; border-color: transparent; border-bottom: 1px solid var(--border); border-radius: 0; } +.tool-group-body .message-row:last-child .activity-card { border-bottom: 0; } + +/* Capability cards: subagent and workflow tool activity rendered as branded + cards instead of generic collapsed tool rows. */ +.activity-card { border: 1px solid var(--border); border-radius: 10px; background: rgba(255, 255, 255, .6); overflow: hidden; } +.activity-card summary { display: flex; min-height: 44px; align-items: center; gap: 10px; padding: 7px 10px; font-size: 13px; } +.activity-card .details-mark { order: 3; } +.activity-icon { display: grid; width: 28px; height: 28px; flex: 0 0 auto; place-items: center; border-radius: 8px; } +.activity-icon .icon { width: 16px; height: 16px; } +.activity-card.subagent .activity-icon { background: rgba(40, 103, 178, .1); color: var(--accent); } +.activity-card.workflow .activity-icon { background: rgba(139, 111, 184, .12); color: #8b6fb8; } +.activity-main { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 1px; } +.activity-title { overflow: hidden; color: var(--text); font-weight: 550; text-overflow: ellipsis; white-space: nowrap; } +.activity-meta { overflow: hidden; color: var(--subtle); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.activity-status { flex: 0 0 auto; font-size: 12px; font-weight: 650; } +.activity-status.done { color: var(--healthy); } +.activity-status.error { color: var(--error); } +.activity-status.warn { color: #9a6700; } +.activity-status.running { display: inline-flex; align-items: center; } +.activity-card .details-body { margin: 0 10px 10px 48px; } + +/* Live capability chips above the composer, fed by the runtime snapshot. */ +.activity-bar { display: flex; width: min(840px, 100%); flex-wrap: wrap; gap: 6px; margin: 0 auto; padding: 0 15px 2px; } +.activity-chip { display: inline-flex; min-width: 0; max-width: 320px; align-items: center; gap: 6px; padding: 3px 10px; border: 1px solid var(--border); border-radius: 999px; background: rgba(255, 255, 255, .78); color: var(--muted); font-size: 11.5px; } +.activity-chip-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.activity-chip-dot { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 0 rgba(40, 103, 178, .35); animation: conversation-pulse 1.5s ease-out infinite; } +.activity-chip-glyph { flex: 0 0 auto; font-style: normal; font-weight: 650; } +.activity-chip.completed .activity-chip-glyph, .activity-chip.done .activity-chip-glyph { color: var(--healthy); } +.activity-chip.error .activity-chip-glyph { color: var(--error); } +.activity-chip.warn .activity-chip-glyph { color: #9a6700; } +.activity-chip.more { color: var(--subtle); } + .composer-dock { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 12px 24px 22px; - background: linear-gradient(to bottom, rgba(250, 247, 237, 0), rgba(250, 247, 237, .94) 18px); } .composer { position: relative; display: flex; - width: min(780px, 100%); + width: min(840px, 100%); min-height: 118px; flex-direction: column; margin: 0 auto; @@ -453,6 +853,7 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } border-radius: 18px; background: var(--page); box-shadow: 0 8px 26px rgba(53, 78, 117, .1); + transition: border-color 160ms ease, box-shadow 160ms ease; } .composer:focus-within { border-color: #d8cfae; box-shadow: 0 0 0 2px rgba(216, 207, 174, .28), 0 8px 26px rgba(86, 76, 48, .1); } .composer.dormant { border-style: dashed; box-shadow: none; cursor: pointer; } @@ -461,16 +862,11 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .composer textarea { width: 100%; min-width: 0; min-height: 52px; max-height: 220px; flex: 0 0 auto; padding: 0 2px; resize: none; overflow-y: hidden; border: 0; outline: 0; background: transparent; color: var(--text); font-size: 14px; line-height: 1.55; scrollbar-width: thin; scrollbar-color: rgba(125, 132, 138, .45) transparent; } .composer textarea::placeholder { color: var(--subtle); } .composer-toolbar { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 7px; } -.conversation-running { display: flex; width: min(780px, calc(100% - 48px)); margin: 2px auto 18px; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; } -.runtime-activity { display: flex; width: min(780px, calc(100% - 48px)); flex-wrap: wrap; gap: 6px; margin: 8px auto 14px; padding: 0; list-style: none; } -.runtime-activity-item { display: inline-flex; min-width: 0; max-width: 320px; align-items: center; gap: 6px; padding: 4px 9px; border: 1px solid var(--border); border-radius: 999px; background: var(--page); color: var(--muted); font-size: 11px; } -.runtime-activity-item strong { color: var(--text); font-weight: 600; } -.runtime-activity-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.runtime-activity-item small { color: var(--subtle); } +.conversation-running { display: flex; width: min(840px, calc(100% - 48px)); margin: 2px auto 18px; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; } .conversation-running-dot { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 0 rgba(40, 103, 178, .35); animation: conversation-pulse 1.5s ease-out infinite; } @keyframes conversation-pulse { 70% { box-shadow: 0 0 0 6px rgba(40, 103, 178, 0); } 100% { box-shadow: 0 0 0 0 rgba(40, 103, 178, 0); } } .model-picker-wrap { position: relative; min-width: 0; max-width: 48%; } -.model-picker { display: inline-flex; min-width: 150px; max-width: 100%; height: 34px; align-items: center; justify-content: space-between; gap: 8px; margin-right: 0; padding: 0 9px 0 10px; border: 1px solid transparent; border-radius: 8px; outline: 0; background: transparent; color: var(--muted); font-size: 13px; text-align: left; } +.model-picker { display: inline-flex; min-width: 150px; max-width: 100%; height: 34px; align-items: center; justify-content: space-between; gap: 8px; margin-right: 0; padding: 0 9px 0 10px; border: 1px solid transparent; border-radius: 8px; outline: 0; background: transparent; color: var(--muted); font-size: 13px; text-align: left; transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease; } .model-picker:not(:disabled) { cursor: pointer; } .model-picker:not(:disabled):hover, .model-picker[aria-expanded="true"] { border-color: var(--border); background: #f7f8f9; color: var(--text); } .model-picker:disabled { color: var(--subtle); cursor: default; } @@ -479,14 +875,14 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .model-picker[aria-expanded="true"] .model-picker-chevron { transform: rotate(180deg); } .model-menu { position: absolute; top: calc(100% + 6px); right: 0; z-index: 8; width: 240px; max-height: 300px; overflow-y: auto; padding: 5px; border: 1px solid var(--border); border-radius: 10px; background: var(--page); box-shadow: 0 12px 28px rgba(31, 37, 44, .16); scrollbar-width: thin; scrollbar-color: rgba(125, 132, 138, .45) transparent; } .model-menu[hidden] { display: none; } -.model-menu button { display: flex; width: 100%; min-height: 34px; align-items: center; justify-content: space-between; gap: 10px; padding: 0 9px; border: 0; border-radius: 7px; background: transparent; color: var(--text); font-size: 13px; text-align: left; } +.model-menu button { display: flex; width: 100%; min-height: 34px; align-items: center; justify-content: space-between; gap: 10px; padding: 0 9px; border: 0; border-radius: 7px; background: transparent; color: var(--text); font-size: 13px; text-align: left; transition: background-color 120ms ease; } .model-menu button:hover, .model-menu button[aria-selected="true"] { background: #eef1f4; } .model-menu-check { visibility: hidden; color: var(--text); font-size: 15px; } .model-menu button[aria-selected="true"] .model-menu-check { visibility: visible; } -.workspace-tree::-webkit-scrollbar, .model-menu::-webkit-scrollbar, .composer textarea::-webkit-scrollbar { width: 6px; height: 6px; } -.workspace-tree::-webkit-scrollbar-track, .model-menu::-webkit-scrollbar-track, .composer textarea::-webkit-scrollbar-track { background: transparent; border: 0; } -.workspace-tree::-webkit-scrollbar-thumb, .model-menu::-webkit-scrollbar-thumb, .composer textarea::-webkit-scrollbar-thumb { border-radius: 999px; background: rgba(125, 132, 138, .45); } -.workspace-picker-row { display: flex; width: min(780px, 100%); min-width: 0; align-items: center; margin: 0 auto; padding-left: 15px; } +.workspace-tree::-webkit-scrollbar, .model-menu::-webkit-scrollbar, .composer textarea::-webkit-scrollbar, .conversation::-webkit-scrollbar { width: 6px; height: 6px; } +.workspace-tree::-webkit-scrollbar-track, .model-menu::-webkit-scrollbar-track, .composer textarea::-webkit-scrollbar-track, .conversation::-webkit-scrollbar-track { background: transparent; border: 0; } +.workspace-tree::-webkit-scrollbar-thumb, .model-menu::-webkit-scrollbar-thumb, .composer textarea::-webkit-scrollbar-thumb, .conversation::-webkit-scrollbar-thumb { border-radius: 999px; background: rgba(125, 132, 138, .45); } +.workspace-picker-row { display: flex; width: min(840px, 100%); min-width: 0; align-items: center; margin: 0 auto; padding-left: 15px; } .workspace-picker { position: relative; display: inline-flex; width: fit-content; max-width: 100%; min-width: 0; height: 36px; align-items: center; justify-content: center; gap: 0; padding: 0 10px 0 0; padding-right: 28px; padding-left: 28px; border: 1px solid transparent; border-radius: 18px; background: transparent; color: var(--text); font-size: 13px; line-height: 1; text-align: center; transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease; } .workspace-picker:hover, .workspace-picker[aria-expanded="true"] { border-color: #d8cfae; background: var(--sidebar-active); box-shadow: 0 3px 10px rgba(86, 76, 48, .08); } .workspace-picker span { min-width: 0; overflow: hidden; text-align: center; text-overflow: ellipsis; white-space: nowrap; } @@ -495,21 +891,40 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .workspace-picker-row { position: relative; } .workspace-picker-menu { position: absolute; top: calc(100% + 6px); left: 0; z-index: 5; width: 225px; padding: 5px; border: 1px solid var(--border); border-radius: 12px; background: var(--page); box-shadow: 0 12px 28px rgba(31, 37, 44, .16); } .workspace-picker-menu[hidden] { display: none; } -.workspace-picker-menu button { display: flex; width: 100%; height: 38px; align-items: center; gap: 8px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: var(--text); font-size: 12px; text-align: left; } +.workspace-picker-menu button { display: flex; width: 100%; height: 38px; align-items: center; gap: 8px; padding: 0 10px; border: 0; border-radius: 8px; background: transparent; color: var(--text); font-size: 12px; text-align: left; transition: background-color 120ms ease; } .workspace-picker-menu button:hover { background: #f1f3f5; } .workspace-picker-menu button[aria-selected="true"] { background: #f4f5f6; } .workspace-menu-icon { width: 16px; height: 16px; flex: 0 0 auto; color: var(--muted); } .workspace-picker-menu .workspace-menu-check { visibility: hidden; margin-left: auto; font-size: 16px; } .workspace-picker-menu button[aria-selected="true"] .workspace-menu-check { visibility: visible; } .workspace-picker-menu .workspace-menu-add { margin-top: 4px; border-radius: 8px; color: var(--muted); } -.send-button { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 50%; background: var(--warm-accent); color: var(--text); } +.send-button { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 50%; background: var(--warm-accent); color: var(--text); transition: background-color 140ms ease, color 140ms ease, transform 100ms ease; } .send-button:hover { background: var(--warm-accent-hover); } +.send-button:not(:disabled):active { transform: scale(.92); } .send-button:disabled { background: var(--warm-accent-disabled); color: var(--subtle); } .send-button svg { width: 17px; height: 17px; stroke-width: 2; } .composer-hint { display: none; } .composer-hint.error { color: var(--error); } .sidebar-scrim { display: none; } +@keyframes menu-pop { + from { opacity: 0; transform: translateY(-4px) scale(.98); } +} +.workspace-menu[open], +.model-menu:not([hidden]), +.language-menu:not([hidden]), +.workspace-picker-menu:not([hidden]) { + animation: menu-pop 130ms cubic-bezier(.22, 1, .36, 1); +} +@keyframes dialog-in { + from { opacity: 0; transform: translateY(8px) scale(.98); } +} +.settings-dialog[open], +.workspace-rename-dialog[open], +.workspace-delete-dialog[open] { + animation: dialog-in 180ms cubic-bezier(.22, 1, .36, 1); +} + @media (max-width: 760px) { .app-shell, body.sidebar-collapsed .app-shell { grid-template-columns: 1fr; } .session-sidebar { @@ -522,14 +937,15 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } } body.sidebar-open .session-sidebar { transform: translateX(0); } body.sidebar-collapsed .session-sidebar { align-items: stretch; padding: 8px 10px; } - body.sidebar-collapsed .brand-word, body.sidebar-collapsed .workspace-heading-label, + body.sidebar-collapsed .sidebar-brand .brand-word, body.sidebar-collapsed .workspace-heading-label, body.sidebar-collapsed .workspace-tree, body.sidebar-collapsed .session-search, - body.sidebar-collapsed .new-session-button span { display: initial; } + body.sidebar-collapsed .settings-button span, body.sidebar-collapsed .new-session-button span { display: initial; } body.sidebar-collapsed .sidebar-brand { width: auto; justify-content: space-between; padding: 0 4px 0 6px; } body.sidebar-collapsed .sidebar-brand .collapse-button { position: static; } body.sidebar-collapsed .sidebar-brand .collapse-button .icon { display: block; } body.sidebar-collapsed .new-session-button { width: calc(100% - 4px); margin: 8px 2px 10px; } body.sidebar-collapsed .workspace-heading { width: auto; justify-content: space-between; padding: 0 4px 0 8px; } + body.sidebar-collapsed .settings-button { width: 100%; justify-content: flex-start; padding: 0 8px; } .sidebar-scrim { position: fixed; inset: 0; z-index: 10; border: 0; background: rgba(20, 26, 32, .28); } body.sidebar-open .sidebar-scrim { display: block; } .conversation-shell { grid-template-rows: minmax(0, 1fr) auto; } @@ -539,16 +955,32 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } .message-row, .message-details { width: calc(100% - 28px); } .message-row.assistant { width: calc(100% - 24px); } .conversation-running { width: calc(100% - 24px); } + .turn-rail { display: none; } .composer-dock { padding: 10px 12px 12px; } .landing .composer-dock { top: calc(50% - 74px); padding: 0 12px; } .landing-welcome { transform: translateY(-96px); } .composer { min-height: 104px; } .composer-hint { display: none; } + .settings-dialog { width: calc(100% - 24px); min-height: 0; max-height: calc(100dvh - 24px); } + .settings-body { min-height: 0; grid-template-columns: 1fr; } + .settings-nav { padding: 10px 12px; border-right: 0; border-bottom: 1px solid var(--border); } + .settings-nav-item { height: 36px; } + .settings-content { padding: 22px 20px 26px; } } @media (prefers-reduced-motion: reduce) { .app-shell, .session-sidebar, .workspace-chevron { transition: none; } + .message-actions, .turn-rail, .turn-tick-mark { transition: none; } .conversation { scroll-behavior: auto; } + .workspace-menu, .model-menu, .language-menu, .workspace-picker-menu, + .settings-dialog, .workspace-rename-dialog, .workspace-delete-dialog { animation: none; } + /* Skip the collision and land directly on the final OpenPI mark. */ + .landing-brand strong, .landing-brand .brand-word, + .landing-brand .pixel-mark, .landing-brand .pixel-mark i, + .landing-brand .pixel-mark i::after { animation: none; } + .landing-brand .brand-word { opacity: 1; } + .landing-brand .pixel-mark i { background-color: currentColor; } + .landing-brand .pixel-mark i::after { opacity: 0; } } /* The conversation view should start with the actual messages. On narrow @@ -582,11 +1014,6 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } } } -/* Keep the reference grid visible around the landing composer. */ -.conversation-shell.landing .composer-dock { - background: transparent; -} - /* An existing session already belongs to its workspace. */ .conversation-shell:not(.landing) .workspace-picker-row { display: none; @@ -596,3 +1023,117 @@ body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } top: auto; bottom: calc(100% + 6px); } + +/* ------------------------------ Themes ------------------------------ */ + +/* Clean White: pure surfaces, no grid. */ +html[data-theme="white"] { + --sidebar-hover: #eff1f4; + --sidebar-active: #e8eaee; + --warm-accent: #eff1f4; + --warm-accent-hover: #e4e7eb; + --warm-accent-disabled: #eff1f4; +} +html[data-theme="white"] body { background-color: #ffffff; background-image: none; } +html[data-theme="white"] .session-sidebar { background-color: rgba(255, 255, 255, .6); background-image: none; box-shadow: none; } +html[data-theme="white"] .session-sidebar::before { display: none; } +html[data-theme="white"] .conversation-shell:not(.landing) { + background-image: linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, .9) 90px, rgba(255, 255, 255, .9) calc(100% - 90px), rgba(255, 255, 255, 0)); +} +html[data-theme="white"] .message-row.user .message-body { box-shadow: inset 0 0 0 1px rgba(120, 130, 140, .18); } + +/* Midnight: classic dark, with pi.dev's dark engineering grid. */ +html[data-theme="dark"] { + color-scheme: dark; + --page: #161b22; + --text: #e6edf3; + --muted: #9aa4af; + --subtle: #7d8590; + --border: #2d333b; + --border-strong: #3d444d; + --sidebar-hover: rgba(56, 63, 71, .65); + --sidebar-active: rgba(56, 63, 71, .85); + --healthy: #3fb27f; + --error: #e5534b; + --warm-accent: rgba(56, 63, 71, .5); + --warm-accent-hover: rgba(66, 74, 83, .8); + --warm-accent-disabled: rgba(56, 63, 71, .5); +} +html[data-theme="dark"] ::selection { background: rgba(76, 136, 207, .35); } +html[data-theme="dark"] body { + background-color: #0d1116; + background-image: + linear-gradient(to bottom, rgba(13, 17, 22, .35), rgba(13, 17, 22, 0) 40%, rgba(13, 17, 22, 0) 72%, rgba(13, 17, 22, .6)), + linear-gradient(hsl(218 60% 80% / .015) 0 1px, transparent 1px 4px), + linear-gradient(to right, hsl(218 60% 80% / .015) 0 1px, transparent 1px 4px), + linear-gradient(hsl(218 60% 80% / .045) 0 1px, transparent 1px 20px), + linear-gradient(to right, hsl(218 60% 80% / .045) 0 1px, transparent 1px 20px), + radial-gradient(circle at center, transparent 2px, #0d1116 2px 100%), + linear-gradient(hsl(218 60% 80% / .12) 0 1px, transparent 1px 20px), + linear-gradient(to right, hsl(218 60% 80% / .12) 0 1px, transparent 1px 20px); + background-size: + 100% 100%, + 4px 4px, + 4px 4px, + 20px 20px, + 20px 20px, + 20px 20px, + 20px 20px, + 20px 20px; + background-position: + 0 0, + 0 0, + 0 0, + 0 0, + 0 0, + -10px -10px, + 0 0, + 0 0; +} +html[data-theme="dark"] .session-sidebar { + border-right-color: rgba(48, 54, 61, .65); + background-color: rgba(13, 17, 22, .5); + background-image: linear-gradient(to bottom, rgba(255, 255, 255, .05), rgba(255, 255, 255, 0) 36%); + box-shadow: none; +} +html[data-theme="dark"] .session-sidebar::before { + background-image: + linear-gradient(rgba(230, 237, 243, .05) 0 1px, transparent 1px 4px), + linear-gradient(to right, rgba(230, 237, 243, .05) 0 1px, transparent 1px 4px), + linear-gradient(rgba(230, 237, 243, .09) 0 1px, transparent 1px 20px), + linear-gradient(to right, rgba(230, 237, 243, .09) 0 1px, transparent 1px 20px); +} +html[data-theme="dark"] .conversation-shell:not(.landing) { + background-image: linear-gradient(to right, rgba(13, 17, 22, 0), rgba(13, 17, 22, .88) 90px, rgba(13, 17, 22, .88) calc(100% - 90px), rgba(13, 17, 22, 0)); +} +html[data-theme="dark"] .new-session-button { background: #161b22; } +html[data-theme="dark"] .new-session-button:hover { background: #1c2129; border-color: #3d444d; } +html[data-theme="dark"] .session-search input { background: rgba(13, 17, 22, .4); border-color: #2d333b; } +html[data-theme="dark"] .session-search input:focus { background: #161b22; box-shadow: 0 0 0 3px rgba(76, 136, 207, .25); } +html[data-theme="dark"] .message-body { color: #d4dbe2; } +html[data-theme="dark"] .message-row.user .message-body { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .08); } +html[data-theme="dark"] .message-body.markdown code { border-color: rgba(61, 68, 77, .6); background: #21262d; color: #d4dbe2; } +html[data-theme="dark"] .message-body.markdown pre { background: rgba(22, 27, 34, .9); } +html[data-theme="dark"] .message-body.markdown pre code { color: #e6edf3; } +html[data-theme="dark"] .message-body.markdown th { background: #21262d; } +html[data-theme="dark"] .details-body { background: rgba(22, 27, 34, .88); } +html[data-theme="dark"] .message-details summary:hover, +html[data-theme="dark"] .tool-group > summary:hover { background: rgba(56, 63, 71, .5); } +html[data-theme="dark"] .tool-group { background: rgba(22, 27, 34, .5); } +html[data-theme="dark"] .activity-card { background: rgba(22, 27, 34, .6); } +html[data-theme="dark"] .activity-chip { background: rgba(22, 27, 34, .78); } +html[data-theme="dark"] .turn-rail:hover, html[data-theme="dark"] .turn-rail:focus-within { background: rgba(22, 27, 34, .92); } +html[data-theme="dark"] .turn-tick:hover { background: #2d333b; } +html[data-theme="dark"] .message-copy:hover { background: rgba(56, 63, 71, .6); } +html[data-theme="dark"] .composer { box-shadow: 0 8px 26px rgba(0, 0, 0, .35); } +html[data-theme="dark"] .composer:focus-within { border-color: #3d444d; box-shadow: 0 0 0 2px rgba(76, 136, 207, .25), 0 8px 26px rgba(0, 0, 0, .35); } +html[data-theme="dark"] .model-picker:not(:disabled):hover, html[data-theme="dark"] .model-picker[aria-expanded="true"] { background: #21262d; } +html[data-theme="dark"] .settings-nav-item.active, html[data-theme="dark"] .settings-nav-item:hover { background: #21262d; } +html[data-theme="dark"] .language-picker-trigger { background: #21262d; } +html[data-theme="dark"] .language-picker-trigger:hover, html[data-theme="dark"] .language-picker-trigger[aria-expanded="true"] { background: #2d333b; } +html[data-theme="dark"] .workspace-menu button:hover, +html[data-theme="dark"] .language-menu button:hover, +html[data-theme="dark"] .model-menu button:hover, +html[data-theme="dark"] .model-menu button[aria-selected="true"], +html[data-theme="dark"] .workspace-picker-menu button:hover { background: #2d333b; } +html[data-theme="dark"] .dialog-actions button { background: #161b22; } From 1b340f2cfbac7dc16861c3d1a78cce466c6c5cc5 Mon Sep 17 00:00:00 2001 From: QuinnWan <144975606+somewan820@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:17:52 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(web):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?opaque=20narrow=20sidebar,=20defer=20settings=20to=20#350?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: an off-canvas sidebar with backdrop-filter makes Chromium blur the whole page backdrop at <=760px. The narrow drawer is now opaque and skips the backdrop filter and ghost grid; regression assertions lock the media query rules. P2: theme/language pickers persisted to localStorage would fork a second config store from the package configuration contract. Remove the settings entry, dialog, pickers, theme overrides, and persistence from this PR; #350 lands preferences through the canonical setup config. --- tests/web/web-host.test.ts | 32 +++--- web/ui/app.js | 97 +----------------- web/ui/index.html | 45 --------- web/ui/styles.css | 196 +++---------------------------------- 4 files changed, 33 insertions(+), 337 deletions(-) diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index db859f89..3e277f1a 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -128,11 +128,11 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn assert.match(pageHtml, /id="workspace-menu"/); assert.match(pageHtml, /Rename workspace/); assert.match(pageHtml, /Remove from sidebar/); - assert.match(pageHtml, /id="theme-picker-trigger"/); - assert.match(pageHtml, /data-theme-value="dark"/); - assert.match(pageHtml, /id="settings-dialog"/); - assert.match(pageHtml, /id="language-picker-trigger"/); - assert.match(pageHtml, /id="open-settings"/); + // Settings persistence/entry is deferred to #350 (canonical config contract). + assert.doesNotMatch( + pageHtml, + /settings-dialog|open-settings|theme-picker|language-picker/, + ); const app = await fetch(`${launched.origin}/app.js`); assert.equal(app.status, 200); @@ -194,9 +194,8 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn assert.match(appSource, /history\.replaceState/); assert.match(appSource, /\/events\?cursor=/); assert.doesNotMatch(appSource, /openpi\.archived-sessions/); - assert.match(appSource, /applyTheme/); - assert.match(appSource, /data-theme-value/); - assert.match(appSource, /language-picker|open-settings/u); + assert.doesNotMatch(appSource, /applyTheme|data-theme-value|open-settings/); + assert.doesNotMatch(appSource, /openpi\.language|openpi\.web\.theme/); const marked = await fetch(`${launched.origin}/marked.js`); assert.equal(marked.status, 200); @@ -276,10 +275,21 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn assert.match(stylesSource, /\.tool-line\.error/); assert.match(stylesSource, /\.tool-group \{/); assert.match(stylesSource, /\.thinking-line/); - assert.match(stylesSource, /html\[data-theme="dark"\] body/); - assert.match(stylesSource, /html\[data-theme="white"\] body/); + assert.doesNotMatch( + stylesSource, + /html\[data-theme=|settings-dialog|language-picker/, + ); assert.match(stylesSource, /@keyframes pixel-nudge/); - assert.match(stylesSource, /\.settings-dialog \{/); + // Narrow-screen regression: the off-canvas sidebar must not keep a + // backdrop-filter, or Chromium blurs the whole page backdrop (#352 P1). + assert.match( + stylesSource, + /@media \(max-width: 760px\) \{[\s\S]*?\.session-sidebar \{[^}]*backdrop-filter: none;/, + ); + assert.match( + stylesSource, + /@media \(max-width: 760px\) \{[\s\S]*?\.session-sidebar::before \{ display: none; \}/, + ); const unauthorized = await fetch(`${launched.origin}/api/snapshot`); assert.equal(unauthorized.status, 401); diff --git a/web/ui/app.js b/web/ui/app.js index b1505e2e..b369944e 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -34,15 +34,7 @@ const state = { thinkingDurations: {}, query: "", selectedWorkspace: null, - language: (() => { - try { return localStorage.getItem("openpi.language") === "zh" ? "zh" : "en"; } catch { return "en"; } - })(), - theme: (() => { - try { - const saved = localStorage.getItem("openpi.web.theme"); - return ["pi", "white", "dark"].includes(saved) ? saved : "pi"; - } catch { return "pi"; } - })(), + language: navigator.language?.toLowerCase().startsWith("zh") ? "zh" : "en", }; const translations = { @@ -51,11 +43,6 @@ const translations = { untitledSession: "New session", workspaces: "Workspaces", addWorkspace: "Add workspace", - settings: "Settings", - close: "Close", - closeSettings: "Close settings", - generalSettings: "General settings", - language: "Language", selectWorkspace: "Select workspace", describeTask: "Describe a task", chooseWorkspaceHint: "Choose a workspace and describe the work", @@ -95,21 +82,12 @@ const translations = { noOutput: "no output", editMessage: "Edit message", confirmEdit: "OK", - theme: "Theme", - themePi: "PI Grid", - themeWhite: "Clean White", - themeDark: "Midnight", }, zh: { newSession: "新建会话", untitledSession: "新会话", workspaces: "工作区", addWorkspace: "添加工作区", - settings: "设置", - close: "关闭", - closeSettings: "关闭设置", - generalSettings: "通用设置", - language: "语言", selectWorkspace: "选择工作区", describeTask: "描述任务", chooseWorkspaceHint: "选择工作区并描述任务", @@ -149,10 +127,6 @@ const translations = { noOutput: "无输出", editMessage: "编辑消息", confirmEdit: "确定", - theme: "主题", - themePi: "PI 网格", - themeWhite: "简洁白", - themeDark: "暗夜黑", }, }; const t = (key) => translations[state.language][key] || translations.en[key] || key; @@ -164,11 +138,6 @@ function applyLanguage() { document.querySelectorAll("[data-i18n-title]").forEach((element) => { element.title = t(element.dataset.i18nTitle); }); const search = $("search"); if (search) search.placeholder = t("searchPlaceholder"); - const picker = $("language-picker-value"); - if (picker) picker.textContent = state.language === "zh" ? "中文" : "English"; - document.querySelectorAll("#language-menu [data-language]").forEach((option) => { - option.setAttribute("aria-selected", option.dataset.language === state.language ? "true" : "false"); - }); renderWorkspacePicker(); if (state.snapshot) { renderWorkspaces(); @@ -176,17 +145,6 @@ function applyLanguage() { } } -const THEME_KEYS = { pi: "themePi", white: "themeWhite", dark: "themeDark" }; - -function applyTheme() { - document.documentElement.dataset.theme = state.theme; - const value = $("theme-picker-value"); - if (value) value.textContent = t(THEME_KEYS[state.theme] || "themePi"); - document.querySelectorAll("#theme-menu [data-theme-value]").forEach((option) => { - option.setAttribute("aria-selected", option.dataset.themeValue === state.theme ? "true" : "false"); - }); -} - const $ = (id) => document.getElementById(id); const tokenStorageKey = "openpi.web.token"; const fragmentToken = new URLSearchParams(location.hash.slice(1)).get("token"); @@ -2041,58 +1999,5 @@ $("prompt-input")?.addEventListener("keydown", (event) => { } }); -$("open-settings")?.addEventListener("click", () => $("settings-dialog")?.showModal()); -$("close-settings")?.addEventListener("click", () => $("settings-dialog")?.close()); -$("settings-dialog")?.addEventListener("click", (event) => { - if (event.target === event.currentTarget) event.currentTarget.close(); -}); -$("language-picker-trigger")?.addEventListener("click", () => { - const menu = $("language-menu"); - const trigger = $("language-picker-trigger"); - if (!menu || !trigger) return; - menu.hidden = !menu.hidden; - trigger.setAttribute("aria-expanded", String(!menu.hidden)); -}); -document.querySelectorAll("#language-menu [data-language]").forEach((option) => { - option.addEventListener("click", () => { - state.language = option.dataset.language === "zh" ? "zh" : "en"; - try { localStorage.setItem("openpi.language", state.language); } catch {} - applyLanguage(); - renderConversation(); - $("language-menu").hidden = true; - $("language-picker-trigger")?.setAttribute("aria-expanded", "false"); - }); -}); -$("theme-picker-trigger")?.addEventListener("click", () => { - const menu = $("theme-menu"); - const trigger = $("theme-picker-trigger"); - if (!menu || !trigger) return; - menu.hidden = !menu.hidden; - trigger.setAttribute("aria-expanded", String(!menu.hidden)); -}); -document.querySelectorAll("#theme-menu [data-theme-value]").forEach((option) => { - option.addEventListener("click", () => { - state.theme = option.dataset.themeValue in THEME_KEYS ? option.dataset.themeValue : "pi"; - try { localStorage.setItem("openpi.web.theme", state.theme); } catch {} - applyTheme(); - $("theme-menu").hidden = true; - $("theme-picker-trigger")?.setAttribute("aria-expanded", "false"); - }); -}); -document.addEventListener("pointerdown", (event) => { - const picker = document.querySelector(".language-picker"); - if (picker && !picker.contains(event.target)) { - $("language-menu").hidden = true; - $("language-picker-trigger")?.setAttribute("aria-expanded", "false"); - } - const themeTrigger = $("theme-picker-trigger"); - const themeMenu = $("theme-menu"); - if (themeMenu && !themeMenu.hidden && event.target instanceof Element && !themeMenu.contains(event.target) && event.target !== themeTrigger && !themeTrigger?.contains(event.target)) { - themeMenu.hidden = true; - themeTrigger?.setAttribute("aria-expanded", "false"); - } -}); - applyLanguage(); -applyTheme(); void connectEvents(); diff --git a/web/ui/index.html b/web/ui/index.html index cd123e79..b6fd5c34 100644 --- a/web/ui/index.html +++ b/web/ui/index.html @@ -74,11 +74,6 @@
    - -
    @@ -143,45 +138,5 @@ - -
    - Settings - -
    -
    - -
    -
    -
    Language
    -
    - - -
    -
    -
    -
    Theme
    -
    - - -
    -
    -
    -
    -
    diff --git a/web/ui/styles.css b/web/ui/styles.css index 3233867e..8c28ea87 100644 --- a/web/ui/styles.css +++ b/web/ui/styles.css @@ -333,25 +333,6 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); .session-row:focus-within .session-time { visibility: hidden; } .session-action:hover { background: var(--sidebar-active); color: var(--text); } .workspace-sessions .empty, .workspace-tree > .empty { min-height: 32px; padding: 7px 10px 7px 29px; color: var(--subtle); font-size: 12px; } -.settings-button { - display: flex; - width: 100%; - height: 38px; - flex: 0 0 auto; - align-items: center; - gap: 8px; - padding: 0 8px; - border: 0; - border-radius: 6px; - background: transparent; - color: #46515c; - font-size: 12px; - text-align: left; - margin-top: auto; - transition: background-color 120ms ease, color 120ms ease; -} -.settings-button:hover { background: var(--sidebar-hover); } -.settings-button svg { width: 18px; height: 18px; } .workspace-menu { position: fixed; z-index: 40; @@ -401,51 +382,12 @@ body.sidebar-collapsed .app-shell { grid-template-columns: 56px minmax(0, 1fr); .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; } .dialog-actions button { height: 32px; padding: 0 12px; border: 1px solid var(--border-strong); border-radius: 5px; background: var(--page); } .dialog-actions #save-workspace-name { border-color: var(--text); background: var(--text); color: #fff; } -.settings-dialog { - width: min(760px, calc(100% - 32px)); - min-height: 460px; - padding: 0; - border: 1px solid var(--border); - border-radius: 14px; - background: var(--page); - box-shadow: 0 24px 70px rgba(31, 37, 44, .22); - color: var(--text); -} -.settings-dialog::backdrop { background: rgba(20, 26, 32, .28); backdrop-filter: blur(2px); } -.settings-header { display: flex; height: 58px; align-items: center; justify-content: space-between; padding: 0 20px 0 24px; border-bottom: 1px solid var(--border); } -.settings-header strong { font-size: 16px; font-weight: 650; } -.settings-close { display: grid; width: 30px; height: 30px; place-items: center; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: 22px; line-height: 1; transition: background-color 120ms ease, color 120ms ease; } -.settings-close:hover { background: var(--sidebar-hover); color: var(--text); } -.settings-body { display: grid; min-height: 402px; grid-template-columns: 190px minmax(0, 1fr); } -.settings-nav { padding: 18px 12px; border-right: 1px solid var(--border); } -.settings-nav-item { display: flex; width: 100%; height: 40px; align-items: center; gap: 9px; padding: 0 12px; border: 0; border-radius: 9px; background: transparent; color: var(--text); font-size: 13px; text-align: left; transition: background-color 120ms ease; } -.settings-nav-item.active, .settings-nav-item:hover { background: #eef1f4; } -.settings-nav-icon { display: grid; width: 18px; height: 18px; place-items: center; color: var(--muted); font-size: 15px; } -.settings-content { padding: 28px 30px; } -.settings-content h2 { margin: 0 0 22px; font-size: 15px; font-weight: 650; } -.settings-row { display: flex; min-height: 72px; align-items: center; justify-content: space-between; gap: 24px; padding: 16px 0; border-top: 1px solid var(--border); } -.settings-row-first { padding-top: 4px; border-top: 0; } -.settings-row strong, .settings-row small { display: block; } -.settings-row strong { font-size: 13px; font-weight: 550; } -.settings-row small { margin-top: 4px; color: var(--subtle); font-size: 11px; } -.language-picker { position: relative; flex: 0 0 auto; } -.language-picker-trigger { display: inline-flex; min-width: 112px; height: 36px; align-items: center; justify-content: space-between; gap: 12px; padding: 0 12px 0 14px; border: 0; border-radius: 18px; background: #f4f5f6; color: var(--text); font-size: 12px; transition: background-color 120ms ease; } -.language-picker-trigger:hover, .language-picker-trigger[aria-expanded="true"] { background: #eceff1; } -.language-picker-chevron { width: 14px; height: 14px; color: var(--muted); transition: transform 120ms ease; } -.language-picker-trigger[aria-expanded="true"] .language-picker-chevron { transform: rotate(180deg); } -.language-menu { position: absolute; top: calc(100% + 5px); right: 0; z-index: 3; width: 150px; padding: 4px; border: 1px solid var(--border); border-radius: 10px; background: var(--page); box-shadow: 0 10px 24px rgba(31, 37, 44, .14); } -.language-menu[hidden] { display: none; } -.language-menu button { display: flex; width: 100%; height: 34px; align-items: center; justify-content: space-between; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: var(--text); font-size: 12px; text-align: left; transition: background-color 120ms ease; } -.language-menu button:hover { background: #f1f3f5; } -.language-menu button[aria-selected="true"] { background: #f4f5f6; } -.language-check { visibility: hidden; font-size: 16px; } -.language-menu button[aria-selected="true"] .language-check { visibility: visible; } body.sidebar-collapsed .session-sidebar { align-items: center; padding: 8px; } body.sidebar-collapsed .sidebar-brand { position: relative; width: 40px; justify-content: center; padding: 0; } body.sidebar-collapsed .sidebar-brand .brand-word, body.sidebar-collapsed .new-session-button span, body.sidebar-collapsed .workspace-heading-label, body.sidebar-collapsed .workspace-tree, -body.sidebar-collapsed .session-search, body.sidebar-collapsed .settings-button span { display: none; } +body.sidebar-collapsed .session-search { display: none; } body.sidebar-collapsed .sidebar-brand .collapse-button { position: absolute; inset: 7px 5px auto; z-index: 2; } body.sidebar-collapsed .sidebar-brand .collapse-button .icon { display: none; } body.sidebar-collapsed .sidebar-brand .collapse-button:hover .icon { display: block; } @@ -457,7 +399,6 @@ body.sidebar-collapsed .new-session-button:hover { background: var(--sidebar-hov body.sidebar-collapsed .workspace-heading { width: 36px; min-height: 36px; justify-content: center; padding: 0; } body.sidebar-collapsed .workspace-actions { flex-direction: column; gap: 4px; } body.sidebar-collapsed .icon-button { width: 36px; height: 36px; } -body.sidebar-collapsed .settings-button { width: 36px; justify-content: center; padding: 0; } .conversation-shell { position: relative; display: grid; min-width: 0; min-height: 0; grid-template-rows: minmax(0, 1fr) auto; background: transparent; } .conversation-shell.landing { grid-template-rows: minmax(0, 1fr); } @@ -912,14 +853,12 @@ body.sidebar-collapsed .settings-button { width: 36px; justify-content: center; } .workspace-menu[open], .model-menu:not([hidden]), -.language-menu:not([hidden]), .workspace-picker-menu:not([hidden]) { animation: menu-pop 130ms cubic-bezier(.22, 1, .36, 1); } @keyframes dialog-in { from { opacity: 0; transform: translateY(8px) scale(.98); } } -.settings-dialog[open], .workspace-rename-dialog[open], .workspace-delete-dialog[open] { animation: dialog-in 180ms cubic-bezier(.22, 1, .36, 1); @@ -934,18 +873,24 @@ body.sidebar-collapsed .settings-button { width: 36px; justify-content: center; transform: translateX(-100%); box-shadow: 12px 0 32px rgba(31, 37, 44, .16); transition: transform 180ms ease; + /* An off-canvas element with backdrop-filter makes Chromium blur the + whole page backdrop; the narrow drawer stays opaque instead. */ + border-right: 1px solid var(--border); + background: var(--sidebar); + backdrop-filter: none; + -webkit-backdrop-filter: none; } + .session-sidebar::before { display: none; } body.sidebar-open .session-sidebar { transform: translateX(0); } body.sidebar-collapsed .session-sidebar { align-items: stretch; padding: 8px 10px; } body.sidebar-collapsed .sidebar-brand .brand-word, body.sidebar-collapsed .workspace-heading-label, body.sidebar-collapsed .workspace-tree, body.sidebar-collapsed .session-search, - body.sidebar-collapsed .settings-button span, body.sidebar-collapsed .new-session-button span { display: initial; } + body.sidebar-collapsed .new-session-button span { display: initial; } body.sidebar-collapsed .sidebar-brand { width: auto; justify-content: space-between; padding: 0 4px 0 6px; } body.sidebar-collapsed .sidebar-brand .collapse-button { position: static; } body.sidebar-collapsed .sidebar-brand .collapse-button .icon { display: block; } body.sidebar-collapsed .new-session-button { width: calc(100% - 4px); margin: 8px 2px 10px; } body.sidebar-collapsed .workspace-heading { width: auto; justify-content: space-between; padding: 0 4px 0 8px; } - body.sidebar-collapsed .settings-button { width: 100%; justify-content: flex-start; padding: 0 8px; } .sidebar-scrim { position: fixed; inset: 0; z-index: 10; border: 0; background: rgba(20, 26, 32, .28); } body.sidebar-open .sidebar-scrim { display: block; } .conversation-shell { grid-template-rows: minmax(0, 1fr) auto; } @@ -961,19 +906,14 @@ body.sidebar-collapsed .settings-button { width: 36px; justify-content: center; .landing-welcome { transform: translateY(-96px); } .composer { min-height: 104px; } .composer-hint { display: none; } - .settings-dialog { width: calc(100% - 24px); min-height: 0; max-height: calc(100dvh - 24px); } - .settings-body { min-height: 0; grid-template-columns: 1fr; } - .settings-nav { padding: 10px 12px; border-right: 0; border-bottom: 1px solid var(--border); } - .settings-nav-item { height: 36px; } - .settings-content { padding: 22px 20px 26px; } } @media (prefers-reduced-motion: reduce) { .app-shell, .session-sidebar, .workspace-chevron { transition: none; } .message-actions, .turn-rail, .turn-tick-mark { transition: none; } .conversation { scroll-behavior: auto; } - .workspace-menu, .model-menu, .language-menu, .workspace-picker-menu, - .settings-dialog, .workspace-rename-dialog, .workspace-delete-dialog { animation: none; } + .workspace-menu, .model-menu, .workspace-picker-menu, + .workspace-rename-dialog, .workspace-delete-dialog { animation: none; } /* Skip the collision and land directly on the final OpenPI mark. */ .landing-brand strong, .landing-brand .brand-word, .landing-brand .pixel-mark, .landing-brand .pixel-mark i, @@ -1023,117 +963,3 @@ body.sidebar-collapsed .settings-button { width: 36px; justify-content: center; top: auto; bottom: calc(100% + 6px); } - -/* ------------------------------ Themes ------------------------------ */ - -/* Clean White: pure surfaces, no grid. */ -html[data-theme="white"] { - --sidebar-hover: #eff1f4; - --sidebar-active: #e8eaee; - --warm-accent: #eff1f4; - --warm-accent-hover: #e4e7eb; - --warm-accent-disabled: #eff1f4; -} -html[data-theme="white"] body { background-color: #ffffff; background-image: none; } -html[data-theme="white"] .session-sidebar { background-color: rgba(255, 255, 255, .6); background-image: none; box-shadow: none; } -html[data-theme="white"] .session-sidebar::before { display: none; } -html[data-theme="white"] .conversation-shell:not(.landing) { - background-image: linear-gradient(to right, rgba(255, 255, 255, 0), rgba(255, 255, 255, .9) 90px, rgba(255, 255, 255, .9) calc(100% - 90px), rgba(255, 255, 255, 0)); -} -html[data-theme="white"] .message-row.user .message-body { box-shadow: inset 0 0 0 1px rgba(120, 130, 140, .18); } - -/* Midnight: classic dark, with pi.dev's dark engineering grid. */ -html[data-theme="dark"] { - color-scheme: dark; - --page: #161b22; - --text: #e6edf3; - --muted: #9aa4af; - --subtle: #7d8590; - --border: #2d333b; - --border-strong: #3d444d; - --sidebar-hover: rgba(56, 63, 71, .65); - --sidebar-active: rgba(56, 63, 71, .85); - --healthy: #3fb27f; - --error: #e5534b; - --warm-accent: rgba(56, 63, 71, .5); - --warm-accent-hover: rgba(66, 74, 83, .8); - --warm-accent-disabled: rgba(56, 63, 71, .5); -} -html[data-theme="dark"] ::selection { background: rgba(76, 136, 207, .35); } -html[data-theme="dark"] body { - background-color: #0d1116; - background-image: - linear-gradient(to bottom, rgba(13, 17, 22, .35), rgba(13, 17, 22, 0) 40%, rgba(13, 17, 22, 0) 72%, rgba(13, 17, 22, .6)), - linear-gradient(hsl(218 60% 80% / .015) 0 1px, transparent 1px 4px), - linear-gradient(to right, hsl(218 60% 80% / .015) 0 1px, transparent 1px 4px), - linear-gradient(hsl(218 60% 80% / .045) 0 1px, transparent 1px 20px), - linear-gradient(to right, hsl(218 60% 80% / .045) 0 1px, transparent 1px 20px), - radial-gradient(circle at center, transparent 2px, #0d1116 2px 100%), - linear-gradient(hsl(218 60% 80% / .12) 0 1px, transparent 1px 20px), - linear-gradient(to right, hsl(218 60% 80% / .12) 0 1px, transparent 1px 20px); - background-size: - 100% 100%, - 4px 4px, - 4px 4px, - 20px 20px, - 20px 20px, - 20px 20px, - 20px 20px, - 20px 20px; - background-position: - 0 0, - 0 0, - 0 0, - 0 0, - 0 0, - -10px -10px, - 0 0, - 0 0; -} -html[data-theme="dark"] .session-sidebar { - border-right-color: rgba(48, 54, 61, .65); - background-color: rgba(13, 17, 22, .5); - background-image: linear-gradient(to bottom, rgba(255, 255, 255, .05), rgba(255, 255, 255, 0) 36%); - box-shadow: none; -} -html[data-theme="dark"] .session-sidebar::before { - background-image: - linear-gradient(rgba(230, 237, 243, .05) 0 1px, transparent 1px 4px), - linear-gradient(to right, rgba(230, 237, 243, .05) 0 1px, transparent 1px 4px), - linear-gradient(rgba(230, 237, 243, .09) 0 1px, transparent 1px 20px), - linear-gradient(to right, rgba(230, 237, 243, .09) 0 1px, transparent 1px 20px); -} -html[data-theme="dark"] .conversation-shell:not(.landing) { - background-image: linear-gradient(to right, rgba(13, 17, 22, 0), rgba(13, 17, 22, .88) 90px, rgba(13, 17, 22, .88) calc(100% - 90px), rgba(13, 17, 22, 0)); -} -html[data-theme="dark"] .new-session-button { background: #161b22; } -html[data-theme="dark"] .new-session-button:hover { background: #1c2129; border-color: #3d444d; } -html[data-theme="dark"] .session-search input { background: rgba(13, 17, 22, .4); border-color: #2d333b; } -html[data-theme="dark"] .session-search input:focus { background: #161b22; box-shadow: 0 0 0 3px rgba(76, 136, 207, .25); } -html[data-theme="dark"] .message-body { color: #d4dbe2; } -html[data-theme="dark"] .message-row.user .message-body { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .08); } -html[data-theme="dark"] .message-body.markdown code { border-color: rgba(61, 68, 77, .6); background: #21262d; color: #d4dbe2; } -html[data-theme="dark"] .message-body.markdown pre { background: rgba(22, 27, 34, .9); } -html[data-theme="dark"] .message-body.markdown pre code { color: #e6edf3; } -html[data-theme="dark"] .message-body.markdown th { background: #21262d; } -html[data-theme="dark"] .details-body { background: rgba(22, 27, 34, .88); } -html[data-theme="dark"] .message-details summary:hover, -html[data-theme="dark"] .tool-group > summary:hover { background: rgba(56, 63, 71, .5); } -html[data-theme="dark"] .tool-group { background: rgba(22, 27, 34, .5); } -html[data-theme="dark"] .activity-card { background: rgba(22, 27, 34, .6); } -html[data-theme="dark"] .activity-chip { background: rgba(22, 27, 34, .78); } -html[data-theme="dark"] .turn-rail:hover, html[data-theme="dark"] .turn-rail:focus-within { background: rgba(22, 27, 34, .92); } -html[data-theme="dark"] .turn-tick:hover { background: #2d333b; } -html[data-theme="dark"] .message-copy:hover { background: rgba(56, 63, 71, .6); } -html[data-theme="dark"] .composer { box-shadow: 0 8px 26px rgba(0, 0, 0, .35); } -html[data-theme="dark"] .composer:focus-within { border-color: #3d444d; box-shadow: 0 0 0 2px rgba(76, 136, 207, .25), 0 8px 26px rgba(0, 0, 0, .35); } -html[data-theme="dark"] .model-picker:not(:disabled):hover, html[data-theme="dark"] .model-picker[aria-expanded="true"] { background: #21262d; } -html[data-theme="dark"] .settings-nav-item.active, html[data-theme="dark"] .settings-nav-item:hover { background: #21262d; } -html[data-theme="dark"] .language-picker-trigger { background: #21262d; } -html[data-theme="dark"] .language-picker-trigger:hover, html[data-theme="dark"] .language-picker-trigger[aria-expanded="true"] { background: #2d333b; } -html[data-theme="dark"] .workspace-menu button:hover, -html[data-theme="dark"] .language-menu button:hover, -html[data-theme="dark"] .model-menu button:hover, -html[data-theme="dark"] .model-menu button[aria-selected="true"], -html[data-theme="dark"] .workspace-picker-menu button:hover { background: #2d333b; } -html[data-theme="dark"] .dialog-actions button { background: #161b22; }