diff --git a/.ade/ade.yaml b/.ade/ade.yaml index ba417967b..5e97cab6d 100644 --- a/.ade/ade.yaml +++ b/.ade/ade.yaml @@ -3,9 +3,9 @@ processes: - id: dbun9idy name: dogfood code review command: - - /Users/admin/Projects/ADE/scripts/dogfood.sh + - scripts/dogfood.sh - code-review - cwd: /Users/admin/Projects/ADE + cwd: . gracefulShutdownMs: 7000 stackButtons: [] testSuites: [] diff --git a/.claude/commands/finalize.md b/.claude/commands/finalize.md index 99edd4bde..aed59cba0 100644 --- a/.claude/commands/finalize.md +++ b/.claude/commands/finalize.md @@ -29,7 +29,7 @@ The only outputs are the Phase 4 summary and any error messages for genuinely fa ``` Phase 1: Analyze code changes and batch simplification work (lead) -Phase 2: Parallel execution (simplify + docs) (agents) +Phase 2: Parallel execution (simplify + docs + mobile parity)(agents) Phase 3: CI sync + local verification (lead) Phase 4: Summary (lead) ``` @@ -185,6 +185,56 @@ This validator only covers the Mintlify site. For internal docs, self-check: Report what docs were updated and what was changed. ``` +### Mobile parity agent + +Spawn a general-purpose agent with this prompt: + +``` +You are the mobile parity reviewer for the ADE project. + +Analyze all work on the current branch vs main, including changes that are +already under review and any simplifications made during `/finalize`. Determine +whether the iOS companion app under `apps/ios/` needs matching updates. + +Step 1: Get branch context + git diff main --name-only + git diff main --stat | tail -30 + git log main..HEAD --oneline + +Step 2: Identify cross-platform changes +- Shared contracts: apps/desktop/src/shared/**, preload IPC types, sync payloads, + PR mobile snapshots, chat/session models, lane summaries, config schemas. +- Desktop behavior with a mobile surface: PR workflows, lanes, Work chat, + files, sync/multi-device, settings exposed on iOS, model/session controls. +- Renderer-only desktop preferences are only mobile-applicable when the iOS app + has the same user-facing concept and a native implementation path. + +Step 3: Inspect iOS equivalents +- Search `apps/ios/ADE` and `apps/ios/ADETests` for the affected model, view, + service, or workflow names. +- If the branch adds or changes a host/mobile contract, update Swift Codable + models and iOS tests as needed. +- If the branch changes user-facing behavior that iOS already exposes, update + the SwiftUI view using native iOS controls and existing ADE design patterns. +- If the change is not applicable to iOS, explain why in the report. + +Step 4: Apply required iOS updates +- Keep edits scoped to `apps/ios/` unless a shared contract fix is required. +- Prefer existing SwiftUI patterns and native controls. +- Preserve Dynamic Type, VoiceOver labels, and 44x44 tap targets. +- Add or update targeted tests in `apps/ios/ADETests` for contract changes. + +Step 5: Validate what you touched +- At minimum: `xcrun swiftc -parse ` when a full Xcode + build/test run is unavailable. +- Prefer an iOS build/test when the local simulator/runtime environment supports it. + +Report: +- iOS files changed, or "No iOS changes required" +- Why each desktop/shared change was applicable or not applicable to mobile +- Validation run and any environment limitations +``` + Wait for all agents to complete. --- @@ -326,6 +376,11 @@ If Phase 3e fails only inside files the simplifier touched, revert the simplifie - Docs checked but unchanged: [list] - Doc validation: PASS +### Mobile Parity: +- iOS changes: [list or "none required"] +- Applicability notes: [brief list] +- Validation: PASS / blocked with reason + ### CI Verification: - Lock files in sync: PASS - Typecheck (desktop): PASS @@ -347,6 +402,7 @@ If Phase 3e fails only inside files the simplifier touched, revert the simplifie Before marking complete: - [ ] Code simplification completed on all batches - [ ] Documentation updated for all affected areas +- [ ] Mobile parity reviewed; applicable iOS updates made and validated - [ ] CI workflow sync verified (no orphaned test files) - [ ] Lock files in sync (no dirty lock files after install) - [ ] Typecheck passed (desktop + mcp-server + web) 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 c50d437f1..dd26eb0d2 100644 --- a/apps/desktop/src/main/services/ai/tools/grepSearch.test.ts +++ b/apps/desktop/src/main/services/ai/tools/grepSearch.test.ts @@ -1,8 +1,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { execFile } from "node:child_process"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { createGrepSearchTool } from "./grepSearch"; +import { + __testResetRipgrepExecFile, + __testSetRipgrepExecFile, + createGrepSearchTool, +} from "./grepSearch"; const tmpDirs: string[] = []; function makeTmpDir(prefix: string): string { @@ -18,6 +23,7 @@ function writeFixtureFile(root: string, relativePath: string, content: string): } afterEach(() => { + __testResetRipgrepExecFile(); vi.restoreAllMocks(); for (const dir of tmpDirs) { try { @@ -29,22 +35,19 @@ afterEach(() => { tmpDirs.length = 0; }); -// Helper to force JS fallback by making execFile reject for rg +// Force JS fallback by making ripgrep's exec path reject (matches real "rg missing" behavior). function forceJsFallback(): void { - const cp = require("node:child_process"); - const originalExecFile = cp.execFile; - vi.spyOn(cp, "execFile").mockImplementation( - (cmd: unknown, ...rest: unknown[]) => { + __testSetRipgrepExecFile( + ((cmd: unknown, ...rest: unknown[]) => { if (cmd === "rg") { - // Make the promisified version reject const cb = rest[rest.length - 1]; if (typeof cb === "function") { - process.nextTick(() => cb(new Error("rg not available"))); + process.nextTick(() => (cb as (err: Error) => void)(new Error("rg not available"))); return; } } - return originalExecFile(cmd, ...rest); - }, + return (execFile as (typeof import("node:child_process"))["execFile"])(cmd as never, ...rest as never[]); + }) as typeof execFile, ); } @@ -199,6 +202,19 @@ describe("createGrepSearchTool", () => { expect(result.matches[0].displayPath).toBe("src/app.ts"); }); + it("repo-wide JS fallback skips root .ade but still searches .github", async () => { + const cwd = makeTmpDir("grep-hidden-root-"); + writeFixtureFile(cwd, ".ade/secrets.txt", "SECRET_MARKER"); + writeFixtureFile(cwd, ".github/workflows/ci.yml", "SECRET_MARKER"); + writeFixtureFile(cwd, "src/app.ts", "SECRET_MARKER"); + forceJsFallback(); + + const tool = createGrepSearchTool(cwd); + const result = await tool.execute({ pattern: "SECRET_MARKER", context: 0 }); + const paths = result.matches.map((m) => m.displayPath).sort(); + expect(paths).toEqual([".github/workflows/ci.yml", "src/app.ts"]); + }); + it("handles brace expansion in file glob: *.{ts,tsx}", async () => { const cwd = makeTmpDir("grep-brace-"); writeFixtureFile(cwd, "app.ts", "const val = 1;"); @@ -254,6 +270,68 @@ describe("createGrepSearchTool", () => { expect(result.error).toBeDefined(); expect(result.matchCount).toBe(0); }); + + it("surfaces a descriptive 'Invalid regex pattern' error for malformed patterns (JS fallback)", async () => { + const cwd = makeTmpDir("grep-bad-regex-"); + writeFixtureFile(cwd, "code.ts", "const x = 1;"); + forceJsFallback(); + + const tool = createGrepSearchTool(cwd); + // Unmatched `[` — a SyntaxError from `new RegExp`. + const result = await tool.execute({ pattern: "[", context: 0 }); + + expect(result.matchCount).toBe(0); + expect(result.error).toBeDefined(); + expect(result.error).toContain("Invalid regex pattern"); + }); + }); + + // -------------------------------------------------------------------------- + // Glob edge cases + // -------------------------------------------------------------------------- + + describe("glob handling", () => { + it("matches bare filenames under a **/*.ts glob (JS fallback)", async () => { + const cwd = makeTmpDir("grep-starstar-"); + writeFixtureFile(cwd, "foo.ts", "const marker = 1;"); + writeFixtureFile(cwd, "src/bar.ts", "const marker = 2;"); + writeFixtureFile(cwd, "readme.md", "marker"); + forceJsFallback(); + + const tool = createGrepSearchTool(cwd); + const result = await tool.execute({ pattern: "marker", glob: "**/*.ts", context: 0 }); + + 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 412bb61ac..470343db9 100644 --- a/apps/desktop/src/main/services/ai/tools/grepSearch.ts +++ b/apps/desktop/src/main/services/ai/tools/grepSearch.ts @@ -1,12 +1,35 @@ import { executableTool as tool } from "./executableTool"; import { z } from "zod"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; +import { execFile, type ExecFileOptionsWithStringEncoding } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { getErrorMessage, resolvePathWithinRoot } from "../../shared/utils"; -const execFileAsync = promisify(execFile); +/** Swappable for Vitest — defaults to Node's `execFile`. */ +let execFileForRipgrep: typeof execFile = execFile; + +/** @internal Used by grepSearch.test.ts to force the JS fallback path. */ +export function __testSetRipgrepExecFile(fn: typeof execFile): void { + execFileForRipgrep = fn; +} + +/** @internal */ +export function __testResetRipgrepExecFile(): void { + execFileForRipgrep = execFile; +} + +function execFileAsync( + file: string, + args: readonly string[] | null | undefined, + options: ExecFileOptionsWithStringEncoding, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFileForRipgrep(file, args, options, (error, stdout, stderr) => { + if (error) reject(error); + else resolve({ stdout, stderr }); + }); + }); +} type GrepMatch = { path: string; @@ -77,10 +100,13 @@ export function createGrepSearchTool(cwd: string) { const matches = jsFallbackGrep(root, pattern, target, fileGlob); return { matches, matchCount: matches.length, root: target }; } catch (err) { + const message = getErrorMessage(err); return { matches: [], matchCount: 0, - error: `Search failed: ${getErrorMessage(err)}`, + error: message.startsWith("Invalid regex pattern") + ? message + : `Search failed: ${message}`, }; } }, @@ -126,9 +152,17 @@ function jsFallbackGrep( target: string, fileGlob: string | undefined ): GrepMatch[] { - const regex = new RegExp(pattern); + let regex: RegExp; + try { + regex = new RegExp(pattern); + } catch (error) { + // Surface a user-facing message distinct from generic "Search failed". + // Ripgrep itself returns a descriptive error for malformed patterns; match that ergonomic on the fallback path. + throw new Error(`Invalid regex pattern: ${getErrorMessage(error)}`); + } const results: GrepMatch[] = []; - const files = collectFiles(target, fileGlob); + const searchWholeRepo = path.resolve(target) === path.resolve(root); + const files = collectFiles(target, fileGlob, searchWholeRepo); for (const filePath of files) { if (results.length >= 500) break; @@ -151,16 +185,40 @@ function jsFallbackGrep( const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage"]); +/** Hidden first-segment dirs under the repo root we still want repo-wide search to enter. */ +const ALLOW_HIDDEN_ROOT_DIRS = new Set([".github"]); + +function shouldSkipHiddenDirUnderRepoRoot( + rootReal: string, + parentAbs: string, + dirName: string, + searchWholeRepo: boolean, +): boolean { + if (!searchWholeRepo) return false; + if (!dirName.startsWith(".") || dirName === "." || dirName === "..") return false; + if (ALLOW_HIDDEN_ROOT_DIRS.has(dirName)) return false; + const childAbs = path.resolve(path.join(parentAbs, dirName)); + const rel = path.relative(rootReal, childAbs); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false; + const first = rel.split(path.sep)[0] ?? ""; + // Only skip direct children of the repo root (e.g. `.ade`, `.env`) — not `src/.cache`. + return first === dirName; +} + function collectFiles( dir: string, fileGlob: string | undefined, + searchWholeRepo: boolean, maxFiles = 5000 ): string[] { const stat = fs.statSync(dir); 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 { if (files.length >= maxFiles) return; @@ -173,12 +231,19 @@ function collectFiles( for (const entry of entries) { if (files.length >= maxFiles) return; if (entry.isDirectory()) { - if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) { - walk(path.join(current, entry.name)); + if (SKIP_DIRS.has(entry.name)) continue; + const next = path.join(current, entry.name); + if ( + shouldSkipHiddenDirUnderRepoRoot(rootReal, current, entry.name, searchWholeRepo) + ) { + continue; } + 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); } } @@ -190,16 +255,47 @@ function collectFiles( } function globToRegex(glob: string): RegExp { - // Escape special regex chars except * and ? first, BEFORE brace expansion. - // This avoids escaping the parens/pipe that brace expansion introduces. - let pattern = glob.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 54039ae23..046989469 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.processGroups.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.processGroups.test.ts @@ -270,4 +270,128 @@ describe("projectConfigService process groups", () => { expect(Array.isArray(groups)).toBe(true); expect(groups.length).toBe(0); }); + + it("normalizes project-root absolute process and test paths to portable relative paths", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-config-portable-paths-")); + tempDirs.push(root); + + const adeDir = path.join(root, ".ade"); + fs.mkdirSync(path.join(root, "scripts"), { recursive: true }); + fs.mkdirSync(path.join(root, "apps", "desktop"), { recursive: true }); + fs.mkdirSync(adeDir, { recursive: true }); + fs.writeFileSync(path.join(root, "scripts", "dogfood.sh"), "#!/bin/sh\n", "utf8"); + fs.writeFileSync(path.join(root, "scripts", "run-tests.sh"), "#!/bin/sh\n", "utf8"); + + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-portable-paths", + db: makeDb(), + logger: makeLogger(), + }); + + const snapshot = service.save({ + shared: { + version: 1, + processes: [ + { + id: "dogfood", + name: "Dogfood", + command: [path.join(root, "scripts", "dogfood.sh"), "code-review"], + cwd: root, + }, + ], + stackButtons: [], + testSuites: [ + { + id: "desktop-tests", + name: "Desktop tests", + command: [path.join(root, "scripts", "run-tests.sh")], + cwd: path.join(root, "apps", "desktop"), + }, + ], + laneOverlayPolicies: [ + { + id: "desktop", + name: "Desktop", + overrides: { cwd: path.join(root, "apps", "desktop") }, + }, + ], + 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"); + expect(snapshot.effective.testSuites[0]?.cwd).toBe("apps/desktop"); + expect(snapshot.effective.testSuites[0]?.command[0]).toBe("../../scripts/run-tests.sh"); + expect(snapshot.effective.laneOverlayPolicies[0]?.overrides.cwd).toBe("apps/desktop"); + + 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"); + expect(saved.testSuites[0].cwd).toBe("apps/desktop"); + 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 1f8c2fcf6..7f8964b8c 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -148,6 +148,125 @@ function asStringMap(value: unknown): Record | undefined { return out; } +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 (!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 }); + const relative = path.relative(resolvedBase, resolved).replace(/\\/g, "/"); + return relative || "."; + } catch { + return null; + } +} + +function normalizeProjectCwd(projectRoot: string, cwd: string | undefined): string | undefined { + if (cwd == null) return undefined; + const normalized = normalizeConfigPath(cwd); + if (!normalized) return normalized; + return projectRelativePath(projectRoot, normalized, projectRoot) ?? normalized; +} + +function normalizeProjectCommand(projectRoot: string, command: string[] | undefined, cwd: string | undefined): string[] | undefined { + if (!command) return undefined; + const normalizedCommand = command.map((part) => part.trim()).filter(Boolean); + const executable = normalizedCommand[0]; + if (!executable) return normalizedCommand; + + const normalizedExecutable = normalizeConfigPath(executable); + const normalizedCwd = normalizeProjectCwd(projectRoot, cwd) ?? "."; + const absoluteCwd = path.isAbsolute(normalizedCwd) + ? normalizedCwd + : path.join(projectRoot, normalizedCwd); + const portableExecutable = projectRelativePath(projectRoot, normalizedExecutable, absoluteCwd); + if (portableExecutable == null) return [normalizedExecutable, ...normalizedCommand.slice(1)]; + + const executablePath = portableExecutable.includes("/") || portableExecutable.startsWith(".") + ? portableExecutable + : `./${portableExecutable}`; + return [executablePath, ...normalizedCommand.slice(1)]; +} + +function normalizeConfigFilePaths(config: ProjectConfigFile, projectRoot: string): ProjectConfigFile { + return { + ...config, + processes: (config.processes ?? []).map((proc) => { + const cwd = normalizeProjectCwd(projectRoot, proc.cwd); + return { + ...proc, + ...(proc.command ? { command: normalizeProjectCommand(projectRoot, proc.command, cwd) } : {}), + ...(cwd != null ? { cwd } : {}) + }; + }), + testSuites: (config.testSuites ?? []).map((suite) => { + const cwd = normalizeProjectCwd(projectRoot, suite.cwd); + return { + ...suite, + ...(suite.command ? { command: normalizeProjectCommand(projectRoot, suite.command, cwd) } : {}), + ...(cwd != null ? { cwd } : {}) + }; + }), + laneOverlayPolicies: (config.laneOverlayPolicies ?? []).map((policy) => ({ + ...policy, + ...(policy.overrides?.cwd + ? { overrides: { ...policy.overrides, cwd: normalizeProjectCwd(projectRoot, policy.overrides.cwd) } } + : {}) + })) + }; +} + function coerceWorkerSafetyPolicy(value: unknown): AiConfig["workerSafety"] { if (!isRecord(value)) return undefined; const permissionLevel = asString(value.permissionLevel)?.trim(); @@ -2876,8 +2995,10 @@ export function createProjectConfigService({ hashes: { sharedHash: string; localHash: string }, options: { persistSnapshots: boolean } ): ProjectConfigSnapshot => { - const effective = resolveEffectiveConfig(shared, local); - const validation = validateEffectiveConfig(effective, projectRoot, shared, local); + const normalizedShared = normalizeConfigFilePaths(shared, projectRoot); + const normalizedLocal = normalizeConfigFilePaths(local, projectRoot); + const effective = resolveEffectiveConfig(normalizedShared, normalizedLocal); + const validation = validateEffectiveConfig(effective, projectRoot, normalizedShared, normalizedLocal); const trust = buildTrust(hashes); if (options.persistSnapshots && validation.ok) { @@ -2885,8 +3006,8 @@ export function createProjectConfigService({ } return { - shared, - local, + shared: normalizedShared, + local: normalizedLocal, effective, validation, trust, @@ -2922,14 +3043,14 @@ export function createProjectConfigService({ }, validate(candidate: ProjectConfigCandidate): ProjectConfigValidationResult { - const shared = coerceConfigFile(candidate.shared); - const local = coerceConfigFile(candidate.local); + const shared = normalizeConfigFilePaths(coerceConfigFile(candidate.shared), projectRoot); + const local = normalizeConfigFilePaths(coerceConfigFile(candidate.local), projectRoot); return validateCandidate(shared, local); }, save(candidate: ProjectConfigCandidate): ProjectConfigSnapshot { - const shared = coerceConfigFile(candidate.shared); - const local = coerceConfigFile(candidate.local); + const shared = normalizeConfigFilePaths(coerceConfigFile(candidate.shared), projectRoot); + const local = normalizeConfigFilePaths(coerceConfigFile(candidate.local), projectRoot); const validation = validateCandidate(shared, local); if (!validation.ok) { throw invalidConfigError(validation); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 615f2e3e5..296afd373 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -3545,7 +3545,13 @@ export function registerIpc({ ipcMain.handle(IPC.lanesDelete, async (_event, arg: DeleteLaneArgs): Promise => { const ctx = getCtx(); const envContext = ctx.laneEnvironmentService - ? await resolveLaneOverlayContext(ctx, arg.laneId) + ? await resolveLaneOverlayContext(ctx, arg.laneId).catch((error: unknown) => { + ctx.logger.warn("lane_env_cleanup.pre_delete_context_failed", { + laneId: arg.laneId, + error: getErrorMessage(error) + }); + return null; + }) : null; await ctx.laneService.delete(arg); ctx.portAllocationService?.release(arg.laneId); 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..106be403c 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -5510,6 +5510,9 @@ export function createPrService({ primaryBranchRef: primaryLane?.branchRef ?? null, }); const dirty = lane.status?.dirty === true; + // Same `ahead` count the lane list already shows vs the lane's configured base + // (`resolveStableLaneBaseBranch`); keep wording aligned with that UI signal. + 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 +5527,7 @@ export function createPrService({ defaultBaseBranch, defaultTitle: lane.name, dirty, + commitsAheadOfBase, hasExistingPr, canCreate, blockedReason, 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..b261f16a3 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -180,8 +180,14 @@ export function AppShell({ children }: { children: React.ReactNode }) { const [contextStatus, setContextStatus] = useState( null, ); - const [dismissedContextBannerRoots, setDismissedContextBannerRoots] = - useState>({}); + // Banner dismissals live in the store so they can be pruned when projects close/switch + // — AppShell used to own these as local state, which leaked entries across a long session. + const dismissedContextBannerRoots = useAppStore((s) => s.dismissedContextBannerRoots); + const dismissedMissingAiBannerRoots = useAppStore((s) => s.dismissedMissingAiBannerRoots); + const dismissedGithubBannerRoots = useAppStore((s) => s.dismissedGithubBannerRoots); + const dismissMissingAiBanner = useAppStore((s) => s.dismissMissingAiBanner); + const dismissGithubBanner = useAppStore((s) => s.dismissGithubBanner); + const dismissContextBanner = useAppStore((s) => s.dismissContextBanner); const [projectMissing, setProjectMissing] = useState(false); const [feedbackGenerating, setFeedbackGenerating] = useState(false); const previousProjectRootRef = useRef(undefined); @@ -603,6 +609,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 +803,28 @@ 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 +833,28 @@ 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} @@ -906,10 +950,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { className="ml-2 text-amber-900/70 hover:text-amber-900" onClick={() => { if (!currentProjectRoot) return; - setDismissedContextBannerRoots((prev) => ({ - ...prev, - [currentProjectRoot]: true, - })); + dismissContextBanner(currentProjectRoot); }} title="Dismiss for this session" > 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 8e6d3ad6e..85eb162d9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -71,11 +71,12 @@ 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"; 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,11 @@ export function AgentChatPane({ onLaneChange?: (laneId: string) => void; }) { const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const agentTurnCompletionSound = useAppStore((s) => s.agentTurnCompletionSound); + const agentTurnCompletionSoundVolume = useAppStore((s) => s.agentTurnCompletionSoundVolume); + const agentTurnCompletionSoundQuietWhenFocused = useAppStore((s) => s.agentTurnCompletionSoundQuietWhenFocused); + const chatFontSizePx = useAppStore((s) => s.chatFontSizePx); + const chatUiScale = chatFontSizePx / DEFAULT_CHAT_FONT_SIZE_PX; const navigate = useNavigate(); const openAiProvidersSettings = useCallback(() => { navigate("/settings?tab=ai#ai-providers"); @@ -777,6 +783,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 +832,56 @@ 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; + let lastDoneStatus: "completed" | "interrupted" | "failed" | null = null; + for (let i = selectedEventsForDisplay.length - 1; i >= 0; i -= 1) { + const ev = selectedEventsForDisplay[i]?.event; + if (ev?.type === "done") { + lastDoneStatus = ev.status; + break; + } + } + if (lastDoneStatus === "completed") { + playAgentTurnCompletionSound(agentTurnCompletionSound, { + volume: agentTurnCompletionSoundVolume, + skipWhenFocused: agentTurnCompletionSoundQuietWhenFocused, + }); + } + } + }, [ + agentTurnCompletionSound, + agentTurnCompletionSoundVolume, + agentTurnCompletionSoundQuietWhenFocused, + selectedSessionId, + selectedSession?.status, + selectedSessionAwaitingInput, + turnActive, + selectedEventsForDisplay, + ]); + const activeProviderConnection = selectedSession?.provider === "claude" ? (providerConnections?.claude ?? null) : selectedSession?.provider === "codex" @@ -2497,7 +2555,7 @@ export function AgentChatPane({ if (!laneId) { return ( - +
Select a lane to start chatting
@@ -2923,6 +2981,7 @@ export function AgentChatPane({ containerRef={shellRef} mode={surfaceMode} accentColor={presentation?.accentColor ?? draftAccent} + 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 63008f6ee..156583db7 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,8 @@ export function ChatSurfaceShell({ bodyClassName, footerClassName, containerRef, + /** Uniform scale for header, transcript, and composer (CSS transform — works in Firefox; `zoom` does not). */ + contentScale = 1, }: { mode: ChatSurfaceMode; accentColor?: string | null; @@ -27,19 +29,23 @@ export function ChatSurfaceShell({ bodyClassName?: string; footerClassName?: string; containerRef?: Ref; + 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 ? (
{header} @@ -73,6 +79,29 @@ export function ChatSurfaceShell({ {footer}
) : null} + + ); + + return ( +
+ {scaled ? ( +
+ {inner} +
+ ) : ( + inner + )}
); } diff --git a/apps/desktop/src/renderer/components/chat/CodeHighlighter.tsx b/apps/desktop/src/renderer/components/chat/CodeHighlighter.tsx index 6e9f472f2..9740c7b79 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 React, { Suspense, useCallback, useEffect, useState, useRef, type CSSProperties } from "react"; import { CopySimple, Checks } from "@phosphor-icons/react"; +import { useAppStore, type CodeBlockCopyButtonPosition } from "../../state/appStore"; /* ── LRU cache for highlighted HTML ── */ @@ -90,19 +91,22 @@ function DiffCodeBlock({ code }: { code: string }) { return (
{lines.map((line, index) => { - let tone = "text-[var(--chat-code-fg)]/70"; - let bg = ""; + let style: CSSProperties = { color: "color-mix(in srgb, var(--chat-code-fg) 70%, transparent)" }; if (line.startsWith("+")) { - tone = "text-emerald-400/90"; - bg = "bg-emerald-500/[0.06]"; + style = { + color: "var(--color-diff-add)", + background: "color-mix(in srgb, var(--color-diff-add) 8%, transparent)", + }; } else if (line.startsWith("-")) { - tone = "text-red-400/90"; - bg = "bg-rose-500/[0.06]"; + style = { + color: "var(--color-diff-del)", + background: "color-mix(in srgb, var(--color-diff-del) 8%, transparent)", + }; } else if (line.startsWith("@@")) { - tone = "text-accent/60"; + style = { color: "color-mix(in srgb, var(--color-accent) 70%, transparent)" }; } return ( -
+
{line}
); @@ -113,25 +117,80 @@ 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); + } + const ta = document.createElement("textarea"); + try { + 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"); + return Promise.resolve(ok); + } catch { + return Promise.resolve(false); + } finally { + if (ta.parentNode) ta.parentNode.removeChild(ta); + } +} + +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]); + // "auto" wraps the button in a sticky row so it tracks the transcript scroll; top/bottom stay absolute. + if (position === "auto") { + return ( +
+ +
+ ); + } + + const posClass = position === "bottom" ? "bottom-2 top-auto" : "top-2"; + 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"); + +/** Font-size swatches: 3 discrete sizes mapping to whole-pixel integer scales so `transform: scale` stays crisp. */ +const CHAT_FONT_SIZE_SWATCHES: { px: number; label: string; hint: string }[] = [ + { px: 13, label: "Small", hint: "Compact" }, + { px: 14, label: "Default", hint: "Original sizing" }, + { px: 16, label: "Large", hint: "Easier to read" }, +]; + +/** Label a copy-position id for display. */ +const COPY_POSITION_META: Record = { + top: { label: "Top", hint: "Stays pinned to the top corner" }, + bottom: { label: "Bottom", hint: "Easier to tap after scrolling" }, + auto: { label: "Auto-float", hint: "Tracks the viewport as you scroll" }, +}; + +/** Pill-button selected state matching ThemeSwatch language. Keeps the 'disabled-looking' 0.55 opacity out. */ +function pillToggleStyle(selected: boolean): React.CSSProperties { + return { + ...primaryButton({ height: 32, padding: "0 14px", fontSize: 10 }), + background: selected ? `${COLORS.accent}12` : COLORS.cardBg, + color: selected ? COLORS.accent : COLORS.textPrimary, + border: `1px solid ${selected ? COLORS.accent : COLORS.border}`, + boxShadow: selected ? `inset 3px 0 0 ${COLORS.accent}` : "none", + fontWeight: selected ? 700 : 500, + }; +} + +export function AppearanceSection() { + const chatFontGroupId = useId(); + const agentSoundSelectId = useId(); + const volumeSliderId = useId(); + const quietToggleId = useId(); + const terminalFieldId = 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 agentTurnCompletionSoundVolume = useAppStore((s) => s.agentTurnCompletionSoundVolume); + const setAgentTurnCompletionSoundVolume = useAppStore((s) => s.setAgentTurnCompletionSoundVolume); + const agentTurnCompletionSoundQuietWhenFocused = useAppStore( + (s) => s.agentTurnCompletionSoundQuietWhenFocused, + ); + const setAgentTurnCompletionSoundQuietWhenFocused = useAppStore( + (s) => s.setAgentTurnCompletionSoundQuietWhenFocused, + ); + + const terminalPreferences = useAppStore((s) => s.terminalPreferences); + const setTerminalPreferences = useAppStore((s) => s.setTerminalPreferences); + + const previewScale = useMemo(() => chatFontSizePx / DEFAULT_CHAT_FONT_SIZE_PX, [chatFontSizePx]); + const volumePercent = Math.round(agentTurnCompletionSoundVolume * 100); + const soundIsOff = agentTurnCompletionSound === "off"; + + return ( +
+
+
Theme
+
+ {THEME_IDS.map((id) => ( + setTheme(id)} /> + ))} +
+
+ +
+
Chat font size
+
+
+ Scales the agent chat transcript and composer together. Three sizes keep text crisp at integer scales. +
+
+ Chat font size + {CHAT_FONT_SIZE_SWATCHES.map((swatch) => { + const selected = chatFontSizePx === swatch.px; + return ( + + ); + })} +
+ +
+
Live preview
+
+
= 1 ? `${100 / previewScale}%` : "100%", + maxWidth: previewScale >= 1 ? `${100 / previewScale}%` : "100%", + }} + > +
+ Sample assistant reply +
+ {PREVIEW_MARKDOWN} +
+
+
+
+
+ +
+
Chat & notifications
+
+
+
+ Code block copy button +
+
+ 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) => { + const meta = COPY_POSITION_META[id]; + const selected = codeBlockCopyButtonPosition === id; + return ( + + ); + })} +
+
+ +
+ +
+ Plays when the assistant completes a turn and the chat is idle. Rapid successive turns collapse to a single chime. +
+
+ + + {soundIsOff ? ( + + Pick a sound to preview. + + ) : null} +
+
+ +
+ + 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 e09f933a6..444170382 100644 --- a/apps/desktop/src/renderer/components/settings/GeneralSection.tsx +++ b/apps/desktop/src/renderer/components/settings/GeneralSection.tsx @@ -1,8 +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, THEME_IDS } from "../../state/appStore"; -import type { ThemeId } from "../../state/appStore"; +import { useAppStore } from "../../state/appStore"; import { EmptyState } from "../ui/EmptyState"; import { Info } from "@phosphor-icons/react"; import { @@ -13,144 +12,18 @@ 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 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(); 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 theme = useAppStore((s) => s.theme); - const setTheme = useAppStore((s) => s.setTheme); - const terminalPreferences = useAppStore((s) => s.terminalPreferences); - const setTerminalPreferences = useAppStore((s) => s.setTerminalPreferences); useEffect(() => { let cancelled = false; @@ -230,15 +103,6 @@ export function GeneralSection() {
-
-
THEME
-
- {THEME_IDS.map((id) => ( - setTheme(id)} /> - ))} -
-
-
AI MODE
@@ -288,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. -
-
-
-
"monospace"); @@ -206,6 +206,22 @@ function terminalHeightFor(element: HTMLElement): number { beforeAll(() => { vi.stubGlobal("ResizeObserver", MockResizeObserver); vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + // xterm WebGL path needs a getContext("webgl") that succeeds in jsdom / headless CI. + vi.stubGlobal( + "HTMLCanvasElement", + class extends (globalThis as any).HTMLCanvasElement { + getContext(contextId: string) { + if (contextId === "webgl" || contextId === "webgl2") { + return { + getParameter: () => 0, + getExtension: () => null, + isContextLost: () => false, + }; + } + return super.getContext(contextId as "2d"); + } + }, + ); vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0)); vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id)); @@ -270,6 +286,10 @@ beforeAll(() => { }); }); +afterAll(() => { + vi.unstubAllGlobals(); +}); + describe("TerminalView", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index add44c4ce..471964725 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -87,6 +87,16 @@ --chat-panel-border: rgba(255, 255, 255, 0.10); --chat-panel-bg-strong: rgba(28, 25, 38, 0.92); --chat-streaming-shimmer: linear-gradient(90deg, transparent, rgba(255,255,255,0.07), transparent); + --chat-block-bg: rgba(0, 0, 0, 0.25); + --chat-block-border: rgba(255, 255, 255, 0.06); + --chat-inline-code-bg: rgba(0, 0, 0, 0.30); + --chat-table-border: rgba(255, 255, 255, 0.08); + --chat-copy-button-bg: rgba(255, 255, 255, 0.03); + --chat-copy-button-border: rgba(255, 255, 255, 0.08); + --chat-copy-button-fg: rgba(240, 240, 242, 0.45); + --chat-copy-button-hover-bg: rgba(255, 255, 255, 0.05); + --chat-copy-button-hover-border: rgba(255, 255, 255, 0.14); + --chat-copy-button-hover-fg: rgba(240, 240, 242, 0.72); --color-success: #22c55e; --color-warning: #f59e0b; --color-error: #ef4444; @@ -216,6 +226,16 @@ --chat-panel-border: rgba(26, 26, 30, 0.10); --chat-panel-bg-strong: rgba(255, 255, 255, 0.92); --chat-streaming-shimmer: linear-gradient(90deg, transparent, rgba(26,26,30,0.08), transparent); + --chat-block-bg: rgba(15, 23, 42, 0.04); + --chat-block-border: rgba(15, 23, 42, 0.08); + --chat-inline-code-bg: rgba(15, 23, 42, 0.06); + --chat-table-border: rgba(15, 23, 42, 0.10); + --chat-copy-button-bg: rgba(15, 23, 42, 0.04); + --chat-copy-button-border: rgba(15, 23, 42, 0.10); + --chat-copy-button-fg: rgba(26, 26, 30, 0.55); + --chat-copy-button-hover-bg: rgba(15, 23, 42, 0.07); + --chat-copy-button-hover-border: rgba(15, 23, 42, 0.18); + --chat-copy-button-hover-fg: rgba(26, 26, 30, 0.80); --color-success: #16a34a; --color-warning: #d97706; --color-error: #dc2626; @@ -2975,6 +2995,13 @@ button:active, [role="button"]:active { -webkit-backdrop-filter: blur(14px) saturate(140%); } +/* Code block copy button — theme-aware hover */ +.ade-chat-copy-button:hover { + border-color: var(--chat-copy-button-hover-border) !important; + background: var(--chat-copy-button-hover-bg) !important; + color: var(--chat-copy-button-hover-fg) !important; +} + /* Animated timeline connector */ .ade-timeline-line { background: linear-gradient(180deg, 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..cf11a6158 --- /dev/null +++ b/apps/desktop/src/renderer/lib/agentTurnCompletionSound.test.ts @@ -0,0 +1,153 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + __resetAgentTurnCompletionSoundDebounce, + playAgentTurnCompletionSound, +} from "./agentTurnCompletionSound"; + +describe("playAgentTurnCompletionSound", () => { + beforeEach(() => { + __resetAgentTurnCompletionSoundDebounce(); + }); + + 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 after the audio tail", async () => { + vi.useFakeTimers(); + try { + 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); + + playAgentTurnCompletionSound("ping"); + await vi.waitFor(() => { + expect(resume).toHaveBeenCalled(); + }); + await Promise.resolve(); + expect(close).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(449); + expect(close).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(close).toHaveBeenCalledTimes(1); + } finally { + 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 new file mode 100644 index 000000000..396ad85a9 --- /dev/null +++ b/apps/desktop/src/renderer/lib/agentTurnCompletionSound.ts @@ -0,0 +1,120 @@ +/** + * Short Web Audio notification tones for agent chat (no external assets). + * Defers `AudioContext.close()` until after scheduled oscillators finish so playback is audible. + */ +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, + 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(Math.max(peak, 0.0002), 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); +} + +/** + * 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, + 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 ctxNow = ctx.currentTime; + + const play = () => { + try { + if (kind === "chime") { + playChime(ctx, 880, 0.22, "sine", volume); + playChime(ctx, 1320, 0.18, "sine", volume); + } else if (kind === "ping") { + 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, 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(ctxNow); + osc.stop(ctxNow + 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 (ctx.state === "suspended") { + void ctx.resume().then(play).catch(() => { + void ctx.close().catch(() => {}); + }); + } else { + play(); + } + } catch { + 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..4c5b320d4 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -35,8 +35,7 @@ const mockLocalStorage = { }; // Import after window is set up -import type { WorkProjectViewState } from "./appStore"; -import { useAppStore, THEME_IDS, DEFAULT_TERMINAL_PREFERENCES } from "./appStore"; +import { useAppStore, THEME_IDS, DEFAULT_TERMINAL_PREFERENCES, DEFAULT_CHAT_FONT_SIZE_PX } from "./appStore"; // --------------------------------------------------------------------------- // Helpers @@ -57,9 +56,15 @@ 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: {}, + dismissedMissingAiBannerRoots: {}, + dismissedGithubBannerRoots: {}, + dismissedContextBannerRoots: {}, }); } @@ -155,6 +160,33 @@ 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", + }); + }); + + 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); + }); + }); + // ───────────────────────────────────────────────────────────── // Simple setters // ───────────────────────────────────────────────────────────── @@ -466,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 669d13834..94eccb9e8 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -29,6 +29,44 @@ export const DEFAULT_TERMINAL_PREFERENCES: TerminalPreferences = { lineHeight: 1.25, scrollback: 10_000, }; + +/** 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 { + if (value === "bottom" || value === "auto") return value; + return "top"; +} + +function normalizeAgentTurnCompletionSound(value: unknown): AgentTurnCompletionSound { + if (value === "chime" || value === "ping" || value === "bell") return value; + 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; +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"; @@ -214,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() : ""; @@ -225,6 +275,11 @@ type PersistedUserPreferences = { theme: ThemeId; terminalPreferences: TerminalPreferences; smartTooltipsEnabled: boolean; + codeBlockCopyButtonPosition: CodeBlockCopyButtonPosition; + agentTurnCompletionSound: AgentTurnCompletionSound; + agentTurnCompletionSoundVolume: number; + agentTurnCompletionSoundQuietWhenFocused: boolean; + chatFontSizePx: number; }; function coerceTheme(value: unknown): ThemeId | null { @@ -243,6 +298,11 @@ 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), + agentTurnCompletionSoundVolume: normalizeAgentTurnCompletionSoundVolume(parsed.agentTurnCompletionSoundVolume), + agentTurnCompletionSoundQuietWhenFocused: parsed.agentTurnCompletionSoundQuietWhenFocused !== false, + chatFontSizePx: normalizeChatFontSizePx(parsed.chatFontSizePx), }; } catch { return null; @@ -269,7 +329,16 @@ function readLegacyUserPreferences(): PersistedUserPreferences { } catch { // ignore } - return { theme, terminalPreferences, smartTooltipsEnabled }; + return { + theme, + terminalPreferences, + smartTooltipsEnabled, + codeBlockCopyButtonPosition: "top", + agentTurnCompletionSound: "off", + agentTurnCompletionSoundVolume: DEFAULT_AGENT_TURN_COMPLETION_SOUND_VOLUME, + agentTurnCompletionSoundQuietWhenFocused: true, + chatFontSizePx: DEFAULT_CHAT_FONT_SIZE_PX, + }; } function persistUserPreferences(prefs: PersistedUserPreferences) { @@ -280,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; @@ -327,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; @@ -349,6 +444,11 @@ type AppState = { projectRevision: number; theme: ThemeId; terminalPreferences: TerminalPreferences; + codeBlockCopyButtonPosition: CodeBlockCopyButtonPosition; + agentTurnCompletionSound: AgentTurnCompletionSound; + agentTurnCompletionSoundVolume: number; + agentTurnCompletionSoundQuietWhenFocused: boolean; + chatFontSizePx: number; providerMode: ProviderMode; availableModels: ModelDescriptor[]; laneInspectorTabs: Record; @@ -357,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; @@ -369,6 +473,11 @@ type AppState = { selectRunLane: (laneId: string | null) => void; focusSession: (sessionId: string | null) => void; 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: | Partial @@ -393,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; @@ -473,6 +585,11 @@ export const useAppStore = create((set, get) => ({ projectRevision: 0, theme: initialUserPreferences.theme, 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), laneInspectorTabs: {}, @@ -481,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) => { @@ -513,13 +633,39 @@ export const useAppStore = create((set, get) => ({ focusSession: (sessionId) => set({ focusedSessionId: sessionId }), setTheme: (theme) => set((prev) => { - persistUserPreferences({ - theme, - terminalPreferences: prev.terminalPreferences, - smartTooltipsEnabled: prev.smartTooltipsEnabled, - }); + const next = { ...prev, theme }; + persistUserPreferencesFrom(next); return { theme }; }), + setCodeBlockCopyButtonPosition: (position) => + set((prev) => { + const value = normalizeCodeBlockCopyButtonPosition(position); + persistUserPreferencesFrom({ ...prev, codeBlockCopyButtonPosition: value }); + return { codeBlockCopyButtonPosition: value }; + }), + setAgentTurnCompletionSound: (sound) => + set((prev) => { + 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 value = normalizeChatFontSizePx(px); + persistUserPreferencesFrom({ ...prev, chatFontSizePx: value }); + return { chatFontSizePx: value }; + }), setTerminalPreferences: (next) => set((prev) => { const updated = normalizeTerminalPreferences( @@ -527,21 +673,13 @@ export const useAppStore = create((set, get) => ({ ? next(prev.terminalPreferences) : { ...prev.terminalPreferences, ...next } ); - persistUserPreferences({ - theme: prev.theme, - terminalPreferences: updated, - smartTooltipsEnabled: prev.smartTooltipsEnabled, - }); + persistUserPreferencesFrom({ ...prev, terminalPreferences: updated }); return { terminalPreferences: updated }; }), setTerminalAttention: (terminalAttention) => set({ terminalAttention }), setSmartTooltipsEnabled: (enabled) => set((prev) => { - persistUserPreferences({ - theme: prev.theme, - terminalPreferences: prev.terminalPreferences, - smartTooltipsEnabled: enabled, - }); + persistUserPreferencesFrom({ ...prev, smartTooltipsEnabled: enabled }); return { smartTooltipsEnabled: enabled }; }), openNewTab: () => set({ isNewTabOpen: true, showWelcome: true }), @@ -734,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. @@ -753,7 +913,7 @@ export const useAppStore = create((set, get) => ({ return null; } get().setProject(project); - set({ + set((prev) => ({ projectHydrated: true, showWelcome: false, projectTransition: null, @@ -766,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([ @@ -800,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, @@ -813,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); @@ -828,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 = {}; @@ -846,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) { @@ -885,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/desktop/src/shared/modelRegistry.test.ts b/apps/desktop/src/shared/modelRegistry.test.ts index b22828704..371c44265 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,21 @@ 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("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 03f31b7cb..f9f326216 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -876,6 +876,24 @@ 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) { + const direct = getModelById(normalized); + if (direct && !direct.deprecated && matchesProviderGroup(direct, providerHint)) { + return direct.id; + } + return resolveModelDescriptorForProvider(normalized, providerHint)?.id; + } + return resolveModelDescriptor(normalized)?.id; +} + function matchesProviderGroup( descriptor: ModelDescriptor, providerHint?: ModelProviderGroup, @@ -888,9 +906,17 @@ export function resolveModelDescriptorForProvider( modelRef: string | null | undefined, providerHint?: ModelProviderGroup, ): ModelDescriptor | undefined { - const normalized = String(modelRef ?? "").trim().toLowerCase(); - if (!normalized.length) return undefined; + const raw = String(modelRef ?? "").trim(); + if (!raw.length) return undefined; + + // Dynamic local / OpenCode ids can be case-sensitive; try the raw ref before + // lowercasing so paths like `lmstudio/Qwen/...` are not corrupted. + const caseSensitive = getModelById(raw); + if (caseSensitive && !caseSensitive.deprecated && matchesProviderGroup(caseSensitive, providerHint)) { + return caseSensitive; + } + const normalized = raw.toLowerCase(); const exactId = getModelById(normalized); if (exactId && !exactId.deprecated && matchesProviderGroup(exactId, providerHint)) { return exactId; 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..705268656 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2105,6 +2105,9 @@ 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`). + /// Omitted by older desktop hosts — treat as unknown/zero when decoding legacy snapshots. + 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..d3220635c 100644 --- a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift +++ b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift @@ -41,13 +41,13 @@ struct CreatePrWizardView: View { return capabilities.lanes .filter { $0.canCreate } .map { eligibility in - CreatePrLaneOption( + 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: Self.laneProgressSubtitle(for: eligibility) ) } } @@ -77,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: @@ -194,9 +211,17 @@ struct CreatePrWizardView: View { .adeInsetField() if let selectedOption { - Text("Source branch: \(selectedOption.branchRef)") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) + VStack(alignment: .leading, spacing: 4) { + Text("Source branch: \(selectedOption.branchRef)") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + if let subtitle = selectedOption.subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(2) + } + } } } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 9d2a9efca..95764fc1b 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)." @@ -3757,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", @@ -3815,6 +3845,7 @@ final class ADETests: XCTestCase { defaultBaseBranch: "main", defaultTitle: "feat/new", dirty: false, + commitsAheadOfBase: 1, hasExistingPr: false, canCreate: true, blockedReason: nil @@ -3828,6 +3859,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)."