diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ea827b0bb..a2def8e57 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,6 +15,8 @@ ## Checklist - [ ] `npm run verify` passes locally (lint + typecheck + tests + comment-policy gate) +- [ ] If desktop UI changed: `npm run verify:desktop` passes +- [ ] If desktop UI changed: buttons, popovers, sidebars, and chat auto-scroll were checked at narrow desktop widths - [ ] No `Co-Authored-By: Claude` trailer in commits - [ ] Comments follow CONTRIBUTING.md (no module-essay headers, no incident history) - [ ] No edits to `CHANGELOG.md` — release notes are maintainer-written at release time diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd36fcd29..e63d38c8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,19 +23,31 @@ jobs: with: node-version: ${{ matrix.node }} cache: npm + cache-dependency-path: | + package-lock.json + desktop/package-lock.json - name: Install dependencies run: npm ci + - name: Install desktop dependencies + run: npm ci --prefix desktop + - name: Lint (biome) run: npm run lint - name: Typecheck run: npm run typecheck + - name: Cache guard + run: npm run cache:guard + - name: Build (tsup + dashboard) run: npm run build + - name: Build desktop + run: npm run build:desktop + - name: Test (vitest + coverage) run: npm run test:coverage diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index d7126ba91..264c007ca 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -166,6 +166,7 @@ export type PendingRevision = { export type UsageStats = { totalCostUsd: number; + turnCostUsd: number; totalPromptTokens: number; totalCompletionTokens: number; cacheHitTokens: number; @@ -319,6 +320,13 @@ function nextMessageTurn(messages: ChatMessage[]): number { return lastTurn + 1; } +function isIncomingUserNewTurn(state: State, turn: number): boolean { + return ( + !state.busy || + !state.messages.some((m) => (m.kind === "user" || m.kind === "assistant") && m.turn === turn) + ); +} + function reduce(state: State, action: Action): State { return withElidedTranscript(reduceRaw(state, action)); } @@ -329,6 +337,7 @@ function reduceRaw(state: State, action: Action): State { return { ...state, busy: true, + usage: { ...state.usage, turnCostUsd: 0 }, messages: [ ...state.messages, { kind: "user", text: action.text, clientId: action.clientId, turn: nextMessageTurn(state.messages) }, @@ -341,6 +350,7 @@ function reduceRaw(state: State, action: Action): State { ...state, busy: true, activeSkill: action.skill, + usage: { ...state.usage, turnCostUsd: 0 }, messages: [ ...state.messages, { @@ -548,6 +558,7 @@ function mergeSessionFiles(existing: SessionFile[], adds: SessionFile[]): Sessio function zeroUsage(): UsageStats { return { totalCostUsd: 0, + turnCostUsd: 0, totalPromptTokens: 0, totalCompletionTokens: 0, cacheHitTokens: 0, @@ -581,16 +592,20 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { if (state.busy && last?.kind === "user" && last.text === ev.text) { return state; } + const turn = ev.turn > 0 ? ev.turn : nextMessageTurn(state.messages); return { ...state, busy: true, + usage: isIncomingUserNewTurn(state, turn) + ? { ...state.usage, turnCostUsd: 0 } + : state.usage, messages: [ ...state.messages, { kind: "user", text: ev.text, clientId: `remote-${ev.id}`, - turn: ev.turn > 0 ? ev.turn : nextMessageTurn(state.messages), + turn, }, ], }; @@ -751,6 +766,7 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { totalCompletionTokens: ev.totalCompletionTokens, cacheHitTokens: ev.cacheHitTokens, cacheMissTokens: ev.cacheMissTokens, + turnCostUsd: empty ? 0 : state.usage.turnCostUsd, lastCallCacheHit: empty ? null : state.usage.lastCallCacheHit, lastCallCacheMiss: empty ? null : state.usage.lastCallCacheMiss, }, @@ -956,6 +972,7 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { const hasCall = callHit > 0 || callMiss > 0; const usage: UsageStats = { totalCostUsd: state.usage.totalCostUsd + (ev.costUsd ?? 0), + turnCostUsd: state.usage.turnCostUsd + (ev.costUsd ?? 0), totalPromptTokens: state.usage.totalPromptTokens + (u?.prompt_tokens ?? 0), totalCompletionTokens: state.usage.totalCompletionTokens + (u?.completion_tokens ?? 0), cacheHitTokens: state.usage.cacheHitTokens + callHit, diff --git a/dashboard/src/i18n/de.ts b/dashboard/src/i18n/de.ts index 73ab4df3b..de0ac2beb 100644 --- a/dashboard/src/i18n/de.ts +++ b/dashboard/src/i18n/de.ts @@ -1306,6 +1306,7 @@ export const de: typeof en = { themeStyleGlacier: "Gletscher", themeStyleMidnight: "Mitternacht", thisTurn: "Dieser Turn", + session: "Sitzung", tokens: "Tokens", }, thread: { diff --git a/dashboard/src/i18n/en.ts b/dashboard/src/i18n/en.ts index bc047d945..95bc8c07d 100644 --- a/dashboard/src/i18n/en.ts +++ b/dashboard/src/i18n/en.ts @@ -1258,6 +1258,7 @@ export const en = { themeStyleGlacier: "Glacier", themeStyleMidnight: "Midnight", thisTurn: "This turn", + session: "Session", tokens: "Tokens", }, thread: { diff --git a/dashboard/src/i18n/zh-CN.ts b/dashboard/src/i18n/zh-CN.ts index 33078958f..c84a4b6de 100644 --- a/dashboard/src/i18n/zh-CN.ts +++ b/dashboard/src/i18n/zh-CN.ts @@ -1233,6 +1233,7 @@ export const zhCN = { themeStyleGlacier: "冰川", themeStyleMidnight: "午夜", thisTurn: "本轮", + session: "会话", tokens: "Tokens", }, thread: { diff --git a/dashboard/src/ui/statusbar.tsx b/dashboard/src/ui/statusbar.tsx index ed257d5dc..5d72b536e 100644 --- a/dashboard/src/ui/statusbar.tsx +++ b/dashboard/src/ui/statusbar.tsx @@ -70,7 +70,8 @@ export function StatusBar({ ? `${usage.cacheHitTokens.toLocaleString()} / ${totalTokens.toLocaleString()} tokens (${cacheHitPctDisplay}%)` : ""; const runningJobs = jobs.filter((j) => j.running).length; - const spent = formatMoney(usage.totalCostUsd, currency); + const turnSpent = formatMoney(usage.turnCostUsd, currency); + const sessionSpent = formatMoney(usage.totalCostUsd, currency); const balanceLabel = balance ? `${balance.currency === "USD" ? "$" : "¥"} ${balance.total.toFixed(2)}` : "—"; @@ -113,7 +114,12 @@ export function StatusBar({ {t("statusbar.thisTurn")} - {spent} + {turnSpent} + + + + {t("statusbar.session")} + {sessionSpent} diff --git a/desktop/src/App.test.ts b/desktop/src/App.test.ts index 5cf003f22..0c7e23662 100644 --- a/desktop/src/App.test.ts +++ b/desktop/src/App.test.ts @@ -52,6 +52,7 @@ function initialState(): Parameters[0] { activePlan: null, usage: { totalCostUsd: 0, + turnCostUsd: 0, totalPromptTokens: 0, totalCompletionTokens: 0, cacheHitTokens: 0, @@ -136,7 +137,7 @@ function makePathPrompt( } describe("Desktop App reducer — usage", () => { - it("falls back prompt tokens to cache miss tokens when cache fields are absent", () => { + it("falls back prompt tokens to cache miss tokens and tracks turn cost", () => { const next = reduce(initialState(), { t: "incoming", event: { @@ -151,9 +152,61 @@ describe("Desktop App reducer — usage", () => { }); expect(next.usage.totalPromptTokens).toBe(1234); + expect(next.usage.turnCostUsd).toBe(0.001); expect(next.usage.cacheHitTokens).toBe(0); expect(next.usage.cacheMissTokens).toBe(1234); expect(next.usage.lastCallCacheMiss).toBe(1234); + + const accumulated = reduce(next, { + t: "incoming", + event: { + type: "model.final", + id: 2, + ts: "2026-05-27T00:00:01.000Z", + turn: 1, + content: "ok again", + usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 }, + costUsd: 0.002, + }, + }); + expect(accumulated.usage.turnCostUsd).toBeCloseTo(0.003, 6); + + const steered = reduce( + { + ...accumulated, + busy: true, + messages: [{ kind: "assistant", turn: 1, segments: [], pending: false }], + }, + { + t: "incoming", + event: { + type: "user.message", + id: 3, + ts: "2026-05-27T00:00:02.000Z", + turn: 1, + text: "same-turn steer", + }, + }, + ); + expect(steered.usage.turnCostUsd).toBeCloseTo(0.003, 6); + + const reset = reduce(accumulated, { + t: "incoming", + event: { + type: "user.message", + id: 4, + ts: "2026-05-27T00:00:03.000Z", + turn: 2, + text: "next turn", + }, + }); + expect(reset.usage.turnCostUsd).toBe(0); + + const localReset = reduce( + { ...accumulated, usage: { ...accumulated.usage, turnCostUsd: 0.004 } }, + { t: "send_user", text: "local next turn", clientId: "local-1" }, + ); + expect(localReset.usage.turnCostUsd).toBe(0); }); it("settles the pending assistant message when an error ends the turn (#1660)", () => { @@ -396,14 +449,14 @@ describe("desktop thread layout", () => { const side = 244; const ctx = 320; - expect( - getThreadMaxWidth({ viewportWidth: 1000, visibleSide: side, visibleCtx: ctx }), - ).toBe(580); - expect( - getThreadMaxWidth({ viewportWidth: 1400, visibleSide: side, visibleCtx: ctx }), - ).toBe(756); - expect( - getThreadMaxWidth({ viewportWidth: 1800, visibleSide: side, visibleCtx: ctx }), - ).toBe(1120); + expect(getThreadMaxWidth({ viewportWidth: 1000, visibleSide: side, visibleCtx: ctx })).toBe( + 580, + ); + expect(getThreadMaxWidth({ viewportWidth: 1400, visibleSide: side, visibleCtx: ctx })).toBe( + 756, + ); + expect(getThreadMaxWidth({ viewportWidth: 1800, visibleSide: side, visibleCtx: ctx })).toBe( + 1120, + ); }); }); diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 286a4868e..27b31e887 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -7,22 +7,55 @@ import { requestPermission as requestNotificationPermission, sendNotification, } from "@tauri-apps/plugin-notification"; +import { openUrl } from "@tauri-apps/plugin-opener"; import { relaunch } from "@tauri-apps/plugin-process"; import { type Update, check } from "@tauri-apps/plugin-updater"; -import { useCallback, useEffect, useReducer, useRef, useState } from "react"; +import { + type ReactNode, + forwardRef, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; +import { type ListRange, type ScrollerProps, Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import { CommandPalette, Toast, buildCommands, useCommandPalette } from "./CommandPalette"; import { WorkspaceProvider } from "./Markdown"; -import { - nextAbortDraftCandidate, - restoreAbortedDraft, - type AbortDraftSource, -} from "./abort-draft"; +import { type AbortDraftSource, nextAbortDraftCandidate, restoreAbortedDraft } from "./abort-draft"; import { getLang, getLangLabel, getSupportedLangs, setLang, t, useLang } from "./i18n"; import { I } from "./icons"; import { + type ApprovalSnapshot, + deriveDesktopNotifications, + dispatchDesktopNotifications, + shouldShowCompletionToast, +} from "./notifications"; +import type { + CheckpointVerdict, + ChoiceVerdict, + ConfirmationChoice, + ExternalSessionApp, + ExternalSessionSource, + ImportedMcpServer, + IncomingEvent, + JobInfo, + McpSpecInfo, + MemoryDetail, + MemoryEntryInfo, + OutgoingCommand, + PlanVerdict, + PromptHistoryCursor, + RevisionVerdict, + SettingsPatch, + SkillInfo, +} from "./protocol"; +import type { QQDesktopSettingsState } from "./qq-settings"; +import { + type SlashSettingsCommand, buildSlashSettingsDescriptors, parseSlashSettingsCommand, - type SlashSettingsCommand, } from "./slash-settings"; import { FONT_FAMILY, @@ -41,50 +74,23 @@ import { isThemeStyle, themeForStyle, } from "./theme"; -import type { - CheckpointVerdict, - ChoiceVerdict, - ConfirmationChoice, - ExternalSessionApp, - ExternalSessionSource, - ImportedMcpServer, - IncomingEvent, - JobInfo, - McpSpecInfo, - MemoryDetail, - MemoryEntryInfo, - OutgoingCommand, - PlanVerdict, - PromptHistoryCursor, - RevisionVerdict, - SettingsPatch, - SkillInfo, -} from "./protocol"; -import { type QQDesktopSettingsState } from "./qq-settings"; +import { AboutModal } from "./ui/about"; +import { parseEditResult } from "./ui/cards"; import { Composer, type SlashCmd } from "./ui/composer"; import { ContextPanel, type ContextPanelTab } from "./ui/context-panel"; import { JobsPop } from "./ui/jobs-pop"; +import { JumpBar, type JumpBarItem } from "./ui/jump-bar"; import { useElapsed } from "./ui/live"; -import { AboutModal } from "./ui/about"; import { SettingsModal, type PageId as SettingsPageId } from "./ui/settings"; -import { JumpBar } from "./ui/jump-bar"; -import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; - -import { Sidebar } from "./ui/sidebar"; import { Shortcut, localizeShortcutText, shortcutText } from "./ui/shortcut"; +import { Sidebar } from "./ui/sidebar"; import { Splash, shouldShowSplash } from "./ui/splash"; -import { StatusBar } from "./ui/statusbar"; import { StartupFailure, - coerceStartupFailure, type StartupFailureState, + coerceStartupFailure, } from "./ui/startup-failure"; -import { - dispatchDesktopNotifications, - deriveDesktopNotifications, - shouldShowCompletionToast, - type ApprovalSnapshot, -} from "./notifications"; +import { StatusBar } from "./ui/statusbar"; import { ActivePlanTaskCard, AssistantMsg, @@ -98,18 +104,16 @@ import { TurnDivider, UserMsg, } from "./ui/thread"; -import { WorkdirPop } from "./ui/workdir-pop"; -import { parseEditResult } from "./ui/cards"; -import { useAutoCollapse } from "./ui/useAutoCollapse"; -import { useResizable } from "./ui/useResizable"; -// Auto-scroll handled by Virtuoso followOutput + scrollToIndex (useAutoScroll replaced). -import { useDisableTextAssist } from "./ui/useDisableTextAssist"; import { getThreadMaxWidth } from "./ui/thread-layout"; import { elideTranscriptMessages } from "./ui/transcript-elision"; -import { openUrl } from "@tauri-apps/plugin-opener"; +import { useAutoCollapse } from "./ui/useAutoCollapse"; +import { useDisableTextAssist } from "./ui/useDisableTextAssist"; +import { useResizable } from "./ui/useResizable"; +import { WorkdirPop } from "./ui/workdir-pop"; const RIGHT_SIDEBAR_COLLAPSE_WIDTH = 1120; const LEFT_SIDEBAR_COLLAPSE_WIDTH = 760; +const THREAD_BOTTOM_THRESHOLD = 80; const RESPONSIVE_STAGE = { WIDE: "wide", @@ -119,12 +123,30 @@ const RESPONSIVE_STAGE = { type ResponsiveStage = (typeof RESPONSIVE_STAGE)[keyof typeof RESPONSIVE_STAGE]; +type ApprovalQueueItem = { + key: string; + label: string; + node: ReactNode; +}; + function responsiveStage(width: number): ResponsiveStage { if (width < LEFT_SIDEBAR_COLLAPSE_WIDTH) return RESPONSIVE_STAGE.NARROW; if (width < RIGHT_SIDEBAR_COLLAPSE_WIDTH) return RESPONSIVE_STAGE.COMPACT; return RESPONSIVE_STAGE.WIDE; } +function ThreadTail() { + return
; +} + +function hasScrollableOverflow(el: HTMLElement): boolean { + return el.scrollHeight > el.clientHeight + THREAD_BOTTOM_THRESHOLD; +} + +function isElementAtBottom(el: HTMLElement): boolean { + return el.scrollTop + el.clientHeight >= el.scrollHeight - THREAD_BOTTOM_THRESHOLD; +} + export type AssistantSegment = | { kind: "text"; text: string } | { kind: "reasoning"; text: string } @@ -221,6 +243,7 @@ export type PendingRevision = { export type UsageStats = { totalCostUsd: number; + turnCostUsd: number; totalPromptTokens: number; totalCompletionTokens: number; cacheHitTokens: number; @@ -437,9 +460,7 @@ function fallbackSkillDesc(skill: SkillInfo): string { ? t("app.skill.scope.global") : t("app.skill.scope.project"); const runAs = - skill.runAs === "subagent" - ? t("app.skill.runAs.subagent") - : t("app.skill.runAs.inline"); + skill.runAs === "subagent" ? t("app.skill.runAs.subagent") : t("app.skill.runAs.inline"); return t("app.skill.generic", { scope, runAs }); } @@ -451,6 +472,13 @@ function nextMessageTurn(messages: ChatMessage[]): number { return lastTurn + 1; } +function isIncomingUserNewTurn(state: State, turn: number): boolean { + return ( + !state.busy || + !state.messages.some((m) => (m.kind === "user" || m.kind === "assistant") && m.turn === turn) + ); +} + let _errSeq = 0; function nextErrorId(): string { _errSeq += 1; @@ -467,9 +495,15 @@ function reduceRaw(state: State, action: Action): State { return { ...state, busy: true, + usage: { ...state.usage, turnCostUsd: 0 }, messages: [ ...state.messages, - { kind: "user", text: action.text, clientId: action.clientId, turn: nextMessageTurn(state.messages) }, + { + kind: "user", + text: action.text, + clientId: action.clientId, + turn: nextMessageTurn(state.messages), + }, ], }; } @@ -479,6 +513,7 @@ function reduceRaw(state: State, action: Action): State { ...state, busy: true, activeSkill: action.skill, + usage: { ...state.usage, turnCostUsd: 0 }, messages: [ ...state.messages, { @@ -621,9 +656,7 @@ function reduceRaw(state: State, action: Action): State { case "dismiss_error": return { ...state, - messages: state.messages.filter( - (m) => !(m.kind === "error" && m.id === action.id), - ), + messages: state.messages.filter((m) => !(m.kind === "error" && m.id === action.id)), }; case "mention_results": return { ...state, mentionResults: action.results }; @@ -698,16 +731,13 @@ function DiffStats({ stats }: { stats: FileStats }) { const total = stats.entries.length; return (
- @@ -780,6 +810,7 @@ function mergeSessionFiles(existing: SessionFile[], adds: SessionFile[]): Sessio function zeroUsage(): UsageStats { return { totalCostUsd: 0, + turnCostUsd: 0, totalPromptTokens: 0, totalCompletionTokens: 0, cacheHitTokens: 0, @@ -810,16 +841,20 @@ export function applyIncoming(state: State, ev: IncomingEvent): State { function applyIncomingRaw(state: State, ev: IncomingEvent): State { switch (ev.type) { case "user.message": { + const turn = ev.turn > 0 ? ev.turn : nextMessageTurn(state.messages); return { ...state, busy: true, + usage: isIncomingUserNewTurn(state, turn) + ? { ...state.usage, turnCostUsd: 0 } + : state.usage, messages: [ ...state.messages, { kind: "user", text: ev.text, clientId: `remote-${ev.id}`, - turn: ev.turn > 0 ? ev.turn : nextMessageTurn(state.messages), + turn, }, ], }; @@ -1122,10 +1157,7 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { ...state.messages, { kind: "error", - message: - `Session "${ev.name}" loaded with no messages (${sizeNote}). ` + - `The file ~/.reasonix/sessions/${ev.name}.jsonl exists but couldn't be parsed — ` + - `start a new chat or restore from .jsonl.bak if you have one.`, + message: `Session "${ev.name}" loaded with no messages (${sizeNote}). The file ~/.reasonix/sessions/${ev.name}.jsonl exists but couldn't be parsed — start a new chat or restore from .jsonl.bak if you have one.`, id: nextErrorId(), }, ], @@ -1179,8 +1211,10 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { const m = state.messages[i]!; if (m.kind !== "assistant" || m.turn !== ev.turn) continue; let updated = m; - if (ev.channel === "content") updated = { ...m, segments: appendTextSegment(m.segments, "text", ev.text) }; - else if (ev.channel === "reasoning") updated = { ...m, segments: appendTextSegment(m.segments, "reasoning", ev.text) }; + if (ev.channel === "content") + updated = { ...m, segments: appendTextSegment(m.segments, "text", ev.text) }; + else if (ev.channel === "reasoning") + updated = { ...m, segments: appendTextSegment(m.segments, "reasoning", ev.text) }; const next = [...state.messages]; next[i] = updated; return { ...state, messages: next }; @@ -1190,13 +1224,13 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { case "model.final": { const u = ev.usage; const promptTokens = - u?.prompt_tokens ?? - (u?.prompt_cache_hit_tokens ?? 0) + (u?.prompt_cache_miss_tokens ?? 0); + u?.prompt_tokens ?? (u?.prompt_cache_hit_tokens ?? 0) + (u?.prompt_cache_miss_tokens ?? 0); const callHit = u?.prompt_cache_hit_tokens ?? 0; const callMiss = u?.prompt_cache_miss_tokens ?? Math.max(0, promptTokens - callHit); const hasCall = promptTokens > 0 || callHit > 0 || callMiss > 0; const usage: UsageStats = { totalCostUsd: state.usage.totalCostUsd + (ev.costUsd ?? 0), + turnCostUsd: state.usage.turnCostUsd + (ev.costUsd ?? 0), totalPromptTokens: state.usage.totalPromptTokens + promptTokens, totalCompletionTokens: state.usage.totalCompletionTokens + (u?.completion_tokens ?? 0), cacheHitTokens: state.usage.cacheHitTokens + callHit, @@ -1208,18 +1242,19 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { }; // Walk backwards to clear pending flag on the matching assistant let settledPending = false; + let messages = state.messages; for (let i = state.messages.length - 1; i >= 0; i--) { const m = state.messages[i]!; if (m.kind !== "assistant" || m.turn !== ev.turn) continue; if (m.pending) { const s = [...state.messages]; s[i] = { ...m, pending: false }; - state = { ...state, messages: s }; + messages = s; } settledPending = true; break; } - return settledPending ? { ...state, usage } : { ...state, usage }; + return settledPending ? { ...state, messages, usage } : { ...state, usage }; } case "tool.preparing": { for (let i = state.messages.length - 1; i >= 0; i--) { @@ -1227,7 +1262,19 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { if (m.kind !== "assistant" || m.turn !== ev.turn) continue; if (m.segments.some((s) => s.kind === "tool" && s.callId === ev.callId)) return state; const next = [...state.messages]; - next[i] = { ...m, segments: [...m.segments, { kind: "tool" as const, callId: ev.callId, name: ev.name, args: "", startedAt: Date.now() }] }; + next[i] = { + ...m, + segments: [ + ...m.segments, + { + kind: "tool" as const, + callId: ev.callId, + name: ev.name, + args: "", + startedAt: Date.now(), + }, + ], + }; return { ...state, messages: next }; } return state; @@ -1241,13 +1288,26 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { const idx = m.segments.findIndex((s) => s.kind === "tool" && s.callId === ev.callId); if (idx >= 0) { const segs = [...m.segments]; - if (segs[idx]?.kind === "tool") segs[idx] = { ...(segs[idx] as AssistantSegment & { kind: "tool" }), args: ev.args }; + if (segs[idx]?.kind === "tool") + segs[idx] = { ...(segs[idx] as AssistantSegment & { kind: "tool" }), args: ev.args }; const msgs = [...nextState.messages]; msgs[i] = { ...m, segments: segs }; nextState = { ...nextState, messages: msgs }; } else { const msgs = [...nextState.messages]; - msgs[i] = { ...m, segments: [...m.segments, { kind: "tool" as const, callId: ev.callId, name: ev.name, args: ev.args, startedAt: Date.now() }] }; + msgs[i] = { + ...m, + segments: [ + ...m.segments, + { + kind: "tool" as const, + callId: ev.callId, + name: ev.name, + args: ev.args, + startedAt: Date.now(), + }, + ], + }; nextState = { ...nextState, messages: msgs }; } break; @@ -1279,10 +1339,7 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State { return { ...state, busy: false, - messages: [ - ...state.messages, - { kind: "status", text: `≫ btw\n${ev.answer}` }, - ], + messages: [...state.messages, { kind: "status", text: `≫ btw\n${ev.answer}` }], }; case "status": return state; @@ -1335,7 +1392,11 @@ function formatConversationMarkdown(messages: ChatMessage[], userLabel: string): } function sanitizeFilename(name: string): string { - return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").replace(/^\.+/, "").slice(0, 200) || "session"; + const cleaned = Array.from(name, (ch) => { + const code = ch.charCodeAt(0); + return code < 32 || '<>:"/\\|?*'.includes(ch) ? "_" : ch; + }).join(""); + return cleaned.replace(/^\.+/, "").slice(0, 200) || "session"; } function defaultExportFilename(session: string): string { @@ -1473,17 +1534,20 @@ function TabRuntime({ { top?: number; bottom?: number; left: number } | undefined >(undefined); const composerRef = useRef(null); - const threadRef = useRef(null); const virtuosoRef = useRef(null); - const virtScrollerRef = useRef(null); - const atBottomRef = useRef(true); + const virtuosoScrollerRef = useRef(null); + const [virtuosoScroller, setVirtuosoScroller] = useState(null); + const autoFollowRef = useRef(true); + const userDetachedScrollRef = useRef(false); + const scrollFrameRef = useRef(0); + const scrollBusyRef = useRef(false); + const restoredScrollSessionRef = useRef(null); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsPage, setSettingsPage] = useState("general"); - const [mcpEditTarget, setMcpEditTarget] = useState<{ raw: string; nonce: number } | null>( - null, - ); + const [mcpEditTarget, setMcpEditTarget] = useState<{ raw: string; nonce: number } | null>(null); const [jobsOpen, setJobsOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false); + const [approvalTrayExpanded, setApprovalTrayExpanded] = useState(false); const [contextPanelTab, setContextPanelTab] = useState("files"); const [contextPanelTabNonce, setContextPanelTabNonce] = useState(0); const previousApprovalSnapshotRef = useRef({ @@ -1518,6 +1582,26 @@ function TabRuntime({ setSettingsOpen(true); }, []); const palette = useCommandPalette(active); + const VirtuosoScroller = useMemo( + () => + forwardRef(function VirtuosoScroller(props, ref) { + const setScrollerRef = useCallback( + (node: HTMLDivElement | null) => { + virtuosoScrollerRef.current = node; + setVirtuosoScroller(node); + if (typeof ref === "function") { + ref(node); + } else if (ref) { + ref.current = node; + } + }, + [ref], + ); + + return
; + }), + [], + ); useEffect(() => { registerDispatch(tabId, dispatch); @@ -1642,13 +1726,10 @@ function TabRuntime({ } }, [clearAbortDraft, saveSettings, state.settings?.workspaceDir]); - const flashToast = useCallback( - (msg: string, opts?: { yolo?: boolean; duration?: number }) => { - setToast({ msg, yolo: opts?.yolo }); - window.setTimeout(() => setToast(null), opts?.duration ?? 1600); - }, - [], - ); + const flashToast = useCallback((msg: string, opts?: { yolo?: boolean; duration?: number }) => { + setToast({ msg, yolo: opts?.yolo }); + window.setTimeout(() => setToast(null), opts?.duration ?? 1600); + }, []); const importCcSwitchMcp = useCallback(async () => { try { @@ -1771,10 +1852,7 @@ function TabRuntime({ const handle = await webview.onDragDropEvent((event) => { if (!dropActiveRef.current) return; if (event.payload.type === "enter") { - document.body.style.setProperty( - "--drop-overlay-label", - `"${t("dragDrop.overlay")}"`, - ); + document.body.style.setProperty("--drop-overlay-label", `"${t("dragDrop.overlay")}"`); document.body.dataset.dragOver = "1"; return; } @@ -1875,7 +1953,12 @@ function TabRuntime({ const clientId = `skill-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const trimmedArgs = args?.trim() ?? ""; recordAbortDraft("skill_run", text); - dispatch({ t: "start_skill", skill: { name: skill.name, runAs: skill.runAs }, args: trimmedArgs, clientId }); + dispatch({ + t: "start_skill", + skill: { name: skill.name, runAs: skill.runAs }, + args: trimmedArgs, + clientId, + }); sendRpc({ cmd: "skill_run", name: skill.name, args: trimmedArgs || undefined }); if (!override) setDraft(""); return; @@ -1922,21 +2005,26 @@ function TabRuntime({ }, [clearAbortDraft, resetPromptHistoryNav]); // When /retry returns the last user text, set it as the composer draft + const retryTextRef = useRef(state.retryText); + retryTextRef.current = state.retryText; useEffect(() => { - if (state.retryNonce > 0 && state.retryText) { + const retryText = retryTextRef.current; + if (state.retryNonce > 0 && retryText) { resetPromptHistoryNav(); - setDraft(state.retryText); + setDraft(retryText); composerRef.current?.focus(); } // Only fire when retryNonce changes — retryText alone would re-fire on re-renders - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.retryNonce]); + }, [resetPromptHistoryNav, state.retryNonce]); - const onEditUserMsg = useCallback((t: string) => { - resetPromptHistoryNav(); - setDraft(t); - composerRef.current?.focus(); - }, [resetPromptHistoryNav]); + const onEditUserMsg = useCallback( + (t: string) => { + resetPromptHistoryNav(); + setDraft(t); + composerRef.current?.focus(); + }, + [resetPromptHistoryNav], + ); useEffect(() => { if (state.busy || !state.ready || state.queuedSends.length === 0) return; @@ -1977,7 +2065,8 @@ function TabRuntime({ mode: "browsing", draft: prev.mode === "idle" ? request.draft : prev.draft, cursor: result.entry?.cursor ?? null, - originSessionName: prev.mode === "idle" ? (state.currentSession ?? null) : prev.originSessionName, + originSessionName: + prev.mode === "idle" ? (state.currentSession ?? null) : prev.originSessionName, })); requestAnimationFrame(() => { composerRef.current?.focus(); @@ -1986,23 +2075,37 @@ function TabRuntime({ }, [state.promptHistoryResult, state.currentSession]); useEffect(() => { + void state.currentSession; resetPromptHistoryNav(); }, [resetPromptHistoryNav, state.currentSession]); useEffect(() => { const currentSnapshot: ApprovalSnapshot = { confirms: state.pendingConfirms.map((c) => ({ id: c.id, command: c.command })), - pathAccess: state.pendingPathAccess.map((p) => ({ id: p.id, path: p.path, intent: p.intent })), + pathAccess: state.pendingPathAccess.map((p) => ({ + id: p.id, + path: p.path, + intent: p.intent, + })), choices: state.pendingChoices.map((c) => ({ id: c.id, question: c.question })), plans: state.pendingPlans.map((p) => ({ id: p.id, summary: p.summary, plan: p.plan })), - checkpoints: state.pendingCheckpoints.map((c) => ({ id: c.id, title: c.title, result: c.result })), - revisions: state.pendingRevisions.map((r) => ({ id: r.id, summary: r.summary, reason: r.reason })), + checkpoints: state.pendingCheckpoints.map((c) => ({ + id: c.id, + title: c.title, + result: c.result, + })), + revisions: state.pendingRevisions.map((r) => ({ + id: r.id, + summary: r.summary, + reason: r.reason, + })), }; const previousSnapshot = previousApprovalSnapshotRef.current; const wasBusy = wasBusyRef.current; - const busyDurationMs = wasBusy && !state.busy && busyStartedAtRef.current - ? Date.now() - busyStartedAtRef.current - : 0; + const busyDurationMs = + wasBusy && !state.busy && busyStartedAtRef.current + ? Date.now() - busyStartedAtRef.current + : 0; if (state.busy && busyStartedAtRef.current === null) { busyStartedAtRef.current = Date.now(); @@ -2053,6 +2156,18 @@ function TabRuntime({ state.pendingRevisions, ]); + const pendingApprovalCount = + state.pendingPlans.length + + state.pendingCheckpoints.length + + state.pendingRevisions.length + + state.pendingConfirms.length + + state.pendingPathAccess.length + + state.pendingChoices.length; + + useEffect(() => { + if (pendingApprovalCount <= 1) setApprovalTrayExpanded(false); + }, [pendingApprovalCount]); + const resolveConfirm = useCallback( (id: number, response: ConfirmationChoice) => { sendRpc({ cmd: "confirm_response", id, response }); @@ -2108,50 +2223,249 @@ function TabRuntime({ [sendRpc], ); + // Read the latest session inside the stable restore callback below. + const currentSessionRef = useRef(state.currentSession); + currentSessionRef.current = state.currentSession; const messageItems = state.messages; + const [activeJumpTurn, setActiveJumpTurn] = useState(null); + const jumpItems = useMemo( + () => + messageItems.flatMap((message, index) => + message.kind === "user" + ? [{ index, turn: message.turn, text: message.text.slice(0, 80) }] + : [], + ), + [messageItems], + ); + + useEffect(() => { + if (jumpItems.length === 0) { + setActiveJumpTurn(null); + return; + } + setActiveJumpTurn((current) => + current !== null && jumpItems.some((item) => item.turn === current) + ? current + : jumpItems[jumpItems.length - 1]!.turn, + ); + }, [jumpItems]); + + const restoreScrollTop = useCallback(() => { + const session = currentSessionRef.current; + if (!session) return null; + const raw = localStorage.getItem(`reasonix.scroll.${session}`); + const n = raw ? Number(raw) : Number.NaN; + return Number.isFinite(n) ? n : null; + }, []); const [showJumpButton, setShowJumpButton] = useState(false); + const refreshJumpButton = useCallback(() => { + const el = virtuosoScrollerRef.current; + if (!el) return; + setShowJumpButton( + userDetachedScrollRef.current && hasScrollableOverflow(el) && !isElementAtBottom(el), + ); + }, []); + const handleJumpBarJump = useCallback( + (item: JumpBarItem) => { + setActiveJumpTurn(item.turn); + autoFollowRef.current = false; + userDetachedScrollRef.current = true; + setShowJumpButton(item.index < messageItems.length - 1); + virtuosoRef.current?.scrollToIndex({ + index: item.index, + align: "start", + behavior: "smooth", + }); + window.requestAnimationFrame(refreshJumpButton); + }, + [messageItems.length, refreshJumpButton], + ); + const handleThreadRangeChanged = useCallback( + (range: ListRange) => { + if (messageItems.length === 0) { + setActiveJumpTurn(null); + return; + } - // Reserve scroll to bottom when busy becomes true (message just sent). - const busyPrevRef = useRef(state.busy); - useEffect(() => { - if (state.busy && !busyPrevRef.current) { - atBottomRef.current = true; + let nextTurn: number | null = null; + const startIndex = Math.max(0, Math.min(range.startIndex, messageItems.length - 1)); + for (let index = startIndex; index >= 0; index--) { + const message = messageItems[index]; + if (message?.kind === "user") { + nextTurn = message.turn; + break; + } + } + if (nextTurn === null) { + for (let index = startIndex + 1; index <= range.endIndex; index++) { + const message = messageItems[index]; + if (message?.kind === "user") { + nextTurn = message.turn; + break; + } + } + } + setActiveJumpTurn((current) => (current === nextTurn ? current : nextTurn)); + }, + [messageItems], + ); + + const scrollToBottom = useCallback( + (smooth = true) => { + autoFollowRef.current = true; + userDetachedScrollRef.current = false; setShowJumpButton(false); - } - busyPrevRef.current = state.busy; - }, [state.busy]); - - const scrollToBottom = useCallback(() => { - const len = messageItems.length; - if (len > 0) { - const scroller = virtScrollerRef.current; - if (scroller) { - scroller.scrollTop = scroller.scrollHeight; - } else { - virtuosoRef.current?.scrollToIndex({ index: len - 1, behavior: "auto" }); + + const lastIndex = messageItems.length - 1; + if (lastIndex >= 0) { + virtuosoRef.current?.scrollToIndex({ + index: lastIndex, + align: "end", + behavior: smooth ? "smooth" : "auto", + }); } + + const el = virtuosoScrollerRef.current; + if (el) { + el.scrollTo({ + top: el.scrollHeight, + behavior: smooth ? "smooth" : "auto", + }); + } + }, + [messageItems.length], + ); + + const scheduleScrollToBottom = useCallback( + (smooth = false) => { + if (scrollFrameRef.current) cancelAnimationFrame(scrollFrameRef.current); + scrollFrameRef.current = requestAnimationFrame(() => { + scrollFrameRef.current = 0; + scrollToBottom(smooth); + }); + }, + [scrollToBottom], + ); + + const handleAtBottomStateChange = useCallback( + (atBottom: boolean) => { + if (atBottom) { + autoFollowRef.current = true; + userDetachedScrollRef.current = false; + setShowJumpButton(false); + return; + } + refreshJumpButton(); + }, + [refreshJumpButton], + ); + + const followOutput = useCallback((isAtBottom: boolean) => { + return isAtBottom || autoFollowRef.current ? "auto" : false; + }, []); + + useEffect(() => { + const el = virtuosoScroller; + if (!el) return; + + let pendingFrame = 0; + const markUserScrollIntent = () => { + if (pendingFrame) cancelAnimationFrame(pendingFrame); + pendingFrame = requestAnimationFrame(() => { + pendingFrame = 0; + const atBottom = isElementAtBottom(el); + autoFollowRef.current = atBottom; + userDetachedScrollRef.current = !atBottom; + refreshJumpButton(); + }); + }; + + let draggingScrollbar = false; + const onPointerDown = () => { + draggingScrollbar = true; + markUserScrollIntent(); + el.addEventListener("scroll", markUserScrollIntent, { passive: true }); + }; + const onPointerEnd = () => { + if (!draggingScrollbar) return; + draggingScrollbar = false; + el.removeEventListener("scroll", markUserScrollIntent); + markUserScrollIntent(); + }; + + el.addEventListener("wheel", markUserScrollIntent, { passive: true }); + el.addEventListener("touchmove", markUserScrollIntent, { passive: true }); + el.addEventListener("keydown", markUserScrollIntent); + el.addEventListener("pointerdown", onPointerDown); + window.addEventListener("pointerup", onPointerEnd); + window.addEventListener("pointercancel", onPointerEnd); + + return () => { + if (pendingFrame) cancelAnimationFrame(pendingFrame); + el.removeEventListener("wheel", markUserScrollIntent); + el.removeEventListener("touchmove", markUserScrollIntent); + el.removeEventListener("keydown", markUserScrollIntent); + el.removeEventListener("pointerdown", onPointerDown); + el.removeEventListener("scroll", markUserScrollIntent); + window.removeEventListener("pointerup", onPointerEnd); + window.removeEventListener("pointercancel", onPointerEnd); + }; + }, [refreshJumpButton, virtuosoScroller]); + + useEffect(() => { + if (scrollBusyRef.current !== state.busy) { + if (state.busy || !userDetachedScrollRef.current) { + scheduleScrollToBottom(state.busy); + } + scrollBusyRef.current = state.busy; } - }, [messageItems.length]); + }, [scheduleScrollToBottom, state.busy]); - // Follow the bottom while the assistant is streaming and the user hasn't - // scrolled up. The dependency on messageItems.length covers new messages; - // atBottomRef guards against re-pinning when the user intentionally scrolled - // up to read earlier content (#2159). useEffect(() => { - const s = virtScrollerRef.current; - if (!s || messageItems.length === 0) return; - if (!atBottomRef.current) return; - const id = requestAnimationFrame(() => { - if (atBottomRef.current) s.scrollTop = s.scrollHeight; - }); - return () => cancelAnimationFrame(id); - }, [messageItems]); + if (messageItems.length === 0) return; + if (!autoFollowRef.current) { + refreshJumpButton(); + return; + } + scheduleScrollToBottom(false); + }, [messageItems, refreshJumpButton, scheduleScrollToBottom]); + + useEffect(() => { + return () => { + if (scrollFrameRef.current) cancelAnimationFrame(scrollFrameRef.current); + }; + }, []); + + useEffect(() => { + const el = virtuosoScroller; + const sessionKey = state.currentSession ?? "__new__"; + if (!el || restoredScrollSessionRef.current === sessionKey) return; + restoredScrollSessionRef.current = sessionKey; + const id = window.setTimeout(() => { + const restore = restoreScrollTop(); + if (restore != null && restore > THREAD_BOTTOM_THRESHOLD) { + autoFollowRef.current = false; + userDetachedScrollRef.current = true; + el.scrollTop = restore; + refreshJumpButton(); + return; + } + scheduleScrollToBottom(false); + }, 80); + return () => window.clearTimeout(id); + }, [ + refreshJumpButton, + restoreScrollTop, + scheduleScrollToBottom, + state.currentSession, + virtuosoScroller, + ]); // Persist the transcript scroll offset per session so a restart reopens // the conversation where the user left it (#1244). useEffect(() => { - const el = virtScrollerRef.current; + const el = virtuosoScroller; const session = state.currentSession; if (!el || !session) return; const key = `reasonix.scroll.${session}`; @@ -2169,7 +2483,7 @@ function TabRuntime({ el.removeEventListener("scroll", onScroll); clearTimeout(timer); }; - }, [state.currentSession]); + }, [state.currentSession, virtuosoScroller]); useEffect(() => { if (!active) return; @@ -2330,7 +2644,12 @@ function TabRuntime({ composerRef.current?.focus(); }, }, - { cmd: "/new", desc: t("app.cmd.newSession"), run: () => newChat(), kb: shortcutText(["mod", "N"]) }, + { + cmd: "/new", + desc: t("app.cmd.newSession"), + run: () => newChat(), + kb: shortcutText(["mod", "N"]), + }, { cmd: "/clear", desc: t("app.cmd.clearChat"), run: () => clearConversation() }, { cmd: "/abort", desc: t("app.cmd.abort"), run: () => abort(), kb: "esc" }, { @@ -2500,9 +2819,88 @@ function TabRuntime({ flashToast(t("app.toast.copiedMd")); }, [state.messages, flashToast]); + const approvalItems: ApprovalQueueItem[] = [ + ...state.pendingPlans.map((p) => ({ + key: `pp-${p.id}`, + label: t("thread.planConfirmationKind"), + node: ( + resolvePlan(p.id, { type: "approve" })} + onRefine={() => resolvePlan(p.id, { type: "refine" })} + onCancel={() => resolvePlan(p.id, { type: "cancel" })} + /> + ), + })), + ...state.pendingCheckpoints.map((c) => ({ + key: `cp-${c.id}`, + label: t("thread.checkpointKind"), + node: ( + resolveCheckpoint(c.id, { type: "continue" })} + onRevise={() => resolveCheckpoint(c.id, { type: "revise" })} + onStop={() => resolveCheckpoint(c.id, { type: "stop" })} + /> + ), + })), + ...state.pendingRevisions.map((r) => ({ + key: `rv-${r.id}`, + label: t("thread.planRevisionKind"), + node: ( + resolveRevision(r.id, { type: "accepted" })} + onReject={() => resolveRevision(r.id, { type: "rejected" })} + /> + ), + })), + ...state.pendingConfirms.map((c) => ({ + key: `cc-${c.id}`, + label: t("thread.shellConfirmationKind"), + node: ( + resolveConfirm(c.id, { type: "run_once" })} + onAlwaysAllow={(prefix) => resolveConfirm(c.id, { type: "always_allow", prefix })} + onDeny={() => resolveConfirm(c.id, { type: "deny" })} + /> + ), + })), + ...state.pendingPathAccess.map((p) => ({ + key: `pa-${p.id}`, + label: t("thread.pathAccessKind"), + node: ( + resolvePathAccess(p.id, { type: "run_once" })} + onAlwaysAllow={(prefix) => resolvePathAccess(p.id, { type: "always_allow", prefix })} + onDeny={() => resolvePathAccess(p.id, { type: "deny" })} + /> + ), + })), + ...state.pendingChoices.map((c) => ({ + key: `ch-${c.id}`, + label: t("thread.userChoiceKind"), + node: ( + resolveChoice(c.id, { type: "pick", optionId })} + onCancel={() => resolveChoice(c.id, { type: "cancel" })} + /> + ), + })), + ]; + const visibleApprovalItems = approvalTrayExpanded ? approvalItems : approvalItems.slice(0, 1); + const hiddenApprovalCount = Math.max(approvalItems.length - visibleApprovalItems.length, 0); + return (
- { - const idx = state.messages.findIndex((m) => (m.kind === "user" || m.kind === "assistant") && m.turn === turn); - if (idx >= 0) virtuosoRef.current?.scrollToIndex(idx); - }} /> {state.needsSetup ? ( -
+
+ {state.messages.length === 0 ? (
s.cmd === cmd); - if (match) { match.run(); return; } + if (match) { + match.run(); + return; + } } send(text); }} @@ -2634,31 +3032,29 @@ function TabRuntime({ style={{ height: "90%" }} className="virtuoso-scroll" totalCount={messageItems.length} - followOutput={"auto"} - initialTopMostItemIndex={messageItems.length > 0 ? messageItems.length - 1 : undefined} - scrollerRef={(ref) => { virtScrollerRef.current = ref as HTMLDivElement | null; }} - atBottomStateChange={(atBottom) => { atBottomRef.current = atBottom; setShowJumpButton(!atBottom); }} + alignToBottom + followOutput={followOutput} + rangeChanged={handleThreadRangeChanged} + atBottomThreshold={THREAD_BOTTOM_THRESHOLD} + atBottomStateChange={handleAtBottomStateChange} + increaseViewportBy={{ top: 360, bottom: 720 }} + overscan={{ main: 240, reverse: 240 }} components={{ - Header: state.activePlan ? () => ( -
- dispatch({ t: "dismiss_plan" })} - /> - -
- ) : undefined, - Footer: () => ( -
- {state.pendingPlans.map((p) => resolvePlan(p.id, { type: "approve" })} onRefine={() => resolvePlan(p.id, { type: "refine" })} onCancel={() => resolvePlan(p.id, { type: "cancel" })} />)} - {state.pendingCheckpoints.map((c) => resolveCheckpoint(c.id, { type: "continue" })} onRevise={() => resolveCheckpoint(c.id, { type: "revise" })} onStop={() => resolveCheckpoint(c.id, { type: "stop" })} />)} - {state.pendingRevisions.map((r) => resolveRevision(r.id, { type: "accepted" })} onReject={() => resolveRevision(r.id, { type: "rejected" })} />)} - {state.pendingConfirms.map((c) => resolveConfirm(c.id, { type: "run_once" })} onAlwaysAllow={(prefix) => resolveConfirm(c.id, { type: "always_allow", prefix })} onDeny={() => resolveConfirm(c.id, { type: "deny" })} />)} - {state.pendingPathAccess.map((p) => resolvePathAccess(p.id, { type: "run_once" })} onAlwaysAllow={(prefix) => resolvePathAccess(p.id, { type: "always_allow", prefix })} onDeny={() => resolvePathAccess(p.id, { type: "deny" })} />)} - {state.pendingChoices.map((c) => resolveChoice(c.id, { type: "pick", optionId })} onCancel={() => resolveChoice(c.id, { type: "cancel" })} />)} - {!state.ready ?
{t("app.connecting")}
: null} -
- ), + Scroller: VirtuosoScroller, + Footer: ThreadTail, + Header: state.activePlan + ? () => ( +
+ dispatch({ t: "dismiss_plan" }) + } + /> + +
+ ) + : undefined, }} itemContent={(index) => { const m = state.messages[index]!; @@ -2689,18 +3085,41 @@ function TabRuntime({ } if (m.kind === "error") { const toneVar = m.recoverable ? "var(--tone-warn)" : "var(--tone-err)"; - const bgVar = m.recoverable ? "var(--warn-soft, var(--danger-soft))" : "var(--danger-soft)"; + const bgVar = m.recoverable + ? "var(--warn-soft, var(--danger-soft))" + : "var(--danger-soft)"; const labelKey = m.recoverable ? "app.warningLabel" : "app.errorLabel"; return ( -
- +
+ + +
{t(labelKey)}
{m.message}
-
@@ -2722,8 +3141,9 @@ function TabRuntime({ )} {showJumpButton ? (
+ {approvalItems.length > 0 || !state.ready ? ( +
+
+ + + {approvalItems[0]?.label ?? t("app.connecting")} + + {pendingApprovalCount > 1 ? ( + {pendingApprovalCount} + ) : null} + + {approvalItems.length > 1 ? ( + + ) : null} +
+
+ {visibleApprovalItems.map((item) => ( +
+ {item.node} +
+ ))} + {!approvalTrayExpanded && hiddenApprovalCount > 0 ? ( + + ) : null} + {!state.ready ? ( +
{t("app.connecting")}
+ ) : null} +
+
+ ) : null} + + Minimize ); @@ -2921,23 +3390,66 @@ function WinMinimize() { function WinMaximize() { return ( - + Maximize + ); } function WinRestore() { return ( - - + Restore + + ); } function WinClose() { return ( - - + Close + + ); } @@ -2982,13 +3494,21 @@ function TitleBar({ }; void syncWindowState(); let unlisten: (() => void) | undefined; - win.listen("tauri://resize", async () => { - await syncWindowState(); - }).then((fn) => { unlisten = fn; }); + win + .listen("tauri://resize", async () => { + await syncWindowState(); + }) + .then((fn) => { + unlisten = fn; + }); let fullscreenUnlisten: (() => void) | undefined; - win.listen("tauri://fullscreen", async () => { - await syncWindowState(); - }).then((fn) => { fullscreenUnlisten = fn; }); + win + .listen("tauri://fullscreen", async () => { + await syncWindowState(); + }) + .then((fn) => { + fullscreenUnlisten = fn; + }); return () => { unlisten?.(); fullscreenUnlisten?.(); @@ -3101,43 +3621,100 @@ function TitleBar({ {menuOpen ? (
-
{ onOpenCommands(); setMenuOpen(false); }}> - -
{t("app.titlebar.commandPalette")}
+
-
+
-
+ + +
+ {t("app.titlebar.copyMd")} +
+ +
-
{ onClear(); setMenuOpen(false); }}> - -
{t("app.titlebar.clearChat")}
-
-
{ onOpenSettings(); setMenuOpen(false); }}> - -
{t("app.titlebar.settings")}
+ + + +
+ {t("app.titlebar.exportMd")} +
+ + +
+
) : null} @@ -3150,7 +3727,10 @@ function TitleBar({ type="button" className="win-ctrl" title={t("app.titlebar.minimize")} - onMouseDown={(e) => { e.stopPropagation(); win.minimize(); }} + onMouseDown={(e) => { + e.stopPropagation(); + win.minimize(); + }} > @@ -3158,7 +3738,10 @@ function TitleBar({ type="button" className="win-ctrl" title={isMaximized ? t("app.titlebar.restore") : t("app.titlebar.maximize")} - onMouseDown={(e) => { e.stopPropagation(); void toggleWindowExpanded(win, false, isMaximized); }} + onMouseDown={(e) => { + e.stopPropagation(); + void toggleWindowExpanded(win, false, isMaximized); + }} > {isMaximized ? : } @@ -3166,7 +3749,10 @@ function TitleBar({ type="button" className="win-ctrl close" title={t("app.titlebar.close")} - onMouseDown={(e) => { e.stopPropagation(); win.close(); }} + onMouseDown={(e) => { + e.stopPropagation(); + win.close(); + }} > @@ -3193,6 +3779,7 @@ function TabBar({ singleTab?: boolean; }) { useLang(); + const closeLabel = t("app.titlebar.close"); return (
{tabs.map((t) => { @@ -3203,33 +3790,37 @@ function TabBar({ .split(/[\\/]/) .pop() || "workspace"; return ( -
setActive(t.id)} - title={ws || label} - > - - {label} +
+ {!singleTab ? ( - { e.stopPropagation(); onClose(t.id); }} + title={closeLabel} + aria-label={closeLabel} > - + ) : null}
); })} -
+
+
); } @@ -3274,17 +3865,17 @@ function MainHead({ ) : null}
- { const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); onOpenWorkdir({ top: r.bottom + 6, left: r.left }); }} - style={{ cursor: "pointer" }} title={workspaceDir ?? t("app.header.clickToSelect")} > {wsLabel} - + {model ? ( {model} @@ -3488,7 +4079,7 @@ function UpdateOverlay({ }) { useLang(); const ratio = - progress && progress.total && progress.total > 0 + progress?.total && progress.total > 0 ? Math.min(1, progress.downloaded / progress.total) : null; const statusText = @@ -3680,7 +4271,7 @@ export function App() { const stack = fontFamily === FONT_FAMILY.CUSTOM && custom ? custom - : FONT_FAMILY_STACK[fontFamily] ?? FONT_FAMILY_STACK.sans; + : (FONT_FAMILY_STACK[fontFamily] ?? FONT_FAMILY_STACK.sans); document.documentElement.style.setProperty("--font-sans", stack); localStorage.setItem("reasonix.fontFamily", fontFamily); localStorage.setItem("reasonix.customFontFamily", customFontFamily); @@ -3786,7 +4377,7 @@ export function App() { } }; - const setup = async () => { + const setup = async (_retryAttempt: number) => { startupStderrRef.current = []; setStartupFailure(null); const subs = await Promise.all([ @@ -3920,7 +4511,7 @@ export function App() { } } }; - void setup(); + void setup(startupRetryNonce); return () => { cancelled = true; for (const c of cleanups) c(); diff --git a/desktop/src/CodeView.tsx b/desktop/src/CodeView.tsx index 661f21323..d42abfbbc 100644 --- a/desktop/src/CodeView.tsx +++ b/desktop/src/CodeView.tsx @@ -4,13 +4,25 @@ import { useEffect, useRef, useState } from "react"; const DARK_THEME: PrismTheme = { plain: { color: "#dde1ea", backgroundColor: "transparent" }, styles: [ - { types: ["comment", "prolog", "doctype", "cdata"], style: { color: "#6d6e80", fontStyle: "italic" } }, + { + types: ["comment", "prolog", "doctype", "cdata"], + style: { color: "#6d6e80", fontStyle: "italic" }, + }, { types: ["punctuation"], style: { color: "#a8a9b8" } }, - { types: ["property", "tag", "boolean", "number", "constant", "symbol", "deleted"], style: { color: "#fbbf24" } }, - { types: ["selector", "attr-name", "string", "char", "builtin", "inserted"], style: { color: "#86dcb1" } }, + { + types: ["property", "tag", "boolean", "number", "constant", "symbol", "deleted"], + style: { color: "#fbbf24" }, + }, + { + types: ["selector", "attr-name", "string", "char", "builtin", "inserted"], + style: { color: "#86dcb1" }, + }, { types: ["operator", "entity", "url"], style: { color: "#84b9e8" } }, { types: ["atrule", "attr-value", "keyword"], style: { color: "#b4a8f0" } }, - { types: ["function", "class-name", "maybe-class-name"], style: { color: "#84b9e8", fontWeight: "500" } }, + { + types: ["function", "class-name", "maybe-class-name"], + style: { color: "#84b9e8", fontWeight: "500" }, + }, { types: ["regex", "important", "variable"], style: { color: "#f0c062" } }, { types: ["important", "bold"], style: { fontWeight: "bold" } }, { types: ["italic"], style: { fontStyle: "italic" } }, @@ -20,13 +32,25 @@ const DARK_THEME: PrismTheme = { const LIGHT_THEME: PrismTheme = { plain: { color: "#24292e", backgroundColor: "transparent" }, styles: [ - { types: ["comment", "prolog", "doctype", "cdata"], style: { color: "#6a737d", fontStyle: "italic" } }, + { + types: ["comment", "prolog", "doctype", "cdata"], + style: { color: "#6a737d", fontStyle: "italic" }, + }, { types: ["punctuation"], style: { color: "#24292e" } }, - { types: ["property", "tag", "boolean", "number", "constant", "symbol", "deleted"], style: { color: "#d73a49" } }, - { types: ["selector", "attr-name", "string", "char", "builtin", "inserted"], style: { color: "#032f62" } }, + { + types: ["property", "tag", "boolean", "number", "constant", "symbol", "deleted"], + style: { color: "#d73a49" }, + }, + { + types: ["selector", "attr-name", "string", "char", "builtin", "inserted"], + style: { color: "#032f62" }, + }, { types: ["operator", "entity", "url"], style: { color: "#d73a49" } }, { types: ["atrule", "attr-value", "keyword"], style: { color: "#d73a49" } }, - { types: ["function", "class-name", "maybe-class-name"], style: { color: "#6f42c1", fontWeight: "500" } }, + { + types: ["function", "class-name", "maybe-class-name"], + style: { color: "#6f42c1", fontWeight: "500" }, + }, { types: ["regex", "important", "variable"], style: { color: "#e36209" } }, { types: ["important", "bold"], style: { fontWeight: "bold" } }, { types: ["italic"], style: { fontStyle: "italic" } }, @@ -56,37 +80,82 @@ function usePrismTheme(): PrismTheme { export const PRISM_THEME = DARK_THEME; +function hashString(value: string): string { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + return `${value.length}-${(hash >>> 0).toString(36)}`; +} + +function keyed(items: readonly T[], keyFor: (item: T) => string): { item: T; key: string }[] { + const seen = new Map(); + return items.map((item) => { + const base = keyFor(item); + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + return { item, key: count === 0 ? base : `${base}-${count}` }; + }); +} + const EXTS: Record = { - ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript", - js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript", - py: "python", pyi: "python", + ts: "typescript", + tsx: "tsx", + mts: "typescript", + cts: "typescript", + js: "javascript", + jsx: "jsx", + mjs: "javascript", + cjs: "javascript", + py: "python", + pyi: "python", rs: "rust", go: "go", - json: "json", jsonc: "json", - md: "markdown", mdx: "markdown", - css: "css", scss: "scss", less: "less", - html: "markup", htm: "markup", xml: "markup", svg: "markup", - yaml: "yaml", yml: "yaml", + json: "json", + jsonc: "json", + md: "markdown", + mdx: "markdown", + css: "css", + scss: "scss", + less: "less", + html: "markup", + htm: "markup", + xml: "markup", + svg: "markup", + yaml: "yaml", + yml: "yaml", toml: "toml", - sh: "bash", bash: "bash", zsh: "bash", fish: "bash", + sh: "bash", + bash: "bash", + zsh: "bash", + fish: "bash", sql: "sql", rb: "ruby", - java: "java", kt: "kotlin", + java: "java", + kt: "kotlin", swift: "swift", - c: "c", h: "c", - cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", hxx: "cpp", + c: "c", + h: "c", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + hpp: "cpp", + hxx: "cpp", cs: "csharp", php: "php", lua: "lua", dart: "dart", - ex: "elixir", exs: "elixir", + ex: "elixir", + exs: "elixir", erl: "erlang", hs: "haskell", - clj: "clojure", cljs: "clojure", + clj: "clojure", + cljs: "clojure", zig: "zig", vue: "markup", svelte: "markup", - graphql: "graphql", gql: "graphql", + graphql: "graphql", + gql: "graphql", proto: "protobuf", dockerfile: "docker", }; @@ -117,14 +186,16 @@ export function CodeView({ {({ className, tokens, getLineProps, getTokenProps }) => (
-          {tokens.map((line, i) => (
-            
- {showLineNumbers && ( - {i + startLine} - )} + {keyed(tokens, (line) => + hashString(line.map((token) => `${token.types.join(".")}:${token.content}`).join("|")), + ).map(({ item: line, key }, i) => ( +
+ {showLineNumbers && {i + startLine}} - {line.map((token, k) => ( - + {keyed(line, (token) => + hashString(`${token.types.join(".")}:${token.content}`), + ).map(({ item: token, key: tokenKey }) => ( + ))}
diff --git a/desktop/src/CommandPalette.tsx b/desktop/src/CommandPalette.tsx index 63f903394..787538ef0 100644 --- a/desktop/src/CommandPalette.tsx +++ b/desktop/src/CommandPalette.tsx @@ -28,7 +28,7 @@ export type Command = { run: () => void; }; -export function useCommandPalette(active: boolean = true) { +export function useCommandPalette(active = true) { const [open, setOpen] = useState(false); useEffect(() => { // Skip in background tabs — each TabRuntime calls this hook, so without the gate Cmd+K toggles every tab's palette at once. @@ -181,10 +181,14 @@ const GROUP_ORDER: CommandGroup[] = ["nav", "action", "workspace", "settings"]; function groupLabel(g: CommandGroup): string { switch (g) { - case "nav": return t("palette.groupNav"); - case "action": return t("palette.groupAction"); - case "workspace": return t("palette.groupWorkspace"); - case "settings": return t("palette.groupSettings"); + case "nav": + return t("palette.groupNav"); + case "action": + return t("palette.groupAction"); + case "workspace": + return t("palette.groupWorkspace"); + case "settings": + return t("palette.groupSettings"); } } @@ -235,9 +239,9 @@ export function CommandPalette({ arr.push(c); byGroup.set(c.group, arr); } - return GROUP_ORDER - .map((g) => ({ group: g, items: byGroup.get(g) ?? [] })) - .filter((s) => s.items.length > 0); + return GROUP_ORDER.map((g) => ({ group: g, items: byGroup.get(g) ?? [] })).filter( + (s) => s.items.length > 0, + ); }, [filtered]); if (!open) return null; @@ -276,16 +280,15 @@ export function CommandPalette({
- {filtered.length === 0 ? ( -
{t("palette.empty")}
- ) : null} + {filtered.length === 0 ?
{t("palette.empty")}
: null} {grouped.map((section) => (
{groupLabel(section.group)}
{section.items.map((c) => { const i = filtered.indexOf(c); return ( -
)} -
+ ); })}
diff --git a/desktop/src/Markdown.tsx b/desktop/src/Markdown.tsx index 6f9cea1b8..1faad3414 100644 --- a/desktop/src/Markdown.tsx +++ b/desktop/src/Markdown.tsx @@ -33,7 +33,12 @@ async function openWithEditor( type TrackedFile = { path: string; status: string }; -type WorkspaceCtx = { dir?: string; editor?: string; sessionFiles?: TrackedFile[]; currentToolPaths?: string[] }; +type WorkspaceCtx = { + dir?: string; + editor?: string; + sessionFiles?: TrackedFile[]; + currentToolPaths?: string[]; +}; const WorkspaceContext = createContext({}); export const WorkspaceProvider = WorkspaceContext.Provider; @@ -41,9 +46,16 @@ export const WorkspaceProvider = WorkspaceContext.Provider; * Wrap children with a WorkspaceProvider that merges currentToolPaths * into the existing context (preserving dir/editor/sessionFiles). */ -export function WithToolPaths({ toolPaths, children }: { toolPaths: string[]; children: React.ReactNode }) { +export function WithToolPaths({ + toolPaths, + children, +}: { toolPaths: string[]; children: React.ReactNode }) { const ctx = useContext(WorkspaceContext); - return {children}; + return ( + + {children} + + ); } /** @@ -56,11 +68,7 @@ export function WithToolPaths({ toolPaths, children }: { toolPaths: string[]; ch * Try to match displayPath against a list of paths (exact → suffix). * Returns the best matching path, or null if none match. */ -function matchPathList( - displayPath: string, - paths: string[], - matchIndex: number = 0, -): string | null { +function matchPathList(displayPath: string, paths: string[], matchIndex = 0): string | null { if (!paths || paths.length === 0) return null; const normalizedDisplay = displayPath.replace(/\\/g, "/"); let exact: string | null = null; @@ -68,8 +76,11 @@ function matchPathList( for (const raw of paths) { const n = raw.replace(/\\/g, "/"); - if (n === normalizedDisplay) { exact = raw; continue; } - if (n.endsWith("/" + normalizedDisplay) || n.endsWith("\\" + normalizedDisplay)) { + if (n === normalizedDisplay) { + exact = raw; + continue; + } + if (n.endsWith(`/${normalizedDisplay}`) || n.endsWith(`\\${normalizedDisplay}`)) { suffixMatches.push(raw); } } @@ -94,7 +105,7 @@ function resolveBySessionFiles( displayPath: string, sessionFiles: TrackedFile[] | undefined, currentToolPaths?: string[], - matchIndex: number = 0, + matchIndex = 0, ): string | null { // Step 1: Try the current message's tool paths first (most specific context) if (currentToolPaths && currentToolPaths.length > 0) { @@ -113,7 +124,7 @@ function resolveAgainstWorkspace( ws: string | undefined, sessionFiles?: TrackedFile[], currentToolPaths?: string[], - matchIndex: number = 0, + matchIndex = 0, ): string { // Step 1: Try suffix matching against tracked session files. // If found, use the tracked path as the base instead of the display text. @@ -205,7 +216,13 @@ function FilePill({ path, line, matchIndex }: { path: string; line?: string; mat const ctx = useContext(WorkspaceContext); const [done, setDone] = useState<"open" | "copy" | null>(null); const display = line ? `${path}:${line}` : path; - const abs = resolveAgainstWorkspace(path, ctx.dir, ctx.sessionFiles, ctx.currentToolPaths, matchIndex); + const abs = resolveAgainstWorkspace( + path, + ctx.dir, + ctx.sessionFiles, + ctx.currentToolPaths, + matchIndex, + ); const openInEditor = async () => { try { await openWithEditor(ctx.editor, abs, firstLine(line)); @@ -232,28 +249,21 @@ function FilePill({ path, line, matchIndex }: { path: string; line?: string; mat } }; return ( - { e.preventDefault(); void copyOnly(e); }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - void openInEditor(); - } - }} title={t("markdown.filePillTitle")} > {path} {line && :{line}} {done && } - + ); } @@ -268,7 +278,9 @@ function splitFilePaths(text: string): ReactNode[] | string { const line = m[3]; const pillStart = m.index + prefix.length; if (pillStart > last) out.push(text.slice(last, pillStart)); - out.push(); + out.push( + , + ); _filePillIndex++; last = pillStart + path.length + (line ? line.length + 1 : 0); m = FILE_PATH_RE.exec(text); @@ -318,7 +330,7 @@ function normalizeMathDelimiters(source: string): string { .replace(/\\\(/g, "$$") .replace(/\\\)/g, "$$"); // Restore protected sequences - result = result.replace(/\x00LB\x00/g, "\\\\["); + result = result.split(LB).join("\\\\["); // Replace | with \vert inside math to prevent GFM table column splitting. // \vert renders identically to | in KaTeX — it's the same vertical-bar @@ -353,7 +365,10 @@ export const Markdown = memo(function Markdown({ source }: { source: string }) { code: ({ className, children }) => { const text = String(children ?? ""); const parsed = !className ? parseFileRef(text.trim()) : null; - if (parsed) return ; + if (parsed) + return ( + + ); return {children}; }, a: ({ href, children }) => {children}, @@ -393,7 +408,13 @@ function SafeLink({ href, children }: { href?: string; children: ReactNode }) { try { const parsed = parseFileHref(href); const target = parsed ?? { path: decodeMaybeUri(stripFileScheme(href)) }; - const abs = resolveAgainstWorkspace(target.path, ctx.dir, ctx.sessionFiles, ctx.currentToolPaths, 0); + const abs = resolveAgainstWorkspace( + target.path, + ctx.dir, + ctx.sessionFiles, + ctx.currentToolPaths, + 0, + ); await openWithEditor(ctx.editor, abs, firstLine(target.line)); } catch { try { diff --git a/desktop/src/i18n/de.ts b/desktop/src/i18n/de.ts index 679e32dd3..16f7ea5a1 100644 --- a/desktop/src/i18n/de.ts +++ b/desktop/src/i18n/de.ts @@ -202,7 +202,8 @@ export const de: typeof en = { webSearchEngineTavily: "tavily — 1000/Monat kostenlos (TAVILY_API_KEY setzen)", webSearchEnginePerplexity: "perplexity — AI-native (PERPLEXITY_API_KEY setzen)", webSearchEngineExa: "exa — AI-native 1000/Monat kostenlos (EXA_API_KEY setzen)", - webSearchEngineBrave: "brave — unabhängiger Index, 2000/Monat kostenlos (BRAVE_SEARCH_API_KEY setzen)", + webSearchEngineBrave: + "brave — unabhängiger Index, 2000/Monat kostenlos (BRAVE_SEARCH_API_KEY setzen)", webSearchEngineOllama: "ollama — Ollama Cloud-Websuche (OLLAMA_API_KEY setzen)", webSearchEngineNote: "gilt für den nächsten web_search-Aufruf", webSearchEndpoint: "SearXNG-Endpunkt", @@ -304,7 +305,8 @@ export const de: typeof en = { "Jede OpenAI-kompatible ID, die dein Endpunkt bereitstellt (vLLM, Ollama, Together, …).", modelCustomActive: "Läuft aktuell auf benutzerdefinierter ID: {model}", contextTokensLabel: "Kontextfenstergröße", - contextTokensHint: "Überschreiben Sie die Prompt-seitige Token-Obergrenze für das aktuelle Modell (z. B. 1000000 für 1M). Leer lassen für den eingebauten Standard.", + contextTokensHint: + "Überschreiben Sie die Prompt-seitige Token-Obergrenze für das aktuelle Modell (z. B. 1000000 für 1M). Leer lassen für den eingebauten Standard.", contextTokensPlaceholder: "Automatisch", effortSection: "Reasoning-Effort", ctxWindow: "Kontext", @@ -667,6 +669,7 @@ export const de: typeof en = { cache: "Cache", tokens: "Tokens", thisTurn: "dieser Turn", + session: "Sitzung", switchWorkspace: "Arbeitsbereich wechseln · {workspace}", switchCurrency: "Währung wechseln (CNY / USD)", balance: "Guthaben", @@ -761,7 +764,8 @@ export const de: typeof en = { importSessionCount: "{count} Sitzungen · importiert alle", importNotFound: "Keine lokalen Sitzungen gefunden", importPrivacyHint: "Bestehende App-Einstellungen bleiben unverändert.", - importResult: "{imported} Sitzung(en) importiert, {skipped} übersprungen, {failed} fehlgeschlagen.", + importResult: + "{imported} Sitzung(en) importiert, {skipped} übersprungen, {failed} fehlgeschlagen.", continue: "Weiter", refresh: "Aktualisieren", importSource: "Quelle", diff --git a/desktop/src/i18n/en.ts b/desktop/src/i18n/en.ts index 059e95b71..bfa22508f 100644 --- a/desktop/src/i18n/en.ts +++ b/desktop/src/i18n/en.ts @@ -296,7 +296,8 @@ export const en = { modelCustomHint: "Any OpenAI-compatible id your endpoint serves (vLLM, Ollama, Together, …).", modelCustomActive: "Currently running on a custom id: {model}", contextTokensLabel: "Context window size", - contextTokensHint: "Override the prompt-side token cap for the current model (e.g. 1000000 for 1M). Leave empty to use the built-in default.", + contextTokensHint: + "Override the prompt-side token cap for the current model (e.g. 1000000 for 1M). Leave empty to use the built-in default.", contextTokensPlaceholder: "auto", effortSection: "Reasoning effort", ctxWindow: "Context", @@ -638,6 +639,7 @@ export const en = { cache: "cache", tokens: "tokens", thisTurn: "this turn", + session: "session", switchWorkspace: "Switch workspace · {workspace}", switchCurrency: "Switch currency (CNY / USD)", balance: "balance", diff --git a/desktop/src/i18n/ja.ts b/desktop/src/i18n/ja.ts index 32981d30d..f5ecc3ea4 100644 --- a/desktop/src/i18n/ja.ts +++ b/desktop/src/i18n/ja.ts @@ -661,6 +661,7 @@ export const ja: typeof en = { cache: "キャッシュ", tokens: "トークン", thisTurn: "このターン", + session: "セッション", switchWorkspace: "ワークスペース切替 · {workspace}", switchCurrency: "通貨切替 (CNY / USD)", balance: "残高", diff --git a/desktop/src/i18n/zh-CN.ts b/desktop/src/i18n/zh-CN.ts index 11f80343c..72998f4a0 100644 --- a/desktop/src/i18n/zh-CN.ts +++ b/desktop/src/i18n/zh-CN.ts @@ -289,7 +289,8 @@ export const zhCN: typeof en = { modelCustomHint: "任何 OpenAI 兼容的 ID(vLLM、Ollama、Together …)。", modelCustomActive: "当前运行自定义 ID:{model}", contextTokensLabel: "上下文窗口大小", - contextTokensHint: "为当前模型覆盖提示侧 token 上限(如 1000000 表示 1M)。留空使用内置默认值。", + contextTokensHint: + "为当前模型覆盖提示侧 token 上限(如 1000000 表示 1M)。留空使用内置默认值。", contextTokensPlaceholder: "自动", effortSection: "推理强度", ctxWindow: "上下文", @@ -439,6 +440,7 @@ export const zhCN: typeof en = { cache: "缓存", tokens: "tokens", thisTurn: "本次", + session: "会话", switchWorkspace: "切换工作区 · {workspace}", switchCurrency: "切换货币 (CNY / USD)", balance: "余额", diff --git a/desktop/src/icons.tsx b/desktop/src/icons.tsx index ade0d397a..b40385c07 100644 --- a/desktop/src/icons.tsx +++ b/desktop/src/icons.tsx @@ -14,6 +14,8 @@ function Ic({ size = 14, children, ...rest }: IconProps & { children: React.Reac strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" + aria-hidden="true" + focusable="false" {...rest} > {children} @@ -22,61 +24,291 @@ function Ic({ size = 14, children, ...rest }: IconProps & { children: React.Reac } export const I = { - plus: (p: IconProps) => (), - search: (p: IconProps) => (), - send: (p: IconProps) => (), - chev: (p: IconProps) => (), - chevR: (p: IconProps) => (), - check: (p: IconProps) => (), - x: (p: IconProps) => (), - pencil: (p: IconProps) => (), - terminal: (p: IconProps) => (), + plus: (p: IconProps) => ( + + + + ), + search: (p: IconProps) => ( + + + + + ), + send: (p: IconProps) => ( + + + + ), + chev: (p: IconProps) => ( + + + + ), + chevR: (p: IconProps) => ( + + + + ), + check: (p: IconProps) => ( + + + + ), + x: (p: IconProps) => ( + + + + ), + pencil: (p: IconProps) => ( + + + + + ), + terminal: (p: IconProps) => ( + + + + + ), brain: (p: IconProps) => ( ), - list: (p: IconProps) => (), - diff: (p: IconProps) => (), - globe: (p: IconProps) => (), - link: (p: IconProps) => (), - wrench: (p: IconProps) => (), - bot: (p: IconProps) => (), - archive: (p: IconProps) => (), - bookmark: (p: IconProps) => (), - warning: (p: IconProps) => (), - zap: (p: IconProps) => (), - database: (p: IconProps) => (), - cpu: (p: IconProps) => (), - coin: (p: IconProps) => (), - file: (p: IconProps) => (), - folder: (p: IconProps) => (), - image: (p: IconProps) => (), - paperclip: (p: IconProps) => (), - mic: (p: IconProps) => (), - sun: (p: IconProps) => (), - moon: (p: IconProps) => (), - panel_l: (p: IconProps) => (), - panel_r: (p: IconProps) => (), - cog: (p: IconProps) => (), - stop: (p: IconProps) => (), - play: (p: IconProps) => (), - more: (p: IconProps) => (), - pin: (p: IconProps) => (), - rotate: (p: IconProps) => (), - branch: (p: IconProps) => (), - at: (p: IconProps) => (), - slash: (p: IconProps) => (), - layers: (p: IconProps) => (), - download: (p: IconProps) => (), - upload: (p: IconProps) => (), - history: (p: IconProps) => (), - shield: (p: IconProps) => (), - warn: (p: IconProps) => (), - help: (p: IconProps) => (), - refresh: (p: IconProps) => (), - copy: (p: IconProps) => (), + list: (p: IconProps) => ( + + + + ), + diff: (p: IconProps) => ( + + + + + + ), + globe: (p: IconProps) => ( + + + + + ), + link: (p: IconProps) => ( + + + + + ), + wrench: (p: IconProps) => ( + + + + ), + bot: (p: IconProps) => ( + + + + + ), + archive: (p: IconProps) => ( + + + + + ), + bookmark: (p: IconProps) => ( + + + + ), + warning: (p: IconProps) => ( + + + + + ), + zap: (p: IconProps) => ( + + + + ), + database: (p: IconProps) => ( + + + + + ), + cpu: (p: IconProps) => ( + + + + + + ), + coin: (p: IconProps) => ( + + + + + ), + file: (p: IconProps) => ( + + + + + ), + folder: (p: IconProps) => ( + + + + ), + image: (p: IconProps) => ( + + + + + + ), + paperclip: (p: IconProps) => ( + + + + ), + mic: (p: IconProps) => ( + + + + + ), + sun: (p: IconProps) => ( + + + + + ), + moon: (p: IconProps) => ( + + + + ), + panel_l: (p: IconProps) => ( + + + + + ), + panel_r: (p: IconProps) => ( + + + + + ), + cog: (p: IconProps) => ( + + + + + ), + stop: (p: IconProps) => ( + + + + ), + play: (p: IconProps) => ( + + + + ), + more: (p: IconProps) => ( + + + + + + ), + pin: (p: IconProps) => ( + + + + ), + rotate: (p: IconProps) => ( + + + + + ), + branch: (p: IconProps) => ( + + + + + + + ), + at: (p: IconProps) => ( + + + + + ), + slash: (p: IconProps) => ( + + + + ), + layers: (p: IconProps) => ( + + + + + ), + download: (p: IconProps) => ( + + + + ), + upload: (p: IconProps) => ( + + + + ), + history: (p: IconProps) => ( + + + + ), + shield: (p: IconProps) => ( + + + + + ), + warn: (p: IconProps) => ( + + + + + ), + help: (p: IconProps) => ( + + + + + ), + refresh: (p: IconProps) => ( + + + + ), + copy: (p: IconProps) => ( + + + + + ), trash: (p: IconProps) => ( diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 6e44cafd1..76aa99fc1 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -12,12 +12,7 @@ import "@fontsource/inter/700.css"; import "katex/dist/katex.min.css"; import { createRoot } from "react-dom/client"; import { App } from "./App"; -import { - defaultStyleForTheme, - isTheme, - isThemeStyle, - themeForStyle, -} from "./theme"; +import { defaultStyleForTheme, isTheme, isThemeStyle, themeForStyle } from "./theme"; const stored = localStorage.getItem("reasonix.theme"); const storedStyle = localStorage.getItem("reasonix.themeStyle"); diff --git a/desktop/src/notifications.test.ts b/desktop/src/notifications.test.ts index 1829a40c5..fcf8d7a45 100644 --- a/desktop/src/notifications.test.ts +++ b/desktop/src/notifications.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; import { setLang } from "./i18n"; import { + type ApprovalSnapshot, COMPLETION_NOTIFY_MIN_MS, deriveDesktopNotifications, - type ApprovalSnapshot, } from "./notifications"; function emptySnapshot(): ApprovalSnapshot { diff --git a/desktop/src/notifications.ts b/desktop/src/notifications.ts index d38634053..ee2124d3e 100644 --- a/desktop/src/notifications.ts +++ b/desktop/src/notifications.ts @@ -115,10 +115,7 @@ export function shouldShowCompletionToast(args: { focused: boolean; }): boolean { return ( - args.focused && - args.wasBusy && - !args.isBusy && - args.busyDurationMs >= COMPLETION_NOTIFY_MIN_MS + args.focused && args.wasBusy && !args.isBusy && args.busyDurationMs >= COMPLETION_NOTIFY_MIN_MS ); } diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 73b51e078..a8800ce31 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -226,11 +226,11 @@ body[data-drag-over="1"]::after { --card: oklch(99.5% 0.003 80); --card-hover: oklch(96.5% 0.009 78); --border: oklch(88% 0.016 76); - --border-strong: oklch(78% 0.020 72); + --border-strong: oklch(78% 0.02 72); --fg: oklch(22% 0.014 55); --fg-2: oklch(36% 0.013 55); --muted: oklch(53% 0.011 60); - --muted-2: oklch(67% 0.010 65); + --muted-2: oklch(67% 0.01 65); --accent: oklch(60% 0.19 38); --accent-soft: oklch(60% 0.19 38 / 0.1); @@ -244,11 +244,11 @@ body[data-drag-over="1"]::after { --danger-soft: oklch(54% 0.2 22 / 0.1); /* warm amber replaces cool violet in light mode */ --violet: oklch(62% 0.16 52); - --violet-soft: oklch(62% 0.16 52 / 0.10); + --violet-soft: oklch(62% 0.16 52 / 0.1); --shadow-sm: 0 1px 0 oklch(30% 0.05 50 / 0.05); --shadow-md: 0 8px 24px -10px oklch(30% 0.05 50 / 0.13); - --shadow-lg: 0 24px 60px -20px oklch(30% 0.05 50 / 0.20); + --shadow-lg: 0 24px 60px -20px oklch(30% 0.05 50 / 0.2); } [data-theme-style="porcelain"] { @@ -561,6 +561,15 @@ html[data-platform="macos"] .app { text-overflow: ellipsis; white-space: nowrap; } +.tab .tab-main { + min-width: 0; + flex: 1; + display: inline-flex; + align-items: center; + gap: 8px; + color: inherit; + text-align: left; +} .tab .close { width: 16px; height: 16px; @@ -596,8 +605,7 @@ html[data-platform="macos"] .app { display: grid; place-items: center; padding: 32px; - background: - radial-gradient(circle at top, var(--accent-soft), transparent 36%), + background: radial-gradient(circle at top, var(--accent-soft), transparent 36%), linear-gradient(180deg, transparent, oklch(0% 0 0 / 0.04)); } @@ -1037,10 +1045,10 @@ html[data-platform="macos"] .titlebar .tb-left { } .side-head { - padding: 10px 12px 8px; + padding: 10px 10px 8px; display: flex; align-items: center; - gap: 8px; + gap: 6px; } .side-head .new-btn { flex: 1; @@ -1049,14 +1057,17 @@ html[data-platform="macos"] .titlebar .tb-left { display: inline-flex; align-items: center; flex-wrap: nowrap; - gap: 8px; - padding: 0 10px; - font-size: 14px; + gap: 6px; + padding: 0 9px; + font-size: 13px; + line-height: 1; font-weight: 500; border-radius: 6px; background: var(--accent); color: oklch(99% 0 0); box-shadow: var(--shadow-sm); + white-space: nowrap; + overflow: hidden; } .side-head .new-btn:hover { background: var(--accent-strong); @@ -1072,7 +1083,7 @@ html[data-platform="macos"] .titlebar .tb-left { } .side-head .new-btn kbd { font-family: inherit; - font-size: 14px; + font-size: 11px; background: oklch(100% 0 0 / 0.18); padding: 1px 5px; border-radius: 3px; @@ -1085,7 +1096,7 @@ html[data-platform="macos"] .titlebar .tb-left { } .side-head .new-btn .shortcut kbd { min-width: 0; - font-size: 14px; + font-size: 11px; font-weight: 500; line-height: inherit; color: inherit; @@ -1095,16 +1106,11 @@ html[data-platform="macos"] .titlebar .tb-left { padding: 1px 5px; box-shadow: none; } -@container sidebar (min-width: 191px) { +@container sidebar (min-width: 272px) { .side-head .new-btn .shortcut { display: inline-flex; } } -@container sidebar (max-width: 240px) { - .side-head .new-btn .shortcut { - display: none; - } -} @container sidebar (max-width: 190px) { .side-head .new-btn > span:not(.shortcut) { display: none; @@ -1594,27 +1600,44 @@ html[data-platform="macos"] .titlebar .tb-left { color: var(--fg-2); cursor: pointer; } -.session-menu .sm-item > svg { flex-shrink: 0; opacity: 0.7; } +.session-menu .sm-item > svg { + flex-shrink: 0; + opacity: 0.7; +} .session-menu .sm-item:hover { background: var(--panel); color: var(--fg); } -.session-menu .sm-item:hover > svg { opacity: 1; } +.session-menu .sm-item:hover > svg { + opacity: 1; +} .session-menu .sm-item:disabled { opacity: 0.35; cursor: not-allowed; } -.session-menu .sm-item.danger { color: var(--danger); } -.session-menu .sm-item.danger > svg { opacity: 0.8; } -.session-menu .sm-item.danger:hover { background: var(--danger-soft); } +.session-menu .sm-item.danger { + color: var(--danger); +} +.session-menu .sm-item.danger > svg { + opacity: 0.8; +} +.session-menu .sm-item.danger:hover { + background: var(--danger-soft); +} .session-menu .sm-sep { height: 1px; background: var(--border); margin: 4px 0; } @keyframes sm-confirm-in { - from { opacity: 0; transform: scale(0.96) translateY(4px); } - to { opacity: 1; transform: none; } + from { + opacity: 0; + transform: scale(0.96) translateY(4px); + } + to { + opacity: 1; + transform: none; + } } .session-menu .sm-confirm { padding: 14px 12px 12px; @@ -1675,18 +1698,24 @@ html[data-platform="macos"] .titlebar .tb-left { background: var(--card); color: var(--fg-2); } -.session-menu .sm-confirm-cancel:hover { background: var(--panel); color: var(--fg); } +.session-menu .sm-confirm-cancel:hover { + background: var(--panel); + color: var(--fg); +} .session-menu .sm-confirm-ok { border: 1px solid transparent; background: var(--danger); color: oklch(99% 0 0); } -.session-menu .sm-confirm-ok:hover { background: oklch(52% 0.22 25); } +.session-menu .sm-confirm-ok:hover { + background: oklch(52% 0.22 25); +} /* ---- session delete confirmation popover (right-click) ---- */ .session-delete-popover { position: fixed; z-index: 80; + margin: 0; background: var(--card); border: 1px solid var(--border-strong); border-radius: 8px; @@ -1694,6 +1723,8 @@ html[data-platform="macos"] .titlebar .tb-left { padding: 12px; min-width: 220px; max-width: 280px; + max-height: min(72vh, 360px); + overflow-y: auto; font-size: 14px; color: var(--fg); animation: rise 0.16s ease-out; @@ -1714,10 +1745,12 @@ html[data-platform="macos"] .titlebar .tb-left { } .session-delete-popover .actions { display: flex; + flex-wrap: wrap; gap: 6px; } .session-delete-popover button { - flex: 1; + flex: 1 1 92px; + min-width: 0; display: inline-flex; align-items: center; justify-content: center; @@ -1728,6 +1761,9 @@ html[data-platform="macos"] .titlebar .tb-left { font-weight: 500; font-family: inherit; cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; transition: background 0.12s ease, color 0.12s ease; } .session-delete-popover button.cancel { @@ -1751,8 +1787,12 @@ html[data-platform="macos"] .titlebar .tb-left { .session-import-popover { position: fixed; z-index: 80; + margin: 0; width: 320px; max-width: min(320px, calc(100vw - 16px)); + max-height: min(82vh, 560px); + overflow-y: auto; + overscroll-behavior: contain; background: var(--card); border: 1px solid var(--border-strong); border-radius: 10px; @@ -1975,17 +2015,22 @@ html[data-platform="macos"] .titlebar .tb-left { } .session-import-popover .actions { display: flex; + flex-wrap: wrap; gap: 6px; margin-top: 4px; } .session-import-popover .actions button { - flex: 1; + flex: 1 1 120px; + min-width: 0; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 8px 12px; border: 1px solid var(--border-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .session-import-popover .actions button.cancel { background: transparent; @@ -2002,7 +2047,9 @@ html[data-platform="macos"] .titlebar .tb-left { } /* Folder-delete confirm needs more room: workspace name + session count */ -.folder-menu { max-width: 260px; } +.folder-menu { + max-width: 260px; +} .folder-menu .sm-confirm-desc { max-width: 220px; -webkit-line-clamp: 3; @@ -2026,12 +2073,17 @@ html[data-platform="macos"] .titlebar .tb-left { .side-foot .row { display: flex; align-items: center; + width: 100%; gap: 8px; padding: 6px 8px; + border: 0; border-radius: 6px; + background: transparent; font-size: 14px; + font-family: inherit; color: var(--fg-2); cursor: pointer; + text-align: left; } .side-foot .row:hover { background: var(--panel); @@ -2164,32 +2216,44 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { flex: 1; min-height: 0; position: relative; + overflow: hidden; +} +.thread-scroller { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + overscroll-behavior: contain; } -.thread::-webkit-scrollbar { +.thread-scroller:focus { + outline: none; +} +.thread-scroller::-webkit-scrollbar { width: 8px; } -.thread::-webkit-scrollbar-thumb { +.thread-scroller::-webkit-scrollbar-thumb { background-clip: content-box; border: 2px solid transparent; background-color: var(--border); border-radius: 999px; transition: border-width 150ms, background-color 200ms; } -.thread::-webkit-scrollbar-thumb:hover { +.thread-scroller::-webkit-scrollbar-thumb:hover { border-width: 0; background-color: var(--border-strong); } -.thread::-webkit-scrollbar-track { +.thread-scroller::-webkit-scrollbar-track { background: transparent; } .thread-inner { max-width: var(--thread-max-width, 740px); - margin: 0 auto 28px; + margin: 0 auto 18px; padding: 0 32px; } .thread-inner--standalone { margin-top: 28px; } +.thread-tail { + height: 10px; +} .thread-inner > div[data-turn] { content-visibility: auto; contain-intrinsic-size: auto 100px; @@ -2199,14 +2263,21 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { .jump-bar { position: absolute; - right: 13px; + right: 12px; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; align-items: center; - padding: 0 12px; + padding: 6px 10px; z-index: 20; + opacity: 0.18; + transition: opacity 150ms ease, transform 150ms ease; +} +.thread:hover .jump-bar, +.jump-bar:focus-within, +.jump-bar:hover { + opacity: 0.9; } .jump-scroll { display: flex; @@ -2214,10 +2285,16 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { align-items: center; gap: 6px; padding: 8px 0; - max-height: 240px; + max-height: min(220px, calc(100vh - 360px)); overflow-y: auto; scrollbar-width: none; - -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 84%, transparent 100%); + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0%, + #000 16%, + #000 84%, + transparent 100% + ); mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 84%, transparent 100%); } .jump-scroll::-webkit-scrollbar { @@ -2230,8 +2307,21 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { justify-content: flex-end; width: 32px; min-height: 14px; + padding: 0; + border: 0; + background: transparent; cursor: pointer; flex-shrink: 0; + opacity: 0.78; + transition: opacity 120ms ease; +} +.jump-item:hover, +.jump-item:focus-visible { + opacity: 1; +} +.jump-item:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 2px; } .jump-dot { height: 3px; @@ -2239,9 +2329,15 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { background: var(--border-strong); transition: background 200ms, width 400ms cubic-bezier(0.34, 1.56, 0.64, 1); } -.jump-dot[data-d="0"] { background: var(--accent); } -.jump-dot[data-d="1"] { background: color-mix(in srgb, var(--accent) 60%, transparent); } -.jump-dot[data-d="2"] { background: color-mix(in srgb, var(--accent) 35%, transparent); } +.jump-dot[data-d="0"] { + background: var(--accent); +} +.jump-dot[data-d="1"] { + background: color-mix(in srgb, var(--accent) 60%, transparent); +} +.jump-dot[data-d="2"] { + background: color-mix(in srgb, var(--accent) 35%, transparent); +} .jump-preview { position: absolute; right: 100%; @@ -2265,7 +2361,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { /* Jump-to-bottom button — shown when user has scrolled up during streaming */ .thread-jump-bottom { - position: sticky; + position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); @@ -2280,7 +2376,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { background: var(--bg); color: var(--fg); cursor: pointer; - box-shadow: 0 2px 8px rgba(0,0,0,0.15); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); transition: opacity 0.15s, transform 0.15s; opacity: 0.9; } @@ -2290,7 +2386,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { transform: translateX(-50%) scale(1.05); } [data-theme="light"] .thread-jump-bottom { - box-shadow: 0 2px 12px rgba(0,0,0,0.10); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); } /* ---------- TURN HEADERS ---------- */ @@ -2477,13 +2573,18 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { .markdown li:last-child { margin-bottom: 0; } -.msg-text ul ul, .markdown ul ul, -.msg-text ol ol, .markdown ol ol, -.msg-text ul ol, .markdown ul ol, -.msg-text ol ul, .markdown ol ul { +.msg-text ul ul, +.markdown ul ul, +.msg-text ol ol, +.markdown ol ol, +.msg-text ul ol, +.markdown ul ol, +.msg-text ol ul, +.markdown ol ul { margin: 4px 0; } -.msg-text h1, .markdown h1 { +.msg-text h1, +.markdown h1 { font-size: 1.45em; font-weight: 700; letter-spacing: -0.02em; @@ -2491,7 +2592,8 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { margin: 22px 0 10px; color: var(--fg); } -.msg-text h2, .markdown h2 { +.msg-text h2, +.markdown h2 { font-size: 1.2em; font-weight: 700; letter-spacing: -0.015em; @@ -2499,24 +2601,31 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { margin: 20px 0 8px; color: var(--fg); } -.msg-text h3, .markdown h3 { +.msg-text h3, +.markdown h3 { font-size: 1.05em; font-weight: 600; line-height: 1.35; margin: 18px 0 6px; color: var(--fg); } -.msg-text h4, .markdown h4, -.msg-text h5, .markdown h5, -.msg-text h6, .markdown h6 { +.msg-text h4, +.markdown h4, +.msg-text h5, +.markdown h5, +.msg-text h6, +.markdown h6 { font-size: 1em; font-weight: 600; margin: 14px 0 4px; color: var(--fg-2); } -.msg-text h1:first-child, .markdown h1:first-child, -.msg-text h2:first-child, .markdown h2:first-child, -.msg-text h3:first-child, .markdown h3:first-child { +.msg-text h1:first-child, +.markdown h1:first-child, +.msg-text h2:first-child, +.markdown h2:first-child, +.msg-text h3:first-child, +.markdown h3:first-child { margin-top: 0; } .msg-text blockquote, @@ -2802,9 +2911,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { cursor: pointer; vertical-align: baseline; text-decoration: none; - transition: - background 0.12s, - color 0.12s; + transition: background 0.12s, color 0.12s; white-space: nowrap; } .file-pill:hover { @@ -2950,10 +3057,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { border-radius: 999px; box-shadow: var(--shadow-sm); cursor: pointer; - transition: - background 0.13s ease, - color 0.13s ease, - transform 0.13s ease; + transition: background 0.13s ease, color 0.13s ease, transform 0.13s ease; } .proc-group.is-clipped .proc-group-toggle { position: absolute; @@ -3441,11 +3545,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { width: 80px; height: 56px; border-radius: 6px; - background: repeating-linear-gradient( - -45deg, - var(--panel-2) 0 6px, - var(--card) 6px 12px - ); + background: repeating-linear-gradient(-45deg, var(--panel-2) 0 6px, var(--card) 6px 12px); border: 1px solid var(--border); display: flex; align-items: center; @@ -3773,6 +3873,20 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { background: var(--violet-soft); border-color: transparent; } +.chip .x { + cursor: pointer; + opacity: 0.5; + border: 0; + padding: 0; + background: transparent; + color: inherit; + display: inline-flex; + align-items: center; +} +.chip .x:focus-visible { + outline: 1px solid currentColor; + outline-offset: 2px; +} .composer-textarea-wrap { position: relative; } @@ -3837,7 +3951,9 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { color: var(--muted); display: inline-flex; align-items: center; + justify-content: center; gap: 5px; + min-width: 30px; } .composer-foot .cf-btn:hover { background: var(--panel); @@ -3883,6 +3999,35 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { padding: 1px 5px; border-radius: 3px; } +.composer-model-direct { + position: relative; + min-width: 0; + flex: 0 1 auto; +} +.composer-tools-more { + position: relative; + display: none; + flex-shrink: 0; +} +.composer-tools-menu { + left: auto; + right: 0; + width: min(360px, calc(100vw - 44px)); + max-height: min(70vh, 520px); + display: block; + overflow-y: auto; +} +.composer-tools-menu .popup-list { + overflow: visible; +} +.composer-tools-actions { + border-bottom: 1px solid var(--border); +} +.popup-item:is(button) { + border: 0; + background: transparent; + font: inherit; +} .send-btn { width: 30px; @@ -3914,7 +4059,9 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { font-size: 14px; color: var(--muted-2); } -.hint-row .grow { flex: 1; } +.hint-row .grow { + flex: 1; +} .hint-row .hint-sep { width: 1px; height: 12px; @@ -3980,6 +4127,20 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { .popup .ph .grow { flex: 1; } +.popup-close { + border: 0; + padding: 2px; + border-radius: 5px; + background: transparent; + color: inherit; + cursor: pointer; + display: inline-flex; + align-items: center; +} +.popup-close:hover { + background: var(--panel); + color: var(--fg); +} .popup-list { overflow-y: auto; overflow-x: hidden; @@ -3989,6 +4150,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { min-width: 0; } .popup-item { + width: 100%; display: grid; grid-template-columns: 24px 1fr auto; align-items: center; @@ -3997,6 +4159,8 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { border-radius: 6px; cursor: pointer; font-size: 14px; + color: inherit; + text-align: left; } .popup-item:hover, .popup-item[data-active="true"] { @@ -4097,9 +4261,15 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { font-size: 14px; color: var(--muted); border-radius: 5px 5px 0 0; + border: 1px solid transparent; + background: transparent; font-family: inherit; cursor: pointer; } +.ctx-tab:focus-visible { + outline: 1px solid var(--accent); + outline-offset: -2px; +} .ctx-tab:hover { color: var(--fg); } @@ -4469,18 +4639,36 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { gap: 6px; padding: 0 10px; height: 100%; + max-width: min(280px, 42vw); + border: 0; + background: transparent; + color: inherit; + font: inherit; cursor: pointer; + min-width: 0; +} +.statusbar .seg:disabled { + cursor: default; + opacity: 0.75; } .statusbar .seg:hover { background: var(--panel); color: var(--fg); } +.statusbar .seg:disabled:hover { + background: transparent; + color: var(--muted); +} .statusbar .seg.theme-trigger.active { background: var(--accent-soft); color: var(--accent); } .statusbar .seg .v { color: var(--fg); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .statusbar .seg .v.ok { color: var(--success); @@ -4650,7 +4838,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { display: inline-block; flex-shrink: 0; } -.status-dot.warn { background: var(--warning); } +.status-dot.warn { + background: var(--warning); +} .meta-label { font-size: 10px; @@ -4734,7 +4924,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { animation-delay: 0.3s; } @keyframes bounce { - 0%, 80%, 100% { + 0%, + 80%, + 100% { transform: translateY(0); opacity: 0.4; } @@ -4869,11 +5061,21 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { background-size: 220% 100%; animation: shim 1.4s linear infinite; } -.skel-line.w-90 { width: 92%; } -.skel-line.w-70 { width: 72%; } -.skel-line.w-60 { width: 64%; } -.skel-line.w-40 { width: 44%; } -.skel-line.w-30 { width: 34%; } +.skel-line.w-90 { + width: 92%; +} +.skel-line.w-70 { + width: 72%; +} +.skel-line.w-60 { + width: 64%; +} +.skel-line.w-40 { + width: 44%; +} +.skel-line.w-30 { + width: 34%; +} /* progressive log (tool live output) */ .live-log { @@ -4902,15 +5104,27 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { opacity: 0; animation: line-in 0.25s ease-out forwards; } -.live-log .line.ok { color: var(--success); } -.live-log .line.dim { color: var(--muted); } +.live-log .line.ok { + color: var(--success); +} +.live-log .line.dim { + color: var(--muted); +} @keyframes line-in { - to { opacity: 1; } + to { + opacity: 1; + } } @keyframes rise { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } } .composer-busy-status { @@ -4976,7 +5190,10 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { justify-content: center; width: 14px; height: 14px; + border: 0; border-radius: 50%; + padding: 0; + background: transparent; cursor: pointer; color: var(--muted); } @@ -5004,8 +5221,12 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { border-radius: 50%; background: var(--accent); } -.queue-strip .pip.q { background: var(--muted-2); } -.queue-strip .pip.w { background: var(--warning); } +.queue-strip .pip.q { + background: var(--muted-2); +} +.queue-strip .pip.w { + background: var(--warning); +} /* ---- token stream rate bar ---- */ .tps { @@ -5033,14 +5254,34 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { opacity: 0.65; animation: bar 0.9s ease-in-out infinite; } -.tps .bars span:nth-child(1) { height: 30%; animation-delay: 0s; } -.tps .bars span:nth-child(2) { height: 65%; animation-delay: 0.1s; } -.tps .bars span:nth-child(3) { height: 90%; animation-delay: 0.2s; } -.tps .bars span:nth-child(4) { height: 50%; animation-delay: 0.3s; } -.tps .bars span:nth-child(5) { height: 75%; animation-delay: 0.4s; } +.tps .bars span:nth-child(1) { + height: 30%; + animation-delay: 0s; +} +.tps .bars span:nth-child(2) { + height: 65%; + animation-delay: 0.1s; +} +.tps .bars span:nth-child(3) { + height: 90%; + animation-delay: 0.2s; +} +.tps .bars span:nth-child(4) { + height: 50%; + animation-delay: 0.3s; +} +.tps .bars span:nth-child(5) { + height: 75%; + animation-delay: 0.4s; +} @keyframes bar { - 0%, 100% { transform: scaleY(0.5); } - 50% { transform: scaleY(1); } + 0%, + 100% { + transform: scaleY(0.5); + } + 50% { + transform: scaleY(1); + } } /* shell card while running */ @@ -5100,8 +5341,12 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { background: oklch(0% 0 0 / 0.18); } @keyframes fade-in { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } .settings { @@ -5134,12 +5379,17 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { .settings-side .row { display: flex; align-items: center; + width: 100%; gap: 10px; padding: 7px 10px; + border: 0; border-radius: 6px; + background: transparent; font-size: 14px; + font-family: inherit; color: var(--fg-2); cursor: pointer; + text-align: left; } .settings-side .row:hover { background: var(--panel); @@ -5185,7 +5435,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { font-size: 14px; margin-top: 2px; } -.settings-head .grow { flex: 1; } +.settings-head .grow { + flex: 1; +} .settings-head .close-btn { width: 26px; height: 26px; @@ -5627,7 +5879,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { color: var(--muted); margin-top: 2px; } -.scard .grow { flex: 1; } +.scard .grow { + flex: 1; +} .scard .mcp-spec-body { min-width: 0; flex: 1 1 auto; @@ -5919,6 +6173,10 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding: 14px; position: relative; cursor: pointer; + color: var(--fg); + font: inherit; + text-align: left; + width: 100%; } .mcard[data-on="true"] { border-color: var(--accent); @@ -5952,8 +6210,12 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { font-family: inherit; font-size: 14px; } -.mcard .spec .k { color: var(--muted); } -.mcard .spec .v { color: var(--fg); } +.mcard .spec .k { + color: var(--muted); +} +.mcard .spec .v { + color: var(--fg); +} .mcard .price { margin-top: 8px; padding-top: 8px; @@ -6025,13 +6287,16 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { text-transform: uppercase; } .mem-edit .scope[data-s="project"] { - background: var(--accent-soft); color: var(--accent); + background: var(--accent-soft); + color: var(--accent); } .mem-edit .scope[data-s="user"] { - background: var(--violet-soft); color: var(--violet); + background: var(--violet-soft); + color: var(--violet); } .mem-edit .scope[data-s="global"] { - background: var(--success-soft); color: var(--success); + background: var(--success-soft); + color: var(--success); } .mem-edit .txt { font-size: 14px; @@ -6069,8 +6334,12 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { margin-top: 4px; letter-spacing: -0.02em; } -.bill-card .v.ok { color: var(--success); } -.bill-card .v.acc { color: var(--accent); } +.bill-card .v.ok { + color: var(--success); +} +.bill-card .v.acc { + color: var(--accent); +} .bill-card .sub { font-size: 14px; color: var(--muted); @@ -6120,8 +6389,12 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { .usage-table td.num { text-align: right; } -.usage-table td .pos { color: var(--accent); } -.usage-table td .ok { color: var(--success); } +.usage-table td .pos { + color: var(--accent); +} +.usage-table td .ok { + color: var(--success); +} /* plan-approved banner inline in thread */ .plan-banner { @@ -6208,48 +6481,150 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { TONE PALETTE — systematized ============================================================ */ :root { - --tone-ok: oklch(72% 0.16 152); - --tone-ok-soft: oklch(72% 0.16 152 / 0.12); - --tone-warn: oklch(78% 0.16 80); + --tone-ok: oklch(72% 0.16 152); + --tone-ok-soft: oklch(72% 0.16 152 / 0.12); + --tone-warn: oklch(78% 0.16 80); --tone-warn-soft: oklch(78% 0.16 80 / 0.14); - --tone-err: oklch(68% 0.20 25); - --tone-err-soft: oklch(68% 0.20 25 / 0.14); - --tone-info: oklch(70% 0.13 230); + --tone-err: oklch(68% 0.2 25); + --tone-err-soft: oklch(68% 0.2 25 / 0.14); + --tone-info: oklch(70% 0.13 230); --tone-info-soft: oklch(70% 0.13 230 / 0.14); - --tone-brand: oklch(66% 0.18 38); - --tone-brand-soft:oklch(66% 0.18 38 / 0.14); - --tone-accent: var(--accent); + --tone-brand: oklch(66% 0.18 38); + --tone-brand-soft: oklch(66% 0.18 38 / 0.14); + --tone-accent: var(--accent); --tone-accent-soft: var(--accent-soft); - --tone-ghost: oklch(60% 0.005 250); - --tone-ghost-soft:oklch(60% 0.005 250 / 0.10); + --tone-ghost: oklch(60% 0.005 250); + --tone-ghost-soft: oklch(60% 0.005 250 / 0.1); /* states */ - --st-running: oklch(72% 0.16 200); - --st-done: var(--tone-ok); - --st-failed: var(--tone-err); - --st-queued: oklch(65% 0.005 250); - --st-blocked: oklch(70% 0.14 50); - --st-skipped: oklch(60% 0.04 250); - --st-aborted: oklch(60% 0.13 25); + --st-running: oklch(72% 0.16 200); + --st-done: var(--tone-ok); + --st-failed: var(--tone-err); + --st-queued: oklch(65% 0.005 250); + --st-blocked: oklch(70% 0.14 50); + --st-skipped: oklch(60% 0.04 250); + --st-aborted: oklch(60% 0.13 25); } [data-theme="light"] { - --tone-ok: oklch(50% 0.16 152); - --tone-warn: oklch(58% 0.16 75); - --tone-err: oklch(56% 0.20 25); - --tone-info: oklch(55% 0.15 230); - --tone-brand: oklch(50% 0.18 258); - --tone-ghost: oklch(45% 0.005 250); + --tone-ok: oklch(50% 0.16 152); + --tone-warn: oklch(58% 0.16 75); + --tone-err: oklch(56% 0.2 25); + --tone-info: oklch(55% 0.15 230); + --tone-brand: oklch(50% 0.18 258); + --tone-ghost: oklch(45% 0.005 250); } /* ============================================================ APPROVAL CARD — universal (plan / edit / shell / path / checkpoint / refinement / revision) ============================================================ */ -.approval { - border: 1px solid var(--border); - background: var(--card); - border-radius: 10px; - overflow: hidden; - position: relative; -} +.approval-tray { + width: 100%; + max-width: var(--thread-max-width, 740px); + margin: 0 auto; + padding: 0 32px 8px; + flex-shrink: 0; +} +.approval-tray-head { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 2px; + color: var(--muted); + font-size: 14px; +} +.approval-tray-title { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--fg-2); +} +.approval-tray-title span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.approval-tray-count { + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--accent-soft); + color: var(--accent); + font-size: 12px; + font-weight: 600; +} +.approval-tray-head .grow { + flex: 1; +} +.approval-tray-toggle, +.approval-queue-more { + border: 1px solid var(--border); + background: var(--panel); + color: var(--fg-2); + border-radius: 6px; + font: inherit; + font-size: 13px; + cursor: pointer; +} +.approval-tray-toggle { + padding: 3px 8px; + flex-shrink: 0; +} +.approval-tray-toggle:hover, +.approval-queue-more:hover { + background: var(--panel-2); + color: var(--fg); +} +.approval-stack { + display: grid; + gap: 8px; + max-height: min(28vh, 340px); + overflow-y: auto; + overscroll-behavior: contain; + padding-right: 4px; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +.approval-stack::-webkit-scrollbar { + width: 8px; +} +.approval-stack::-webkit-scrollbar-thumb { + background-color: var(--border); + border: 2px solid transparent; + background-clip: content-box; + border-radius: 999px; +} +.approval-slot { + min-width: 0; +} +.approval-queue-more { + min-height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 7px 10px; +} +.approval-connecting { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + color: var(--muted); + font-family: inherit; + font-size: 13px; +} +.approval { + border: 1px solid var(--border); + background: var(--card); + border-radius: 10px; + overflow: hidden; + position: relative; +} .approval::before { content: ""; position: absolute; @@ -6257,12 +6632,24 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { width: 3px; background: var(--approval-accent, var(--accent)); } -.approval[data-tone="ok"] { --approval-accent: var(--tone-ok); } -.approval[data-tone="warn"] { --approval-accent: var(--tone-warn); } -.approval[data-tone="danger"] { --approval-accent: var(--tone-err); } -.approval[data-tone="info"] { --approval-accent: var(--tone-info); } -.approval[data-tone="brand"] { --approval-accent: var(--tone-brand); } -.approval[data-tone="ghost"] { --approval-accent: var(--tone-ghost); } +.approval[data-tone="ok"] { + --approval-accent: var(--tone-ok); +} +.approval[data-tone="warn"] { + --approval-accent: var(--tone-warn); +} +.approval[data-tone="danger"] { + --approval-accent: var(--tone-err); +} +.approval[data-tone="info"] { + --approval-accent: var(--tone-info); +} +.approval[data-tone="brand"] { + --approval-accent: var(--tone-brand); +} +.approval[data-tone="ghost"] { + --approval-accent: var(--tone-ghost); +} .approval .ap-head { display: flex; @@ -6280,7 +6667,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { background: var(--approval-accent); color: oklch(99% 0 0); } -[data-theme="light"] .approval .ap-ico { color: oklch(100% 0 0); } +[data-theme="light"] .approval .ap-ico { + color: oklch(100% 0 0); +} .approval .ap-kind { font-family: inherit; font-size: 14px; @@ -6349,16 +6738,42 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { .approval .ap-foot { display: flex; align-items: center; + justify-content: flex-end; + flex-wrap: wrap; gap: 6px; padding: 8px 12px; border-top: 1px solid var(--border); background: var(--bg-2); } -.approval .ap-foot .grow { flex: 1; } +.approval .ap-foot .btn { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.approval .ap-foot .grow { + flex: 1 1 16px; + min-width: 12px; +} .approval .ap-foot .meta { font-family: inherit; font-size: 14px; color: var(--muted); + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; +} +.approval .ap-body .btn { + min-width: 0; + max-width: 100%; +} +.approval .ap-body .btn > div { + min-width: 0; + overflow: hidden; } /* ============================================================ @@ -6378,20 +6793,43 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { border-bottom: 1px solid var(--border); } .task-card .th .ico { - width: 24px; height: 24px; border-radius: 6px; - display: inline-flex; align-items: center; justify-content: center; - background: var(--accent-soft); color: var(--accent); + width: 24px; + height: 24px; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--accent-soft); + color: var(--accent); +} +.task-card .th .tt { + font-size: 14px; + font-weight: 600; +} +.task-card .th .ss { + font-family: inherit; + font-size: 14px; + color: var(--muted); + margin-top: 1px; +} +.task-card .th .grow { + flex: 1; } -.task-card .th .tt { font-size: 14px; font-weight: 600; } -.task-card .th .ss { font-family: inherit; font-size: 14px; color: var(--muted); margin-top: 1px; } -.task-card .th .grow { flex: 1; } .task-card .th .meter { - width: 64px; height: 4px; border-radius: 999px; + width: 64px; + height: 4px; + border-radius: 999px; background: var(--panel); overflow: hidden; } -.task-card .th .meter > span { display: block; height: 100%; background: var(--accent); } -.task-card .tb { padding: 6px 0; } +.task-card .th .meter > span { + display: block; + height: 100%; + background: var(--accent); +} +.task-card .tb { + padding: 6px 0; +} .task-step { display: grid; grid-template-columns: 48px 18px 1fr auto; @@ -6400,11 +6838,23 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding: 8px 14px; border-left: 2px solid transparent; } -.task-step[data-state="running"] { border-left-color: var(--st-running); background: oklch(72% 0.16 200 / 0.04); } -.task-step[data-state="done"] { border-left-color: var(--st-done); } -.task-step[data-state="failed"] { border-left-color: var(--st-failed); background: oklch(68% 0.20 25 / 0.05); } -.task-step[data-state="blocked"] { border-left-color: var(--st-blocked); } -.task-step[data-state="skipped"] { opacity: 0.55; } +.task-step[data-state="running"] { + border-left-color: var(--st-running); + background: oklch(72% 0.16 200 / 0.04); +} +.task-step[data-state="done"] { + border-left-color: var(--st-done); +} +.task-step[data-state="failed"] { + border-left-color: var(--st-failed); + background: oklch(68% 0.2 25 / 0.05); +} +.task-step[data-state="blocked"] { + border-left-color: var(--st-blocked); +} +.task-step[data-state="skipped"] { + opacity: 0.55; +} .task-step .nx { font-family: inherit; font-size: 14px; @@ -6412,34 +6862,127 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding-top: 1px; } .task-step .st { - width: 14px; height: 14px; border-radius: 50%; + width: 14px; + height: 14px; + border-radius: 50%; margin-top: 2px; position: relative; } -.task-step[data-state="queued"] .st { border: 1.5px dashed var(--border-strong); } -.task-step[data-state="running"] .st { background: var(--st-running); animation: pulse 1.4s ease-in-out infinite; } -.task-step[data-state="done"] .st { background: var(--st-done); } -.task-step[data-state="done"] .st::after { content:"\2713"; position:absolute; inset:0; color:#fff; font-size:14px; display:flex; align-items:center; justify-content:center; } -.task-step[data-state="failed"] .st { background: var(--st-failed); } -.task-step[data-state="failed"] .st::after { content:"!"; position:absolute; inset:0; color:#fff; font-size:14px; font-weight:700; display:flex; align-items:center; justify-content:center; } -.task-step[data-state="blocked"] .st { background: var(--st-blocked); } -.task-step[data-state="blocked"] .st::after { content:"\f7"; position:absolute; inset:0; color:#fff; font-size:14px; display:flex; align-items:center; justify-content:center; } -.task-step[data-state="skipped"] .st { background: var(--st-skipped); } -.task-step[data-state="skipped"] .st::after { content:"\2014"; position:absolute; inset:0; color:#fff; font-size:14px; display:flex; align-items:center; justify-content:center; } -.task-step .l { font-size: 14px; color: var(--fg); } -.task-step .l .h { color: var(--muted); font-size: 14px; margin-top: 2px; font-family: inherit; } -.task-step .t { font-family: inherit; font-size: 14px; color: var(--muted); } +.task-step[data-state="queued"] .st { + border: 1.5px dashed var(--border-strong); +} +.task-step[data-state="running"] .st { + background: var(--st-running); + animation: pulse 1.4s ease-in-out infinite; +} +.task-step[data-state="done"] .st { + background: var(--st-done); +} +.task-step[data-state="done"] .st::after { + content: "\2713"; + position: absolute; + inset: 0; + color: #fff; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; +} +.task-step[data-state="failed"] .st { + background: var(--st-failed); +} +.task-step[data-state="failed"] .st::after { + content: "!"; + position: absolute; + inset: 0; + color: #fff; + font-size: 14px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; +} +.task-step[data-state="blocked"] .st { + background: var(--st-blocked); +} +.task-step[data-state="blocked"] .st::after { + content: "\f7"; + position: absolute; + inset: 0; + color: #fff; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; +} +.task-step[data-state="skipped"] .st { + background: var(--st-skipped); +} +.task-step[data-state="skipped"] .st::after { + content: "\2014"; + position: absolute; + inset: 0; + color: #fff; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; +} +.task-step .l { + font-size: 14px; + color: var(--fg); +} +.task-step .l .h { + color: var(--muted); + font-size: 14px; + margin-top: 2px; + font-family: inherit; +} +.task-step .t { + font-family: inherit; + font-size: 14px; + color: var(--muted); +} /* plan state extensions */ -.plan-row[data-state="failed"] .check { background: var(--st-failed); border-color: var(--st-failed); } -.plan-row[data-state="blocked"] .check { background: var(--st-blocked); border-color: var(--st-blocked); } -.plan-row[data-state="skipped"] .check { background: var(--st-skipped); border-color: var(--st-skipped); } -.plan-row[data-state="failed"] .check::after { content:"!"; color:#fff; font-size:14px; font-weight:700; } -.plan-row[data-state="blocked"] .check::after { content:"\f7"; color:#fff; font-size:14px; } -.plan-row[data-state="skipped"] .check::after { content:"\2014"; color:#fff; font-size:14px; } -.plan-row[data-state="failed"] > .body > .l { color: var(--st-failed); } -.plan-row[data-state="blocked"] > .body > .l { color: var(--st-blocked); } -.plan-row[data-state="skipped"] > .body > .l { color: var(--st-skipped); text-decoration: line-through; } +.plan-row[data-state="failed"] .check { + background: var(--st-failed); + border-color: var(--st-failed); +} +.plan-row[data-state="blocked"] .check { + background: var(--st-blocked); + border-color: var(--st-blocked); +} +.plan-row[data-state="skipped"] .check { + background: var(--st-skipped); + border-color: var(--st-skipped); +} +.plan-row[data-state="failed"] .check::after { + content: "!"; + color: #fff; + font-size: 14px; + font-weight: 700; +} +.plan-row[data-state="blocked"] .check::after { + content: "\f7"; + color: #fff; + font-size: 14px; +} +.plan-row[data-state="skipped"] .check::after { + content: "\2014"; + color: #fff; + font-size: 14px; +} +.plan-row[data-state="failed"] > .body > .l { + color: var(--st-failed); +} +.plan-row[data-state="blocked"] > .body > .l { + color: var(--st-blocked); +} +.plan-row[data-state="skipped"] > .body > .l { + color: var(--st-skipped); + text-decoration: line-through; +} /* ============================================================ WARN CARD @@ -6453,10 +6996,27 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { grid-template-columns: 24px 1fr; gap: 10px; } -.warn-card .ico { color: var(--tone-warn); margin-top: 1px; } -.warn-card .tt { font-size: 14px; font-weight: 600; color: var(--fg); } -.warn-card .ds { font-size: 14px; color: var(--fg-2); margin-top: 4px; line-height: 1.55; } -.warn-card .ds code { background: var(--panel); padding: 1px 5px; border-radius: 3px; font-size: 14px; } +.warn-card .ico { + color: var(--tone-warn); + margin-top: 1px; +} +.warn-card .tt { + font-size: 14px; + font-weight: 600; + color: var(--fg); +} +.warn-card .ds { + font-size: 14px; + color: var(--fg-2); + margin-top: 4px; + line-height: 1.55; +} +.warn-card .ds code { + background: var(--panel); + padding: 1px 5px; + border-radius: 3px; + font-size: 14px; +} /* ============================================================ TIP CARD @@ -6468,16 +7028,28 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding: 12px 14px 8px; } .tip-card .head { - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; margin-bottom: 8px; } .tip-card .head .ico { - width: 22px; height: 22px; border-radius: 5px; - display: inline-flex; align-items: center; justify-content: center; - background: var(--tone-info); color: oklch(99% 0 0); + width: 22px; + height: 22px; + border-radius: 5px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--tone-info); + color: oklch(99% 0 0); +} +.tip-card .head .topic { + font-size: 14px; + font-weight: 600; +} +.tip-card .head .grow { + flex: 1; } -.tip-card .head .topic { font-size: 14px; font-weight: 600; } -.tip-card .head .grow { flex: 1; } .tip-card .head .pill { font-family: inherit; font-size: 14px; @@ -6486,7 +7058,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { border-radius: 4px; color: var(--tone-info); } -.tip-card .sec { margin-bottom: 8px; } +.tip-card .sec { + margin-bottom: 8px; +} .tip-card .sec .stt { font-family: inherit; font-size: 14px; @@ -6526,26 +7100,47 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { overflow: hidden; } .doctor-card .dh { - display: flex; align-items: center; gap: 10px; + display: flex; + align-items: center; + gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); } .doctor-card .dh .ico { - width: 24px; height: 24px; border-radius: 6px; - background: var(--tone-info-soft); color: var(--tone-info); - display: inline-flex; align-items: center; justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + background: var(--tone-info-soft); + color: var(--tone-info); + display: inline-flex; + align-items: center; + justify-content: center; +} +.doctor-card .dh .tt { + font-size: 14px; + font-weight: 600; +} +.doctor-card .dh .grow { + flex: 1; } -.doctor-card .dh .tt { font-size: 14px; font-weight: 600; } -.doctor-card .dh .grow { flex: 1; } .doctor-card .dh .summary { font-family: inherit; font-size: 14px; - display: inline-flex; gap: 8px; + display: inline-flex; + gap: 8px; +} +.doctor-card .dh .summary .b { + font-weight: 600; +} +.doctor-card .dh .summary .ok { + color: var(--tone-ok); +} +.doctor-card .dh .summary .warn { + color: var(--tone-warn); +} +.doctor-card .dh .summary .err { + color: var(--tone-err); } -.doctor-card .dh .summary .b { font-weight: 600; } -.doctor-card .dh .summary .ok { color: var(--tone-ok); } -.doctor-card .dh .summary .warn { color: var(--tone-warn); } -.doctor-card .dh .summary .err { color: var(--tone-err); } .doctor-row { display: grid; grid-template-columns: 22px 1fr auto; @@ -6554,22 +7149,46 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { border-bottom: 1px dashed var(--border); align-items: center; } -.doctor-row:last-child { border-bottom: none; } +.doctor-row:last-child { + border-bottom: none; +} .doctor-row .ic { - width: 18px; height: 18px; border-radius: 50%; - display: inline-flex; align-items: center; justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; color: oklch(99% 0 0); - font-size: 14px; font-weight: 700; + font-size: 14px; + font-weight: 700; +} +.doctor-row[data-s="ok"] .ic { + background: var(--tone-ok); +} +.doctor-row[data-s="warn"] .ic { + background: var(--tone-warn); +} +.doctor-row[data-s="fail"] .ic { + background: var(--tone-err); } -.doctor-row[data-s="ok"] .ic { background: var(--tone-ok); } -.doctor-row[data-s="warn"] .ic { background: var(--tone-warn); } -.doctor-row[data-s="fail"] .ic { background: var(--tone-err); } .doctor-row .ic::after { content: attr(data-mark); } -.doctor-row .body .nm { font-size: 14px; } -.doctor-row .body .sub { font-family: inherit; font-size:14px; color: var(--muted); margin-top: 2px; } -.doctor-row .v { font-family: inherit; font-size:14px; color: var(--fg-2); } +.doctor-row .body .nm { + font-size: 14px; +} +.doctor-row .body .sub { + font-family: inherit; + font-size: 14px; + color: var(--muted); + margin-top: 2px; +} +.doctor-row .v { + font-family: inherit; + font-size: 14px; + color: var(--fg-2); +} /* ============================================================ USAGE CARD — full @@ -6583,11 +7202,23 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { .usage-full .uh { padding: 10px 14px; border-bottom: 1px solid var(--border); - display: flex; gap: 8px; align-items: center; + display: flex; + gap: 8px; + align-items: center; +} +.usage-full .uh .grow { + flex: 1; +} +.usage-full .uh .tt { + font-size: 14px; + font-weight: 600; +} +.usage-full .uh .ss { + font-family: inherit; + font-size: 14px; + color: var(--muted); + margin-top: 1px; } -.usage-full .uh .grow { flex: 1; } -.usage-full .uh .tt { font-size: 14px; font-weight: 600; } -.usage-full .uh .ss { font-family: inherit; font-size: 14px; color: var(--muted); margin-top:1px; } .usage-full .ub { display: grid; grid-template-columns: repeat(4, 1fr); @@ -6596,7 +7227,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding: 12px 14px; border-right: 1px solid var(--border); } -.usage-full .ucol:last-child { border-right: none; } +.usage-full .ucol:last-child { + border-right: none; +} .usage-full .ucol .l { font-family: inherit; font-size: 14px; @@ -6605,34 +7238,67 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { color: var(--muted); } .usage-full .ucol .v { - font-size: 17px; font-weight: 600; + font-size: 17px; + font-weight: 600; margin-top: 4px; letter-spacing: -0.02em; } -.usage-full .ucol .v.acc { color: var(--accent); } -.usage-full .ucol .v.ok { color: var(--tone-ok); } -.usage-full .ucol .v.vio { color: var(--violet); } -.usage-full .ucol .pct { font-family: inherit; font-size: 14px; color: var(--muted); margin-top: 2px; } +.usage-full .ucol .v.acc { + color: var(--accent); +} +.usage-full .ucol .v.ok { + color: var(--tone-ok); +} +.usage-full .ucol .v.vio { + color: var(--violet); +} +.usage-full .ucol .pct { + font-family: inherit; + font-size: 14px; + color: var(--muted); + margin-top: 2px; +} .usage-full .stack { - display: flex; height: 6px; + display: flex; + height: 6px; border-top: 1px solid var(--border); } -.usage-full .stack span { display: block; height: 100%; } -.usage-full .stack .s1 { background: var(--accent); } -.usage-full .stack .s2 { background: var(--violet); } -.usage-full .stack .s3 { background: var(--tone-ok); } -.usage-full .stack .s4 { background: var(--border-strong); } +.usage-full .stack span { + display: block; + height: 100%; +} +.usage-full .stack .s1 { + background: var(--accent); +} +.usage-full .stack .s2 { + background: var(--violet); +} +.usage-full .stack .s3 { + background: var(--tone-ok); +} +.usage-full .stack .s4 { + background: var(--border-strong); +} .usage-full .uf { - display: flex; gap: 6px; flex-wrap: wrap; + display: flex; + gap: 6px; + flex-wrap: wrap; padding: 8px 14px; border-top: 1px solid var(--border); background: var(--bg-2); - font-family: inherit; font-size: 14px; + font-family: inherit; + font-size: 14px; color: var(--muted); } -.usage-full .uf .x { display:inline-flex; align-items:center; gap:4px; } +.usage-full .uf .x { + display: inline-flex; + align-items: center; + gap: 4px; +} .usage-full .uf .x .sw { - width: 8px; height: 8px; border-radius: 2px; + width: 8px; + height: 8px; + border-radius: 2px; } /* ============================================================ @@ -6645,7 +7311,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { overflow: hidden; } .mem-groups .gh { - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; padding: 9px 12px; border-bottom: 1px solid var(--border); font-family: inherit; @@ -6655,13 +7323,26 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { color: var(--muted); background: var(--bg-2); } -.mem-groups .gh.first {} -.mem-groups .gh .sw { width: 6px; height: 6px; border-radius: 50%; } -.mem-groups .gh[data-g="user"] .sw { background: var(--violet); } -.mem-groups .gh[data-g="feedback"] .sw { background: var(--tone-warn); } -.mem-groups .gh[data-g="project"] .sw { background: var(--accent); } -.mem-groups .gh[data-g="reference"] .sw { background: var(--tone-info); } -.mem-groups .gh .grow { flex: 1; } +.mem-groups .gh .sw { + width: 6px; + height: 6px; + border-radius: 50%; +} +.mem-groups .gh[data-g="user"] .sw { + background: var(--violet); +} +.mem-groups .gh[data-g="feedback"] .sw { + background: var(--tone-warn); +} +.mem-groups .gh[data-g="project"] .sw { + background: var(--accent); +} +.mem-groups .gh[data-g="reference"] .sw { + background: var(--tone-info); +} +.mem-groups .gh .grow { + flex: 1; +} .mem-groups .gh .cnt { background: var(--card); border: 1px solid var(--border); @@ -6679,10 +7360,20 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { color: var(--fg-2); align-items: start; } -.mem-groups .mrow:last-child { border-bottom: none; } -.mem-groups .mrow .b { color: var(--muted-2); } -.mem-groups .mrow .t { line-height: 1.55; } -.mem-groups .mrow .meta { font-family: inherit; font-size: 14px; color: var(--muted); } +.mem-groups .mrow:last-child { + border-bottom: none; +} +.mem-groups .mrow .b { + color: var(--muted-2); +} +.mem-groups .mrow .t { + line-height: 1.55; +} +.mem-groups .mrow .meta { + font-family: inherit; + font-size: 14px; + color: var(--muted); +} /* ============================================================ SUBAGENT NESTED @@ -6694,18 +7385,34 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { overflow: hidden; } .subagent-nested .sh { - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); } .subagent-nested .sh .ico { - width: 22px; height: 22px; border-radius: 5px; - background: var(--violet-soft); color: var(--violet); - display: inline-flex; align-items: center; justify-content: center; + width: 22px; + height: 22px; + border-radius: 5px; + background: var(--violet-soft); + color: var(--violet); + display: inline-flex; + align-items: center; + justify-content: center; +} +.subagent-nested .sh .nm { + font-size: 14px; + font-weight: 600; +} +.subagent-nested .sh .ss { + font-family: inherit; + font-size: 14px; + color: var(--muted); +} +.subagent-nested .sh .grow { + flex: 1; } -.subagent-nested .sh .nm { font-size: 14px; font-weight: 600; } -.subagent-nested .sh .ss { font-family: inherit; font-size: 14px; color: var(--muted); } -.subagent-nested .sh .grow { flex: 1; } .subagent-nested .nest { border-left: 2px solid var(--violet); margin-left: 22px; @@ -6719,28 +7426,69 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { border: 1px solid var(--border); font-size: 14px; } -.subagent-nested .nest .lab { - font-family: inherit; +.subagent-nested .nest .lab { + font-family: inherit; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin-bottom: 4px; +} +.subagent-nested .nest .lab .dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + margin-right: 5px; + vertical-align: middle; +} +.subagent-nested .nest .lab.reason .dot { + background: var(--violet); +} +.subagent-nested .nest .lab.tool .dot { + background: var(--accent); +} +.subagent-nested .nest .lab.stream .dot { + background: var(--tone-info); +} +.subagent-nested .nest .lab.diff .dot { + background: var(--tone-ok); +} +.subagent-nested .nest .lab.err .dot { + background: var(--tone-err); +} +.subagent-nested .nest .txt { + color: var(--fg-2); + line-height: 1.55; +} +.subagent-nested .nest .txt code { + background: var(--panel); + padding: 1px 5px; + border-radius: 3px; + font-size: 14px; +} +.subagent-nested .nest .mono { + font-family: "Geist Mono", monospace; + font-size: 14px; + color: var(--fg-2); + white-space: pre; +} +.subagent-nested .nest .diffbox { + font-family: "Geist Mono", monospace; font-size: 14px; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--muted); - margin-bottom: 4px; } -.subagent-nested .nest .lab .dot { - display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 5px; vertical-align: middle; -} -.subagent-nested .nest .lab.reason .dot { background: var(--violet); } -.subagent-nested .nest .lab.tool .dot { background: var(--accent); } -.subagent-nested .nest .lab.stream .dot { background: var(--tone-info); } -.subagent-nested .nest .lab.diff .dot { background: var(--tone-ok); } -.subagent-nested .nest .lab.err .dot { background: var(--tone-err); } -.subagent-nested .nest .txt { color: var(--fg-2); line-height: 1.55; } -.subagent-nested .nest .txt code { background: var(--panel); padding:1px 5px; border-radius:3px; font-size:14px; } -.subagent-nested .nest .mono { font-family: "Geist Mono", monospace; font-size: 14px; color: var(--fg-2); white-space: pre; } -.subagent-nested .nest .diffbox { font-family: "Geist Mono", monospace; font-size: 14px; } -.subagent-nested .nest .diffbox .add { color: var(--tone-ok); background: oklch(72% 0.16 152 / 0.08); padding: 0 4px; } -.subagent-nested .nest .diffbox .del { color: var(--tone-err); background: oklch(68% 0.20 25 / 0.08); padding: 0 4px; text-decoration: line-through; opacity: 0.85; } +.subagent-nested .nest .diffbox .add { + color: var(--tone-ok); + background: oklch(72% 0.16 152 / 0.08); + padding: 0 4px; +} +.subagent-nested .nest .diffbox .del { + color: var(--tone-err); + background: oklch(68% 0.2 25 / 0.08); + padding: 0 4px; + text-decoration: line-through; + opacity: 0.85; +} /* ============================================================ CODE SEARCH @@ -6754,17 +7502,29 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { .code-search .ch { padding: 9px 12px; border-bottom: 1px solid var(--border); - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; } .code-search .ch .pat { font-family: inherit; font-size: 14px; color: var(--accent); } -.code-search .ch .grow { flex: 1; } -.code-search .ch .stat { font-family: inherit; font-size: 14px; color: var(--muted); } -.code-search .file-block { border-bottom: 1px solid var(--border); } -.code-search .file-block:last-child { border-bottom: none; } +.code-search .ch .grow { + flex: 1; +} +.code-search .ch .stat { + font-family: inherit; + font-size: 14px; + color: var(--muted); +} +.code-search .file-block { + border-bottom: 1px solid var(--border); +} +.code-search .file-block:last-child { + border-bottom: none; +} .code-search .fh { padding: 6px 12px; background: var(--bg-2); @@ -6774,7 +7534,10 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { display: flex; gap: 8px; } -.code-search .fh .n { color: var(--muted); margin-left: auto; } +.code-search .fh .n { + color: var(--muted); + margin-left: auto; +} .code-search .hit { display: grid; grid-template-columns: 44px 1fr; @@ -6784,9 +7547,19 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding: 3px 12px; align-items: center; } -.code-search .hit:hover { background: var(--bg-2); } -.code-search .hit .ln { color: var(--muted-2); text-align: right; } -.code-search .hit .ct { color: var(--fg-2); white-space: pre; overflow: hidden; text-overflow: ellipsis; } +.code-search .hit:hover { + background: var(--bg-2); +} +.code-search .hit .ln { + color: var(--muted-2); + text-align: right; +} +.code-search .hit .ct { + color: var(--fg-2); + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; +} .code-search .hit .ct mark { background: oklch(78% 0.16 80 / 0.35); color: var(--fg); @@ -6804,22 +7577,49 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding: 12px 14px 10px; } .ctx-card .h { - display: flex; align-items: baseline; gap: 6px; + display: flex; + align-items: baseline; + gap: 6px; margin-bottom: 8px; } -.ctx-card .h .tt { font-size: 14px; font-weight: 600; } -.ctx-card .h .grow { flex: 1; } -.ctx-card .h .v { font-family: inherit; font-size: 14px; color: var(--fg); } -.ctx-card .h .v .mut { color: var(--muted); } +.ctx-card .h .tt { + font-size: 14px; + font-weight: 600; +} +.ctx-card .h .grow { + flex: 1; +} +.ctx-card .h .v { + font-family: inherit; + font-size: 14px; + color: var(--fg); +} +.ctx-card .h .v .mut { + color: var(--muted); +} .ctx-card .bar { - display: flex; height: 8px; border-radius: 999px; overflow: hidden; + display: flex; + height: 8px; + border-radius: 999px; + overflow: hidden; background: var(--panel); } -.ctx-card .bar span { display: block; height: 100%; } -.ctx-card .bar .system { background: var(--accent); } -.ctx-card .bar .tools { background: var(--violet); } -.ctx-card .bar .log { background: var(--tone-ok); } -.ctx-card .bar .input { background: var(--tone-warn); } +.ctx-card .bar span { + display: block; + height: 100%; +} +.ctx-card .bar .system { + background: var(--accent); +} +.ctx-card .bar .tools { + background: var(--violet); +} +.ctx-card .bar .log { + background: var(--tone-ok); +} +.ctx-card .bar .input { + background: var(--tone-warn); +} .ctx-card .legend { display: grid; grid-template-columns: repeat(4, 1fr); @@ -6828,10 +7628,23 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { font-family: inherit; font-size: 14px; } -.ctx-card .legend > div { display: flex; gap: 5px; align-items: center; } -.ctx-card .legend .sw { width: 8px; height: 8px; border-radius: 2px; } -.ctx-card .legend .l { color: var(--muted); } -.ctx-card .legend .v { color: var(--fg); margin-left: auto; } +.ctx-card .legend > div { + display: flex; + gap: 5px; + align-items: center; +} +.ctx-card .legend .sw { + width: 8px; + height: 8px; + border-radius: 2px; +} +.ctx-card .legend .l { + color: var(--muted); +} +.ctx-card .legend .v { + color: var(--fg); + margin-left: auto; +} .ctx-card .ttop { margin-top: 12px; padding-top: 9px; @@ -6854,10 +7667,24 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { font-size: 14px; align-items: center; } -.ctx-card .ttop .row .n { color: var(--fg-2); } -.ctx-card .ttop .row .bbar { background: var(--panel); border-radius:2px; height: 4px; overflow:hidden; } -.ctx-card .ttop .row .bbar span { display:block; height:100%; background: var(--violet); } -.ctx-card .ttop .row .v { color: var(--muted); text-align: right; } +.ctx-card .ttop .row .n { + color: var(--fg-2); +} +.ctx-card .ttop .row .bbar { + background: var(--panel); + border-radius: 2px; + height: 4px; + overflow: hidden; +} +.ctx-card .ttop .row .bbar span { + display: block; + height: 100%; + background: var(--violet); +} +.ctx-card .ttop .row .v { + color: var(--muted); + text-align: right; +} /* ============================================================ FALLBACK @@ -6877,10 +7704,21 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { font-size: 14px; color: var(--muted); } -.fallback-card .hd { color: var(--fg-2); margin-bottom: 4px; } -.fallback-card .kv { display: grid; grid-template-columns: 70px 1fr; gap: 4px 12px; } -.fallback-card .kv .k { color: var(--muted-2); } -.fallback-card .kv .v { color: var(--fg-2); } +.fallback-card .hd { + color: var(--fg-2); + margin-bottom: 4px; +} +.fallback-card .kv { + display: grid; + grid-template-columns: 70px 1fr; + gap: 4px 12px; +} +.fallback-card .kv .k { + color: var(--muted-2); +} +.fallback-card .kv .v { + color: var(--fg-2); +} /* ============================================================ LIVE CARD VARIANTS @@ -6899,37 +7737,86 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { max-width: 100%; } .live-card .lc-ico { - width: 16px; height: 16px; - display: inline-flex; align-items: center; justify-content: center; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--lc-color); +} +.live-card .lc-body { + color: var(--fg-2); + font-size: 14px; +} +.live-card .lc-body .b { + color: var(--lc-color); + font-weight: 600; +} +.live-card .lc-act { color: var(--lc-color); + padding: 0 6px; +} +.live-card .lc-act:hover { + background: var(--lc-soft); + border-radius: 4px; +} + +.live-card[data-v="thinking"] { + --lc-color: var(--violet); + --lc-soft: var(--violet-soft); +} +.live-card[data-v="undo"] { + --lc-color: var(--tone-info); + --lc-soft: var(--tone-info-soft); +} +.live-card[data-v="ctxPressure"] { + --lc-color: var(--tone-warn); + --lc-soft: var(--tone-warn-soft); +} +.live-card[data-v="aborted"] { + --lc-color: var(--st-aborted); + --lc-soft: oklch(60% 0.13 25 / 0.1); +} +.live-card[data-v="retry"] { + --lc-color: var(--tone-warn); + --lc-soft: var(--tone-warn-soft); +} +.live-card[data-v="checkpoint"] { + --lc-color: var(--tone-ok); + --lc-soft: var(--tone-ok-soft); +} +.live-card[data-v="stepProgress"] { + --lc-color: var(--accent); + --lc-soft: var(--accent-soft); +} +.live-card[data-v="mcpEvent"] { + --lc-color: var(--violet); + --lc-soft: var(--violet-soft); +} +.live-card[data-v="sessionOp"] { + --lc-color: var(--tone-ghost); + --lc-soft: var(--tone-ghost-soft); } -.live-card .lc-body { color: var(--fg-2); font-size: 14px; } -.live-card .lc-body .b { color: var(--lc-color); font-weight: 600; } -.live-card .lc-act { color: var(--lc-color); padding: 0 6px; } -.live-card .lc-act:hover { background: var(--lc-soft); border-radius: 4px; } - -.live-card[data-v="thinking"] { --lc-color: var(--violet); --lc-soft: var(--violet-soft); } -.live-card[data-v="undo"] { --lc-color: var(--tone-info); --lc-soft: var(--tone-info-soft); } -.live-card[data-v="ctxPressure"] { --lc-color: var(--tone-warn); --lc-soft: var(--tone-warn-soft); } -.live-card[data-v="aborted"] { --lc-color: var(--st-aborted); --lc-soft: oklch(60% 0.13 25 / 0.10); } -.live-card[data-v="retry"] { --lc-color: var(--tone-warn); --lc-soft: var(--tone-warn-soft); } -.live-card[data-v="checkpoint"] { --lc-color: var(--tone-ok); --lc-soft: var(--tone-ok-soft); } -.live-card[data-v="stepProgress"]{ --lc-color: var(--accent); --lc-soft: var(--accent-soft); } -.live-card[data-v="mcpEvent"] { --lc-color: var(--violet); --lc-soft: var(--violet-soft); } -.live-card[data-v="sessionOp"] { --lc-color: var(--tone-ghost); --lc-soft: var(--tone-ghost-soft); } .live-row { - display: flex; flex-wrap: wrap; gap: 6px; + display: flex; + flex-wrap: wrap; + gap: 6px; } .live-card.step .lc-meter { - width: 60px; height: 4px; + width: 60px; + height: 4px; background: var(--panel); border-radius: 999px; overflow: hidden; margin-left: 4px; } -.live-card.step .lc-meter > span { display: block; height: 100%; background: var(--accent); } +.live-card.step .lc-meter > span { + display: block; + height: 100%; + background: var(--accent); +} /* tone palette gallery */ .tone-gallery { @@ -6950,8 +7837,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { letter-spacing: 0.04em; text-transform: uppercase; } -[data-theme="light"] .tone-gallery .sw { color: oklch(100% 0 0); } - +[data-theme="light"] .tone-gallery .sw { + color: oklch(100% 0 0); +} /* horizontal-cramp fixes */ .main-head h1 { @@ -6981,7 +7869,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { overflow-x: auto; scrollbar-width: none; } -.statusbar::-webkit-scrollbar { display: none; } +.statusbar::-webkit-scrollbar { + display: none; +} /* ============================================================ Composer narrow-width adaptation ============================================================ */ @@ -7034,14 +7924,35 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { /* Collapse composer-foot when the composer column itself is cramped — container query so panel state, not viewport width, drives it. */ @container composer (max-width: 620px) { - .composer-foot .cf-btn .label { display: none; } + .composer-foot .cf-btn .label { + display: none; + } +} +@container composer (max-width: 560px) { + .composer-foot .composer-secondary-action { + display: none; + } + .composer-tools-more { + display: inline-flex; + } } @container composer (max-width: 520px) { - .hint-row > span:first-child { display: none; } - .composer-foot .model-pill { max-width: 140px; } + .hint-row > span:first-child { + display: none; + } + .composer-foot .model-pill { + max-width: 140px; + } +} +@container composer (max-width: 460px) { + .composer-model-direct { + display: none; + } } @container composer (max-width: 420px) { - .composer-foot .model-pill { max-width: 100px; } + .composer-foot .model-pill { + max-width: 100px; + } } /* Main head — same compression behavior */ @@ -7049,7 +7960,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { white-space: nowrap; } @media (max-width: 1000px) { - .main-head .h-btn:not(.primary) span:not(:first-child) { display: none; } + .main-head .h-btn:not(.primary) span:not(:first-child) { + display: none; + } } /* ============================================================ @@ -7067,7 +7980,7 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { } .mode-switch[data-mode="yolo"] { border-color: var(--tone-err); - box-shadow: 0 0 0 2px oklch(68% 0.20 25 / 0.18); + box-shadow: 0 0 0 2px oklch(68% 0.2 25 / 0.18); } .ms-seg { display: inline-flex; @@ -7079,25 +7992,33 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { color: var(--muted); transition: background 0.12s ease, color 0.12s ease; } -.ms-seg:hover { color: var(--fg); } +.ms-seg:hover { + color: var(--fg); +} .ms-seg[data-on="true"][data-k="review"] { - background: var(--tone-info-soft); color: var(--tone-info); + background: var(--tone-info-soft); + color: var(--tone-info); } .ms-seg[data-on="true"][data-k="plan"] { - background: var(--tone-warn-soft); color: var(--tone-warn); + background: var(--tone-warn-soft); + color: var(--tone-warn); } .ms-seg[data-on="true"][data-k="auto"] { - background: var(--accent-soft); color: var(--accent); + background: var(--accent-soft); + color: var(--accent); } .ms-seg[data-on="true"][data-k="yolo"] { - background: var(--tone-err); color: oklch(99% 0 0); + background: var(--tone-err); + color: oklch(99% 0 0); +} +[data-theme="light"] .ms-seg[data-on="true"][data-k="yolo"] { + color: oklch(100% 0 0); } -[data-theme="light"] .ms-seg[data-on="true"][data-k="yolo"] { color: oklch(100% 0 0); } /* YOLO toast variant */ .toast-yolo { border-color: var(--tone-err); - box-shadow: 0 0 0 1px oklch(68% 0.20 25 / 0.18), var(--shadow-lg); + box-shadow: 0 0 0 1px oklch(68% 0.2 25 / 0.18), var(--shadow-lg); display: flex; align-items: center; gap: 8px; @@ -7119,8 +8040,12 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { /* Hide mode labels when narrow; keep icons */ @media (max-width: 1100px) { - .mode-switch .ms-seg > span { display: none; } - .mode-switch .ms-seg { padding: 4px 7px; } + .mode-switch .ms-seg > span { + display: none; + } + .mode-switch .ms-seg { + padding: 4px 7px; + } } /* ============================================================ @@ -7144,8 +8069,14 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { white-space: nowrap; } @keyframes toast-rise { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: none; } + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: none; + } } /* ============================================================ @@ -7163,7 +8094,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding-top: 12vh; animation: fade-in 0.18s ease-out; } -[data-theme="light"] .cmdk-mask { background: oklch(0% 0 0 / 0.2); } +[data-theme="light"] .cmdk-mask { + background: oklch(0% 0 0 / 0.2); +} .cmdk { width: min(640px, 92vw); max-height: 70vh; @@ -7205,7 +8138,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { overflow-y: auto; padding: 4px 0; } -.cmdk-group { padding: 4px 0; } +.cmdk-group { + padding: 4px 0; +} .cmdk-gh { padding: 6px 14px 2px; font-family: inherit; @@ -7219,8 +8154,13 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { grid-template-columns: 22px 1fr auto auto; gap: 10px; align-items: center; + width: 100%; padding: 7px 14px; + border: 0; + background: transparent; + font: inherit; font-size: 14px; + text-align: left; cursor: pointer; color: var(--fg-2); } @@ -7228,7 +8168,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { background: var(--accent-soft); color: var(--fg); } -.cmdk-row[data-active="true"] .ic { color: var(--accent); } +.cmdk-row[data-active="true"] .ic { + color: var(--accent); +} .cmdk-row .ic { color: var(--muted); display: inline-flex; @@ -7255,7 +8197,10 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { padding: 1px 5px; border-radius: 4px; } -.cmdk-row .kb-empty { display: inline-block; width: 1px; } +.cmdk-row .kb-empty { + display: inline-block; + width: 1px; +} .cmdk-empty { padding: 24px; text-align: center; @@ -7327,16 +8272,38 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { } .wd-row { display: grid; - grid-template-columns: 20px 1fr auto; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + padding: 0 8px 0 0; +} +.wd-row:hover { + background: var(--bg-2); +} +.wd-row-main { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); gap: 8px; align-items: center; - padding: 7px 12px; + min-width: 0; + width: 100%; + padding: 7px 0 7px 12px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; cursor: pointer; } -.wd-row:hover { background: var(--bg-2); } -.wd-row .ic { color: var(--muted); display: inline-flex; } -.wd-row .b { min-width: 0; } -.wd-row .b .p { +.wd-row-main .ic { + color: var(--muted); + display: inline-flex; +} +.wd-row-main .b { + min-width: 0; + display: block; +} +.wd-row-main .b .p { + display: block; font-size: 14px; font-family: inherit; color: var(--fg); @@ -7344,7 +8311,8 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { overflow: hidden; text-overflow: ellipsis; } -.wd-row .b .br { +.wd-row-main .b .br { + display: block; font-family: inherit; font-size: 14px; color: var(--muted); @@ -7353,7 +8321,9 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { text-overflow: ellipsis; margin-top: 1px; } -.wd-row .pin { color: var(--accent); } +.wd-row .pin { + color: var(--accent); +} .wd-row .wd-del { background: transparent; border: none; @@ -7371,7 +8341,7 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { opacity: 1; } .wd-row .wd-del:hover { - background: var(--bg-3, rgba(255,255,255,0.08)); + background: var(--bg-3, rgba(255, 255, 255, 0.08)); color: var(--tone-err, #ff3b30); } .wd-foot { @@ -7405,6 +8375,13 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { .main-head .sub .ws-crumb:hover { color: var(--accent); } +.main-head .sub .ws-crumb { + display: inline-flex; + align-items: center; + gap: 4px; + color: inherit; + cursor: pointer; +} /* ============================================================ Splash — opening intro (sub-sea drift, "Reasonix" reveal) @@ -7498,7 +8475,8 @@ html[data-platform="macos"] .splash { animation-delay: 0.32s; } @keyframes splash-pulse { - 0%, 100% { + 0%, + 100% { opacity: 0.25; transform: scale(0.8); } @@ -7627,16 +8605,34 @@ html[data-platform="macos"] .splash { .jobs-body > .job-row:first-child { border-top: none; } +.jr-line { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: stretch; +} +.jr-line:hover { + background: var(--bg-2); +} .jr-main { display: grid; - grid-template-columns: 22px auto 1fr 60px auto; + grid-template-columns: 22px auto minmax(0, 1fr) 60px; gap: 10px; align-items: center; + width: 100%; padding: 10px 14px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; cursor: pointer; } .jr-main:hover { - background: var(--bg-2); + background: transparent; +} +.jr-main:focus-visible { + outline: 1px solid var(--accent); + outline-offset: -2px; } .jr-state { display: inline-flex; @@ -7699,6 +8695,7 @@ html[data-platform="macos"] .splash { display: inline-flex; gap: 4px; align-items: center; + padding-right: 14px; } .jr-exit { font-family: inherit; @@ -7854,8 +8851,14 @@ html[data-platform="macos"] .splash { } @keyframes slide-down { - from { opacity: 0; transform: translateY(-6px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } } .msg-approval { @@ -7863,8 +8866,14 @@ html[data-platform="macos"] .splash { } @keyframes card-in { - from { opacity: 0; transform: translateY(4px) scale(0.98); } - to { opacity: 1; transform: translateY(0) scale(1); } + from { + opacity: 0; + transform: translateY(4px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } } .toast { @@ -7872,8 +8881,14 @@ html[data-platform="macos"] .splash { } @keyframes toast-fall { - from { opacity: 1; transform: translateY(0); } - to { opacity: 0; transform: translateY(12px); } + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(12px); + } } .statusbar .seg { diff --git a/desktop/src/ui/about.tsx b/desktop/src/ui/about.tsx index c475acc53..f1023419a 100644 --- a/desktop/src/ui/about.tsx +++ b/desktop/src/ui/about.tsx @@ -49,9 +49,19 @@ export function AboutModal({ onClose }: { onClose: () => void }) { }, []); return ( -
-
e.stopPropagation()}> -
@@ -88,10 +98,7 @@ export function AboutModal({ onClose }: { onClose: () => void }) { ); } -function CheckStatus({ - check, - onOpenReleases, -}: { check: CheckState; onOpenReleases: () => void }) { +function CheckStatus({ check, onOpenReleases }: { check: CheckState; onOpenReleases: () => void }) { if (check.kind === "idle" || check.kind === "checking") return null; if (check.kind === "up-to-date") { return ( diff --git a/desktop/src/ui/cards.tsx b/desktop/src/ui/cards.tsx index 6dbde8d36..db2533ee1 100644 --- a/desktop/src/ui/cards.tsx +++ b/desktop/src/ui/cards.tsx @@ -1,11 +1,55 @@ -import { memo, useState, type ReactNode } from "react"; -import { I } from "../icons"; +import { type ReactNode, memo, useState } from "react"; import { Markdown } from "../Markdown"; import { t, useLang } from "../i18n"; +import { I } from "../icons"; import { Shortcut } from "./shortcut"; type Tone = "default" | "success" | "warning" | "danger" | "accent" | "violet"; +function hashString(value: string): string { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + return `${value.length}-${(hash >>> 0).toString(36)}`; +} + +function keyed(items: readonly T[], keyFor: (item: T) => string): { item: T; key: string }[] { + const seen = new Map(); + return items.map((item) => { + const base = keyFor(item); + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + return { item, key: count === 0 ? base : `${base}-${count}` }; + }); +} + +function renderReasoningInline(text: string): ReactNode[] { + const out: ReactNode[] = []; + const re = /`([^`]+)`|\*\*([^*]+)\*\*/g; + let last = 0; + let match: RegExpExecArray | null = re.exec(text); + while (match) { + if (match.index > last) out.push(text.slice(last, match.index)); + const code = match[1]; + const strong = match[2]; + const raw = match[0]; + if (code !== undefined) { + out.push( + + {code} + , + ); + } else if (strong !== undefined) { + out.push({strong}); + } + last = match.index + raw.length; + match = re.exec(text); + } + if (last < text.length) out.push(text.slice(last)); + return out; +} + export function Card({ tone = "default", icon, @@ -70,15 +114,25 @@ export type PlanItem = { note?: string; }; -function derivePlanBadge(items: PlanItem[]): { state: "running" | "done" | "failed" | "waiting" | "blocked"; label: string } { - if (items.some((x) => x.status === "failed")) return { state: "failed", label: t("planBadge.failed") }; - if (items.some((x) => x.status === "blocked")) return { state: "blocked", label: t("planBadge.blocked") }; - if (items.some((x) => x.status === "active")) return { state: "running", label: t("planBadge.running") }; - if (items.length > 0 && items.every((x) => x.status === "done")) return { state: "done", label: t("planBadge.done") }; +function derivePlanBadge(items: PlanItem[]): { + state: "running" | "done" | "failed" | "waiting" | "blocked"; + label: string; +} { + if (items.some((x) => x.status === "failed")) + return { state: "failed", label: t("planBadge.failed") }; + if (items.some((x) => x.status === "blocked")) + return { state: "blocked", label: t("planBadge.blocked") }; + if (items.some((x) => x.status === "active")) + return { state: "running", label: t("planBadge.running") }; + if (items.length > 0 && items.every((x) => x.status === "done")) + return { state: "done", label: t("planBadge.done") }; return { state: "waiting", label: t("planBadge.pending") }; } -function StatusIcon({ state, label }: { state: "running" | "done" | "failed" | "waiting" | "blocked"; label: string }) { +function StatusIcon({ + state, + label, +}: { state: "running" | "done" | "failed" | "waiting" | "blocked"; label: string }) { switch (state) { case "running": return ; @@ -127,7 +181,9 @@ export function PlanCardView({ items, title }: { items: PlanItem[]; title?: stri
) : null}
- {it.status === "active" ? : null} + + {it.status === "active" ? : null} + ))} @@ -151,6 +207,7 @@ export function ReasoningCard({ model?: string; }) { useLang(); + const paragraphs = keyed(text.split(/\n\n+/), hashString); return (
- {text.split(/\n\n+/).map((para, i) => ( -

$1') - .replace(/\*\*([^*]+)\*\*/g, "$1"), - }} - /> + {paragraphs.map(({ item: para, key }) => ( +

{renderReasoningInline(para)}

))}
{model || tokens !== undefined ? ( @@ -231,6 +281,7 @@ export function ShellCard({ }) { useLang(); const tone: Tone = state === "failed" ? "danger" : state === "done" ? "success" : "warning"; + const outputLines = output ? keyed(output.split("\n"), hashString) : []; return ( {output ? (
-            {output.split("\n").map((ln, i) => {
+            {outputLines.map(({ item: ln, key }) => {
               if (ln.startsWith(" ✓") || ln.startsWith("✓"))
                 return (
-                  
+
{ln}
); if (ln.startsWith(" ✗") || ln.startsWith("✗") || /error/i.test(ln)) return ( -
+
{ln}
); - return
{ln}
; + return
{ln}
; })}
) : null} @@ -484,6 +535,7 @@ export function DiffCard({ useLang(); const adds = lines.filter((x) => x.t === "add").length; const rms = lines.filter((x) => x.t === "rm").length; + const keyedLines = keyed(lines, (line) => hashString(JSON.stringify(line))); return (
- {lines.map((ln, i) => { + {keyedLines.map(({ item: ln, key }) => { if (ln.t === "hunk") return ( -
+
{ln.s}
); @@ -515,7 +567,7 @@ export function DiffCard({ const l = ln.t === "ctx" ? ln.l : ln.t === "rm" ? ln.l : undefined; const r = ln.t === "ctx" ? ln.r : ln.t === "add" ? ln.r : undefined; return ( -
+
{l ?? ""} {r ?? ""} @@ -552,7 +604,11 @@ export function DiffCard({ // ---- Error ---- -export function ErrorCard({ message, hint, code }: { message: string; hint?: ReactNode; code?: string }) { +export function ErrorCard({ + message, + hint, + code, +}: { message: string; hint?: ReactNode; code?: string }) { useLang(); return ( `${hashString(r.url)}-${hashString(r.title)}-${hashString(r.snippet)}`, + ); return ( "{query}" - {results.length} {t("cards.hits")} + + {results.length} {t("cards.hits")} + } >
- {results.map((r, i) => ( -
+ {keyedResults.map(({ item: r, key }) => ( +
{r.url} @@ -625,6 +687,10 @@ export function SubagentCard({ }) { useLang(); const done = children.filter((c) => c.status === "done").length; + const keyedChildren = keyed( + children, + (child) => `${child.avatar}-${child.what}-${child.role}-${child.status}`, + ); return (
- {children.map((c, i) => ( -
+ {keyedChildren.map(({ item: c, key }) => ( +
{c.avatar}
{c.what}
@@ -674,17 +740,22 @@ export type MemRow = { scope: string; txt: string }; export function MemoryCard({ rows }: { rows: MemRow[] }) { useLang(); + const keyedRows = keyed(rows, (m) => `${m.scope}-${m.txt}`); return ( } kind="memory" name={t("cards.memoryName")} - meta={+ {rows.length} {t("cards.memoryCountSuffix")}} + meta={ + + + {rows.length} {t("cards.memoryCountSuffix")} + + } >
- {rows.map((m, i) => ( -
+ {keyedRows.map(({ item: m, key }) => ( +
{m.scope} {m.txt}
diff --git a/desktop/src/ui/composer-at-popup.test.tsx b/desktop/src/ui/composer-at-popup.test.tsx index 47a4d90f5..7a05fe89f 100644 --- a/desktop/src/ui/composer-at-popup.test.tsx +++ b/desktop/src/ui/composer-at-popup.test.tsx @@ -251,8 +251,6 @@ describe("desktop Composer @ popup", () => { expect(onMentionPicked).toHaveBeenCalledWith("src/very/deep/foo.ts"); const draftUpdate = setDraft.mock.calls.at(-1)?.[0]; expect(typeof draftUpdate).toBe("function"); - expect((draftUpdate as (current: string) => string)("@foo")).toBe( - "@src/very/deep/foo.ts ", - ); + expect((draftUpdate as (current: string) => string)("@foo")).toBe("@src/very/deep/foo.ts "); }); }); diff --git a/desktop/src/ui/composer.tsx b/desktop/src/ui/composer.tsx index a64b97161..f260fa102 100644 --- a/desktop/src/ui/composer.tsx +++ b/desktop/src/ui/composer.tsx @@ -1,3 +1,5 @@ +import { invoke } from "@tauri-apps/api/core"; +import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; import { type ChangeEvent, type KeyboardEvent, @@ -9,14 +11,9 @@ import { useState, } from "react"; import type React from "react"; -import { invoke } from "@tauri-apps/api/core"; -import { open as openFileDialog } from "@tauri-apps/plugin-dialog"; -import { t, type TKey } from "../i18n"; +import { type TKey, t } from "../i18n"; import { I } from "../icons"; -import { - DEFAULT_COMPOSER_ROWS, - applyComposerTextareaAutosize, -} from "./composer-sizing"; +import { DEFAULT_COMPOSER_ROWS, applyComposerTextareaAutosize } from "./composer-sizing"; import { fmtElapsed } from "./live"; import { Shortcut } from "./shortcut"; @@ -29,7 +26,12 @@ const EFFORTS: readonly ReasoningEffort[] = ["low", "medium", "high", "max"]; const MODE_INFO: ModeEntry[] = [ { k: "plan", label: "editMode.plan", icon: , hint: "editMode.planHint" }, - { k: "review", label: "editMode.review", icon: , hint: "editMode.reviewHint" }, + { + k: "review", + label: "editMode.review", + icon: , + hint: "editMode.reviewHint", + }, { k: "auto", label: "editMode.auto", icon: , hint: "editMode.autoHint" }, { k: "yolo", label: "editMode.yolo", icon: , hint: "editMode.yoloHint" }, ]; @@ -75,9 +77,7 @@ export type MentionItem = { desc?: string; }; -export type Chip = - | { kind: "at"; label: string } - | { kind: "slash"; label: string }; +export type Chip = { kind: "at"; label: string } | { kind: "slash"; label: string }; /** For long paths show only the filename; truncate filename if it's still too long. */ function chipLabel(label: string, maxLen = 32): string { @@ -86,13 +86,10 @@ function chipLabel(label: string, maxLen = 32): string { const segments = label.split(sep); const filename = segments[segments.length - 1]!; if (filename.length <= maxLen) return filename; - return filename.slice(0, maxLen - 1) + "…"; + return `${filename.slice(0, maxLen - 1)}…`; } -type Popup = - | { kind: "slash"; query: string } - | { kind: "at"; query: string; nonce: number } - | null; +type Popup = { kind: "slash"; query: string } | { kind: "at"; query: string; nonce: number } | null; type ActiveRange = { start: number; end: number; sigil: string; query: string }; @@ -228,7 +225,7 @@ export function Composer({ setDraft: React.Dispatch>; onDraftUserEdit?: () => void; promptHistoryBrowsing?: boolean; - onPromptHistoryNavigate?: (direction: "older" | "newer") => boolean | void; + onPromptHistoryNavigate?: (direction: "older" | "newer") => boolean | undefined; onSend: () => void; onAbort: () => void; disabled?: boolean; @@ -271,12 +268,31 @@ export function Composer({ }, [draft, pickedChips]); const [activeIdx, setActiveIdx] = useState(0); const [modelMenuOpen, setModelMenuOpen] = useState(false); + const [toolsMenuOpen, setToolsMenuOpen] = useState(false); const backdropContent = useMemo(() => highlightTokens(draft), [draft]); const nonceRef = useRef(0); const modelWrapRef = useRef(null); + const toolsWrapRef = useRef(null); // macOS Chinese IME fires compositionend BEFORE the confirm keydown. const composingRef = useRef(false); const compositionEndedAtRef = useRef(0); + const queuedSendRows = useMemo(() => { + const seen = new Map(); + return (queuedSends ?? []).map((text, index) => { + const occurrence = seen.get(text) ?? 0; + seen.set(text, occurrence + 1); + return { key: `${text}-${occurrence}`, text, index }; + }); + }, [queuedSends]); + const chipRows = useMemo(() => { + const seen = new Map(); + return chips.map((chip, index) => { + const base = `${chip.kind}-${chip.label}`; + const occurrence = seen.get(base) ?? 0; + seen.set(base, occurrence + 1); + return { key: `${base}-${occurrence}`, chip, index }; + }); + }, [chips]); const activeRangeRef = useRef(null); const backdropRef = useRef(null); @@ -297,9 +313,7 @@ export function Composer({ return `${before}${spacerBefore}${insertion}${spacerAfter}${after}`; }); } else { - setDraft((current) => - current ? `${current.replace(/\s+$/, "")} @${rel} ` : `@${rel} `, - ); + setDraft((current) => (current ? `${current.replace(/\s+$/, "")} @${rel} ` : `@${rel} `)); } activeRangeRef.current = null; setPickedChips((prev) => new Map(prev).set(rel, "at")); @@ -328,10 +342,7 @@ export function Composer({ useEffect(() => { if (!modelMenuOpen) return; const onDown = (e: MouseEvent) => { - if ( - modelWrapRef.current && - !modelWrapRef.current.contains(e.target as Node) - ) { + if (modelWrapRef.current && !modelWrapRef.current.contains(e.target as Node)) { setModelMenuOpen(false); } }; @@ -339,6 +350,17 @@ export function Composer({ return () => window.removeEventListener("mousedown", onDown); }, [modelMenuOpen]); + useEffect(() => { + if (!toolsMenuOpen) return; + const onDown = (e: MouseEvent) => { + if (toolsWrapRef.current && !toolsWrapRef.current.contains(e.target as Node)) { + setToolsMenuOpen(false); + } + }; + window.addEventListener("mousedown", onDown); + return () => window.removeEventListener("mousedown", onDown); + }, [toolsMenuOpen]); + const attachFile = async (filter?: "image") => { try { const picked = await openFileDialog({ @@ -412,12 +434,14 @@ export function Composer({ return base; }, [popup, mentionResults]); - const items = - popup?.kind === "slash" ? slashItems : popup?.kind === "at" ? atItems : []; + const popupKind = popup?.kind; + const items = popupKind === "slash" ? slashItems : popupKind === "at" ? atItems : []; useEffect(() => { - setActiveIdx(0); - }, [popup?.kind]); + if (!popupKind || popupKind === "slash" || popupKind === "at") { + setActiveIdx(0); + } + }, [popupKind]); useEffect(() => { setActiveIdx((i) => (items.length ? Math.min(i, items.length - 1) : 0)); @@ -471,6 +495,19 @@ export function Composer({ const dismiss = () => setPopup(null); + const openSlashPopup = () => { + setToolsMenuOpen(false); + setModelMenuOpen(false); + setPopup({ kind: "slash", query: "" }); + }; + + const openMentionPopup = () => { + setToolsMenuOpen(false); + setModelMenuOpen(false); + const nonce = ++nonceRef.current; + setPopup({ kind: "at", query: "", nonce }); + }; + const pickItem = (idx: number) => { const it = items[idx]; if (!it || !popup) return; @@ -540,9 +577,7 @@ export function Composer({ } if (e.key === "ArrowUp") { e.preventDefault(); - setActiveIdx((i) => - items.length ? (i - 1 + items.length) % items.length : 0, - ); + setActiveIdx((i) => (items.length ? (i - 1 + items.length) % items.length : 0)); return; } if (e.key === "Escape") { @@ -611,7 +646,12 @@ export function Composer({ } } } - if (composingRef.current || e.nativeEvent.isComposing || Date.now() - compositionEndedAtRef.current < 50) return; + if ( + composingRef.current || + e.nativeEvent.isComposing || + Date.now() - compositionEndedAtRef.current < 50 + ) + return; if (e.key === "Enter" && !e.shiftKey && !popup) { e.preventDefault(); if (busy) { @@ -630,18 +670,23 @@ export function Composer({ return (
- {queuedSends && queuedSends.length > 0 ? ( + {queuedSendRows.length > 0 ? (
- {t("composer.queueCount", { n: queuedSends.length })} + {t("composer.queueCount", { n: queuedSendRows.length })} - {queuedSends.map((text, i) => ( - - {text} + {queuedSendRows.map((row) => ( + + {row.text} {onDequeueSend ? ( - onDequeueSend(i)}> + ) : null} ))} @@ -654,9 +699,7 @@ export function Composer({ {busyLabel} - - {fmtElapsed(busyElapsedMs ?? 0)} - + {fmtElapsed(busyElapsedMs ?? 0)} @@ -685,16 +728,27 @@ export function Composer({
- {chips.length > 0 ? ( + {chipRows.length > 0 ? (
- {chips.map((c, i) => ( - - {c.kind === "slash" ? ( - - ) : ( - - )} - {chipLabel(c.label)} + {chipRows.map((row) => ( + + {row.chip.kind === "slash" ? : } + {chipLabel(row.chip.label)} + ))}
@@ -713,7 +767,9 @@ export function Composer({ onScroll={handleTextareaScroll} onPaste={(e) => void handlePaste(e)} onKeyDown={handleKeyDown} - onCompositionStart={() => { composingRef.current = true; }} + onCompositionStart={() => { + composingRef.current = true; + }} onCompositionEnd={() => { composingRef.current = false; compositionEndedAtRef.current = Date.now(); @@ -746,8 +802,8 @@ export function Composer({
+
+ + {toolsMenuOpen ? ( +
+
+ ... + {t("app.titlebar.more")} +
+
+ + +
+ { + onModelChange(m); + setToolsMenuOpen(false); + }} + onPickEffort={(e) => { + onEffortChange(e); + setToolsMenuOpen(false); + }} + /> +
+ ) : null} +
{busy ? (
{items.length === 0 ? ( @@ -902,9 +1016,15 @@ function Popup({
) : null} {items.map((it, i) => ( -
onPick(i)} onMouseEnter={() => onHover(i, it)} @@ -929,10 +1049,8 @@ function Popup({ )}
- - {kind === "slash" ? ((it as SlashCmd).kb ?? "") : ""} - -
+ {kind === "slash" ? ((it as SlashCmd).kb ?? "") : ""} + ))}
@@ -963,7 +1081,6 @@ function ModelEffortMenu({ onPickModel: (model: string) => void; onPickEffort: (effort: ReasoningEffort) => void; }) { - const [draft, setDraft] = useState(modelLabel); return (
+ +
+ ); +} + +function ModelEffortContent({ + modelLabel, + currentEffort, + onPickModel, + onPickEffort, +}: { + modelLabel: string; + currentEffort: ReasoningEffort; + onPickModel: (model: string) => void; + onPickEffort: (effort: ReasoningEffort) => void; +}) { + const [draft, setDraft] = useState(modelLabel); + return ( + <>
M {t("composer.switchModel")}
{KNOWN_MODELS.map((m) => ( -
{m}
-
+ ))}
{EFFORTS.map((e) => ( -
{e}
{t(`effort.${e}Desc` as TKey)}
-
+ ))}
-
+ ); } diff --git a/desktop/src/ui/context-panel.test.tsx b/desktop/src/ui/context-panel.test.tsx index 622832100..70c681969 100644 --- a/desktop/src/ui/context-panel.test.tsx +++ b/desktop/src/ui/context-panel.test.tsx @@ -13,6 +13,7 @@ vi.mock("@tauri-apps/plugin-opener", () => ({ openPath: vi.fn() })); const usage: UsageStats = { totalCostUsd: 0, + turnCostUsd: 0, totalPromptTokens: 0, totalCompletionTokens: 0, cacheHitTokens: 0, diff --git a/desktop/src/ui/context-panel.tsx b/desktop/src/ui/context-panel.tsx index 671845311..3502dfdac 100644 --- a/desktop/src/ui/context-panel.tsx +++ b/desktop/src/ui/context-panel.tsx @@ -45,7 +45,7 @@ export function ContextPanel({ useLang(); const [tab, setTab] = useState("files"); useEffect(() => { - if (activeTab) setTab(activeTab); + if (activeTab && (activeTabNonce ?? 0) >= 0) setTab(activeTab); }, [activeTab, activeTabNonce]); const reserved = usage.reservedTokens; const lastHit = usage.lastCallCacheHit ?? 0; @@ -58,21 +58,26 @@ export function ContextPanel({ const usedPct = Math.min(100, (used / CONTEXT_MAX_TOKENS) * 100); const cachedPct = Math.min(100, (cached / CONTEXT_MAX_TOKENS) * 100); const free = Math.max(0, CONTEXT_MAX_TOKENS - reserved - used - cached); + const tabs: { id: ContextPanelTab; label: string }[] = [ + { id: "files", label: t("contextPanel.filesTab") }, + { id: "tools", label: t("contextPanel.toolsTab") }, + { id: "memory", label: t("contextPanel.memoryTab") }, + { id: "rules", label: t("contextPanel.rulesTab") }, + ]; return (