From 236f75ace93443dc6a3105d5c6ced2d40bdd3bc2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 22:01:34 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20port=20t3code=20UX=20=E2=80=94=20mo?= =?UTF-8?q?del=20slug,=20code=20copy,=20sounds,=20PR=20ahead=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspired by upstream t3code PRs: - https://github.com/pingdotgg/t3code/pull/1 (resolveModelSlug-style normalization) - https://github.com/pingdotgg/t3code/pull/2092 (code block copy for touch / position / clipboard fallback) - https://github.com/pingdotgg/t3code/pull/2057 (completion chime when agent turn settles) - https://github.com/pingdotgg/t3code/pull/2081 (surface ahead-of-base for clean pushed branches on mobile create PR) Adds resolveModelSlug with optional provider hint, chat code copy controls and non-secure clipboard fallback, optional Web Audio completion sound with settings, and commitsAheadOfBase on mobile PR create eligibility with iOS subtitle. Co-authored-by: Arul Sharma --- .../prs/prService.mobileSnapshot.test.ts | 1 + .../src/main/services/prs/prService.ts | 2 + .../components/chat/AgentChatPane.tsx | 38 +++++++++ .../components/chat/CodeHighlighter.tsx | 42 +++++++--- .../components/settings/GeneralSection.tsx | 77 ++++++++++++++++++- .../renderer/lib/agentTurnCompletionSound.ts | 50 ++++++++++++ apps/desktop/src/renderer/state/appStore.ts | 65 +++++++++++++++- apps/desktop/src/shared/modelRegistry.test.ts | 11 +++ apps/desktop/src/shared/modelRegistry.ts | 14 ++++ apps/desktop/src/shared/types/prs.ts | 5 ++ apps/ios/ADE/Models/RemoteModels.swift | 2 + .../ADE/Views/PRs/CreatePrWizardView.swift | 8 +- apps/ios/ADETests/ADETests.swift | 4 + 13 files changed, 305 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/agentTurnCompletionSound.ts diff --git a/apps/desktop/src/main/services/prs/prService.mobileSnapshot.test.ts b/apps/desktop/src/main/services/prs/prService.mobileSnapshot.test.ts index bd3bdfa41..a9c1868ac 100644 --- a/apps/desktop/src/main/services/prs/prService.mobileSnapshot.test.ts +++ b/apps/desktop/src/main/services/prs/prService.mobileSnapshot.test.ts @@ -306,6 +306,7 @@ describe("prService.getMobileSnapshot", () => { const eligibleEntry = snapshot.createCapabilities.lanes.find((lane) => lane.laneId === "lane-feat")!; expect(eligibleEntry.canCreate).toBe(true); expect(eligibleEntry.blockedReason).toBeNull(); + expect(eligibleEntry.commitsAheadOfBase).toBe(0); }); it("includes queue and rebase workflow cards and skips completed queues", async () => { diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 5f8612d5d..e9e331258 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -5510,6 +5510,7 @@ export function createPrService({ primaryBranchRef: primaryLane?.branchRef ?? null, }); const dirty = lane.status?.dirty === true; + const commitsAheadOfBase = Math.max(0, Number(lane.status?.ahead ?? 0) || 0); const hasExistingPr = existingPr !== null && (existingPr.state === "open" || existingPr.state === "draft"); const canCreate = !hasExistingPr; const blockedReason = hasExistingPr @@ -5524,6 +5525,7 @@ export function createPrService({ defaultBaseBranch, defaultTitle: lane.name, dirty, + commitsAheadOfBase, hasExistingPr, canCreate, blockedReason, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 8e6d3ad6e..44db1a3c6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -76,6 +76,7 @@ import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; import { getAgentChatModelsCached, getAiStatusCached } from "../../lib/aiDiscoveryCache"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; +import { playAgentTurnCompletionSound } from "../../lib/agentTurnCompletionSound"; const LAST_MODEL_ID_KEY = "ade.chat.lastModelId"; const LAST_REASONING_KEY_PREFIX = "ade.chat.lastReasoningEffort"; @@ -710,6 +711,7 @@ export function AgentChatPane({ onLaneChange?: (laneId: string) => void; }) { const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const agentTurnCompletionSound = useAppStore((s) => s.agentTurnCompletionSound); const navigate = useNavigate(); const openAiProvidersSettings = useCallback(() => { navigate("/settings?tab=ai#ai-providers"); @@ -777,6 +779,8 @@ export function AgentChatPane({ const shellRef = useRef(null); const composerMaxHeightPx = layoutVariant === "grid-tile" ? 144 : null; const sessionsRef = useRef(sessions); + const completionSoundPrevTurnActiveRef = useRef(false); + const completionSoundArmedRef = useRef(true); const appliedInitialSessionIdRef = useRef(initialSessionId ?? null); const loadedHistoryRef = useRef>(new Set()); @@ -824,6 +828,40 @@ export function AgentChatPane({ const pendingInput = selectedSessionId ? (pendingInputsBySession[selectedSessionId]?.[0] ?? null) : null; const selectedSessionAwaitingInput = Boolean(pendingInput) || selectedSession?.awaitingInput === true; const turnActive = selectedSessionId ? (turnActiveBySession[selectedSessionId] ?? false) : false; + + useEffect(() => { + completionSoundPrevTurnActiveRef.current = false; + completionSoundArmedRef.current = true; + }, [selectedSessionId]); + + useEffect(() => { + if (agentTurnCompletionSound === "off") { + completionSoundPrevTurnActiveRef.current = turnActive; + return; + } + if (turnActive) { + completionSoundArmedRef.current = true; + } + const sessionEnded = selectedSession?.status === "ended"; + const settled = + Boolean(selectedSessionId) + && !selectedSessionAwaitingInput + && !sessionEnded; + const prevTurn = completionSoundPrevTurnActiveRef.current; + const becameIdle = settled && prevTurn && !turnActive; + completionSoundPrevTurnActiveRef.current = turnActive; + if (becameIdle && completionSoundArmedRef.current) { + completionSoundArmedRef.current = false; + playAgentTurnCompletionSound(agentTurnCompletionSound); + } + }, [ + agentTurnCompletionSound, + selectedSessionId, + selectedSession?.status, + selectedSessionAwaitingInput, + turnActive, + ]); + const activeProviderConnection = selectedSession?.provider === "claude" ? (providerConnections?.claude ?? null) : selectedSession?.provider === "codex" diff --git a/apps/desktop/src/renderer/components/chat/CodeHighlighter.tsx b/apps/desktop/src/renderer/components/chat/CodeHighlighter.tsx index 6e9f472f2..e73bd572b 100644 --- a/apps/desktop/src/renderer/components/chat/CodeHighlighter.tsx +++ b/apps/desktop/src/renderer/components/chat/CodeHighlighter.tsx @@ -1,5 +1,6 @@ import React, { Suspense, useCallback, useEffect, useState, useRef } from "react"; import { CopySimple, Checks } from "@phosphor-icons/react"; +import { useAppStore, type CodeBlockCopyButtonPosition } from "../../state/appStore"; /* ── LRU cache for highlighted HTML ── */ @@ -113,25 +114,47 @@ function DiffCodeBlock({ code }: { code: string }) { /* ── Copy button ── */ -function CodeCopyButton({ code }: { code: string }) { +function copyTextToClipboard(text: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + return navigator.clipboard.writeText(text).then(() => true).catch(() => false); + } + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return Promise.resolve(ok); + } catch { + return Promise.resolve(false); + } +} + +function CodeCopyButton({ code, position }: { code: string; position: CodeBlockCopyButtonPosition }) { const [copied, setCopied] = useState(false); const handleCopy = useCallback(() => { - if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) return; - void navigator.clipboard.writeText(code) - .then(() => { + void copyTextToClipboard(code) + .then((ok) => { + if (!ok) { + setCopied(false); + return; + } setCopied(true); window.setTimeout(() => setCopied(false), 1_500); - }) - .catch(() => { - setCopied(false); }); }, [code]); + const posClass = position === "bottom" ? "bottom-2 top-auto" : "top-2"; + return ( + ))} + + + +
+
AGENT TURN COMPLETION SOUND
+
+ Plays when the assistant finishes a turn and the session is idle (not while you still owe a reply or approval). +
+
+ + +
+
+ + +
AI MODE
diff --git a/apps/desktop/src/renderer/lib/agentTurnCompletionSound.ts b/apps/desktop/src/renderer/lib/agentTurnCompletionSound.ts new file mode 100644 index 000000000..8874a4279 --- /dev/null +++ b/apps/desktop/src/renderer/lib/agentTurnCompletionSound.ts @@ -0,0 +1,50 @@ +import type { AgentTurnCompletionSound } from "../state/appStore"; + +function playChime(ctx: AudioContext, frequency: number, durationSec: number, type: OscillatorType) { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(frequency, ctx.currentTime); + gain.gain.setValueAtTime(0.0001, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.12, ctx.currentTime + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + durationSec); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + durationSec + 0.05); +} + +/** + * Short synthesized notification (no asset files). Safe to call from UI after user gesture for preview. + */ +export function playAgentTurnCompletionSound(kind: Exclude): void { + const Ctor = window.AudioContext ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctor) return; + const ctx = new Ctor(); + const now = ctx.currentTime; + try { + if (kind === "chime") { + playChime(ctx, 880, 0.22, "sine"); + playChime(ctx, 1320, 0.18, "sine"); + } else if (kind === "ping") { + playChime(ctx, 1200, 0.12, "triangle"); + } else { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = "square"; + osc.frequency.setValueAtTime(520, now); + osc.frequency.exponentialRampToValueAtTime(380, now + 0.08); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(0.06, now + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.2); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now); + osc.stop(now + 0.25); + } + } catch { + // ignore — autoplay or suspended context + } finally { + void ctx.close().catch(() => {}); + } +} diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 669d13834..9fe82a033 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -29,6 +29,23 @@ export const DEFAULT_TERMINAL_PREFERENCES: TerminalPreferences = { lineHeight: 1.25, scrollback: 10_000, }; + +/** Where the copy control sits on fenced code blocks in chat (touch-friendly when bottom). */ +export type CodeBlockCopyButtonPosition = "top" | "bottom"; +export const CODE_BLOCK_COPY_POSITION_IDS: CodeBlockCopyButtonPosition[] = ["top", "bottom"]; + +/** Web Audio chime when an agent chat turn finishes (idle session). */ +export type AgentTurnCompletionSound = "off" | "chime" | "ping" | "bell"; +export const AGENT_TURN_COMPLETION_SOUND_IDS: AgentTurnCompletionSound[] = ["off", "chime", "ping", "bell"]; + +function normalizeCodeBlockCopyButtonPosition(value: unknown): CodeBlockCopyButtonPosition { + return value === "bottom" ? "bottom" : "top"; +} + +function normalizeAgentTurnCompletionSound(value: unknown): AgentTurnCompletionSound { + if (value === "chime" || value === "ping" || value === "bell") return value; + return "off"; +} export type TerminalAttentionIndicator = "none" | "running-active" | "running-needs-attention"; export type WorkViewMode = "tabs" | "grid"; export type WorkStatusFilter = "all" | "running" | "awaiting-input" | "ended"; @@ -225,6 +242,8 @@ type PersistedUserPreferences = { theme: ThemeId; terminalPreferences: TerminalPreferences; smartTooltipsEnabled: boolean; + codeBlockCopyButtonPosition: CodeBlockCopyButtonPosition; + agentTurnCompletionSound: AgentTurnCompletionSound; }; function coerceTheme(value: unknown): ThemeId | null { @@ -243,6 +262,8 @@ function readUnifiedUserPreferences(): PersistedUserPreferences | null { theme: coerceTheme(parsed.theme) ?? "dark", terminalPreferences: normalizeTerminalPreferences(parsed.terminalPreferences), smartTooltipsEnabled: parsed.smartTooltipsEnabled !== false, + codeBlockCopyButtonPosition: normalizeCodeBlockCopyButtonPosition(parsed.codeBlockCopyButtonPosition), + agentTurnCompletionSound: normalizeAgentTurnCompletionSound(parsed.agentTurnCompletionSound), }; } catch { return null; @@ -269,7 +290,13 @@ function readLegacyUserPreferences(): PersistedUserPreferences { } catch { // ignore } - return { theme, terminalPreferences, smartTooltipsEnabled }; + return { + theme, + terminalPreferences, + smartTooltipsEnabled, + codeBlockCopyButtonPosition: "top", + agentTurnCompletionSound: "off", + }; } function persistUserPreferences(prefs: PersistedUserPreferences) { @@ -349,6 +376,8 @@ type AppState = { projectRevision: number; theme: ThemeId; terminalPreferences: TerminalPreferences; + codeBlockCopyButtonPosition: CodeBlockCopyButtonPosition; + agentTurnCompletionSound: AgentTurnCompletionSound; providerMode: ProviderMode; availableModels: ModelDescriptor[]; laneInspectorTabs: Record; @@ -369,6 +398,8 @@ type AppState = { selectRunLane: (laneId: string | null) => void; focusSession: (sessionId: string | null) => void; setTheme: (theme: ThemeId) => void; + setCodeBlockCopyButtonPosition: (position: CodeBlockCopyButtonPosition) => void; + setAgentTurnCompletionSound: (sound: AgentTurnCompletionSound) => void; setTerminalPreferences: ( next: | Partial @@ -473,6 +504,8 @@ export const useAppStore = create((set, get) => ({ projectRevision: 0, theme: initialUserPreferences.theme, terminalPreferences: initialUserPreferences.terminalPreferences, + codeBlockCopyButtonPosition: initialUserPreferences.codeBlockCopyButtonPosition, + agentTurnCompletionSound: initialUserPreferences.agentTurnCompletionSound, providerMode: "guest", availableModels: [...MODEL_REGISTRY].filter((m) => !m.deprecated), laneInspectorTabs: {}, @@ -517,9 +550,35 @@ export const useAppStore = create((set, get) => ({ theme, terminalPreferences: prev.terminalPreferences, smartTooltipsEnabled: prev.smartTooltipsEnabled, + codeBlockCopyButtonPosition: prev.codeBlockCopyButtonPosition, + agentTurnCompletionSound: prev.agentTurnCompletionSound, }); return { theme }; }), + setCodeBlockCopyButtonPosition: (position) => + set((prev) => { + const next = normalizeCodeBlockCopyButtonPosition(position); + persistUserPreferences({ + theme: prev.theme, + terminalPreferences: prev.terminalPreferences, + smartTooltipsEnabled: prev.smartTooltipsEnabled, + codeBlockCopyButtonPosition: next, + agentTurnCompletionSound: prev.agentTurnCompletionSound, + }); + return { codeBlockCopyButtonPosition: next }; + }), + setAgentTurnCompletionSound: (sound) => + set((prev) => { + const next = normalizeAgentTurnCompletionSound(sound); + persistUserPreferences({ + theme: prev.theme, + terminalPreferences: prev.terminalPreferences, + smartTooltipsEnabled: prev.smartTooltipsEnabled, + codeBlockCopyButtonPosition: prev.codeBlockCopyButtonPosition, + agentTurnCompletionSound: next, + }); + return { agentTurnCompletionSound: next }; + }), setTerminalPreferences: (next) => set((prev) => { const updated = normalizeTerminalPreferences( @@ -531,6 +590,8 @@ export const useAppStore = create((set, get) => ({ theme: prev.theme, terminalPreferences: updated, smartTooltipsEnabled: prev.smartTooltipsEnabled, + codeBlockCopyButtonPosition: prev.codeBlockCopyButtonPosition, + agentTurnCompletionSound: prev.agentTurnCompletionSound, }); return { terminalPreferences: updated }; }), @@ -541,6 +602,8 @@ export const useAppStore = create((set, get) => ({ theme: prev.theme, terminalPreferences: prev.terminalPreferences, smartTooltipsEnabled: enabled, + codeBlockCopyButtonPosition: prev.codeBlockCopyButtonPosition, + agentTurnCompletionSound: prev.agentTurnCompletionSound, }); return { smartTooltipsEnabled: enabled }; }), diff --git a/apps/desktop/src/shared/modelRegistry.test.ts b/apps/desktop/src/shared/modelRegistry.test.ts index b22828704..a7dad6284 100644 --- a/apps/desktop/src/shared/modelRegistry.test.ts +++ b/apps/desktop/src/shared/modelRegistry.test.ts @@ -15,6 +15,7 @@ import { resolveModelAlias, resolveModelDescriptor, resolveModelDescriptorForProvider, + resolveModelSlug, } from "./modelRegistry"; import type { ProviderFamily } from "./modelRegistry"; import { describeModelSource } from "../renderer/lib/modelOptions"; @@ -45,6 +46,16 @@ describe("modelRegistry", () => { expect(descriptor?.displayName).toBe("qwen2.5-coder:32b (Ollama)"); }); + it("resolveModelSlug returns canonical id for registry input and codex-hinted refs", () => { + const byId = resolveModelSlug(" anthropic/claude-opus-4-7 "); + expect(byId).toBe("anthropic/claude-opus-4-7"); + expect(resolveModelSlug("gpt-5.4")).toBeUndefined(); + expect(resolveModelSlug("gpt-5.4", "codex")).toBe("openai/gpt-5.4-codex"); + expect(resolveModelSlug("")).toBeUndefined(); + expect(resolveModelSlug(" ")).toBeUndefined(); + expect(resolveModelSlug("not-a-real-model-xyz")).toBeUndefined(); + }); + it("returns dynamic local descriptors from getModelById", () => { const descriptor = getModelById("lmstudio/meta-llama-3.1-70b-instruct"); expect(descriptor).toBeTruthy(); diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index 03f31b7cb..41c1223bd 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -876,6 +876,20 @@ export function resolveModelDescriptor(modelRef: string): ModelDescriptor | unde return getModelById(normalized) ?? resolveModelAlias(normalized); } +/** + * Normalize a free-form model reference to a canonical registry id when possible. + * Accepts aliases and mixed casing; returns undefined when unknown or blank. + * When `providerHint` is set, ambiguous refs (e.g. bare Codex runtime names) resolve like the chat runtime. + */ +export function resolveModelSlug(modelRef: string, providerHint?: ModelProviderGroup): string | undefined { + const normalized = modelRef.trim(); + if (!normalized.length) return undefined; + if (providerHint) { + return resolveModelDescriptorForProvider(normalized, providerHint)?.id; + } + return resolveModelDescriptor(normalized)?.id; +} + function matchesProviderGroup( descriptor: ModelDescriptor, providerHint?: ModelProviderGroup, diff --git a/apps/desktop/src/shared/types/prs.ts b/apps/desktop/src/shared/types/prs.ts index 89aa87564..4ac3e0106 100644 --- a/apps/desktop/src/shared/types/prs.ts +++ b/apps/desktop/src/shared/types/prs.ts @@ -1432,6 +1432,11 @@ export type PrCreateLaneEligibility = { defaultBaseBranch: string; defaultTitle: string; dirty: boolean; + /** + * Commits on this lane branch not present on `defaultBaseBranch` (merge-base diff). + * Lets mobile show "already pushed, open PR" when the worktree is clean but ahead. + */ + commitsAheadOfBase: number; hasExistingPr: boolean; canCreate: boolean; /** Why creation is not allowed. Null when canCreate is true. */ diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 98c35d2b5..ed30d3d01 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2105,6 +2105,8 @@ struct PrCreateLaneEligibility: Codable, Identifiable, Equatable { var defaultBaseBranch: String var defaultTitle: String var dirty: Bool + /// Commits on the lane branch not on `defaultBaseBranch` (same signal as desktop lane status `ahead`). + var commitsAheadOfBase: Int var hasExistingPr: Bool var canCreate: Bool var blockedReason: String? diff --git a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift index 330f41296..0d1d1fa7d 100644 --- a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift +++ b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift @@ -41,13 +41,17 @@ struct CreatePrWizardView: View { return capabilities.lanes .filter { $0.canCreate } .map { eligibility in - CreatePrLaneOption( + let aheadNote: String? = + eligibility.commitsAheadOfBase > 0 + ? "\(eligibility.commitsAheadOfBase) commit\(eligibility.commitsAheadOfBase == 1 ? "" : "s") ahead of \(eligibility.defaultBaseBranch)" + : nil + return CreatePrLaneOption( id: eligibility.laneId, title: eligibility.laneName, branchRef: lanes.first(where: { $0.id == eligibility.laneId })?.branchRef ?? eligibility.laneName, defaultBaseBranch: eligibility.defaultBaseBranch, defaultTitle: eligibility.defaultTitle, - subtitle: eligibility.blockedReason + subtitle: aheadNote ) } } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 9d2a9efca..ea4fee53d 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -3601,6 +3601,7 @@ final class ADETests: XCTestCase { "defaultBaseBranch": "main", "defaultTitle": "new", "dirty": false, + "commitsAheadOfBase": 0, "hasExistingPr": false, "canCreate": true, "blockedReason": null @@ -3614,6 +3615,7 @@ final class ADETests: XCTestCase { "defaultBaseBranch": "main", "defaultTitle": "blocked", "dirty": false, + "commitsAheadOfBase": 2, "hasExistingPr": true, "canCreate": false, "blockedReason": "Lane already has an open PR (#7)." @@ -3815,6 +3817,7 @@ final class ADETests: XCTestCase { defaultBaseBranch: "main", defaultTitle: "feat/new", dirty: false, + commitsAheadOfBase: 1, hasExistingPr: false, canCreate: true, blockedReason: nil @@ -3828,6 +3831,7 @@ final class ADETests: XCTestCase { defaultBaseBranch: "main", defaultTitle: "feat/blocked", dirty: false, + commitsAheadOfBase: 0, hasExistingPr: true, canCreate: false, blockedReason: "Lane already has an open PR (#12)." From aafb1f3edad61657cebd8a984ff53a48db099edd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 05:16:27 +0000 Subject: [PATCH 2/8] fix: banner dismiss controls, resilient completion sound, tests - Dismiss missing-AI and GitHub setup banners per session (inspired by https://github.com/pingdotgg/t3code/pull/773) - Resume suspended AudioContext, defer AudioContext.close, use global setTimeout - Add vitest coverage for sound helper and banner dismiss; extend appStore prefs test Co-authored-by: Arul Sharma --- .../renderer/components/app/AppShell.test.tsx | 38 ++++++++++ .../src/renderer/components/app/AppShell.tsx | 73 ++++++++++++++++--- .../lib/agentTurnCompletionSound.test.ts | 56 ++++++++++++++ .../renderer/lib/agentTurnCompletionSound.ts | 55 +++++++++----- .../src/renderer/state/appStore.test.ts | 20 ++++- 5 files changed, 212 insertions(+), 30 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/agentTurnCompletionSound.test.ts diff --git a/apps/desktop/src/renderer/components/app/AppShell.test.tsx b/apps/desktop/src/renderer/components/app/AppShell.test.tsx index 43840d537..22765385c 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.test.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.test.tsx @@ -332,6 +332,44 @@ describe("AppShell", () => { expect( screen.getByText(/No AI provider is configured yet/i), ).toBeTruthy(); + + fireEvent.click(screen.getByTestId("dismiss-missing-ai-banner")); + + expect( + screen.queryByText(/No AI provider is configured yet/i), + ).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("dismisses the GitHub not connected banner for the current session", async () => { + vi.useFakeTimers(); + try { + globalThis.window.ade.github.getStatus = vi.fn(async () => ({ tokenStored: false })) as any; + + render( + + +
child
+
+
, + ); + + await act(async () => { + vi.advanceTimersByTime(1_000); + await Promise.resolve(); + }); + + expect( + screen.getByText(/GitHub is not connected for this ADE app yet/i), + ).toBeTruthy(); + + fireEvent.click(screen.getByTestId("dismiss-github-banner")); + + expect( + screen.queryByText(/GitHub is not connected for this ADE app yet/i), + ).toBeNull(); } finally { vi.useRealTimers(); } diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 215c5cefd..415e99560 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -182,6 +182,12 @@ export function AppShell({ children }: { children: React.ReactNode }) { ); const [dismissedContextBannerRoots, setDismissedContextBannerRoots] = useState>({}); + /** Session dismiss for the “no AI provider” banner (per project root). */ + const [dismissedMissingAiBannerRoots, setDismissedMissingAiBannerRoots] = + useState>({}); + /** Session dismiss for the “GitHub not connected” banner (per project root). */ + const [dismissedGithubBannerRoots, setDismissedGithubBannerRoots] = + useState>({}); const [projectMissing, setProjectMissing] = useState(false); const [feedbackGenerating, setFeedbackGenerating] = useState(false); const previousProjectRootRef = useRef(undefined); @@ -480,6 +486,11 @@ export function AppShell({ children }: { children: React.ReactNode }) { setProjectMissing(false); }, [project?.rootPath]); + useEffect(() => { + setDismissedMissingAiBannerRoots({}); + setDismissedGithubBannerRoots({}); + }, [project?.rootPath]); + useEffect(() => { const previousProjectRoot = previousProjectRootRef.current; const nextProjectRoot = project?.rootPath ?? null; @@ -603,6 +614,12 @@ export function AppShell({ children }: { children: React.ReactNode }) { [missingContextDocs], ); const currentProjectRoot = project?.rootPath ?? null; + const missingAiBannerDismissed = Boolean( + currentProjectRoot && dismissedMissingAiBannerRoots[currentProjectRoot], + ); + const githubBannerDismissed = Boolean( + currentProjectRoot && dismissedGithubBannerRoots[currentProjectRoot], + ); const contextBannerDismissed = Boolean( currentProjectRoot && dismissedContextBannerRoots[currentProjectRoot], ); @@ -791,12 +808,30 @@ export function AppShell({ children }: { children: React.ReactNode }) { !showWelcome && aiStatusLoaded && aiStatus !== null && - !hasAnyAiProvider ? ( + !hasAnyAiProvider && + !missingAiBannerDismissed ? (
- No AI provider is configured yet.{" "} - - Set up AI - + + No AI provider is configured yet.{" "} + + Set up AI + + +
) : null} @@ -805,12 +840,30 @@ export function AppShell({ children }: { children: React.ReactNode }) { !showWelcome && !isOnboardingRoute && githubStatus !== null && - !githubStatus.tokenStored ? ( + !githubStatus.tokenStored && + !githubBannerDismissed ? (
- GitHub is not connected for this ADE app yet.{" "} - - Connect GitHub - + + GitHub is not connected for this ADE app yet.{" "} + + Connect GitHub + + +
) : null} diff --git a/apps/desktop/src/renderer/lib/agentTurnCompletionSound.test.ts b/apps/desktop/src/renderer/lib/agentTurnCompletionSound.test.ts new file mode 100644 index 000000000..6628097c1 --- /dev/null +++ b/apps/desktop/src/renderer/lib/agentTurnCompletionSound.test.ts @@ -0,0 +1,56 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { playAgentTurnCompletionSound } from "./agentTurnCompletionSound"; +describe("playAgentTurnCompletionSound", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("no-ops when AudioContext is unavailable", () => { + vi.stubGlobal("AudioContext", undefined); + vi.stubGlobal("webkitAudioContext", undefined); + expect(() => playAgentTurnCompletionSound("chime")).not.toThrow(); + }); + + it("resumes suspended context then schedules close", async () => { + const resume = vi.fn(() => Promise.resolve()); + const close = vi.fn(() => Promise.resolve()); + class MockAudioContext { + state = "suspended"; + currentTime = 0; + destination = {} as AudioDestinationNode; + resume = resume; + close = close; + createOscillator() { + const osc = { + type: "sine", + frequency: { setValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn() }, + connect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + }; + return osc as unknown as OscillatorNode; + } + createGain() { + const gain = { + gain: { setValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn() }, + connect: vi.fn(), + }; + return gain as unknown as GainNode; + } + } + vi.stubGlobal("AudioContext", MockAudioContext as unknown as typeof AudioContext); + vi.spyOn(globalThis, "setTimeout").mockImplementation((fn: TimerHandler) => { + if (typeof fn === "function") fn(); + return 0 as unknown as ReturnType; + }); + + playAgentTurnCompletionSound("ping"); + await vi.waitFor(() => { + expect(resume).toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/desktop/src/renderer/lib/agentTurnCompletionSound.ts b/apps/desktop/src/renderer/lib/agentTurnCompletionSound.ts index 8874a4279..5dbf5b10d 100644 --- a/apps/desktop/src/renderer/lib/agentTurnCompletionSound.ts +++ b/apps/desktop/src/renderer/lib/agentTurnCompletionSound.ts @@ -22,29 +22,46 @@ export function playAgentTurnCompletionSound(kind: Exclude { + try { + if (kind === "chime") { + playChime(ctx, 880, 0.22, "sine"); + playChime(ctx, 1320, 0.18, "sine"); + } else if (kind === "ping") { + playChime(ctx, 1200, 0.12, "triangle"); + } else { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = "square"; + osc.frequency.setValueAtTime(520, now); + osc.frequency.exponentialRampToValueAtTime(380, now + 0.08); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(0.06, now + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.2); + osc.connect(gain); + gain.connect(ctx.destination); + osc.start(now); + osc.stop(now + 0.25); + } + } catch { + // ignore — rare graph failures + } + // Let oscillators finish before closing (immediate close can silence output). + globalThis.setTimeout(() => { + void ctx.close().catch(() => {}); + }, 450); + }; + try { - if (kind === "chime") { - playChime(ctx, 880, 0.22, "sine"); - playChime(ctx, 1320, 0.18, "sine"); - } else if (kind === "ping") { - playChime(ctx, 1200, 0.12, "triangle"); + if (ctx.state === "suspended") { + void ctx.resume().then(play).catch(() => { + void ctx.close().catch(() => {}); + }); } else { - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = "square"; - osc.frequency.setValueAtTime(520, now); - osc.frequency.exponentialRampToValueAtTime(380, now + 0.08); - gain.gain.setValueAtTime(0.0001, now); - gain.gain.exponentialRampToValueAtTime(0.06, now + 0.01); - gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.2); - osc.connect(gain); - gain.connect(ctx.destination); - osc.start(now); - osc.stop(now + 0.25); + play(); } } catch { - // ignore — autoplay or suspended context - } finally { void ctx.close().catch(() => {}); } } diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index fe1b34ea0..a81d5d397 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -35,7 +35,6 @@ const mockLocalStorage = { }; // Import after window is set up -import type { WorkProjectViewState } from "./appStore"; import { useAppStore, THEME_IDS, DEFAULT_TERMINAL_PREFERENCES } from "./appStore"; // --------------------------------------------------------------------------- @@ -155,6 +154,25 @@ describe("appStore", () => { }); }); + describe("chat and notification preferences", () => { + it("persists code block copy position and agent completion sound", () => { + useAppStore.getState().setCodeBlockCopyButtonPosition("bottom"); + useAppStore.getState().setAgentTurnCompletionSound("chime"); + expect(useAppStore.getState().codeBlockCopyButtonPosition).toBe("bottom"); + expect(useAppStore.getState().agentTurnCompletionSound).toBe("chime"); + const calls = mockLocalStorage.setItem.mock.calls.filter( + ([key]) => key === "ade.userPreferences.v1", + ); + const latest = calls[calls.length - 1]; + expect(latest).toBeTruthy(); + expect(JSON.parse(latest![1])).toMatchObject({ + codeBlockCopyButtonPosition: "bottom", + agentTurnCompletionSound: "chime", + }); + }); + + }); + // ───────────────────────────────────────────────────────────── // Simple setters // ───────────────────────────────────────────────────────────── From 96c9035b0525c33112cee5785d7b1fbc96a4e06f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 14:58:02 +0000 Subject: [PATCH 3/8] feat(settings): appearance tab with chat font preview (t3 #2174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Appearance settings: theme swatches, chat font size 12–24px with live ChatMarkdown preview, copy-button position + completion sound (moved from General) - chatFontSizePx in appStore; work chat scales via ChatSurfaceShell zoom - resolveModelSlug: exact getModelById before provider-hint lowercasing (review) - iOS Create PR lane subtitle when commitsAheadOfBase is 0 - A11y: label range input; fix preview sample template literal Co-authored-by: Arul Sharma --- .../components/app/CommandPalette.tsx | 3 +- .../renderer/components/app/SettingsPage.tsx | 5 +- .../components/chat/AgentChatPane.tsx | 8 +- .../components/chat/ChatSurfaceShell.tsx | 11 +- .../components/settings/AppearanceSection.tsx | 291 ++++++++++++++++++ .../components/settings/GeneralSection.tsx | 196 +----------- .../src/renderer/state/appStore.test.ts | 13 +- apps/desktop/src/renderer/state/appStore.ts | 35 +++ apps/desktop/src/shared/modelRegistry.test.ts | 5 + apps/desktop/src/shared/modelRegistry.ts | 4 + .../ADE/Views/PRs/CreatePrWizardView.swift | 4 +- 11 files changed, 371 insertions(+), 204 deletions(-) create mode 100644 apps/desktop/src/renderer/components/settings/AppearanceSection.tsx diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index a2a0c178b..5b19e9cf9 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -206,7 +206,8 @@ export function CommandPalette({ { id: "go-missions", title: "Go to Missions", shortcut: "G M", group: "Navigation", run: () => navigate("/missions") }, { id: "go-automations", title: "Go to Automations", hint: "Automation rules and agent workflows", group: "Navigation", run: () => navigate("/automations") }, { id: "go-settings", title: "Go to Settings", shortcut: "G S", group: "Navigation", run: () => navigate("/settings") }, - { id: "go-settings-general", title: "Go to General Settings", hint: "Theme, setup reminder, app info", group: "Settings", run: () => navigate("/settings?tab=general") }, + { id: "go-settings-general", title: "Go to General Settings", hint: "Setup reminder, app info", group: "Settings", run: () => navigate("/settings?tab=general") }, + { id: "go-settings-appearance", title: "Go to Appearance", hint: "Theme, chat font size, chat notifications", group: "Settings", run: () => navigate("/settings?tab=appearance") }, { id: "go-settings-ai", title: "Go to AI Settings", hint: "Providers, models, AI defaults", group: "Settings", run: () => navigate("/settings?tab=ai") }, { id: "go-settings-integrations", title: "Go to Integrations", hint: "GitHub, Linear, managed MCP, computer use", group: "Settings", run: () => navigate("/settings?tab=integrations") }, { id: "go-settings-workspace", title: "Go to Workspace Settings", hint: "Project health and docs generation", group: "Settings", run: () => navigate("/settings?tab=workspace") }, diff --git a/apps/desktop/src/renderer/components/app/SettingsPage.tsx b/apps/desktop/src/renderer/components/app/SettingsPage.tsx index 308e33dd5..8dc9677c6 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -1,7 +1,8 @@ import React, { useState, useCallback, useEffect } from "react"; import { useSearchParams, useLocation } from "react-router-dom"; -import { Brain, GearSix, Lightning, Stack, Database, FolderSimple, Plus, X, Plugs, DesktopTower } from "@phosphor-icons/react"; +import { Brain, GearSix, Lightning, Stack, Database, FolderSimple, Plus, X, Plugs, DesktopTower, Palette } from "@phosphor-icons/react"; import { GeneralSection } from "../settings/GeneralSection"; +import { AppearanceSection } from "../settings/AppearanceSection"; import { LaneTemplatesSection } from "../settings/LaneTemplatesSection"; import { LaneBehaviorSection } from "../settings/LaneBehaviorSection"; import { MemoryHealthTab } from "../settings/MemoryHealthTab"; @@ -17,6 +18,7 @@ import { PhaseCardEditor } from "../missions/PhaseCardEditor"; const SECTIONS = [ { id: "general", label: "General", icon: GearSix }, + { id: "appearance", label: "Appearance", icon: Palette }, { id: "workspace", label: "Workspace", icon: FolderSimple }, { id: "ai", label: "AI", icon: Brain }, { id: "sync", label: "Sync", icon: DesktopTower }, @@ -556,6 +558,7 @@ export function SettingsPage() { }} > {section === "general" && } + {section === "appearance" && } {section === "workspace" && } {section === "ai" && } {section === "sync" && } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 44db1a3c6..4290d5fe4 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -71,7 +71,7 @@ import { deriveChatSubagentSnapshots, deriveTodoItems, deriveTurnDiffSummaries } import { derivePendingInputRequests, type DerivedPendingInput } from "./pendingInput"; import { ProviderModelSelector } from "../shared/ProviderModelSelector"; import { useClickOutside } from "../../hooks/useClickOutside"; -import { useAppStore } from "../../state/appStore"; +import { DEFAULT_CHAT_FONT_SIZE_PX, useAppStore } from "../../state/appStore"; import { ClaudeCacheTtlBadge } from "../shared/ClaudeCacheTtlBadge"; import { shouldShowClaudeCacheTtl } from "../../lib/claudeCacheTtl"; import { getAgentChatModelsCached, getAiStatusCached } from "../../lib/aiDiscoveryCache"; @@ -712,6 +712,9 @@ export function AgentChatPane({ }) { const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); const agentTurnCompletionSound = useAppStore((s) => s.agentTurnCompletionSound); + const chatFontSizePx = useAppStore((s) => s.chatFontSizePx); + const chatUiScale = chatFontSizePx / DEFAULT_CHAT_FONT_SIZE_PX; + const chatSurfaceZoomStyle = { zoom: chatUiScale } as const; const navigate = useNavigate(); const openAiProvidersSettings = useCallback(() => { navigate("/settings?tab=ai#ai-providers"); @@ -2535,7 +2538,7 @@ export function AgentChatPane({ if (!laneId) { return ( - +
Select a lane to start chatting
@@ -2961,6 +2964,7 @@ export function AgentChatPane({ containerRef={shellRef} mode={surfaceMode} accentColor={presentation?.accentColor ?? draftAccent} + extraSurfaceStyle={chatSurfaceZoomStyle} className={compactShell ? cn("border-0 shadow-none rounded-none bg-transparent") : undefined} header={compactShell ? undefined : shellHeader} footer={isEmptyState ? undefined : composerElement} diff --git a/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx b/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx index 63008f6ee..3d1b8e92f 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx @@ -1,4 +1,4 @@ -import type { ReactNode, Ref } from "react"; +import type { CSSProperties, ReactNode, Ref } from "react"; import type { ChatSurfaceMode } from "../../../shared/types"; import { cn } from "../ui/cn"; import { chatSurfaceVars } from "./chatSurfaceTheme"; @@ -16,6 +16,7 @@ export function ChatSurfaceShell({ bodyClassName, footerClassName, containerRef, + extraSurfaceStyle, }: { mode: ChatSurfaceMode; accentColor?: string | null; @@ -27,6 +28,8 @@ export function ChatSurfaceShell({ bodyClassName?: string; footerClassName?: string; containerRef?: Ref; + /** Merged into the outer section (e.g. chat font size + zoom from settings). */ + extraSurfaceStyle?: CSSProperties; }) { const mobileChrome = layoutVariant === "mobile"; @@ -38,7 +41,11 @@ export function ChatSurfaceShell({ "relative flex h-full min-h-0 flex-col overflow-hidden", className, )} - style={{ ...chatSurfaceVars(mode, accentColor), background: "var(--color-bg)" }} + style={{ + ...chatSurfaceVars(mode, accentColor), + background: "var(--color-bg)", + ...extraSurfaceStyle, + }} > {header ? (
= { + dark: { + label: "DARK", + description: "After-hours office. Cyan glows against dark surfaces.", + colors: { bg: "#0f0f11", fg: "#e4e4e7", accent: "#A78BFA", card: "#18181b", border: "#27272a" }, + }, + light: { + label: "LIGHT", + description: "Morning office. Sunlit, clean, crisp accent.", + colors: { bg: "#f5f5f6", fg: "#0f0f11", accent: "#7C3AED", card: "#ffffff", border: "#d4d4d8" }, + }, +}; + +export function ThemeSwatch({ + themeId, + selected, + onClick, +}: { + themeId: ThemeId; + selected: boolean; + onClick: () => void; +}) { + const { label, description, colors } = THEME_META[themeId]; + const [hovered, setHovered] = useState(false); + + return ( + + ); +} + +const PREVIEW_MARKDOWN = [ + "Here's a **sample** reply with a list:", + "", + "- First item", + "- Second item", + "", + "Inline `code` and a block:", + "", + "```ts", + "export function greet(name: string) {", + ' return `Hello, ${name}`;', + "}", + "```", + "", +].join("\n"); + +export function AppearanceSection() { + const chatFontSliderId = useId(); + const theme = useAppStore((s) => s.theme); + const setTheme = useAppStore((s) => s.setTheme); + const chatFontSizePx = useAppStore((s) => s.chatFontSizePx); + const setChatFontSizePx = useAppStore((s) => s.setChatFontSizePx); + const codeBlockCopyButtonPosition = useAppStore((s) => s.codeBlockCopyButtonPosition); + const setCodeBlockCopyButtonPosition = useAppStore((s) => s.setCodeBlockCopyButtonPosition); + const agentTurnCompletionSound = useAppStore((s) => s.agentTurnCompletionSound); + const setAgentTurnCompletionSound = useAppStore((s) => s.setAgentTurnCompletionSound); + + const previewScale = useMemo(() => chatFontSizePx / DEFAULT_CHAT_FONT_SIZE_PX, [chatFontSizePx]); + + return ( +
+
+
THEME
+
+ {THEME_IDS.map((id) => ( + setTheme(id)} /> + ))} +
+
+ +
+
CHAT FONT SIZE
+
+
+ Scales the work chat timeline and composer together (inspired by{" "} + + t3code #2174 + + ). Default {DEFAULT_CHAT_FONT_SIZE_PX}px matches the previous layout. +
+
+ + setChatFontSizePx(Number(e.target.value))} + style={{ flex: "1 1 200px", maxWidth: 360, accentColor: COLORS.accent }} + /> +
+ +
+
LIVE PREVIEW
+
+
+ Assistant · preview +
+ {PREVIEW_MARKDOWN} +
+
+
+
+ +
+
CHAT & NOTIFICATIONS
+
+
+
CODE BLOCK COPY BUTTON
+
+ On touch devices the copy control stays visible. Choose top or bottom so long fenced blocks are easier to copy after scrolling. +
+
+ {CODE_BLOCK_COPY_POSITION_IDS.map((id) => ( + + ))} +
+
+ +
+
AGENT TURN COMPLETION SOUND
+
+ Plays when the assistant finishes a turn and the session is idle (not while you still owe a reply or approval). +
+
+ + +
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/settings/GeneralSection.tsx b/apps/desktop/src/renderer/components/settings/GeneralSection.tsx index b8cdabcf7..b010a48bb 100644 --- a/apps/desktop/src/renderer/components/settings/GeneralSection.tsx +++ b/apps/desktop/src/renderer/components/settings/GeneralSection.tsx @@ -1,15 +1,7 @@ import React, { useEffect, useId, useState } from "react"; import { useNavigate } from "react-router-dom"; import type { AppInfo } from "../../../shared/types"; -import { - AGENT_TURN_COMPLETION_SOUND_IDS, - CODE_BLOCK_COPY_POSITION_IDS, - DEFAULT_TERMINAL_FONT_FAMILY, - useAppStore, - THEME_IDS, -} from "../../state/appStore"; -import type { AgentTurnCompletionSound, CodeBlockCopyButtonPosition, ThemeId } from "../../state/appStore"; -import { playAgentTurnCompletionSound } from "../../lib/agentTurnCompletionSound"; +import { DEFAULT_TERMINAL_FONT_FAMILY, useAppStore } from "../../state/appStore"; import { EmptyState } from "../ui/EmptyState"; import { Info } from "@phosphor-icons/react"; import { @@ -32,121 +24,12 @@ const TERMINAL_FONT_FAMILY_OPTIONS = [ { label: "Monaco", value: "Monaco, " + DEFAULT_TERMINAL_FONT_FAMILY }, ]; -const THEME_META: Record< - ThemeId, - { - label: string; - description: string; - colors: { bg: string; fg: string; accent: string; card: string; border: string }; - } -> = { - dark: { - label: "DARK", - description: "After-hours office. Cyan glows against dark surfaces.", - colors: { bg: "#0f0f11", fg: "#e4e4e7", accent: "#A78BFA", card: "#18181b", border: "#27272a" }, - }, - light: { - label: "LIGHT", - description: "Morning office. Sunlit, clean, crisp accent.", - colors: { bg: "#f5f5f6", fg: "#0f0f11", accent: "#7C3AED", card: "#ffffff", border: "#d4d4d8" }, - }, -}; - const sectionLabelStyle: React.CSSProperties = { ...LABEL_STYLE, fontSize: 11, marginBottom: 16, }; -function ThemeSwatch({ - themeId, - selected, - onClick, -}: { - themeId: ThemeId; - selected: boolean; - onClick: () => void; -}) { - const { label, description, colors } = THEME_META[themeId]; - const [hovered, setHovered] = useState(false); - - return ( - - ); -} - export function GeneralSection() { const navigate = useNavigate(); const terminalFieldId = useId(); @@ -154,12 +37,6 @@ export function GeneralSection() { const [onboardingStatus, setOnboardingStatus] = useState<{ completedAt: string | null; dismissedAt: string | null; freshProject?: boolean } | null>(null); const [loadError, setLoadError] = useState(null); const providerMode = useAppStore((s) => s.providerMode); - const theme = useAppStore((s) => s.theme); - const setTheme = useAppStore((s) => s.setTheme); - const codeBlockCopyButtonPosition = useAppStore((s) => s.codeBlockCopyButtonPosition); - const setCodeBlockCopyButtonPosition = useAppStore((s) => s.setCodeBlockCopyButtonPosition); - const agentTurnCompletionSound = useAppStore((s) => s.agentTurnCompletionSound); - const setAgentTurnCompletionSound = useAppStore((s) => s.setAgentTurnCompletionSound); const terminalPreferences = useAppStore((s) => s.terminalPreferences); const setTerminalPreferences = useAppStore((s) => s.setTerminalPreferences); @@ -241,77 +118,6 @@ export function GeneralSection() {
-
-
THEME
-
- {THEME_IDS.map((id) => ( - setTheme(id)} /> - ))} -
-
- -
-
CHAT & NOTIFICATIONS
-
-
-
CODE BLOCK COPY BUTTON
-
- On touch devices the copy control stays visible. Choose top or bottom so long fenced blocks are easier to copy after scrolling. -
-
- {CODE_BLOCK_COPY_POSITION_IDS.map((id) => ( - - ))} -
-
- -
-
AGENT TURN COMPLETION SOUND
-
- Plays when the assistant finishes a turn and the session is idle (not while you still owe a reply or approval). -
-
- - -
-
-
-
-
AI MODE
diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index a81d5d397..f54fe64f6 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -35,7 +35,7 @@ const mockLocalStorage = { }; // Import after window is set up -import { useAppStore, THEME_IDS, DEFAULT_TERMINAL_PREFERENCES } from "./appStore"; +import { useAppStore, THEME_IDS, DEFAULT_TERMINAL_PREFERENCES, DEFAULT_CHAT_FONT_SIZE_PX } from "./appStore"; // --------------------------------------------------------------------------- // Helpers @@ -56,6 +56,9 @@ function resetStore() { focusedSessionId: null, theme: "dark", terminalPreferences: { ...DEFAULT_TERMINAL_PREFERENCES }, + codeBlockCopyButtonPosition: "top" as const, + agentTurnCompletionSound: "off" as const, + chatFontSizePx: DEFAULT_CHAT_FONT_SIZE_PX, laneInspectorTabs: {}, workViewByProject: {}, laneWorkViewByScope: {}, @@ -171,6 +174,14 @@ describe("appStore", () => { }); }); + it("persists chat font size and clamps to range", () => { + useAppStore.getState().setChatFontSizePx(20); + expect(useAppStore.getState().chatFontSizePx).toBe(20); + useAppStore.getState().setChatFontSizePx(99); + expect(useAppStore.getState().chatFontSizePx).toBe(24); + useAppStore.getState().setChatFontSizePx(8); + expect(useAppStore.getState().chatFontSizePx).toBe(12); + }); }); // ───────────────────────────────────────────────────────────── diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 9fe82a033..90a70cc46 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -46,6 +46,17 @@ function normalizeAgentTurnCompletionSound(value: unknown): AgentTurnCompletionS if (value === "chime" || value === "ping" || value === "bell") return value; return "off"; } + +/** Base chat body font size in px (timeline + composer scale from this). Default matches prior ~14px body. */ +export const DEFAULT_CHAT_FONT_SIZE_PX = 14; +export const CHAT_FONT_SIZE_MIN_PX = 12; +export const CHAT_FONT_SIZE_MAX_PX = 24; + +function normalizeChatFontSizePx(value: unknown): number { + const next = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(next)) return DEFAULT_CHAT_FONT_SIZE_PX; + return Math.max(CHAT_FONT_SIZE_MIN_PX, Math.min(CHAT_FONT_SIZE_MAX_PX, Math.round(next))); +} export type TerminalAttentionIndicator = "none" | "running-active" | "running-needs-attention"; export type WorkViewMode = "tabs" | "grid"; export type WorkStatusFilter = "all" | "running" | "awaiting-input" | "ended"; @@ -244,6 +255,7 @@ type PersistedUserPreferences = { smartTooltipsEnabled: boolean; codeBlockCopyButtonPosition: CodeBlockCopyButtonPosition; agentTurnCompletionSound: AgentTurnCompletionSound; + chatFontSizePx: number; }; function coerceTheme(value: unknown): ThemeId | null { @@ -264,6 +276,7 @@ function readUnifiedUserPreferences(): PersistedUserPreferences | null { smartTooltipsEnabled: parsed.smartTooltipsEnabled !== false, codeBlockCopyButtonPosition: normalizeCodeBlockCopyButtonPosition(parsed.codeBlockCopyButtonPosition), agentTurnCompletionSound: normalizeAgentTurnCompletionSound(parsed.agentTurnCompletionSound), + chatFontSizePx: normalizeChatFontSizePx(parsed.chatFontSizePx), }; } catch { return null; @@ -296,6 +309,7 @@ function readLegacyUserPreferences(): PersistedUserPreferences { smartTooltipsEnabled, codeBlockCopyButtonPosition: "top", agentTurnCompletionSound: "off", + chatFontSizePx: DEFAULT_CHAT_FONT_SIZE_PX, }; } @@ -378,6 +392,7 @@ type AppState = { terminalPreferences: TerminalPreferences; codeBlockCopyButtonPosition: CodeBlockCopyButtonPosition; agentTurnCompletionSound: AgentTurnCompletionSound; + chatFontSizePx: number; providerMode: ProviderMode; availableModels: ModelDescriptor[]; laneInspectorTabs: Record; @@ -400,6 +415,7 @@ type AppState = { setTheme: (theme: ThemeId) => void; setCodeBlockCopyButtonPosition: (position: CodeBlockCopyButtonPosition) => void; setAgentTurnCompletionSound: (sound: AgentTurnCompletionSound) => void; + setChatFontSizePx: (px: number) => void; setTerminalPreferences: ( next: | Partial @@ -506,6 +522,7 @@ export const useAppStore = create((set, get) => ({ terminalPreferences: initialUserPreferences.terminalPreferences, codeBlockCopyButtonPosition: initialUserPreferences.codeBlockCopyButtonPosition, agentTurnCompletionSound: initialUserPreferences.agentTurnCompletionSound, + chatFontSizePx: initialUserPreferences.chatFontSizePx, providerMode: "guest", availableModels: [...MODEL_REGISTRY].filter((m) => !m.deprecated), laneInspectorTabs: {}, @@ -552,6 +569,7 @@ export const useAppStore = create((set, get) => ({ smartTooltipsEnabled: prev.smartTooltipsEnabled, codeBlockCopyButtonPosition: prev.codeBlockCopyButtonPosition, agentTurnCompletionSound: prev.agentTurnCompletionSound, + chatFontSizePx: prev.chatFontSizePx, }); return { theme }; }), @@ -564,6 +582,7 @@ export const useAppStore = create((set, get) => ({ smartTooltipsEnabled: prev.smartTooltipsEnabled, codeBlockCopyButtonPosition: next, agentTurnCompletionSound: prev.agentTurnCompletionSound, + chatFontSizePx: prev.chatFontSizePx, }); return { codeBlockCopyButtonPosition: next }; }), @@ -576,9 +595,23 @@ export const useAppStore = create((set, get) => ({ smartTooltipsEnabled: prev.smartTooltipsEnabled, codeBlockCopyButtonPosition: prev.codeBlockCopyButtonPosition, agentTurnCompletionSound: next, + chatFontSizePx: prev.chatFontSizePx, }); return { agentTurnCompletionSound: next }; }), + setChatFontSizePx: (px) => + set((prev) => { + const next = normalizeChatFontSizePx(px); + persistUserPreferences({ + theme: prev.theme, + terminalPreferences: prev.terminalPreferences, + smartTooltipsEnabled: prev.smartTooltipsEnabled, + codeBlockCopyButtonPosition: prev.codeBlockCopyButtonPosition, + agentTurnCompletionSound: prev.agentTurnCompletionSound, + chatFontSizePx: next, + }); + return { chatFontSizePx: next }; + }), setTerminalPreferences: (next) => set((prev) => { const updated = normalizeTerminalPreferences( @@ -592,6 +625,7 @@ export const useAppStore = create((set, get) => ({ smartTooltipsEnabled: prev.smartTooltipsEnabled, codeBlockCopyButtonPosition: prev.codeBlockCopyButtonPosition, agentTurnCompletionSound: prev.agentTurnCompletionSound, + chatFontSizePx: prev.chatFontSizePx, }); return { terminalPreferences: updated }; }), @@ -604,6 +638,7 @@ export const useAppStore = create((set, get) => ({ smartTooltipsEnabled: enabled, codeBlockCopyButtonPosition: prev.codeBlockCopyButtonPosition, agentTurnCompletionSound: prev.agentTurnCompletionSound, + chatFontSizePx: prev.chatFontSizePx, }); return { smartTooltipsEnabled: enabled }; }), diff --git a/apps/desktop/src/shared/modelRegistry.test.ts b/apps/desktop/src/shared/modelRegistry.test.ts index a7dad6284..371c44265 100644 --- a/apps/desktop/src/shared/modelRegistry.test.ts +++ b/apps/desktop/src/shared/modelRegistry.test.ts @@ -56,6 +56,11 @@ describe("modelRegistry", () => { expect(resolveModelSlug("not-a-real-model-xyz")).toBeUndefined(); }); + it("resolveModelSlug preserves case-sensitive dynamic local ids when hinted", () => { + const id = "lmstudio/Qwen/Qwen2.5-Coder"; + expect(resolveModelSlug(id, "opencode")).toBe(id); + }); + it("returns dynamic local descriptors from getModelById", () => { const descriptor = getModelById("lmstudio/meta-llama-3.1-70b-instruct"); expect(descriptor).toBeTruthy(); diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index 41c1223bd..3abb5e35a 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -885,6 +885,10 @@ export function resolveModelSlug(modelRef: string, providerHint?: ModelProviderG const normalized = modelRef.trim(); if (!normalized.length) return undefined; if (providerHint) { + const direct = getModelById(normalized); + if (direct && !direct.deprecated && matchesProviderGroup(direct, providerHint)) { + return direct.id; + } return resolveModelDescriptorForProvider(normalized, providerHint)?.id; } return resolveModelDescriptor(normalized)?.id; diff --git a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift index 0d1d1fa7d..9e536cda8 100644 --- a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift +++ b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift @@ -41,10 +41,10 @@ struct CreatePrWizardView: View { return capabilities.lanes .filter { $0.canCreate } .map { eligibility in - let aheadNote: String? = + let aheadNote: String = eligibility.commitsAheadOfBase > 0 ? "\(eligibility.commitsAheadOfBase) commit\(eligibility.commitsAheadOfBase == 1 ? "" : "s") ahead of \(eligibility.defaultBaseBranch)" - : nil + : "Aligned with \(eligibility.defaultBaseBranch) (no commits ahead)" return CreatePrLaneOption( id: eligibility.laneId, title: eligibility.laneName, From 0331e3a4c42718c6c39def5d66850a08a4e69e06 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 17:21:19 +0000 Subject: [PATCH 4/8] fix: remove upstream PR link from UI; cross-browser chat scaling - Appearance: drop user-visible t3code/GitHub link; keep neutral copy - ChatSurfaceShell: scale header/body/footer via transform + inverse dimensions (Firefox-safe) instead of CSS zoom; contentScale prop - AgentChatPane: pass contentScale from chat font preference - agentTurnCompletionSound: module + function docstrings (CodeRabbit hint) - Add ChatSurfaceShell scale wrapper tests with cleanup between cases Co-authored-by: Arul Sharma --- .../components/chat/AgentChatPane.tsx | 5 +- .../components/chat/ChatSurfaceShell.test.tsx | 33 +++++++++++ .../components/chat/ChatSurfaceShell.tsx | 56 +++++++++++++------ .../components/settings/AppearanceSection.tsx | 6 +- .../renderer/lib/agentTurnCompletionSound.ts | 7 ++- 5 files changed, 81 insertions(+), 26 deletions(-) create mode 100644 apps/desktop/src/renderer/components/chat/ChatSurfaceShell.test.tsx diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 4290d5fe4..a9a0568eb 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -714,7 +714,6 @@ export function AgentChatPane({ const agentTurnCompletionSound = useAppStore((s) => s.agentTurnCompletionSound); const chatFontSizePx = useAppStore((s) => s.chatFontSizePx); const chatUiScale = chatFontSizePx / DEFAULT_CHAT_FONT_SIZE_PX; - const chatSurfaceZoomStyle = { zoom: chatUiScale } as const; const navigate = useNavigate(); const openAiProvidersSettings = useCallback(() => { navigate("/settings?tab=ai#ai-providers"); @@ -2538,7 +2537,7 @@ export function AgentChatPane({ if (!laneId) { return ( - +
Select a lane to start chatting
@@ -2964,7 +2963,7 @@ export function AgentChatPane({ containerRef={shellRef} mode={surfaceMode} accentColor={presentation?.accentColor ?? draftAccent} - extraSurfaceStyle={chatSurfaceZoomStyle} + contentScale={chatUiScale} className={compactShell ? cn("border-0 shadow-none rounded-none bg-transparent") : undefined} header={compactShell ? undefined : shellHeader} footer={isEmptyState ? undefined : composerElement} diff --git a/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.test.tsx b/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.test.tsx new file mode 100644 index 000000000..7410eb215 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.test.tsx @@ -0,0 +1,33 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { ChatSurfaceShell } from "./ChatSurfaceShell"; + +describe("ChatSurfaceShell", () => { + afterEach(() => { + cleanup(); + }); + + it("wraps content in a scale transform when contentScale is not 1", () => { + const { container } = render( + +
hello
+
, + ); + const scaled = container.querySelector('[style*="scale(1.5)"]'); + expect(scaled).toBeTruthy(); + expect(screen.getByTestId("child")).toBeTruthy(); + }); + + it("does not add an extra scale wrapper when contentScale is 1", () => { + const { container } = render( + +
hello
+
, + ); + expect(container.querySelector('[style*="scale(1)"]')).toBeNull(); + expect(screen.getByTestId("child")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx b/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx index 3d1b8e92f..d9b180fe0 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSurfaceShell.tsx @@ -16,7 +16,8 @@ export function ChatSurfaceShell({ bodyClassName, footerClassName, containerRef, - extraSurfaceStyle, + /** Uniform scale for header, transcript, and composer (CSS transform — works in Firefox; `zoom` does not). */ + contentScale = 1, }: { mode: ChatSurfaceMode; accentColor?: string | null; @@ -28,25 +29,23 @@ export function ChatSurfaceShell({ bodyClassName?: string; footerClassName?: string; containerRef?: Ref; - /** Merged into the outer section (e.g. chat font size + zoom from settings). */ - extraSurfaceStyle?: CSSProperties; + contentScale?: number; }) { const mobileChrome = layoutVariant === "mobile"; + const scale = Number.isFinite(contentScale) && contentScale > 0 ? contentScale : 1; + const scaled = Math.abs(scale - 1) > 0.001; + const scaleWrapperStyle: CSSProperties | undefined = scaled + ? { + transform: `scale(${scale})`, + transformOrigin: "top left", + width: `${100 / scale}%`, + height: `${100 / scale}%`, + minHeight: 0, + } + : undefined; - return ( -
+ const inner = ( + <> {header ? (
) : null} + + ); + + return ( +
+ {scaled ? ( +
+ {inner} +
+ ) : ( + inner + )}
); } diff --git a/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx b/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx index 8f9ef3851..c4fc74cb1 100644 --- a/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx @@ -179,11 +179,7 @@ export function AppearanceSection() {
CHAT FONT SIZE
- Scales the work chat timeline and composer together (inspired by{" "} - - t3code #2174 - - ). Default {DEFAULT_CHAT_FONT_SIZE_PX}px matches the previous layout. + Scales the work chat timeline and composer together. Default {DEFAULT_CHAT_FONT_SIZE_PX}px matches the previous layout.
@@ -225,7 +231,9 @@ export function AppearanceSection() {
CHAT & NOTIFICATIONS
-
CODE BLOCK COPY BUTTON
+
+ CODE BLOCK COPY BUTTON +
On touch devices the copy control stays visible. Choose top or bottom so long fenced blocks are easier to copy after scrolling.
@@ -234,6 +242,8 @@ export function AppearanceSection() {
-
AGENT TURN COMPLETION SOUND
+
Plays when the assistant finishes a turn and the session is idle (not while you still owe a reply or approval).
-
LIVE PREVIEW
+
Live preview
-
- Assistant · preview +
+ Sample assistant reply
{PREVIEW_MARKDOWN}
@@ -228,14 +227,14 @@ export function AppearanceSection() {
-
CHAT & NOTIFICATIONS
+
Chat & notifications
- CODE BLOCK COPY BUTTON + Code block copy button
- On touch devices the copy control stays visible. Choose top or bottom so long fenced blocks are easier to copy after scrolling. + On devices without hover, the copy control stays visible. Pick top or bottom so long code blocks are easier to copy after you scroll.
{CODE_BLOCK_COPY_POSITION_IDS.map((id) => ( @@ -259,10 +258,10 @@ export function AppearanceSection() {
- Plays when the assistant finishes a turn and the session is idle (not while you still owe a reply or approval). + Plays when the assistant completes a turn and the chat is idle. It does not play while a reply, approval, or other input is still pending.
setChatFontSizePx(Number(e.target.value))} - style={{ flex: "1 1 200px", maxWidth: 360, accentColor: COLORS.accent }} - /> +
+ Chat font size + {CHAT_FONT_SIZE_SWATCHES.map((swatch) => { + const selected = chatFontSizePx === swatch.px; + return ( + + ); + })}
@@ -212,8 +289,9 @@ export function AppearanceSection() { style={{ transform: `scale(${previewScale})`, transformOrigin: "top left", - width: `${100 / previewScale}%`, - maxWidth: `${100 / previewScale}%`, + // Downscale previews must not exceed the parent width; only expand the wrapper when scaling up. + width: previewScale >= 1 ? `${100 / previewScale}%` : "100%", + maxWidth: previewScale >= 1 ? `${100 / previewScale}%` : "100%", }} >
@@ -228,31 +306,48 @@ export function AppearanceSection() {
Chat & notifications
-
+
Code block copy button
- On devices without hover, the copy control stays visible. Pick top or bottom so long code blocks are easier to copy after you scroll. + Where the copy control sits on code blocks in chat. Auto-float tracks the top of the viewport while you scroll a long block.
-
- {CODE_BLOCK_COPY_POSITION_IDS.map((id) => ( - - ))} +
+ {CODE_BLOCK_COPY_POSITION_IDS.map((id) => { + const meta = COPY_POSITION_META[id]; + const selected = codeBlockCopyButtonPosition === id; + return ( + + ); + })}
@@ -261,7 +356,7 @@ export function AppearanceSection() { Agent turn completion sound
- Plays when the assistant completes a turn and the chat is idle. It does not play while a reply, approval, or other input is still pending. + Plays when the assistant completes a turn and the chat is idle. Rapid successive turns collapse to a single chime.
setAgentTurnCompletionSoundVolume(Number(e.target.value) / 100)} + style={{ width: 260, accentColor: COLORS.accent, opacity: soundIsOff ? 0.5 : 1 }} + /> +
+ + +
+
+ +
+
Terminal
+
+
+ + + setTerminalPreferences({ fontFamily: event.target.value })} + placeholder={DEFAULT_TERMINAL_FONT_FAMILY} + style={{ height: 34, border: `1px solid ${COLORS.border}`, background: COLORS.recessedBg, color: COLORS.textPrimary, fontSize: 12, fontFamily: MONO_FONT, padding: "0 10px" }} + /> +
+ Use a CSS font-family stack. Example: "JetBrains Mono", monospace
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ These preferences apply across work terminals, lane shells, resolver terminals, and the chat drawer. +
diff --git a/apps/desktop/src/renderer/components/settings/GeneralSection.tsx b/apps/desktop/src/renderer/components/settings/GeneralSection.tsx index b010a48bb..444170382 100644 --- a/apps/desktop/src/renderer/components/settings/GeneralSection.tsx +++ b/apps/desktop/src/renderer/components/settings/GeneralSection.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useId, useState } from "react"; +import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import type { AppInfo } from "../../../shared/types"; -import { DEFAULT_TERMINAL_FONT_FAMILY, useAppStore } from "../../state/appStore"; +import { useAppStore } from "../../state/appStore"; import { EmptyState } from "../ui/EmptyState"; import { Info } from "@phosphor-icons/react"; import { @@ -12,18 +12,6 @@ import { primaryButton, } from "../lanes/laneDesignTokens"; -const TERMINAL_FONT_SIZE_OPTIONS = [11, 11.5, 12, 12.5, 13, 13.5, 14, 15]; -const TERMINAL_LINE_HEIGHT_OPTIONS = [1.1, 1.15, 1.2, 1.25, 1.3, 1.35]; -const TERMINAL_SCROLLBACK_OPTIONS = [5000, 10000, 20000, 30000]; -const TERMINAL_FONT_FAMILY_OPTIONS = [ - { label: "ADE default", value: DEFAULT_TERMINAL_FONT_FAMILY }, - { label: "JetBrains Mono", value: "\"JetBrains Mono\", " + DEFAULT_TERMINAL_FONT_FAMILY }, - { label: "Geist Mono", value: "\"Geist Mono\", " + DEFAULT_TERMINAL_FONT_FAMILY }, - { label: "Cascadia Mono", value: "\"Cascadia Mono\", " + DEFAULT_TERMINAL_FONT_FAMILY }, - { label: "Menlo", value: "Menlo, " + DEFAULT_TERMINAL_FONT_FAMILY }, - { label: "Monaco", value: "Monaco, " + DEFAULT_TERMINAL_FONT_FAMILY }, -]; - const sectionLabelStyle: React.CSSProperties = { ...LABEL_STYLE, fontSize: 11, @@ -32,13 +20,10 @@ const sectionLabelStyle: React.CSSProperties = { export function GeneralSection() { const navigate = useNavigate(); - const terminalFieldId = useId(); const [info, setInfo] = useState(null); const [onboardingStatus, setOnboardingStatus] = useState<{ completedAt: string | null; dismissedAt: string | null; freshProject?: boolean } | null>(null); const [loadError, setLoadError] = useState(null); const providerMode = useAppStore((s) => s.providerMode); - const terminalPreferences = useAppStore((s) => s.terminalPreferences); - const setTerminalPreferences = useAppStore((s) => s.setTerminalPreferences); useEffect(() => { let cancelled = false; @@ -167,105 +152,6 @@ export function GeneralSection() {
-
-
TERMINAL
-
-
- - - setTerminalPreferences({ fontFamily: event.target.value })} - placeholder={DEFAULT_TERMINAL_FONT_FAMILY} - style={{ height: 34, border: `1px solid ${COLORS.border}`, background: COLORS.recessedBg, color: COLORS.textPrimary, fontSize: 12, fontFamily: MONO_FONT, padding: "0 10px" }} - /> -
- Use a CSS font-family stack. Example: "JetBrains Mono", monospace -
-
- -
- - -
- -
- - -
- -
- - -
- -
- These preferences apply across work terminals, lane shells, resolver terminals, and the chat drawer. -
-
-
-
{ + beforeEach(() => { + __resetAgentTurnCompletionSoundDebounce(); + }); + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); @@ -59,4 +67,87 @@ describe("playAgentTurnCompletionSound", () => { vi.useRealTimers(); } }); + + it("scales the gain node's peak target by volume", () => { + const rampCalls: Array<[unknown, unknown]> = []; + class MockAudioContext { + state = "running"; + currentTime = 0; + destination = {} as AudioDestinationNode; + resume = vi.fn(() => Promise.resolve()); + close = vi.fn(() => Promise.resolve()); + createOscillator() { + return { + type: "sine", + frequency: { setValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn() }, + connect: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + } as unknown as OscillatorNode; + } + createGain() { + return { + gain: { + setValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn((value: unknown, time: unknown) => rampCalls.push([value, time])), + }, + connect: vi.fn(), + } as unknown as GainNode; + } + } + vi.stubGlobal("AudioContext", MockAudioContext as unknown as typeof AudioContext); + + playAgentTurnCompletionSound("ping", { volume: 0.25 }); + + // Ping schedules one oscillator + gain. The first ramp is the peak (duration 0.12, peak target 0.12 * volume). + expect(rampCalls.length).toBeGreaterThan(0); + const [peakValue] = rampCalls[0]; + expect(peakValue).toBeCloseTo(0.12 * 0.25, 4); + }); + + it("returns without playing when volume is 0", () => { + const ctor = vi.fn(); + vi.stubGlobal("AudioContext", ctor as unknown as typeof AudioContext); + + playAgentTurnCompletionSound("chime", { volume: 0 }); + + expect(ctor).not.toHaveBeenCalled(); + }); + + it("drops the second call inside the 1.5s debounce window", () => { + const ctor = vi.fn(function (this: { state: string; currentTime: number; destination: unknown; resume: () => Promise; close: () => Promise; createOscillator: () => unknown; createGain: () => unknown }) { + this.state = "running"; + this.currentTime = 0; + this.destination = {}; + this.resume = () => Promise.resolve(); + this.close = () => Promise.resolve(); + this.createOscillator = () => ({ + type: "sine", + frequency: { setValueAtTime: () => {}, exponentialRampToValueAtTime: () => {} }, + connect: () => {}, + start: () => {}, + stop: () => {}, + }); + this.createGain = () => ({ + gain: { setValueAtTime: () => {}, exponentialRampToValueAtTime: () => {} }, + connect: () => {}, + }); + }); + vi.stubGlobal("AudioContext", ctor as unknown as typeof AudioContext); + + playAgentTurnCompletionSound("ping"); + playAgentTurnCompletionSound("ping"); + + expect(ctor).toHaveBeenCalledTimes(1); + }); + + it("is a no-op when skipWhenFocused=true and the document has focus", () => { + const ctor = vi.fn(); + vi.stubGlobal("AudioContext", ctor as unknown as typeof AudioContext); + vi.spyOn(document, "hasFocus").mockReturnValue(true); + + playAgentTurnCompletionSound("ping", { skipWhenFocused: true }); + + expect(ctor).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/renderer/lib/agentTurnCompletionSound.ts b/apps/desktop/src/renderer/lib/agentTurnCompletionSound.ts index 807cb60c0..396ad85a9 100644 --- a/apps/desktop/src/renderer/lib/agentTurnCompletionSound.ts +++ b/apps/desktop/src/renderer/lib/agentTurnCompletionSound.ts @@ -4,14 +4,42 @@ */ import type { AgentTurnCompletionSound } from "../state/appStore"; +/** Collapse rapid successive turn-completions into a single chime. */ +const DEBOUNCE_MS = 1_500; +let lastPlayedAtMs = 0; + +/** @internal — resets the module-level debounce timestamp (tests only). */ +export function __resetAgentTurnCompletionSoundDebounce(): void { + lastPlayedAtMs = 0; +} + +export type PlayAgentTurnCompletionSoundOptions = { + /** 0..1 gain multiplier. Values outside the range are clamped. */ + volume?: number; + /** When true and the document currently has focus, the call is a no-op. */ + skipWhenFocused?: boolean; +}; + +function clampVolume(value: number | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 1; + return Math.max(0, Math.min(1, value)); +} + /** Schedule a short tone on `ctx` (caller must not close `ctx` until after stop + tail). */ -function playChime(ctx: AudioContext, frequency: number, durationSec: number, type: OscillatorType) { +function playChime( + ctx: AudioContext, + frequency: number, + durationSec: number, + type: OscillatorType, + volume: number, +) { const osc = ctx.createOscillator(); const gain = ctx.createGain(); + const peak = 0.12 * volume; osc.type = type; osc.frequency.setValueAtTime(frequency, ctx.currentTime); gain.gain.setValueAtTime(0.0001, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.12, ctx.currentTime + 0.02); + gain.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), ctx.currentTime + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + durationSec); osc.connect(gain); gain.connect(ctx.destination); @@ -21,33 +49,53 @@ function playChime(ctx: AudioContext, frequency: number, durationSec: number, ty /** * Play one completion tone. Call from a user gesture when possible; resumes a suspended context when needed. + * + * @param kind Which tone to play. + * @param options Playback modulation — volume (0..1) and skip-when-focused gate. */ -export function playAgentTurnCompletionSound(kind: Exclude): void { +export function playAgentTurnCompletionSound( + kind: Exclude, + options: PlayAgentTurnCompletionSoundOptions = {}, +): void { + if (options.skipWhenFocused && typeof document !== "undefined" && typeof document.hasFocus === "function" && document.hasFocus()) { + return; + } + const volume = clampVolume(options.volume); + if (volume === 0) return; + const Ctor = window.AudioContext ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!Ctor) return; + + // Debounce after early-bail checks so no-op paths (muted, no AudioContext, focus-gated) + // do not burn the window that a real chime would then get suppressed by. + const now = Date.now(); + if (now - lastPlayedAtMs < DEBOUNCE_MS) return; + lastPlayedAtMs = now; + const ctx = new Ctor(); - const now = ctx.currentTime; + const ctxNow = ctx.currentTime; const play = () => { try { if (kind === "chime") { - playChime(ctx, 880, 0.22, "sine"); - playChime(ctx, 1320, 0.18, "sine"); + playChime(ctx, 880, 0.22, "sine", volume); + playChime(ctx, 1320, 0.18, "sine", volume); } else if (kind === "ping") { - playChime(ctx, 1200, 0.12, "triangle"); + playChime(ctx, 1200, 0.12, "triangle", volume); } else { const osc = ctx.createOscillator(); const gain = ctx.createGain(); + const peak = 0.06 * volume; osc.type = "square"; - osc.frequency.setValueAtTime(520, now); - osc.frequency.exponentialRampToValueAtTime(380, now + 0.08); - gain.gain.setValueAtTime(0.0001, now); - gain.gain.exponentialRampToValueAtTime(0.06, now + 0.01); - gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.2); + osc.frequency.setValueAtTime(520, ctxNow); + osc.frequency.exponentialRampToValueAtTime(380, ctxNow + 0.08); + gain.gain.setValueAtTime(0.0001, ctxNow); + gain.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), ctxNow + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, ctxNow + 0.2); osc.connect(gain); gain.connect(ctx.destination); - osc.start(now); - osc.stop(now + 0.25); + osc.start(ctxNow); + osc.stop(ctxNow + 0.25); } } catch { // ignore — rare graph failures diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index f54fe64f6..4c5b320d4 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -62,6 +62,9 @@ function resetStore() { laneInspectorTabs: {}, workViewByProject: {}, laneWorkViewByScope: {}, + dismissedMissingAiBannerRoots: {}, + dismissedGithubBannerRoots: {}, + dismissedContextBannerRoots: {}, }); } @@ -495,6 +498,62 @@ describe("appStore", () => { ); }); + it("prunes banner-dismiss maps to the new project on switch", async () => { + // Seed dismissals for three projects, then switch to one of them with a + // listRecent that only includes two. The third should be dropped. + useAppStore.setState({ + dismissedMissingAiBannerRoots: { "/p/a": true, "/p/b": true, "/p/c": true }, + dismissedGithubBannerRoots: { "/p/a": true, "/p/b": true }, + dismissedContextBannerRoots: { "/p/c": true }, + } as any); + + const nextProject = { rootPath: "/p/a", displayName: "A", baseRef: "main" } as any; + (window.ade.project.switchToPath as any).mockResolvedValueOnce(nextProject); + (window.ade.project.listRecent as any).mockResolvedValueOnce([ + { rootPath: "/p/a" }, + { rootPath: "/p/b" }, + ]); + + await useAppStore.getState().switchProjectToPath("/p/a"); + + // `/p/c` was neither active nor in recents → pruned from all three maps. + expect(useAppStore.getState().dismissedMissingAiBannerRoots).toEqual({ + "/p/a": true, + "/p/b": true, + }); + expect(useAppStore.getState().dismissedGithubBannerRoots).toEqual({ + "/p/a": true, + "/p/b": true, + }); + expect(useAppStore.getState().dismissedContextBannerRoots).toEqual({}); + }); + + it("clears all banner-dismiss maps when the project is closed", async () => { + useAppStore.setState({ + project: { rootPath: "/p/x" } as any, + dismissedMissingAiBannerRoots: { "/p/x": true, "/p/y": true }, + dismissedGithubBannerRoots: { "/p/x": true }, + dismissedContextBannerRoots: { "/p/y": true }, + } as any); + + await useAppStore.getState().closeProject(); + + expect(useAppStore.getState().dismissedMissingAiBannerRoots).toEqual({}); + expect(useAppStore.getState().dismissedGithubBannerRoots).toEqual({}); + expect(useAppStore.getState().dismissedContextBannerRoots).toEqual({}); + }); + + it("dismiss setters append to the session-scoped map without touching other keys", () => { + useAppStore.setState({ + dismissedMissingAiBannerRoots: { "/p/existing": true }, + } as any); + useAppStore.getState().dismissMissingAiBanner("/p/new"); + expect(useAppStore.getState().dismissedMissingAiBannerRoots).toEqual({ + "/p/existing": true, + "/p/new": true, + }); + }); + it("tracks project opening progress and clears it when the user cancels", async () => { let resolveOpen: (value: any) => void = () => {}; (window.ade.project.openRepo as any).mockImplementationOnce( diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 90a70cc46..94eccb9e8 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -30,16 +30,20 @@ export const DEFAULT_TERMINAL_PREFERENCES: TerminalPreferences = { scrollback: 10_000, }; -/** Where the copy control sits on fenced code blocks in chat (touch-friendly when bottom). */ -export type CodeBlockCopyButtonPosition = "top" | "bottom"; -export const CODE_BLOCK_COPY_POSITION_IDS: CodeBlockCopyButtonPosition[] = ["top", "bottom"]; +/** Where the copy control sits on fenced code blocks in chat. + * - "top" / "bottom": fixed absolute corner (touch-friendly when bottom). + * - "auto": sticks to the top of the viewport while a long block is being scrolled. */ +export type CodeBlockCopyButtonPosition = "top" | "bottom" | "auto"; +export const CODE_BLOCK_COPY_POSITION_IDS: CodeBlockCopyButtonPosition[] = ["top", "bottom", "auto"]; /** Web Audio chime when an agent chat turn finishes (idle session). */ export type AgentTurnCompletionSound = "off" | "chime" | "ping" | "bell"; export const AGENT_TURN_COMPLETION_SOUND_IDS: AgentTurnCompletionSound[] = ["off", "chime", "ping", "bell"]; +export const DEFAULT_AGENT_TURN_COMPLETION_SOUND_VOLUME = 0.7; function normalizeCodeBlockCopyButtonPosition(value: unknown): CodeBlockCopyButtonPosition { - return value === "bottom" ? "bottom" : "top"; + if (value === "bottom" || value === "auto") return value; + return "top"; } function normalizeAgentTurnCompletionSound(value: unknown): AgentTurnCompletionSound { @@ -47,6 +51,12 @@ function normalizeAgentTurnCompletionSound(value: unknown): AgentTurnCompletionS return "off"; } +function normalizeAgentTurnCompletionSoundVolume(value: unknown): number { + const next = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(next)) return DEFAULT_AGENT_TURN_COMPLETION_SOUND_VOLUME; + return Math.max(0, Math.min(1, next)); +} + /** Base chat body font size in px (timeline + composer scale from this). Default matches prior ~14px body. */ export const DEFAULT_CHAT_FONT_SIZE_PX = 14; export const CHAT_FONT_SIZE_MIN_PX = 12; @@ -242,6 +252,18 @@ function normalizeProjectKey(projectRoot: string | null | undefined): string { return typeof projectRoot === "string" ? projectRoot.trim() : ""; } +/** + * Drops keys from a session-dismiss map that aren't in the allow-list. Used on project + * close/switch so banner-dismiss maps don't grow unbounded across a long session. + */ +function pickDismissMapForRoots(map: Record, roots: readonly (string | null | undefined)[]): Record { + const allow = new Set(roots.map((r) => normalizeProjectKey(r)).filter((r) => r.length > 0)); + if (allow.size === 0) return {}; + const next: Record = {}; + for (const key of Object.keys(map)) if (allow.has(key)) next[key] = true; + return next; +} + function normalizeLaneWorkScopeKey(projectRoot: string | null | undefined, laneId: string | null | undefined): string { const projectKey = normalizeProjectKey(projectRoot); const normalizedLaneId = typeof laneId === "string" ? laneId.trim() : ""; @@ -255,6 +277,8 @@ type PersistedUserPreferences = { smartTooltipsEnabled: boolean; codeBlockCopyButtonPosition: CodeBlockCopyButtonPosition; agentTurnCompletionSound: AgentTurnCompletionSound; + agentTurnCompletionSoundVolume: number; + agentTurnCompletionSoundQuietWhenFocused: boolean; chatFontSizePx: number; }; @@ -276,6 +300,8 @@ function readUnifiedUserPreferences(): PersistedUserPreferences | null { smartTooltipsEnabled: parsed.smartTooltipsEnabled !== false, codeBlockCopyButtonPosition: normalizeCodeBlockCopyButtonPosition(parsed.codeBlockCopyButtonPosition), agentTurnCompletionSound: normalizeAgentTurnCompletionSound(parsed.agentTurnCompletionSound), + agentTurnCompletionSoundVolume: normalizeAgentTurnCompletionSoundVolume(parsed.agentTurnCompletionSoundVolume), + agentTurnCompletionSoundQuietWhenFocused: parsed.agentTurnCompletionSoundQuietWhenFocused !== false, chatFontSizePx: normalizeChatFontSizePx(parsed.chatFontSizePx), }; } catch { @@ -309,6 +335,8 @@ function readLegacyUserPreferences(): PersistedUserPreferences { smartTooltipsEnabled, codeBlockCopyButtonPosition: "top", agentTurnCompletionSound: "off", + agentTurnCompletionSoundVolume: DEFAULT_AGENT_TURN_COMPLETION_SOUND_VOLUME, + agentTurnCompletionSoundQuietWhenFocused: true, chatFontSizePx: DEFAULT_CHAT_FONT_SIZE_PX, }; } @@ -321,6 +349,29 @@ function persistUserPreferences(prefs: PersistedUserPreferences) { } } +/** Assemble the persisted-prefs payload from current store state. Keeps setters DRY as we add prefs. */ +function persistUserPreferencesFrom(state: { + theme: ThemeId; + terminalPreferences: TerminalPreferences; + smartTooltipsEnabled: boolean; + codeBlockCopyButtonPosition: CodeBlockCopyButtonPosition; + agentTurnCompletionSound: AgentTurnCompletionSound; + agentTurnCompletionSoundVolume: number; + agentTurnCompletionSoundQuietWhenFocused: boolean; + chatFontSizePx: number; +}) { + persistUserPreferences({ + theme: state.theme, + terminalPreferences: state.terminalPreferences, + smartTooltipsEnabled: state.smartTooltipsEnabled, + codeBlockCopyButtonPosition: state.codeBlockCopyButtonPosition, + agentTurnCompletionSound: state.agentTurnCompletionSound, + agentTurnCompletionSoundVolume: state.agentTurnCompletionSoundVolume, + agentTurnCompletionSoundQuietWhenFocused: state.agentTurnCompletionSoundQuietWhenFocused, + chatFontSizePx: state.chatFontSizePx, + }); +} + function readInitialUserPreferences(): PersistedUserPreferences { const unified = readUnifiedUserPreferences(); if (unified) return unified; @@ -368,6 +419,9 @@ function normalizeTerminalPreferences(value: unknown): TerminalPreferences { }; } +/** Session-scoped banner dismissals keyed by project root. Not persisted — "dismiss for this session" only. */ +export type SessionDismissMap = Record; + type AppState = { project: ProjectInfo | null; projectHydrated: boolean; @@ -392,6 +446,8 @@ type AppState = { terminalPreferences: TerminalPreferences; codeBlockCopyButtonPosition: CodeBlockCopyButtonPosition; agentTurnCompletionSound: AgentTurnCompletionSound; + agentTurnCompletionSoundVolume: number; + agentTurnCompletionSoundQuietWhenFocused: boolean; chatFontSizePx: number; providerMode: ProviderMode; availableModels: ModelDescriptor[]; @@ -401,6 +457,10 @@ type AppState = { smartTooltipsEnabled: boolean; workViewByProject: Record; laneWorkViewByScope: Record; + /** Session-scoped banner dismissals. Pruned when a project is closed/switched so the maps don't leak. */ + dismissedMissingAiBannerRoots: SessionDismissMap; + dismissedGithubBannerRoots: SessionDismissMap; + dismissedContextBannerRoots: SessionDismissMap; setProject: (project: ProjectInfo | null) => void; setProjectHydrated: (hydrated: boolean) => void; @@ -415,6 +475,8 @@ type AppState = { setTheme: (theme: ThemeId) => void; setCodeBlockCopyButtonPosition: (position: CodeBlockCopyButtonPosition) => void; setAgentTurnCompletionSound: (sound: AgentTurnCompletionSound) => void; + setAgentTurnCompletionSoundVolume: (volume: number) => void; + setAgentTurnCompletionSoundQuietWhenFocused: (quiet: boolean) => void; setChatFontSizePx: (px: number) => void; setTerminalPreferences: ( next: @@ -440,6 +502,9 @@ type AppState = { ) => void; refreshProviderMode: () => Promise; refreshKeybindings: () => Promise; + dismissMissingAiBanner: (projectRoot: string) => void; + dismissGithubBanner: (projectRoot: string) => void; + dismissContextBanner: (projectRoot: string) => void; openNewTab: () => void; cancelNewTab: () => void; @@ -522,6 +587,8 @@ export const useAppStore = create((set, get) => ({ terminalPreferences: initialUserPreferences.terminalPreferences, codeBlockCopyButtonPosition: initialUserPreferences.codeBlockCopyButtonPosition, agentTurnCompletionSound: initialUserPreferences.agentTurnCompletionSound, + agentTurnCompletionSoundVolume: initialUserPreferences.agentTurnCompletionSoundVolume, + agentTurnCompletionSoundQuietWhenFocused: initialUserPreferences.agentTurnCompletionSoundQuietWhenFocused, chatFontSizePx: initialUserPreferences.chatFontSizePx, providerMode: "guest", availableModels: [...MODEL_REGISTRY].filter((m) => !m.deprecated), @@ -531,6 +598,9 @@ export const useAppStore = create((set, get) => ({ smartTooltipsEnabled: initialUserPreferences.smartTooltipsEnabled, workViewByProject: initialPersistedWorkViews.workViewByProject, laneWorkViewByScope: initialPersistedWorkViews.laneWorkViewByScope, + dismissedMissingAiBannerRoots: {}, + dismissedGithubBannerRoots: {}, + dismissedContextBannerRoots: {}, setProject: (project) => set((prev) => { @@ -563,54 +633,38 @@ export const useAppStore = create((set, get) => ({ focusSession: (sessionId) => set({ focusedSessionId: sessionId }), setTheme: (theme) => set((prev) => { - persistUserPreferences({ - theme, - terminalPreferences: prev.terminalPreferences, - smartTooltipsEnabled: prev.smartTooltipsEnabled, - codeBlockCopyButtonPosition: prev.codeBlockCopyButtonPosition, - agentTurnCompletionSound: prev.agentTurnCompletionSound, - chatFontSizePx: prev.chatFontSizePx, - }); + const next = { ...prev, theme }; + persistUserPreferencesFrom(next); return { theme }; }), setCodeBlockCopyButtonPosition: (position) => set((prev) => { - const next = normalizeCodeBlockCopyButtonPosition(position); - persistUserPreferences({ - theme: prev.theme, - terminalPreferences: prev.terminalPreferences, - smartTooltipsEnabled: prev.smartTooltipsEnabled, - codeBlockCopyButtonPosition: next, - agentTurnCompletionSound: prev.agentTurnCompletionSound, - chatFontSizePx: prev.chatFontSizePx, - }); - return { codeBlockCopyButtonPosition: next }; + const value = normalizeCodeBlockCopyButtonPosition(position); + persistUserPreferencesFrom({ ...prev, codeBlockCopyButtonPosition: value }); + return { codeBlockCopyButtonPosition: value }; }), setAgentTurnCompletionSound: (sound) => set((prev) => { - const next = normalizeAgentTurnCompletionSound(sound); - persistUserPreferences({ - theme: prev.theme, - terminalPreferences: prev.terminalPreferences, - smartTooltipsEnabled: prev.smartTooltipsEnabled, - codeBlockCopyButtonPosition: prev.codeBlockCopyButtonPosition, - agentTurnCompletionSound: next, - chatFontSizePx: prev.chatFontSizePx, - }); - return { agentTurnCompletionSound: next }; + const value = normalizeAgentTurnCompletionSound(sound); + persistUserPreferencesFrom({ ...prev, agentTurnCompletionSound: value }); + return { agentTurnCompletionSound: value }; + }), + setAgentTurnCompletionSoundVolume: (volume) => + set((prev) => { + const value = normalizeAgentTurnCompletionSoundVolume(volume); + persistUserPreferencesFrom({ ...prev, agentTurnCompletionSoundVolume: value }); + return { agentTurnCompletionSoundVolume: value }; + }), + setAgentTurnCompletionSoundQuietWhenFocused: (quiet) => + set((prev) => { + persistUserPreferencesFrom({ ...prev, agentTurnCompletionSoundQuietWhenFocused: quiet }); + return { agentTurnCompletionSoundQuietWhenFocused: quiet }; }), setChatFontSizePx: (px) => set((prev) => { - const next = normalizeChatFontSizePx(px); - persistUserPreferences({ - theme: prev.theme, - terminalPreferences: prev.terminalPreferences, - smartTooltipsEnabled: prev.smartTooltipsEnabled, - codeBlockCopyButtonPosition: prev.codeBlockCopyButtonPosition, - agentTurnCompletionSound: prev.agentTurnCompletionSound, - chatFontSizePx: next, - }); - return { chatFontSizePx: next }; + const value = normalizeChatFontSizePx(px); + persistUserPreferencesFrom({ ...prev, chatFontSizePx: value }); + return { chatFontSizePx: value }; }), setTerminalPreferences: (next) => set((prev) => { @@ -619,27 +673,13 @@ export const useAppStore = create((set, get) => ({ ? next(prev.terminalPreferences) : { ...prev.terminalPreferences, ...next } ); - persistUserPreferences({ - theme: prev.theme, - terminalPreferences: updated, - smartTooltipsEnabled: prev.smartTooltipsEnabled, - codeBlockCopyButtonPosition: prev.codeBlockCopyButtonPosition, - agentTurnCompletionSound: prev.agentTurnCompletionSound, - chatFontSizePx: prev.chatFontSizePx, - }); + persistUserPreferencesFrom({ ...prev, terminalPreferences: updated }); return { terminalPreferences: updated }; }), setTerminalAttention: (terminalAttention) => set({ terminalAttention }), setSmartTooltipsEnabled: (enabled) => set((prev) => { - persistUserPreferences({ - theme: prev.theme, - terminalPreferences: prev.terminalPreferences, - smartTooltipsEnabled: enabled, - codeBlockCopyButtonPosition: prev.codeBlockCopyButtonPosition, - agentTurnCompletionSound: prev.agentTurnCompletionSound, - chatFontSizePx: prev.chatFontSizePx, - }); + persistUserPreferencesFrom({ ...prev, smartTooltipsEnabled: enabled }); return { smartTooltipsEnabled: enabled }; }), openNewTab: () => set({ isNewTabOpen: true, showWelcome: true }), @@ -832,6 +872,28 @@ export const useAppStore = create((set, get) => ({ set({ keybindings }); }, + dismissMissingAiBanner: (projectRoot) => { + const key = normalizeProjectKey(projectRoot); + if (!key) return; + set((prev) => ({ + dismissedMissingAiBannerRoots: { ...prev.dismissedMissingAiBannerRoots, [key]: true }, + })); + }, + dismissGithubBanner: (projectRoot) => { + const key = normalizeProjectKey(projectRoot); + if (!key) return; + set((prev) => ({ + dismissedGithubBannerRoots: { ...prev.dismissedGithubBannerRoots, [key]: true }, + })); + }, + dismissContextBanner: (projectRoot) => { + const key = normalizeProjectKey(projectRoot); + if (!key) return; + set((prev) => ({ + dismissedContextBannerRoots: { ...prev.dismissedContextBannerRoots, [key]: true }, + })); + }, + openRepo: async () => { // Invalidate in-flight lane refreshes before the async open so stale // responses from the previous project are discarded immediately. @@ -851,7 +913,7 @@ export const useAppStore = create((set, get) => ({ return null; } get().setProject(project); - set({ + set((prev) => ({ projectHydrated: true, showWelcome: false, projectTransition: null, @@ -864,8 +926,11 @@ export const useAppStore = create((set, get) => ({ focusedSessionId: null, laneInspectorTabs: {}, keybindings: null, - terminalAttention: EMPTY_TERMINAL_ATTENTION - }); + terminalAttention: EMPTY_TERMINAL_ATTENTION, + dismissedMissingAiBannerRoots: pickDismissMapForRoots(prev.dismissedMissingAiBannerRoots, [project.rootPath]), + dismissedGithubBannerRoots: pickDismissMapForRoots(prev.dismissedGithubBannerRoots, [project.rootPath]), + dismissedContextBannerRoots: pickDismissMapForRoots(prev.dismissedContextBannerRoots, [project.rootPath]), + })); invalidateAiDiscoveryCache(project.rootPath); invalidateProjectConfigCache(project.rootPath); void Promise.allSettled([ @@ -898,6 +963,8 @@ export const useAppStore = create((set, get) => ({ try { const project = await window.ade.project.switchToPath(rootPath); get().setProject(project); + // Banner-dismiss pruning happens in the second `set` call below, after recents are fetched, + // so we can retain dismissals for the active project + all recent projects in one pass. set({ projectHydrated: true, showWelcome: false, @@ -911,7 +978,7 @@ export const useAppStore = create((set, get) => ({ focusedSessionId: null, laneInspectorTabs: {}, keybindings: null, - terminalAttention: EMPTY_TERMINAL_ATTENTION + terminalAttention: EMPTY_TERMINAL_ATTENTION, }); invalidateAiDiscoveryCache(rootPath); invalidateProjectConfigCache(rootPath); @@ -926,6 +993,7 @@ export const useAppStore = create((set, get) => ({ (await window.ade.project.listRecent().catch(() => [])).map((r: { rootPath: string }) => r.rootPath) ); const activeRoot = get().project?.rootPath ?? null; + const retainedRoots = [activeRoot, ...recentRoots]; set((prev) => { const nextWorkViews: Record = {}; const nextLaneWorkViews: Record = {}; @@ -944,6 +1012,9 @@ export const useAppStore = create((set, get) => ({ projectTransition: null, workViewByProject: nextWorkViews, laneWorkViewByScope: nextLaneWorkViews, + dismissedMissingAiBannerRoots: pickDismissMapForRoots(prev.dismissedMissingAiBannerRoots, retainedRoots), + dismissedGithubBannerRoots: pickDismissMapForRoots(prev.dismissedGithubBannerRoots, retainedRoots), + dismissedContextBannerRoots: pickDismissMapForRoots(prev.dismissedContextBannerRoots, retainedRoots), }; }); } catch (error) { @@ -983,7 +1054,11 @@ export const useAppStore = create((set, get) => ({ focusedSessionId: null, laneInspectorTabs: {}, keybindings: null, - terminalAttention: EMPTY_TERMINAL_ATTENTION + terminalAttention: EMPTY_TERMINAL_ATTENTION, + // No active project: drop every dismiss entry so reopening the same project later starts with a clean slate. + dismissedMissingAiBannerRoots: {}, + dismissedGithubBannerRoots: {}, + dismissedContextBannerRoots: {}, }); } catch (error) { set({ diff --git a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift index abce3e55e..d3220635c 100644 --- a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift +++ b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift @@ -41,18 +41,13 @@ struct CreatePrWizardView: View { return capabilities.lanes .filter { $0.canCreate } .map { eligibility in - let ahead = eligibility.commitsAheadOfBase ?? 0 - let aheadNote: String = - ahead > 0 - ? "\(ahead) commit\(ahead == 1 ? "" : "s") ahead of \(eligibility.defaultBaseBranch)" - : "Aligned with \(eligibility.defaultBaseBranch) (no commits ahead)" return CreatePrLaneOption( id: eligibility.laneId, title: eligibility.laneName, branchRef: lanes.first(where: { $0.id == eligibility.laneId })?.branchRef ?? eligibility.laneName, defaultBaseBranch: eligibility.defaultBaseBranch, defaultTitle: eligibility.defaultTitle, - subtitle: aheadNote + subtitle: Self.laneProgressSubtitle(for: eligibility) ) } } @@ -82,6 +77,23 @@ struct CreatePrWizardView: View { return lanes.first(where: { $0.id == id }) } + private static func laneProgressSubtitle(for eligibility: PrCreateLaneEligibility) -> String? { + guard let ahead = eligibility.commitsAheadOfBase else { + return eligibility.dirty ? "Uncommitted edits present" : nil + } + + let base = eligibility.defaultBaseBranch + let commitLabel = ahead == 1 ? "1 commit" : "\(ahead) commits" + if ahead > 0 { + return eligibility.dirty + ? "\(commitLabel) ahead of \(base) · uncommitted edits" + : "Ready to open: \(commitLabel) ahead of \(base)" + } + return eligibility.dirty + ? "No commits ahead of \(base) · uncommitted edits" + : "No commits ahead of \(base)" + } + private var canAdvance: Bool { switch step { case 1: @@ -205,8 +217,9 @@ struct CreatePrWizardView: View { .foregroundStyle(ADEColor.textSecondary) if let subtitle = selectedOption.subtitle, !subtitle.isEmpty { Text(subtitle) - .font(.caption2) + .font(.caption) .foregroundStyle(ADEColor.textMuted) + .lineLimit(2) } } } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index ea4fee53d..95764fc1b 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -3759,6 +3759,34 @@ final class ADETests: XCTestCase { XCTAssertNil(snapshot.createCapabilities.defaultBaseBranch) } + func testPrCreateCapabilitiesPreserveUnknownLegacyAheadCount() throws { + let json = """ + { + "canCreateAny": true, + "defaultBaseBranch": "main", + "lanes": [ + { + "laneId": "lane-legacy", + "laneName": "legacy", + "parentLaneId": null, + "repoOwner": null, + "repoName": null, + "defaultBaseBranch": "main", + "defaultTitle": "legacy", + "dirty": false, + "hasExistingPr": false, + "canCreate": true, + "blockedReason": null + } + ] + } + """ + + let capabilities = try JSONDecoder().decode(PrCreateCapabilities.self, from: Data(json.utf8)) + XCTAssertEqual(capabilities.lanes.first?.laneId, "lane-legacy") + XCTAssertNil(capabilities.lanes.first?.commitsAheadOfBase) + } + func testPrActionCapabilitiesGateMergeAndSurfaceBlockedReason() { let capabilitiesAllow = PrActionCapabilities( prId: "pr-1", From f000eee5084ae150cda00dd7ebf26a6c8ec3ddbc Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:29:47 -0400 Subject: [PATCH 8/8] Improve glob/path handling and small UI/accessibility fixes Enhance grep glob matching and path normalization, add tests, and apply minor UI/accessibility tweaks. - grepSearch: Normalize backslashes in file globs, detect when a glob includes directory components, and match against relative file paths when appropriate. Rewrote globToRegex to correctly handle **/, **, *, ?, and {a,b} patterns while preserving directory semantics. Added tests to exercise directory globs and JS fallback behavior. - projectConfigService: Add utilities to detect absolute paths across platforms, infer project-relative paths from foreign-platform absolute paths (e.g. Windows paths on POSIX), and return portable relative paths for config saving. Added a test to ensure foreign absolute process paths are normalized to portable relative paths in saved config. - CodeHighlighter: Adjust copy button rendering so the auto position renders consistently. - AppearanceSection: Add aria-pressed to theme swatch button and an aria-label to the custom terminal font input to improve accessibility. These changes improve cross-platform behavior for globs and config paths and address minor UX/accessibility issues. --- .../main/services/ai/tools/grepSearch.test.ts | 30 ++++++++- .../src/main/services/ai/tools/grepSearch.ts | 66 +++++++++++++------ ...projectConfigService.processGroups.test.ts | 52 +++++++++++++++ .../services/config/projectConfigService.ts | 50 +++++++++++++- .../components/chat/CodeHighlighter.tsx | 6 +- .../components/settings/AppearanceSection.tsx | 2 + 6 files changed, 181 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/main/services/ai/tools/grepSearch.test.ts b/apps/desktop/src/main/services/ai/tools/grepSearch.test.ts index c65521603..dd26eb0d2 100644 --- a/apps/desktop/src/main/services/ai/tools/grepSearch.test.ts +++ b/apps/desktop/src/main/services/ai/tools/grepSearch.test.ts @@ -292,8 +292,6 @@ describe("createGrepSearchTool", () => { describe("glob handling", () => { it("matches bare filenames under a **/*.ts glob (JS fallback)", async () => { - // Globs are applied to `entry.name` in the fallback, so `**/*.ts` - // must collapse to `*.ts` to match bare `foo.ts` — aligning with ripgrep. const cwd = makeTmpDir("grep-starstar-"); writeFixtureFile(cwd, "foo.ts", "const marker = 1;"); writeFixtureFile(cwd, "src/bar.ts", "const marker = 2;"); @@ -306,6 +304,34 @@ describe("createGrepSearchTool", () => { const paths = result.matches.map((m) => m.displayPath).sort(); expect(paths).toEqual(["foo.ts", "src/bar.ts"]); }); + + it("preserves directory components in JS fallback globs", async () => { + const cwd = makeTmpDir("grep-dir-glob-"); + writeFixtureFile(cwd, "src/app.ts", "const marker = 1;"); + writeFixtureFile(cwd, "src/deep/app.ts", "const marker = 2;"); + writeFixtureFile(cwd, "lib/app.ts", "const marker = 3;"); + forceJsFallback(); + + const tool = createGrepSearchTool(cwd); + const result = await tool.execute({ pattern: "marker", glob: "src/*.ts", context: 0 }); + + const paths = result.matches.map((m) => m.displayPath).sort(); + expect(paths).toEqual(["src/app.ts"]); + }); + + it("matches directory ** globs without escaping the subtree", async () => { + const cwd = makeTmpDir("grep-dir-starstar-"); + writeFixtureFile(cwd, "services/index.ts", "const marker = 1;"); + writeFixtureFile(cwd, "services/api/handler.ts", "const marker = 2;"); + writeFixtureFile(cwd, "packages/services/index.ts", "const marker = 3;"); + forceJsFallback(); + + const tool = createGrepSearchTool(cwd); + const result = await tool.execute({ pattern: "marker", glob: "services/**/*.ts", context: 0 }); + + const paths = result.matches.map((m) => m.displayPath).sort(); + expect(paths).toEqual(["services/api/handler.ts", "services/index.ts"]); + }); }); // -------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/ai/tools/grepSearch.ts b/apps/desktop/src/main/services/ai/tools/grepSearch.ts index f32b1cfc3..470343db9 100644 --- a/apps/desktop/src/main/services/ai/tools/grepSearch.ts +++ b/apps/desktop/src/main/services/ai/tools/grepSearch.ts @@ -215,7 +215,9 @@ function collectFiles( if (stat.isFile()) return [dir]; const files: string[] = []; - const globRegex = fileGlob ? globToRegex(fileGlob) : null; + const normalizedFileGlob = fileGlob?.replace(/\\/g, "/"); + const globRegex = normalizedFileGlob ? globToRegex(normalizedFileGlob) : null; + const globIncludesDirectory = normalizedFileGlob?.includes("/") ?? false; const rootReal = fs.realpathSync(dir); function walk(current: string): void { @@ -239,7 +241,9 @@ function collectFiles( walk(next); } else if (entry.isFile()) { const fullPath = path.join(current, entry.name); - if (!globRegex || globRegex.test(entry.name)) { + const relativeFilePath = path.relative(rootReal, fullPath).replace(/\\/g, "/"); + const globTarget = globIncludesDirectory ? relativeFilePath : entry.name; + if (!globRegex || globRegex.test(globTarget)) { files.push(fullPath); } } @@ -251,23 +255,47 @@ function collectFiles( } function globToRegex(glob: string): RegExp { - // Globs are matched against bare filenames (`entry.name`) in the fallback, - // so strip directory components before applying glob rules. Collapse `**` - // first so `**/*.ts` → `*/*.ts` → `*.ts`; if any `/` remains, keep only the - // last segment (the filename pattern) so `src/*.ts` still matches `foo.ts`. - let pattern = glob.replace(/\*\*/g, "*"); - const lastSlash = pattern.lastIndexOf("/"); - if (lastSlash !== -1) pattern = pattern.slice(lastSlash + 1); - // Escape special regex chars except * and ? first, BEFORE brace expansion. - // This avoids escaping the parens/pipe that brace expansion introduces. - pattern = pattern.replace(/[.+^$[\]\\]/g, "\\$&"); - // Replace glob wildcards - pattern = pattern.replace(/\*/g, ".*"); - pattern = pattern.replace(/\?/g, "."); - // Handle {a,b} patterns (after escaping, so parens/pipe stay unescaped) - pattern = pattern.replace(/\{([^}]+)\}/g, (_m, inner: string) => { - return `(${inner.split(",").join("|")})`; - }); + let pattern = ""; + for (let i = 0; i < glob.length; i += 1) { + const char = glob[i]; + const next = glob[i + 1]; + + if (char === "*" && next === "*") { + if (glob[i + 2] === "/") { + pattern += "(?:.*/)?"; + i += 2; + } else { + pattern += ".*"; + i += 1; + } + continue; + } + + if (char === "*") { + pattern += "[^/]*"; + continue; + } + + if (char === "?") { + pattern += "[^/]"; + continue; + } + + if (char === "{") { + const close = glob.indexOf("}", i + 1); + if (close !== -1) { + const alternatives = glob + .slice(i + 1, close) + .split(",") + .map((part) => part.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&")); + pattern += `(${alternatives.join("|")})`; + i = close; + continue; + } + } + + pattern += char.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"); + } return new RegExp(`^${pattern}$`); } diff --git a/apps/desktop/src/main/services/config/projectConfigService.processGroups.test.ts b/apps/desktop/src/main/services/config/projectConfigService.processGroups.test.ts index 6842ba1bc..046989469 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.processGroups.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.processGroups.test.ts @@ -342,4 +342,56 @@ describe("projectConfigService process groups", () => { expect(saved.testSuites[0].command[0]).toBe("../../scripts/run-tests.sh"); expect(saved.laneOverlayPolicies[0].overrides.cwd).toBe("apps/desktop"); }); + + it("normalizes foreign-platform absolute process paths to portable relative paths", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-cross-platform-")); + tempDirs.push(root); + + const projectDirName = path.basename(root); + const windowsProjectRoot = `C:\\repo\\${projectDirName}`; + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(path.join(root, "scripts"), { recursive: true }); + fs.mkdirSync(adeDir, { recursive: true }); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-cross-platform-paths", + db: makeDb(), + logger: makeLogger(), + }); + + const snapshot = service.save({ + shared: { + version: 1, + processes: [ + { + id: "dogfood", + name: "Dogfood", + command: [`${windowsProjectRoot}\\scripts\\dogfood.sh`, "code-review"], + cwd: windowsProjectRoot, + }, + ], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }, + local: { + version: 1, + processes: [], + stackButtons: [], + testSuites: [], + laneOverlayPolicies: [], + automations: [], + }, + }); + + expect(snapshot.effective.processes[0]?.cwd).toBe("."); + expect(snapshot.effective.processes[0]?.command[0]).toBe("scripts/dogfood.sh"); + + const saved = YAML.parse(fs.readFileSync(path.join(adeDir, "ade.yaml"), "utf8")); + expect(saved.processes[0].cwd).toBe("."); + expect(saved.processes[0].command[0]).toBe("scripts/dogfood.sh"); + }); }); diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index a9b679b92..7f8964b8c 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -152,8 +152,56 @@ function normalizeConfigPath(value: string): string { return value.trim().replace(/\\/g, "/"); } +function isAbsoluteOnAnyPlatform(value: string): boolean { + return path.isAbsolute(value) || path.win32.isAbsolute(value) || path.posix.isAbsolute(value); +} + +function isLikelyForeignAbsolutePath(value: string): boolean { + const normalized = normalizeConfigPath(value); + if (path.sep === "/") { + return /^[A-Za-z]:\//.test(normalized) || normalized.startsWith("//"); + } + return normalized.startsWith("/") && !/^[A-Za-z]:\//.test(normalized); +} + +function normalizedPathSegments(value: string): string[] { + return normalizeConfigPath(value) + .replace(/^[A-Za-z]:\/?/, "") + .replace(/^\/\/[^/]+\/[^/]+\/?/, "") + .split("/") + .filter(Boolean); +} + +function inferProjectRelativePath(projectRoot: string, candidate: string): string | null { + const rootSegments = normalizedPathSegments(projectRoot); + const candidateSegments = normalizedPathSegments(candidate); + if (!rootSegments.length || !candidateSegments.length) return null; + + if (candidateSegments.length >= rootSegments.length) { + for (let i = 0; i <= candidateSegments.length - rootSegments.length; i += 1) { + const matchesRoot = rootSegments.every((segment, offset) => candidateSegments[i + offset] === segment); + if (matchesRoot) { + return candidateSegments.slice(i + rootSegments.length).join("/") || "."; + } + } + } + + const projectDirName = rootSegments[rootSegments.length - 1]; + const projectDirIndex = candidateSegments.lastIndexOf(projectDirName); + if (projectDirIndex === -1) return null; + return candidateSegments.slice(projectDirIndex + 1).join("/") || "."; +} + function projectRelativePath(projectRoot: string, absolutePath: string, basePath: string): string | null { - if (!path.isAbsolute(absolutePath)) return null; + if (!isAbsoluteOnAnyPlatform(absolutePath)) return null; + const nativeAbsolute = path.isAbsolute(absolutePath); + if (!nativeAbsolute || isLikelyForeignAbsolutePath(absolutePath)) { + const projectRelative = inferProjectRelativePath(projectRoot, absolutePath); + const baseRelative = inferProjectRelativePath(projectRoot, basePath); + if (projectRelative == null || baseRelative == null) return null; + const relative = path.posix.relative(baseRelative === "." ? "" : baseRelative, projectRelative); + return relative || "."; + } try { const resolved = resolvePathWithinRoot(projectRoot, absolutePath, { allowMissing: true }); const resolvedBase = resolvePathWithinRoot(projectRoot, basePath, { allowMissing: true }); diff --git a/apps/desktop/src/renderer/components/chat/CodeHighlighter.tsx b/apps/desktop/src/renderer/components/chat/CodeHighlighter.tsx index 32edf733a..9740c7b79 100644 --- a/apps/desktop/src/renderer/components/chat/CodeHighlighter.tsx +++ b/apps/desktop/src/renderer/components/chat/CodeHighlighter.tsx @@ -295,10 +295,10 @@ export const HighlightedCode = React.memo(function HighlightedCode({ {copyButtonPosition !== "auto" && ( )} + {copyButtonPosition === "auto" && ( + + )}
- {copyButtonPosition === "auto" && ( - - )} {isDiff ? ( ) : ( diff --git a/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx b/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx index 95548902d..7b9573914 100644 --- a/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AppearanceSection.tsx @@ -69,6 +69,7 @@ export function ThemeSwatch({ return (