From a8eb58043def4729042bd2018a27b1750c60b97e Mon Sep 17 00:00:00 2001 From: lab1 Date: Sat, 30 May 2026 10:48:43 +0800 Subject: [PATCH 1/2] fix(desktop): improve chat UX and add regression gates --- .github/PULL_REQUEST_TEMPLATE.md | 2 + .github/workflows/ci.yml | 9 + desktop/src/App.test.ts | 18 +- desktop/src/App.tsx | 881 +++++++-- desktop/src/CodeView.tsx | 131 +- desktop/src/CommandPalette.tsx | 29 +- desktop/src/Markdown.tsx | 32 +- desktop/src/i18n/de.ts | 9 +- desktop/src/i18n/en.ts | 3 +- desktop/src/i18n/ja.ts | 4 +- desktop/src/i18n/zh-CN.ts | 3 +- desktop/src/icons.tsx | 330 +++- desktop/src/main.tsx | 7 +- desktop/src/notifications.test.ts | 2 +- desktop/src/notifications.ts | 5 +- desktop/src/styles.css | 1738 +++++++++++++---- desktop/src/ui/about.tsx | 21 +- desktop/src/ui/cards.tsx | 139 +- desktop/src/ui/composer.tsx | 289 ++- desktop/src/ui/context-panel.tsx | 74 +- desktop/src/ui/extra-cards.tsx | 79 +- desktop/src/ui/jobs-pop.tsx | 68 +- desktop/src/ui/jump-bar.tsx | 34 +- desktop/src/ui/live.tsx | 55 +- desktop/src/ui/settings.tsx | 73 +- desktop/src/ui/shortcut.tsx | 14 +- desktop/src/ui/sidebar.tsx | 150 +- desktop/src/ui/splash.tsx | 2 +- desktop/src/ui/statusbar.tsx | 46 +- desktop/src/ui/thread.tsx | 71 +- desktop/src/ui/useAutoCollapse.ts | 4 +- desktop/src/ui/useAutoScroll.ts | 4 +- desktop/src/ui/useDisableTextAssist.ts | 4 +- desktop/src/ui/workdir-pop.tsx | 36 +- desktop/vite.config.ts | 36 +- package.json | 12 +- tests/desktop-sidebar-new-chat-layout.test.ts | 3 +- 37 files changed, 3349 insertions(+), 1068 deletions(-) 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..30268ab30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,16 @@ 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 @@ -36,6 +42,9 @@ jobs: - 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/desktop/src/App.test.ts b/desktop/src/App.test.ts index 6cced4463..31c76b007 100644 --- a/desktop/src/App.test.ts +++ b/desktop/src/App.test.ts @@ -395,14 +395,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 a06e4715f..95bf73a6a 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -7,22 +7,53 @@ 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 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, + IncomingEvent, + JobInfo, + McpSpecInfo, + MemoryDetail, + MemoryEntryInfo, + OutgoingCommand, + PlanVerdict, + 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,47 +72,23 @@ import { isThemeStyle, themeForStyle, } from "./theme"; -import type { - CheckpointVerdict, - ChoiceVerdict, - ConfirmationChoice, - ExternalSessionApp, - ExternalSessionSource, - IncomingEvent, - JobInfo, - McpSpecInfo, - MemoryDetail, - MemoryEntryInfo, - OutgoingCommand, - PlanVerdict, - 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 } from "./ui/context-panel"; import { JobsPop } from "./ui/jobs-pop"; +import { JumpBar } 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, @@ -95,18 +102,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"; -import { useAutoScroll } from "./ui/useAutoScroll"; -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", @@ -116,12 +121,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 } @@ -230,13 +253,20 @@ export type UsageStats = { liveLogTokens: number; }; -type WindowControls = Pick, "isFullscreen" | "isMaximized" | "setFullscreen" | "toggleMaximize">; +type WindowControls = Pick< + ReturnType, + "isFullscreen" | "isMaximized" | "setFullscreen" | "toggleMaximize" +>; export function readWindowExpanded(win: WindowControls, isMac: boolean): Promise { return isMac ? win.isFullscreen() : win.isMaximized(); } -export function toggleWindowExpanded(win: WindowControls, isMac: boolean, expanded: boolean): Promise { +export function toggleWindowExpanded( + win: WindowControls, + isMac: boolean, + expanded: boolean, +): Promise { if (isMac) return win.setFullscreen(!expanded); return win.toggleMaximize(); } @@ -410,9 +440,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 }); } @@ -442,7 +470,12 @@ function reduceRaw(state: State, action: Action): State { busy: true, 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), + }, ], }; } @@ -594,9 +627,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 }; @@ -671,16 +702,13 @@ function DiffStats({ stats }: { stats: FileStats }) { const total = stats.entries.length; return (
- @@ -1095,10 +1123,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(), }, ], @@ -1147,8 +1172,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 }; @@ -1158,8 +1185,7 @@ 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; @@ -1176,18 +1202,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--) { @@ -1195,7 +1222,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; @@ -1209,13 +1248,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; @@ -1247,10 +1299,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; @@ -1303,7 +1352,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 { @@ -1423,12 +1476,19 @@ function TabRuntime({ >(undefined); const composerRef = useRef(null); const threadRef = useRef(null); - const threadInnerRef = useRef(null); const virtuosoRef = useRef(null); + 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 [jobsOpen, setJobsOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false); + const [approvalTrayExpanded, setApprovalTrayExpanded] = useState(false); const previousApprovalSnapshotRef = useRef({ confirms: [], pathAccess: [], @@ -1455,6 +1515,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); @@ -1537,13 +1617,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 applyReasoningEffort = useCallback( (reasoningEffort: Settings["reasoningEffort"]) => { @@ -1595,10 +1672,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; } @@ -1692,7 +1766,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; @@ -1736,13 +1815,15 @@ function TabRuntime({ }, [clearAbortDraft]); // 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) { - setDraft(state.retryText); + const retryText = retryTextRef.current; + if (state.retryNonce > 0 && 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]); const onEditUserMsg = useCallback((t: string) => { @@ -1761,17 +1842,30 @@ function TabRuntime({ 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(); @@ -1822,6 +1916,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 }); @@ -1891,17 +1997,169 @@ function TabRuntime({ }, []); const [showJumpButton, setShowJumpButton] = useState(false); - const { scrollToBottom } = useAutoScroll( - threadRef, - threadInnerRef, - state.busy, - restoreScrollTop, + const refreshJumpButton = useCallback(() => { + const el = virtuosoScrollerRef.current; + if (!el) return; + setShowJumpButton( + userDetachedScrollRef.current && hasScrollableOverflow(el) && !isElementAtBottom(el), + ); + }, []); + + const scrollToBottom = useCallback( + (smooth = true) => { + autoFollowRef.current = true; + userDetachedScrollRef.current = false; + setShowJumpButton(false); + + 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; + } + }, [scheduleScrollToBottom, state.busy]); + + useEffect(() => { + 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 = threadRef.current; + const el = virtuosoScroller; const session = state.currentSession; if (!el || !session) return; const key = `reasonix.scroll.${session}`; @@ -1919,7 +2177,7 @@ function TabRuntime({ el.removeEventListener("scroll", onScroll); clearTimeout(timer); }; - }, [state.currentSession]); + }, [state.currentSession, virtuosoScroller]); useEffect(() => { if (!active) return; @@ -2080,7 +2338,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" }, { @@ -2239,6 +2502,81 @@ 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 ( s.cmd === cmd); - if (match) { match.run(); return; } + if (match) { + match.run(); + return; + } } send(text); }} @@ -2369,18 +2710,28 @@ function TabRuntime({ ref={virtuosoRef} style={{ height: "100%" }} totalCount={messageItems.length} - followOutput={"auto"} - atBottomStateChange={(atBottom) => setShowJumpButton(!atBottom)} + alignToBottom + followOutput={followOutput} + 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, + Scroller: VirtuosoScroller, + Footer: ThreadTail, + Header: state.activePlan + ? () => ( +
+ dispatch({ t: "dismiss_plan" }) + } + /> + +
+ ) + : undefined, }} itemContent={(index) => { const m = state.messages[index]!; @@ -2415,6 +2766,7 @@ function TabRuntime({ )} {showJumpButton ? (
- {state.pendingPlans.length > 0 || state.pendingCheckpoints.length > 0 || state.pendingRevisions.length > 0 || state.pendingConfirms.length > 0 || state.pendingPathAccess.length > 0 || state.pendingChoices.length > 0 || !state.ready ? ( -
- {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} + {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} @@ -2612,6 +3000,7 @@ function TabRuntime({ function WinMinimize() { return ( + Minimize ); @@ -2619,23 +3008,66 @@ function WinMinimize() { function WinMaximize() { return ( - + Maximize + ); } function WinRestore() { return ( - - + Restore + + ); } function WinClose() { return ( - - + Close + + ); } @@ -2680,13 +3112,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?.(); @@ -2799,43 +3239,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} @@ -2848,7 +3345,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(); + }} > @@ -2856,7 +3356,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 ? : } @@ -2864,7 +3367,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(); + }} > @@ -2891,6 +3397,7 @@ function TabBar({ singleTab?: boolean; }) { useLang(); + const closeLabel = t("app.titlebar.close"); return (
{tabs.map((t) => { @@ -2901,33 +3408,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}
); })} -
+
+
); } @@ -2972,17 +3483,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} @@ -3186,7 +3697,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 = @@ -3377,7 +3888,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); @@ -3483,7 +3994,7 @@ export function App() { } }; - const setup = async () => { + const setup = async (_retryAttempt: number) => { startupStderrRef.current = []; setStartupFailure(null); const subs = await Promise.all([ @@ -3617,7 +4128,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 9544f4347..82ab54501 100644 --- a/desktop/src/Markdown.tsx +++ b/desktop/src/Markdown.tsx @@ -3,11 +3,11 @@ import { openPath, openUrl } from "@tauri-apps/plugin-opener"; import { Check, Copy, ExternalLink, FileText } from "lucide-react"; import { Children, + type ReactNode, cloneElement, createContext, isValidElement, memo, - type ReactNode, useContext, useState, } from "react"; @@ -24,7 +24,7 @@ async function openWithEditor( abs: string, line?: number, ): Promise { - if (editor && editor.trim()) { + if (editor?.trim()) { await invoke("open_in_editor", { command: editor, path: abs, line: line ?? null }); return; } @@ -145,28 +145,21 @@ function FilePill({ path, line }: { path: string; line?: string }) { } }; 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 && } - + ); } @@ -230,16 +223,18 @@ 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 // glyph — but the markdown parser won't mistake it for a table separator. - result = result.replace(/\$\$([\s\S]*?)\$\$/g, (_: string, m: string) => - "$$" + m.replace(/\|/g, "\\vert ") + "$$", + result = result.replace( + /\$\$([\s\S]*?)\$\$/g, + (_: string, m: string) => `\$\$${m.replace(/\|/g, "\\vert ")}\$\$`, ); - result = result.replace(/\$([^$\n]+)\$/g, (_: string, m: string) => - "$" + m.replace(/\|/g, "\\vert ") + "$", + result = result.replace( + /\$([^$\n]+)\$/g, + (_: string, m: string) => `\$${m.replace(/\|/g, "\\vert ")}\$`, ); return result; @@ -349,7 +344,8 @@ export function extractFencedLang(children: ReactNode): string { function flattenChildText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(flattenChildText).join(""); - if (isValidElement(node)) return flattenChildText((node.props as { children?: ReactNode }).children); + if (isValidElement(node)) + return flattenChildText((node.props as { children?: ReactNode }).children); return ""; } diff --git a/desktop/src/i18n/de.ts b/desktop/src/i18n/de.ts index 384ff0236..e51df3438 100644 --- a/desktop/src/i18n/de.ts +++ b/desktop/src/i18n/de.ts @@ -186,7 +186,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", @@ -288,7 +289,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", @@ -699,7 +701,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 773fba16f..fba816a28 100644 --- a/desktop/src/i18n/en.ts +++ b/desktop/src/i18n/en.ts @@ -276,7 +276,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", diff --git a/desktop/src/i18n/ja.ts b/desktop/src/i18n/ja.ts index 3a2a79bbf..0a9aaf871 100644 --- a/desktop/src/i18n/ja.ts +++ b/desktop/src/i18n/ja.ts @@ -51,6 +51,7 @@ export const ja = { searchPlaceholder: "最近のパスを検索…", empty: "最近のワークスペースはありません", browse: "ローカルを参照…", + removeRecent: "最近の一覧から削除", }, sidebar: { newChat: "新しいチャット", @@ -273,7 +274,8 @@ export const ja = { modelCustomHint: "エンドポイントが提供するOpenAI互換ID (vLLM, Ollama, Together, …)。", modelCustomActive: "現在カスタムIDで実行中: {model}", contextTokensLabel: "コンテキストウィンドウサイズ", - contextTokensHint: "現在のモデルのプロンプト側トークン上限を上書きします(例: 1000000 で 1M)。空欄なら既定値を使用。", + contextTokensHint: + "現在のモデルのプロンプト側トークン上限を上書きします(例: 1000000 で 1M)。空欄なら既定値を使用。", contextTokensPlaceholder: "自動", effortSection: "推論努力", ctxWindow: "コンテキスト", diff --git a/desktop/src/i18n/zh-CN.ts b/desktop/src/i18n/zh-CN.ts index 769b3e8e9..cab089ea4 100644 --- a/desktop/src/i18n/zh-CN.ts +++ b/desktop/src/i18n/zh-CN.ts @@ -270,7 +270,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: "上下文", 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 a93b368f7..0dc3ad568 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -95,11 +95,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); @@ -113,11 +113,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"] { @@ -366,6 +366,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; @@ -401,8 +410,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)); } @@ -842,10 +850,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; @@ -854,14 +862,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); @@ -877,7 +888,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; @@ -890,7 +901,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; @@ -900,16 +911,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; @@ -1399,27 +1405,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; @@ -1480,18 +1503,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; @@ -1499,6 +1528,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; @@ -1519,10 +1550,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; @@ -1533,6 +1566,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 { @@ -1556,8 +1592,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; @@ -1780,17 +1820,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; @@ -1807,7 +1852,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; @@ -1831,12 +1878,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); @@ -1969,32 +2021,44 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { flex: 1; min-height: 0; position: relative; + overflow: hidden; } -.thread::-webkit-scrollbar { +.thread-scroller { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + overscroll-behavior: contain; +} +.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; @@ -2022,7 +2086,13 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { max-height: 240px; 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 { @@ -2035,18 +2105,31 @@ 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; } +.jump-item:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 2px; +} .jump-dot { height: 3px; border-radius: 2px; 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%; @@ -2070,7 +2153,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%); @@ -2085,7 +2168,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; } @@ -2095,7 +2178,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 ---------- */ @@ -2282,13 +2365,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; @@ -2296,7 +2384,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; @@ -2304,24 +2393,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, @@ -2607,9 +2703,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 { @@ -2755,10 +2849,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; @@ -3246,11 +3337,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; @@ -3571,6 +3658,12 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { .chip .x { cursor: pointer; opacity: 0.5; + border: 0; + padding: 0; + background: transparent; + color: inherit; + display: inline-flex; + align-items: center; } .chip .x:hover { opacity: 1; @@ -3608,7 +3701,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); @@ -3654,6 +3749,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; @@ -3685,7 +3809,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; @@ -3751,6 +3877,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: auto; @@ -3760,6 +3900,7 @@ html:not([data-platform="macos"]) .shortcut kbd[data-key="mod"] { min-width: max-content; } .popup-item { + width: 100%; display: grid; grid-template-columns: 24px 1fr auto; align-items: center; @@ -3768,6 +3909,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"] { @@ -3860,9 +4003,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); } @@ -4178,18 +4327,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); @@ -4359,7 +4526,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; @@ -4393,12 +4562,7 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { /* shimmer for streaming text */ .shimmer { - background: linear-gradient( - 90deg, - var(--fg-2) 0%, - var(--accent) 50%, - var(--fg-2) 100% - ); + background: linear-gradient(90deg, var(--fg-2) 0%, var(--accent) 50%, var(--fg-2) 100%); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; @@ -4453,7 +4617,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; } @@ -4464,12 +4630,7 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { } .thinking .label .sh { - background: linear-gradient( - 90deg, - var(--muted) 0%, - var(--accent) 50%, - var(--muted) 100% - ); + background: linear-gradient(90deg, var(--muted) 0%, var(--accent) 50%, var(--muted) 100%); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; @@ -4606,11 +4767,21 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { background-position: -100% 0; } } -.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 { @@ -4639,15 +4810,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 { @@ -4713,7 +4896,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); } @@ -4741,8 +4927,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 { @@ -4770,14 +4960,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 */ @@ -4805,8 +5015,13 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { animation: pulse-soft 1.6s ease-in-out infinite; } @keyframes pulse-soft { - 0%, 100% { opacity: 0; } - 50% { opacity: 1; } + 0%, + 100% { + opacity: 0; + } + 50% { + opacity: 1; + } } .user-status { display: inline-flex; @@ -4842,8 +5057,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 { @@ -4876,12 +5095,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); @@ -4927,7 +5151,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; @@ -5284,7 +5510,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; @@ -5339,6 +5567,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); @@ -5372,8 +5604,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; @@ -5445,13 +5681,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; @@ -5489,8 +5728,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); @@ -5540,8 +5783,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 { @@ -5628,47 +5875,149 @@ 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: ""; @@ -5677,12 +6026,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; @@ -5700,7 +6061,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; @@ -5769,16 +6132,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; } /* ============================================================ @@ -5798,20 +6187,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; @@ -5820,11 +6232,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; @@ -5832,34 +6256,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 @@ -5873,10 +6390,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 @@ -5888,16 +6422,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; @@ -5906,7 +6452,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; @@ -5946,26 +6494,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; @@ -5974,22 +6543,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 @@ -6003,11 +6596,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); @@ -6016,7 +6621,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; @@ -6025,34 +6632,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; } /* ============================================================ @@ -6065,7 +6705,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; @@ -6075,13 +6717,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); @@ -6099,10 +6754,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 @@ -6114,18 +6779,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; @@ -6147,20 +6828,61 @@ html:not([data-platform="macos"]) .cmdk-row .kb .shortcut kbd[data-key="mod"] { 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 .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.2 25 / 0.08); + padding: 0 4px; + text-decoration: line-through; + opacity: 0.85; +} /* ============================================================ CODE SEARCH @@ -6174,17 +6896,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); @@ -6194,7 +6928,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; @@ -6204,9 +6941,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); @@ -6224,22 +6971,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); @@ -6248,10 +7022,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; @@ -6274,10 +7061,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 @@ -6297,10 +7098,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 @@ -6319,37 +7131,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 { @@ -6370,8 +7231,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 { @@ -6401,7 +7263,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 ============================================================ */ @@ -6454,14 +7318,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 */ @@ -6469,7 +7354,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; + } } /* ============================================================ @@ -6487,12 +7374,17 @@ 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); animation: yolo-pulse 1.8s ease-in-out infinite; } @keyframes yolo-pulse { - 0%, 100% { box-shadow: 0 0 0 2px oklch(68% 0.20 25 / 0.18); } - 50% { box-shadow: 0 0 0 3px oklch(68% 0.20 25 / 0.32); } + 0%, + 100% { + box-shadow: 0 0 0 2px oklch(68% 0.2 25 / 0.18); + } + 50% { + box-shadow: 0 0 0 3px oklch(68% 0.2 25 / 0.32); + } } .ms-seg { display: inline-flex; @@ -6504,25 +7396,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; @@ -6544,8 +7444,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; + } } /* ============================================================ @@ -6569,8 +7473,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; + } } /* ============================================================ @@ -6588,7 +7498,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; @@ -6630,7 +7542,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; @@ -6644,8 +7558,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); } @@ -6653,7 +7572,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; @@ -6680,7 +7601,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; @@ -6752,16 +7676,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); @@ -6769,7 +7715,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); @@ -6778,7 +7725,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; @@ -6796,7 +7745,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 { @@ -6830,6 +7779,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) @@ -6923,7 +7879,8 @@ html[data-platform="macos"] .splash { animation-delay: 0.32s; } @keyframes splash-pulse { - 0%, 100% { + 0%, + 100% { opacity: 0.25; transform: scale(0.8); } @@ -7052,16 +8009,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; @@ -7124,6 +8099,7 @@ html[data-platform="macos"] .splash { display: inline-flex; gap: 4px; align-items: center; + padding-right: 14px; } .jr-exit { font-family: inherit; @@ -7279,8 +8255,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 { @@ -7288,8 +8270,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 { @@ -7297,8 +8285,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.tsx b/desktop/src/ui/composer.tsx index bfa8bb503..e1d06280e 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" }, ]; @@ -74,14 +76,9 @@ 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 }; -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; function slashIcon(cmd: string) { const m: Record = { @@ -193,8 +190,10 @@ export function Composer({ const [popup, setPopup] = useState(null); const [activeIdx, setActiveIdx] = useState(0); const [modelMenuOpen, setModelMenuOpen] = useState(false); + const [toolsMenuOpen, setToolsMenuOpen] = useState(false); 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); @@ -203,6 +202,23 @@ export function Composer({ const historyRef = useRef(initialHistory ? [...initialHistory].reverse() : []); const [browseIdx, setBrowseIdx] = useState(-1); const savedDraftRef = useRef(""); + 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]); // `initialHistory` arrives asynchronously (settings load after mount). // Sync historyRef when it first becomes available and the user hasn't @@ -218,9 +234,7 @@ export function Composer({ workspaceDir && picked.startsWith(workspaceDir) ? picked.slice(workspaceDir.length).replace(/^[\\/]+/, "") : picked; - setDraft((current) => - current ? `${current.replace(/\s+$/, "")} @${rel} ` : `@${rel} `, - ); + setDraft((current) => (current ? `${current.replace(/\s+$/, "")} @${rel} ` : `@${rel} `)); setChips((c) => [...c, { kind: "at", label: rel }]); onMentionPicked?.(rel); textareaRef.current?.focus(); @@ -245,10 +259,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); } }; @@ -256,6 +267,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({ @@ -325,12 +347,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)); @@ -361,6 +385,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; @@ -436,9 +473,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") { @@ -513,18 +548,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} ))} @@ -537,9 +577,7 @@ export function Composer({ {busyLabel} - - {fmtElapsed(busyElapsedMs ?? 0)} - + {fmtElapsed(busyElapsedMs ?? 0)} @@ -568,24 +606,20 @@ export function Composer({
- {chips.length > 0 ? ( + {chipRows.length > 0 ? (
- {chips.map((c, i) => ( - - {c.kind === "slash" ? ( - - ) : ( - - )} - {c.label} - ( + + {row.chip.kind === "slash" ? : } + {row.chip.label} + ))}
@@ -598,7 +632,9 @@ export function Composer({ onChange={handleChange} onPaste={(e) => void handlePaste(e)} onKeyDown={handleKeyDown} - onCompositionStart={() => { composingRef.current = true; }} + onCompositionStart={() => { + composingRef.current = true; + }} onCompositionEnd={() => { composingRef.current = false; compositionEndedAtRef.current = Date.now(); @@ -630,8 +666,8 @@ export function Composer({
+
+ + {toolsMenuOpen ? ( +
+
+ ... + {t("app.titlebar.more")} +
+
+ + +
+ { + onModelChange(m); + setToolsMenuOpen(false); + }} + onPickEffort={(e) => { + onEffortChange(e); + setToolsMenuOpen(false); + }} + /> +
+ ) : null} +
{busy ? (
{items.length === 0 ? ( @@ -783,9 +877,15 @@ function Popup({
) : null} {items.map((it, i) => ( -
onPick(i)} onMouseEnter={() => onHover(i, it)} @@ -810,10 +910,8 @@ function Popup({ )}
- - {kind === "slash" ? ((it as SlashCmd).kb ?? "") : ""} - -
+ {kind === "slash" ? ((it as SlashCmd).kb ?? "") : ""} + ))}
@@ -844,7 +942,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.tsx b/desktop/src/ui/context-panel.tsx index 876d77169..f81b95d1c 100644 --- a/desktop/src/ui/context-panel.tsx +++ b/desktop/src/ui/context-panel.tsx @@ -44,21 +44,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: Tab; 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 (