diff --git a/frontend/src/renderer/components/PullRequestsPage.test.tsx b/frontend/src/renderer/components/PullRequestsPage.test.tsx
deleted file mode 100644
index 90a770bb63..0000000000
--- a/frontend/src/renderer/components/PullRequestsPage.test.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { render, screen, waitFor, within } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { PullRequestsPage } from "./PullRequestsPage";
-import type { PRState, PullRequestFacts, WorkspaceSession, WorkspaceSummary } from "../types/workspace";
-
-const { navigateMock, postMock, useWorkspaceQueryMock } = vi.hoisted(() => ({
- navigateMock: vi.fn(),
- postMock: vi.fn(),
- useWorkspaceQueryMock: vi.fn(),
-}));
-
-vi.mock("@tanstack/react-router", () => ({ useNavigate: () => navigateMock }));
-vi.mock("../hooks/useWorkspaceQuery", () => ({
- useWorkspaceQuery: () => useWorkspaceQueryMock(),
- workspaceQueryKey: ["workspaces"],
-}));
-vi.mock("../lib/api-client", () => ({
- apiClient: { POST: (...args: unknown[]) => postMock(...args) },
- apiErrorMessage: (e: unknown) => (e instanceof Error ? e.message : "error"),
-}));
-
-const pr = (n: number, state: PRState): PullRequestFacts => ({
- url: `https://example.com/pr/${n}`,
- number: n,
- state,
- ci: "passing",
- review: "approved",
- mergeability: "mergeable",
- reviewComments: false,
- updatedAt: "2026-06-15T00:00:00Z",
-});
-
-const session = (id: string, prs: PullRequestFacts[]): WorkspaceSession => ({
- id,
- workspaceId: "proj-1",
- workspaceName: "my-app",
- title: id,
- provider: "claude-code",
- kind: "worker",
- branch: "feat/ns",
- status: "review_pending",
- updatedAt: "2026-06-15T00:00:00Z",
- prs,
-});
-
-function setWorkspaces(sessions: WorkspaceSession[]) {
- const data: WorkspaceSummary[] = [{ id: "proj-1", name: "my-app", path: "/p", sessions }];
- useWorkspaceQueryMock.mockReturnValue({ data, isError: false, isLoading: false });
-}
-
-function renderPage() {
- render(
-
-
- ,
- );
-}
-
-beforeEach(() => {
- navigateMock.mockReset();
- postMock.mockReset().mockResolvedValue({ data: { method: "squash" }, error: undefined });
-});
-
-afterEach(() => vi.restoreAllMocks());
-
-describe("PullRequestsPage", () => {
- it("renders one row per PR across sessions, actionable PRs first", () => {
- setWorkspaces([session("auth", [pr(41, "open"), pr(42, "draft"), pr(40, "merged")])]);
- renderPage();
-
- const rows = screen.getAllByRole("row").slice(1); // drop header
- const numbers = rows.map((r) => within(r).getByText(/^#\d+$/).textContent);
- expect(numbers).toEqual(["#41", "#42", "#40"]);
- });
-
- it("merges the PR by its own number, not the session's", async () => {
- setWorkspaces([session("auth", [pr(41, "open"), pr(42, "draft")])]);
- renderPage();
- const user = userEvent.setup();
-
- const childRow = screen.getByText("#42").closest("tr")!;
- await user.click(within(childRow).getByRole("button", { name: "Merge" }));
-
- await waitFor(() => expect(postMock).toHaveBeenCalledTimes(1));
- expect(postMock).toHaveBeenCalledWith("/api/v1/prs/{id}/merge", { params: { path: { id: "42" } } });
- });
-
- it("shows an empty state when no session has a PR", () => {
- setWorkspaces([session("idle", [])]);
- renderPage();
- expect(screen.getByText("No open pull requests.")).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/renderer/components/PullRequestsPage.tsx b/frontend/src/renderer/components/PullRequestsPage.tsx
deleted file mode 100644
index 09bf1b9719..0000000000
--- a/frontend/src/renderer/components/PullRequestsPage.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-import { useNavigate } from "@tanstack/react-router";
-import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
-import { useState } from "react";
-import { apiClient, apiErrorMessage } from "../lib/api-client";
-import { useWorkspaceQuery, workspaceQueryKey } from "../hooks/useWorkspaceQuery";
-import {
- sessionScmSummaryQueryKey,
- sessionScmSummaryQueryOptions,
- type SessionPRSummary,
-} from "../hooks/useSessionScmSummary";
-import { comparePRDisplaySummaries, prDiffSummary, sessionPRDisplaySummaries } from "../lib/pr-display";
-import type { WorkspaceSession } from "../types/workspace";
-import { DashboardSubhead } from "./DashboardSubhead";
-import { Badge } from "./ui/badge";
-import { Button } from "./ui/button";
-import { PRSummaryParts } from "./PRSummaryDisplay";
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
-import { cn } from "../lib/utils";
-
-type PRState = SessionPRSummary["state"];
-
-const stateTone: Record
= {
- open: "border-success/40 bg-success/10 text-success",
- draft: "border-border bg-raised text-muted-foreground",
- merged: "border-accent/40 bg-accent-weak text-accent",
- closed: "border-error/40 bg-error/10 text-error",
-};
-
-type PRRow = {
- pr: SessionPRSummary;
- session: WorkspaceSession;
-};
-
-// The PR board, ported from agent-orchestrator's PullRequestsPage. One row per
-// attributed PR — a session can own several (a stack or independent PRs), so we
-// flatMap the session's prs list rather than assuming one. Actions hit
-// /prs/{number}/merge and /resolve-comments. Per-PR CI/review facts also live on
-// the session route's inspector.
-export function PullRequestsPage() {
- const navigate = useNavigate();
- const workspaceQuery = useWorkspaceQuery();
- const sessions = (workspaceQuery.data ?? []).flatMap((w) => w.sessions);
- const prQueries = useQueries({
- queries: sessions.map((session) => sessionScmSummaryQueryOptions(session.id)),
- });
- const rows: PRRow[] = sessions
- .flatMap((session, index) =>
- sessionPRDisplaySummaries(session, prQueries[index]?.data).map((pr) => ({ pr, session })),
- )
- .sort((a, b) => comparePRDisplaySummaries(a.pr, b.pr));
-
- return (
-
-
-
-
- {rows.length === 0 ? (
-
No open pull requests.
- ) : (
-
-
-
- PR
- Worker
- State
- Actions
-
-
-
- {rows.map((row) => (
-
- void navigate({
- to: "/projects/$projectId/sessions/$sessionId",
- params: { projectId: row.session.workspaceId, sessionId: row.session.id },
- })
- }
- />
- ))}
-
-
- )}
-
-
- );
-}
-
-function PRRowView({ row, onOpen }: { row: PRRow; onOpen: () => void }) {
- const queryClient = useQueryClient();
- const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
- const refresh = () => {
- void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
- void queryClient.invalidateQueries({ queryKey: sessionScmSummaryQueryKey() });
- };
-
- const merge = useMutation({
- mutationFn: async () => {
- const { data, error } = await apiClient.POST("/api/v1/prs/{id}/merge", {
- params: { path: { id: String(row.pr.number) } },
- });
- if (error) throw new Error(apiErrorMessage(error));
- return data;
- },
- onSuccess: (data) => {
- setNote({ ok: true, text: `merged (${data?.method ?? "squash"})` });
- refresh();
- },
- onError: (e) => setNote({ ok: false, text: e instanceof Error ? e.message : "merge failed" }),
- });
-
- const resolve = useMutation({
- mutationFn: async () => {
- const { error } = await apiClient.POST("/api/v1/prs/{id}/resolve-comments", {
- params: { path: { id: String(row.pr.number) } },
- });
- if (error) throw new Error(apiErrorMessage(error));
- },
- onSuccess: () => {
- setNote({ ok: true, text: "comments resolved" });
- refresh();
- },
- onError: (e) => setNote({ ok: false, text: e instanceof Error ? e.message : "resolve failed" }),
- });
-
- const actionable = row.pr.state === "open" || row.pr.state === "draft";
-
- return (
-
- #{row.pr.number}
-
- {row.pr.title || row.session.title}
-
- {[
- row.session.workspaceName,
- row.pr.sourceBranch || row.session.branch,
- row.pr.targetBranch ? `-> ${row.pr.targetBranch}` : "",
- prDiffSummary(row.pr),
- ]
- .filter(Boolean)
- .join(" · ")}
-
-
-
-
-
- {row.pr.state}
-
-
- e.stopPropagation()}>
- {note ? (
- {note.text}
- ) : actionable ? (
-
- resolve.mutate()}
- >
- {resolve.isPending ? "…" : "Resolve"}
-
- merge.mutate()}
- >
- {merge.isPending ? "Merging…" : "Merge"}
-
-
- ) : (
- —
- )}
-
-
- );
-}
diff --git a/frontend/src/renderer/components/ReportProblemDialog.tsx b/frontend/src/renderer/components/ReportProblemDialog.tsx
deleted file mode 100644
index bc88e2a391..0000000000
--- a/frontend/src/renderer/components/ReportProblemDialog.tsx
+++ /dev/null
@@ -1,220 +0,0 @@
-import * as Dialog from "@radix-ui/react-dialog";
-import { ChevronDown, GitPullRequest, Mail, MessageSquare, Send, X } from "lucide-react";
-import { useEffect, useId, useMemo, useState } from "react";
-import {
- collectReportProblemDiagnostics,
- formatReportProblemDraft,
- reportProblemDestinationUrl,
- type ReportProblemDiagnostics,
- type ReportProblemInput,
- type ReportProblemOutput,
-} from "../lib/report-problem";
-import { aoBridge } from "../lib/bridge";
-import { Button } from "./ui/button";
-import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
-import { Input } from "./ui/input";
-
-type ReportProblemDialogProps = {
- open: boolean;
- onOpenChange: (open: boolean) => void;
-};
-
-const DEFAULT_DIAGNOSTICS: ReportProblemDiagnostics = {
- appVersion: "unknown",
- buildMode: "unknown",
- daemonState: "unknown",
- generatedAt: "unknown",
- platform: "unknown",
- routeSurface: "unknown",
-};
-
-const OUTPUT_LABELS: Record = {
- github: "GitHub",
- discord: "Discord",
- email: "Email",
-};
-
-const OUTPUT_ACTION_LABELS: Record = {
- github: "Copy and raise GitHub issue",
- discord: "Copy and open Discord",
- email: "Copy and open email",
-};
-
-const OUTPUT_DESTINATION_LABELS: Record = {
- github: "GitHub issue",
- discord: "Discord",
- email: "Email support",
-};
-
-export function ReportProblemDialog({ open, onOpenChange }: ReportProblemDialogProps) {
- const summaryId = useId();
- const detailsId = useId();
- const [selectedOutput, setSelectedOutput] = useState("github");
- const [summary, setSummary] = useState("");
- const [details, setDetails] = useState("");
- const [copiedOutput, setCopiedOutput] = useState(null);
- const [copyError, setCopyError] = useState(null);
- const [diagnostics, setDiagnostics] = useState(DEFAULT_DIAGNOSTICS);
-
- useEffect(() => {
- if (!open) {
- setSummary("");
- setDetails("");
- setSelectedOutput("github");
- setCopiedOutput(null);
- setCopyError(null);
- return;
- }
- let active = true;
- void collectReportProblemDiagnostics().then((nextDiagnostics) => {
- if (active) setDiagnostics(nextDiagnostics);
- });
- return () => {
- active = false;
- };
- }, [open]);
-
- const input = useMemo(
- () => ({
- summary,
- details,
- }),
- [summary, details],
- );
-
- const draft = useMemo(
- () => formatReportProblemDraft(input, diagnostics, selectedOutput),
- [input, diagnostics, selectedOutput],
- );
-
- const copyDraft = async () => {
- setCopyError(null);
- const output = selectedOutput;
- try {
- await aoBridge.clipboard.writeText(draft);
- const destinationUrl = reportProblemDestinationUrl(input, diagnostics, output);
- if (destinationUrl) {
- await aoBridge.app.openExternal(destinationUrl);
- }
- setCopiedOutput(output);
- setSummary("");
- setDetails("");
- setSelectedOutput("github");
- } catch (err) {
- setCopyError(err instanceof Error ? err.message : "Could not copy report draft");
- setCopiedOutput(null);
- }
- };
-
- const selectOutput = (output: ReportProblemOutput) => {
- setSelectedOutput(output);
- setCopiedOutput(null);
- setCopyError(null);
- };
-
- return (
-
-
-
-
-
-
- Report a problem
-
- Write a short note, then copy it to GitHub, Discord, or email.
-
-
-
-
-
-
-
-
-
-
-
-
- Summary
-
- setSummary(event.target.value)}
- placeholder="Brief title"
- />
-
-
-
-
- Details
-
-
-
-
-
Report to
-
-
-
-
- {selectedOutput === "github" && }
- {selectedOutput === "discord" && }
- {selectedOutput === "email" && }
- {OUTPUT_DESTINATION_LABELS[selectedOutput]}
-
-
-
-
-
- selectOutput("github")}>
-
- GitHub issue
-
- selectOutput("discord")}>
-
- Discord
-
- selectOutput("email")}>
-
- Email support
-
-
-
-
-
- {copyError && (
-
- {copyError}
-
- )}
- {copiedOutput && !copyError && (
-
{OUTPUT_LABELS[copiedOutput]} draft copied.
- )}
-
-
-
- void copyDraft()}>
-
- {OUTPUT_ACTION_LABELS[selectedOutput]}
-
-
-
-
-
- );
-}
diff --git a/frontend/src/renderer/components/RestoreUnavailableDialog.tsx b/frontend/src/renderer/components/RestoreUnavailableDialog.tsx
index 07addccf91..be5cf60e5d 100644
--- a/frontend/src/renderer/components/RestoreUnavailableDialog.tsx
+++ b/frontend/src/renderer/components/RestoreUnavailableDialog.tsx
@@ -35,7 +35,7 @@ export function RestoreUnavailableDialog({ open, session, onOpenChange, onRecrea
return (
-
+
Session can no longer be restored
diff --git a/frontend/src/renderer/components/SessionView.tsx b/frontend/src/renderer/components/SessionView.tsx
index d5b3b6c6e6..dc398480e3 100644
--- a/frontend/src/renderer/components/SessionView.tsx
+++ b/frontend/src/renderer/components/SessionView.tsx
@@ -6,7 +6,7 @@ import { CenterPane } from "./CenterPane";
import { SessionFilesView } from "./SessionFilesView";
import { SessionInspector, type InspectorView } from "./SessionInspector";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable";
-import { useUiStore } from "../stores/ui-store";
+import { useResolvedTheme, useUiStore } from "../stores/ui-store";
import { useShell } from "../lib/shell-context";
import { useBrowserView } from "../hooks/useBrowserView";
import { useWorkspaceQuery } from "../hooks/useWorkspaceQuery";
@@ -41,7 +41,7 @@ type SessionViewProps = {
export function SessionView({ sessionId }: SessionViewProps) {
const workspaceQuery = useWorkspaceQuery();
const workspaces = workspaceQuery.data ?? [];
- const { theme } = useUiStore();
+ const theme = useResolvedTheme();
const isInspectorOpen = useUiStore((state) => state.isInspectorOpen);
const toggleInspector = useUiStore((state) => state.toggleInspector);
const { daemonStatus } = useShell();
diff --git a/frontend/src/renderer/components/SessionsBoard.tsx b/frontend/src/renderer/components/SessionsBoard.tsx
index 3e3e6f3063..3865492be3 100644
--- a/frontend/src/renderer/components/SessionsBoard.tsx
+++ b/frontend/src/renderer/components/SessionsBoard.tsx
@@ -287,9 +287,7 @@ export function SessionsBoard({ projectId }: SessionsBoardProps) {
{done.length > 0 && (
{/* agent-orchestrator's done-bar (Dashboard.tsx + globals.css):
- a full-width chevron + label + count toggle row. min-h matches
- the sidebar footer (7px pad ×2 + 37px Settings button) so this
- border-t aligns with the sidebar's footer border. The button is
+ a full-width chevron + label + count toggle row. The button is
37px (not the 35.5px its text-control implies) because the
unlayered `button { font: inherit }` in styles.css outranks
Tailwind's layered text utilities, leaving it at 14px/21px. */}
diff --git a/frontend/src/renderer/components/Sidebar.test.tsx b/frontend/src/renderer/components/Sidebar.test.tsx
index 614c53e7a5..e662e7b3f1 100644
--- a/frontend/src/renderer/components/Sidebar.test.tsx
+++ b/frontend/src/renderer/components/Sidebar.test.tsx
@@ -117,7 +117,6 @@ function renderSidebar({
{
);
});
- it("opens feedback above Settings and copies redacted report drafts", async () => {
- const user = userEvent.setup();
- const writeText = vi.fn().mockResolvedValue(undefined);
- const openExternal = vi.fn().mockResolvedValue(undefined);
- const open = vi.spyOn(window, "open").mockReturnValue(null);
- window.ao!.clipboard.writeText = writeText;
- window.ao!.app.openExternal = openExternal;
- window.ao!.app.getVersion = vi.fn().mockResolvedValue("9.9.9-test");
- window.ao!.daemon.getStatus = vi.fn().mockResolvedValue({
- state: "ready",
- message: "Listening at http://127.0.0.1:31001?token=secret",
- });
- renderSidebar();
-
- const feedbackButton = screen.getAllByRole("button", { name: "Feedback" })[0];
- const settingsButton = screen.getAllByRole("button", { name: "Settings" })[0];
- expect(feedbackButton.compareDocumentPosition(settingsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
-
- await user.click(feedbackButton);
- expect(await screen.findByRole("dialog", { name: "Report a problem" })).toBeInTheDocument();
-
- await user.type(screen.getByLabelText("Summary"), "Create project fails in /Users/alice/private-repo");
- await user.type(
- screen.getByLabelText("Details"),
- "Open http://127.0.0.1:5173/projects/demo?access_token=local-secret and click Create. Show a clear prerequisite error.",
- );
- expect(screen.queryByRole("combobox", { name: "Report type" })).not.toBeInTheDocument();
- expect(screen.queryByLabelText("Include safe diagnostics")).not.toBeInTheDocument();
- expect(screen.queryByLabelText("Expected behavior")).not.toBeInTheDocument();
- const destinationButton = screen.getByRole("button", { name: "Report destination" });
- expect(destinationButton).toHaveTextContent("GitHub issue");
- await user.click(destinationButton);
- expect(await screen.findByRole("menu")).toHaveClass("w-[var(--radix-dropdown-menu-trigger-width)]");
- await user.click(await screen.findByRole("menuitem", { name: "GitHub issue" }));
- expect(screen.queryByLabelText("Report preview")).not.toBeInTheDocument();
-
- expect(screen.getByRole("button", { name: "Copy and raise GitHub issue" })).toBeInTheDocument();
- expect(screen.queryByRole("button", { name: "Copy and open email" })).not.toBeInTheDocument();
- await user.click(screen.getByRole("button", { name: "Copy and raise GitHub issue" }));
-
- await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
- const copied = writeText.mock.calls[0][0] as string;
- expect(copied).toContain("Create project fails");
- expect(copied).toContain("AO version: 9.9.9-test");
- expect(copied).toContain("Daemon: ready");
- expect(copied).toContain("[redacted-local-path]");
- expect(copied).toContain("[redacted-local-url]");
- expect(copied).not.toContain("/Users/alice");
- expect(copied).not.toContain("local-secret");
- expect(copied).not.toContain("## Type");
- expect(copied).not.toContain("Generated locally by AO");
- expect(openExternal).toHaveBeenCalledWith(
- expect.stringContaining("https://github.com/AgentWrapper/agent-orchestrator/issues/new"),
- );
- expect(open).not.toHaveBeenCalled();
- expect(screen.getByLabelText("Summary")).toHaveValue("");
- expect(screen.getByLabelText("Details")).toHaveValue("");
- });
-
- it("opens Discord with an official invite and email with the support mailbox", async () => {
- const user = userEvent.setup();
- const writeText = vi.fn().mockResolvedValue(undefined);
- const openExternal = vi.fn().mockResolvedValue(undefined);
- const open = vi.spyOn(window, "open").mockReturnValue(null);
- window.ao!.clipboard.writeText = writeText;
- window.ao!.app.openExternal = openExternal;
- window.ao!.app.getVersion = vi.fn().mockRejectedValue(new Error("version unavailable"));
- window.ao!.daemon.getStatus = vi.fn().mockRejectedValue(new Error("daemon unavailable"));
- renderSidebar();
-
- await user.click(screen.getAllByRole("button", { name: "Feedback" })[0]);
- expect(await screen.findByRole("dialog", { name: "Report a problem" })).toBeInTheDocument();
- await user.type(screen.getByLabelText("Summary"), "Need help with setup");
-
- await user.click(screen.getByRole("button", { name: "Report destination" }));
- await user.click(await screen.findByRole("menuitem", { name: "Discord" }));
- expect(screen.getByRole("button", { name: "Copy and open Discord" })).toHaveClass("w-full");
- expect(screen.queryByRole("button", { name: "Copy and open email" })).not.toBeInTheDocument();
- await user.click(screen.getByRole("button", { name: "Copy and open Discord" }));
- await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
- expect(writeText.mock.calls[0][0]).toContain("**AO feedback**");
- expect(screen.getByText("Discord draft copied.")).toBeInTheDocument();
-
- await user.click(screen.getByRole("button", { name: "Report destination" }));
- await user.click(await screen.findByRole("menuitem", { name: "Email support" }));
- expect(screen.getByRole("button", { name: "Copy and open email" })).toBeInTheDocument();
- expect(screen.queryByRole("button", { name: "Copy and open Discord" })).not.toBeInTheDocument();
- expect(screen.queryByText("Discord draft copied.")).not.toBeInTheDocument();
- await user.click(screen.getByRole("button", { name: "Copy and open email" }));
-
- await waitFor(() => expect(writeText).toHaveBeenCalledTimes(2));
- expect(writeText.mock.calls[0][0]).toContain("Daemon: unknown");
- expect(writeText.mock.calls[1][0]).toContain("To: support@aoagents.dev");
- expect(writeText.mock.calls[1][0]).toContain("AO feedback");
- expect(openExternal).toHaveBeenCalledWith("https://discord.com/invite/UZv7JjxbwG");
- expect(openExternal).toHaveBeenCalledWith(expect.stringContaining("mailto:support@aoagents.dev"));
- expect(open).not.toHaveBeenCalled();
- });
-
- it("clears draft text when the feedback dialog closes", async () => {
- const user = userEvent.setup();
- const githubToken = `ghp_${"abcdefghijklmnopqrstuvwxyz"}${"1234567890AB"}`;
- renderSidebar();
-
- await user.click(screen.getAllByRole("button", { name: "Feedback" })[0]);
- expect(await screen.findByRole("dialog", { name: "Report a problem" })).toBeInTheDocument();
- await user.type(screen.getByLabelText("Summary"), "Sensitive setup problem");
- await user.type(screen.getByLabelText("Details"), `Token is ${githubToken}`);
-
- await user.click(screen.getByRole("button", { name: "Close report dialog" }));
- await waitFor(() => expect(screen.queryByRole("dialog", { name: "Report a problem" })).not.toBeInTheDocument());
-
- await user.click(screen.getAllByRole("button", { name: "Feedback" })[0]);
- expect(await screen.findByRole("dialog", { name: "Report a problem" })).toBeInTheDocument();
- expect(screen.getByLabelText("Summary")).toHaveValue("");
- expect(screen.getByLabelText("Details")).toHaveValue("");
- });
-
- it("keeps the report form to summary and details while tailoring placeholder guidance", async () => {
+ it("navigates to settings when the footer Settings button is clicked", async () => {
const user = userEvent.setup();
renderSidebar();
-
- await user.click(screen.getAllByRole("button", { name: "Feedback" })[0]);
- expect(await screen.findByRole("dialog", { name: "Report a problem" })).toBeInTheDocument();
- expect(screen.getByLabelText("Summary")).toHaveAttribute("placeholder", "Brief title");
- expect(screen.getByLabelText("Details")).toHaveAttribute(
- "placeholder",
- "Share what happened, what you want, or what you need help with.",
- );
- expect(screen.queryByLabelText("Expected behavior")).not.toBeInTheDocument();
- expect(screen.queryByRole("combobox", { name: "Report type" })).not.toBeInTheDocument();
- expect(screen.queryByLabelText("Include safe diagnostics")).not.toBeInTheDocument();
- expect(screen.queryByLabelText("Report preview")).not.toBeInTheDocument();
+ await user.click(screen.getAllByRole("button", { name: "Settings" })[0]);
+ expect(navigateMock).toHaveBeenCalledWith({ to: "/settings" });
});
it("shows the project name and context in the ConfirmDialog description", async () => {
diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx
index b672fbb984..94a2efc61a 100644
--- a/frontend/src/renderer/components/Sidebar.tsx
+++ b/frontend/src/renderer/components/Sidebar.tsx
@@ -1,21 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams, useRouterState } from "@tanstack/react-router";
-import {
- ChevronRight,
- GitPullRequest,
- LayoutDashboard,
- MessageSquare,
- Moon,
- MoreVertical,
- Pencil,
- Plus,
- RefreshCw,
- Search,
- Settings,
- Smartphone,
- Sun,
- Trash2,
-} from "lucide-react";
+import { ChevronRight, LayoutDashboard, MoreVertical, Pencil, Plus, RefreshCw, Settings, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { UpdateStatus } from "../../main/update-settings";
import {
@@ -30,16 +15,13 @@ import { aoBridge } from "../lib/bridge";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { renameSession } from "../lib/rename-session";
-import { useEventsConnection } from "../hooks/useEventsConnection";
import { useResizable } from "../hooks/useResizable";
import { useUpdateStatus } from "../hooks/useUpdateStatus";
-import { ConnectMobileModal } from "./ConnectMobileModal";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
- DropdownMenuShortcut,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import {
@@ -66,7 +48,6 @@ import { cn } from "../lib/utils";
import { useUiStore } from "../stores/ui-store";
import { ConfirmDialog } from "./ConfirmDialog";
import { CreateProjectFlow, type CreateProjectInput } from "./CreateProjectFlow";
-import { ReportProblemDialog } from "./ReportProblemDialog";
import { ResizeHandle } from "./ResizeHandle";
// The macOS hiddenInset traffic lights and the fixed TitlebarNav overlay live
@@ -91,8 +72,9 @@ const SIDEBAR_MAX_WIDTH = 420;
const SIDEBAR_COLLAPSE_THRESHOLD = SIDEBAR_MIN_WIDTH;
type SidebarProps = {
- daemonStatus: { state: string; message?: string };
underTopbar?: boolean;
+ /** Chrome height to clear when underTopbar is set. Defaults to the 56px shell toolbar. */
+ topbarOffset?: "toolbar" | "titlebar";
workspaceError?: string;
workspaces: WorkspaceSummary[];
onCreateProject: (input: CreateProjectInput) => Promise;
@@ -111,7 +93,6 @@ function useSelection() {
activeProjectId: params.projectId,
activeSessionId: params.sessionId,
goHome: () => void navigate({ to: "/" }),
- goPrs: () => void navigate({ to: "/prs" }),
goGlobalSettings: () => void navigate({ to: "/settings" }),
goSettings: (projectId: string) => void navigate({ to: "/projects/$projectId/settings", params: { projectId } }),
goProject: (projectId: string) => void navigate({ to: "/projects/$projectId", params: { projectId } }),
@@ -132,8 +113,8 @@ function SessionDot({ session }: { session: WorkspaceSession }) {
// replaces the old hand-rolled CollapsedRail — the same tree restyles itself
// via group-data-[collapsible=icon] into the 48px letter rail.
export function Sidebar({
- daemonStatus,
underTopbar = true,
+ topbarOffset = "toolbar",
workspaceError,
workspaces,
onCreateProject,
@@ -141,13 +122,9 @@ export function Sidebar({
onRemoveProject,
}: SidebarProps) {
const selection = useSelection();
- const eventsConnection = useEventsConnection();
const { state, setOpen } = useSidebar();
const isCollapsed = state === "collapsed";
const [expandedChromeVisible, setExpandedChromeVisible] = useState(!isCollapsed);
- const theme = useUiStore((s) => s.theme);
- const toggleTheme = useUiStore((s) => s.toggleTheme);
- const [isFeedbackOpen, setIsFeedbackOpen] = useState(false);
// One IPC subscription for both footer variants of the restart-to-update prompt.
const updateStatus = useUpdateStatus();
@@ -168,9 +145,6 @@ export function Sidebar({
return () => window.clearTimeout(timer);
}, [isCollapsed]);
- // Connect Mobile pairing modal, opened from the Settings menu.
- const [mobileOpen, setMobileOpen] = useState(false);
-
// Disclosure state: projects are expanded by default; a project id present in
// this set is collapsed (sessions hidden).
const [collapsedIds, setCollapsedIds] = useState>(() => new Set());
@@ -216,9 +190,16 @@ export function Sidebar({
-
+
{/* Brand (project-sidebar__brand); in the icon rail it becomes the old
36px board button wrapping the 22px accent mark. */}
@@ -243,17 +224,6 @@ export function Sidebar({
Orchestrator board
- {!isMac && (
-
-
-
-
- Expand sidebar · ⌘B
-
- )}
Agent Orchestrator
@@ -262,16 +232,24 @@ export function Sidebar({
nightly
)}
- {/* On macOS the toggle lives in the titlebar cluster instead. */}
+ {/* On macOS the toggle lives in the titlebar cluster instead. One trigger
+ for Win/Linux — SidebarTrigger already toggles open/closed. */}
{!isMac && (
- Collapse sidebar · ⌘B
+
+ {isCollapsed ? "Expand sidebar · ⌘B" : "Collapse sidebar · ⌘B"}
+
)}
@@ -322,149 +300,35 @@ export function Sidebar({
- {/* Footer (project-sidebar__footer) — Feedback plus Settings menu. Divergence
- (user-requested 2026-06-10): the trigger stretches the full row width
- (flex-1) with a uniform 7px footer inset on all sides (reference uses
- 12px top, 0 bottom, content-hugging button). The icon rail keeps the
- icon-only settings action plus expand toggle (off macOS). */}
-
-
+ {/* Footer — Settings opens the global settings page directly. */}
+
+
+
setIsFeedbackOpen(true)}
+ aria-label="Settings"
+ className="flex w-full items-center justify-center gap-2.5 rounded-md border border-border p-2 text-control font-medium text-passive transition-colors hover:bg-interactive-hover hover:text-foreground [&_svg]:size-icon-lg [&_svg]:text-passive"
+ onClick={() => selection.goGlobalSettings()}
type="button"
>
-
- Feedback
+
+ Settings
-
-
-
-
-
- Settings
-
-
-
-
- {theme === "dark" ? : }
- {theme === "dark" ? "Light mode" : "Dark mode"}
-
-
-
-
- Pull requests
-
-
-
- Search
- ⌘K
-
-
- setTimeout(() => setMobileOpen(true), 0)}>
-
- Connect Mobile
-
-
- {selection.activeProjectId && (
- selection.goSettings(selection.activeProjectId!)}>
-
- Project settings
-
- )}
-
-
- Global settings
-
-
-
-
-
- daemon {daemonStatus.state}
- {eventsConnection === "disconnected" && " · events offline"}
-
-
-
+
setIsFeedbackOpen(true)}
+ aria-label="Settings"
+ className="grid size-control-board place-items-center rounded-lg border border-border text-passive transition-colors hover:bg-interactive-hover hover:text-foreground [&_svg]:size-icon-base"
+ onClick={() => selection.goGlobalSettings()}
type="button"
>
-
+
- Feedback
+ Settings
-
-
-
-
-
-
-
-
-
- Settings
-
-
-
- {theme === "dark" ? : }
- {theme === "dark" ? "Light mode" : "Dark mode"}
-
-
-
-
- Pull requests
-
-
-
- Search
- ⌘K
-
-
- setTimeout(() => setMobileOpen(true), 0)}>
-
- Connect Mobile
-
-
- {selection.activeProjectId && (
- selection.goSettings(selection.activeProjectId!)}>
-
- Project settings
-
- )}
-
-
- Global settings
-
-
-
- {!isMac && (
-
-
-
-
- Expand sidebar · ⌘B
-
- )}
@@ -481,9 +345,6 @@ export function Sidebar({
onClick={() => setOpen(true)}
onPointerDown={onCollapsedResizePointerDown}
/>
-
-
-
);
}
diff --git a/frontend/src/renderer/components/UpdatesSection.tsx b/frontend/src/renderer/components/UpdatesSection.tsx
deleted file mode 100644
index 6b2e8888f2..0000000000
--- a/frontend/src/renderer/components/UpdatesSection.tsx
+++ /dev/null
@@ -1,407 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { useEffect, useRef, useState } from "react";
-import { Loader2 } from "lucide-react";
-import { aoBridge } from "../lib/bridge";
-import type { FeatureBuild } from "../lib/bridge";
-import { useUpdateStatus } from "../hooks/useUpdateStatus";
-import type { UpdateSettings, UpdateState, UpdateStatus } from "../../main/update-settings";
-import { Badge } from "./ui/badge";
-import { Button } from "./ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
-import { ConfirmDialog } from "./ConfirmDialog";
-import { Label } from "./ui/label";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
-import { Skeleton } from "./ui/skeleton";
-
-export const updateSettingsQueryKey = ["update-settings"] as const;
-
-// relativeAge converts an ISO timestamp to a short human-readable relative string.
-function relativeAge(iso: string): string {
- const diffMs = Date.now() - new Date(iso).getTime();
- const mins = Math.floor(diffMs / 60000);
- if (mins < 1) return "just now";
- if (mins < 60) return `${mins}m ago`;
- const hours = Math.floor(mins / 60);
- if (hours < 24) return `${hours}h ago`;
- const days = Math.floor(hours / 24);
- return `${days}d ago`;
-}
-
-const STALE_THRESHOLD_MS = 5 * 24 * 60 * 60 * 1000; // 5 days
-
-// UpdatesSection is the Global Settings card for the desktop auto-update channel.
-// It supports three modes: Stable, Nightly, and Feature Releases (pinned PR build).
-// `channel` in UpdateSettings is always the home channel (latest or nightly); the
-// `feature` field is a separate overlay that pins a specific PR build.
-export function UpdatesSection() {
- const queryClient = useQueryClient();
- const query = useQuery({
- queryKey: updateSettingsQueryKey,
- queryFn: () => aoBridge.updateSettings.get(),
- });
-
- const [form, setForm] = useState
({
- enabled: false,
- channel: "latest",
- nightlyAck: false,
- feature: null,
- });
- const [savedAt, setSavedAt] = useState(null);
- // Reveals the feature-build picker when the user selects "Feature Releases"
- // but has not pinned a build yet (form.feature is still null). Without this,
- // the controlled select would snap back to the home channel and the picker
- // could never be opened from a clean state.
- const [showFeature, setShowFeature] = useState(false);
- // Pending confirmation for pinning a feature build (replaces window.confirm).
- const [pendingPin, setPendingPin] = useState<{ pr: number; title: string } | null>(null);
-
- // Live update status, shared with UpdateActions below so there is a single
- // updates:status subscription for the whole card.
- const status = useUpdateStatus();
- // Set only right after the user pins a build or returns to their home channel,
- // so the check() that follows is allowed to auto-progress through download and
- // install. A normal manual "Check for updates" click never sets this, so it
- // stops at "available"/"downloaded" for the user to act on.
- const autoProgressRef = useRef(false);
- // Last status.state this effect already reacted to, so a status object that is
- // re-delivered with the same state doesn't re-trigger download()/install().
- const handledStatusRef = useRef(null);
-
- useEffect(() => {
- if (query.data) setForm(query.data);
- }, [query.data]);
-
- useEffect(() => {
- if (!autoProgressRef.current) return;
- if (handledStatusRef.current === status.state) return;
- handledStatusRef.current = status.state;
- if (status.state === "available") {
- void aoBridge.updates.download();
- } else if (status.state === "downloaded") {
- void aoBridge.updates.install();
- autoProgressRef.current = false;
- } else if (status.state === "error" || status.state === "unsupported" || status.state === "not-available") {
- // Dev/unsupported build, or nothing to update: stop rather than loop.
- autoProgressRef.current = false;
- }
- }, [status]);
-
- const save = useMutation({
- mutationFn: async (next: UpdateSettings) => {
- await aoBridge.updateSettings.set(next);
- },
- onSuccess: () => {
- setSavedAt(Date.now());
- void queryClient.invalidateQueries({ queryKey: updateSettingsQueryKey });
- },
- });
-
- // Derived primary select value: "feature" when a PR is pinned OR the user has
- // chosen Feature Releases (showFeature) but not pinned yet; else the home channel.
- const primaryValue = form.feature != null || showFeature ? "feature" : form.channel;
-
- const setEnabled = (enabled: boolean) => {
- setSavedAt(null);
- setForm((f) => ({ ...f, enabled }));
- };
-
- const handlePrimaryChannel = (v: string) => {
- setSavedAt(null);
- if (v === "latest") {
- setShowFeature(false);
- setForm((f) => ({ ...f, channel: "latest", nightlyAck: false, feature: null }));
- } else if (v === "nightly") {
- setShowFeature(false);
- setForm((f) => ({ ...f, channel: "nightly", nightlyAck: true, feature: null }));
- } else if (v === "feature") {
- // Reveal the secondary picker; the pin is only written once the user
- // selects a specific build (handlePinBuild). Home channel is untouched.
- setShowFeature(true);
- }
- };
-
- // Opens the confirmation dialog; the actual pin happens in confirmPinBuild.
- const handlePinBuild = async (pr: number, title: string) => {
- setPendingPin({ pr, title });
- };
-
- const confirmPinBuild = async () => {
- if (!pendingPin) return;
- const { pr } = pendingPin;
- setPendingPin(null);
- const next = { ...form, feature: { pr } };
- setForm(next);
- autoProgressRef.current = true;
- handledStatusRef.current = null;
- await aoBridge.updateSettings.set(next);
- void queryClient.invalidateQueries({ queryKey: updateSettingsQueryKey });
- void aoBridge.updates.check();
- };
-
- const handleReturnToHome = async () => {
- setShowFeature(false);
- const next = { ...form, feature: null };
- setForm(next);
- autoProgressRef.current = true;
- handledStatusRef.current = null;
- await aoBridge.updateSettings.set(next);
- void queryClient.invalidateQueries({ queryKey: updateSettingsQueryKey });
- void aoBridge.updates.check();
- };
-
- const activeQuery = useQuery({
- queryKey: ["feature-active"],
- queryFn: () => aoBridge.featureBuilds.getActive(),
- });
- const activeBuild = activeQuery.data ?? null;
-
- return (
- <>
-
-
- Updates
-
-
- {activeBuild && (
- <>
-
- PR #{activeBuild.pr}
- You are on PR #{activeBuild.pr}'s build.
- void handleReturnToHome()}>
- Return to {form.channel === "nightly" ? "Nightly" : "Stable"}
-
-
-
- Automatic updates, if enabled, will return you to your home channel on the next check.
-
- >
- )}
-
-
-
- Automatic updates
-
-
-
-
-
-
- Update channel
-
-
-
-
-
-
- Stable (latest release)
- Nightly (pre-release)
- Feature Releases
-
-
-
-
- {primaryValue === "feature" && (
-
- )}
-
- {form.channel === "nightly" && form.feature === null && form.enabled && (
-
- Nightly builds are cut every day and can be unstable or lose data. Only use Nightly if you are comfortable
- with that.
-
- )}
-
-
- save.mutate(form)} disabled={save.isPending}>
- {save.isPending ? "Saving..." : "Save changes"}
-
- {save.isError && (
-
- {save.error instanceof Error ? save.error.message : "Save failed"}
-
- )}
- {savedAt && !save.isPending && !save.isError && Saved. }
-
-
-
-
-
- void confirmPinBuild()}
- onOpenChange={(open) => {
- if (!open) setPendingPin(null);
- }}
- />
- >
- );
-}
-
-// FeatureBuildsSelect renders the secondary PR-build picker, shown when "Feature Releases"
-// is the active primary channel value. It fetches the list of live feature builds and
-// lets the user pick one to pin.
-function FeatureBuildsSelect({
- currentPr,
- onPin,
-}: {
- currentPr: number | null;
- onPin: (pr: number, title: string) => Promise;
-}) {
- const buildsQuery = useQuery({
- queryKey: ["feature-builds"],
- queryFn: () => aoBridge.featureBuilds.list(),
- });
-
- if (buildsQuery.isLoading) {
- return (
-
-
Feature build
-
-
-
-
-
- );
- }
-
- const builds = buildsQuery.data ?? [];
-
- if (builds.length === 0) {
- return (
-
-
Feature build
-
No live feature releases.
-
- );
- }
-
- const handleChange = (v: string) => {
- const pr = parseInt(v, 10);
- const build = builds.find((b) => b.pr === pr);
- if (!build) return;
- void onPin(build.pr, build.title);
- };
-
- return (
-
-
- Feature build
-
-
-
-
-
-
- {builds.map((b) => (
-
- ))}
-
-
-
- );
-}
-
-function FeatureBuildItem({ build }: { build: FeatureBuild }) {
- const ageMs = Date.now() - new Date(build.publishedAt).getTime();
- const isStale = ageMs > STALE_THRESHOLD_MS;
- const ageLabel = relativeAge(build.publishedAt);
-
- return (
-
-
-
- PR #{build.pr}: {build.title}
-
-
- {build.buildId}
- {ageLabel}
-
-
-
- );
-}
-
-// UpdateActions is the on-demand update control. `status` is passed down from
-// UpdatesSection so the card has a single updates:status subscription shared
-// between this manual control and the pin/return-home auto-progress effect.
-function UpdateActions({ status }: { status: UpdateStatus }) {
- const version = useQuery({ queryKey: ["app-version"], queryFn: () => aoBridge.app.getVersion() });
-
- const checking = status.state === "checking";
- const downloading = status.state === "downloading";
- const busy = checking || downloading;
-
- return (
-
-
- Current version
- {version.data ? `v${version.data}` : "..."}
-
-
- void aoBridge.updates.check()} disabled={busy}>
- {checking && }
- Check for updates
-
-
- {status.state === "available" && (
- void aoBridge.updates.download()}>
- Update to {status.version ? `v${status.version}` : "latest"}
-
- )}
- {status.state === "downloaded" && (
- void aoBridge.updates.install()}>
- Restart & install
-
- )}
-
-
-
-
- );
-}
-
-function UpdateStatusLine({ status }: { status: UpdateStatus }) {
- switch (status.state) {
- case "checking":
- return Checking for updates... ;
- case "available":
- return (
-
- Update available{status.version ? ` (v${status.version})` : ""}.
-
- );
- case "downloading":
- return Downloading... {status.percent ?? 0}% ;
- case "downloaded":
- return Downloaded. Restart to finish updating. ;
- case "not-available":
- return You're on the latest version. ;
- case "unsupported":
- return {status.message ?? "Updates need the installed app."} ;
- case "error":
- return {status.message ?? "Update failed."} ;
- default:
- return null;
- }
-}
-
-function EnabledSelect({ id, value, onChange }: { id: string; value: boolean; onChange: (value: boolean) => void }) {
- return (
- onChange(v === "on")}>
-
-
-
-
- Enabled
- Disabled
-
-
- );
-}
diff --git a/frontend/src/renderer/components/WindowTitlebar.tsx b/frontend/src/renderer/components/WindowTitlebar.tsx
index ca8917bb0d..77895c931b 100644
--- a/frontend/src/renderer/components/WindowTitlebar.tsx
+++ b/frontend/src/renderer/components/WindowTitlebar.tsx
@@ -1,7 +1,7 @@
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import aoLogo from "../assets/ao-logo.png";
-import { useUiStore } from "../stores/ui-store";
+import { useResolvedTheme } from "../stores/ui-store";
import {
DropdownMenu,
DropdownMenuContent,
@@ -68,7 +68,7 @@ function TopMenu({
export function WindowTitlebar() {
const navigate = useNavigate();
- const theme = useUiStore((state) => state.theme);
+ const theme = useResolvedTheme();
const [openMenu, setOpenMenu] = useState(null);
// Electron draws the min/max/close overlay natively and can't read our CSS, so
diff --git a/frontend/src/renderer/components/settings/GeneralSettingsSection.tsx b/frontend/src/renderer/components/settings/GeneralSettingsSection.tsx
new file mode 100644
index 0000000000..0c0991b6c6
--- /dev/null
+++ b/frontend/src/renderer/components/settings/GeneralSettingsSection.tsx
@@ -0,0 +1,31 @@
+import { Monitor, Moon, Palette, Smartphone, Sun } from "lucide-react";
+import type { ThemePreference } from "../../lib/theme";
+import { useUiStore } from "../../stores/ui-store";
+import { SettingsOptionMenu, type SettingsOption } from "./SettingsOptionMenu";
+import { SettingsLinkRow, SettingsRow } from "./SettingsRow";
+import { SettingsSection } from "./SettingsSection";
+
+const THEME_OPTIONS = [
+ { value: "light", label: "Light", icon: },
+ { value: "dark", label: "Dark", icon: },
+ { value: "system", label: "System", icon: },
+] satisfies SettingsOption[];
+
+export function GeneralSettingsSection({ onConnectMobile }: { onConnectMobile: () => void }) {
+ const themePreference = useUiStore((state) => state.themePreference);
+ const setThemePreference = useUiStore((state) => state.setThemePreference);
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/renderer/components/settings/ReportProblemDialog.tsx b/frontend/src/renderer/components/settings/ReportProblemDialog.tsx
new file mode 100644
index 0000000000..9bbf19e739
--- /dev/null
+++ b/frontend/src/renderer/components/settings/ReportProblemDialog.tsx
@@ -0,0 +1,242 @@
+import * as Dialog from "@radix-ui/react-dialog";
+import { RadioGroup } from "radix-ui";
+import { CircleDot, Mail, MessageSquare, X, type LucideIcon } from "lucide-react";
+import { useEffect, useId, useRef, useState } from "react";
+import {
+ collectReportProblemDiagnostics,
+ formatReportProblemDraft,
+ reportProblemDestinationUrl,
+ type ReportProblemDiagnostics,
+ type ReportProblemOutput,
+} from "../../lib/report-problem";
+import { aoBridge } from "../../lib/bridge";
+import { cn } from "../../lib/utils";
+
+type ReportProblemDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+};
+
+const DEFAULT_DIAGNOSTICS: ReportProblemDiagnostics = {
+ appVersion: "unknown",
+ buildMode: "unknown",
+ daemonState: "unknown",
+ generatedAt: "unknown",
+ platform: "unknown",
+ routeSurface: "unknown",
+};
+
+const DESTINATIONS: {
+ value: ReportProblemOutput;
+ label: string;
+ action: string;
+ icon: LucideIcon;
+}[] = [
+ { value: "github", label: "GitHub", action: "Copy & Create GitHub Issue", icon: CircleDot },
+ { value: "discord", label: "Discord", action: "Copy & Open Discord", icon: MessageSquare },
+ { value: "email", label: "Email", action: "Copy & Open Email", icon: Mail },
+];
+
+// Same field language as the Connect Mobile dialog: subtle bg, low-contrast
+// border, smooth accent focus ring.
+const fieldLabelClass = "text-control font-medium leading-4 text-settings-label";
+const fieldControlClass =
+ "w-full rounded-(--radius-settings-action) border border-[var(--color-border-settings-input)] bg-[var(--color-bg-settings-input)] px-3.5 text-sm leading-5 text-[var(--color-text-settings-input)] outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-[var(--color-text-settings-placeholder)] focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-weak";
+
+const footerButtonClass =
+ "inline-flex h-(--size-settings-action-height) shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-(--radius-settings-action) border px-3 text-sm leading-5 outline-none transition duration-150 focus-visible:ring-2 focus-visible:ring-accent-weak";
+
+export function ReportProblemDialog({ open, onOpenChange }: ReportProblemDialogProps) {
+ const titleId = useId();
+ const detailsId = useId();
+ const titleRef = useRef(null);
+ const [selectedOutput, setSelectedOutput] = useState("github");
+ const [summary, setSummary] = useState("");
+ const [details, setDetails] = useState("");
+ const [copiedOutput, setCopiedOutput] = useState(null);
+ const [copyError, setCopyError] = useState(null);
+ const [diagnostics, setDiagnostics] = useState(DEFAULT_DIAGNOSTICS);
+
+ const copiedLabel = DESTINATIONS.find((option) => option.value === copiedOutput)?.label;
+
+ useEffect(() => {
+ if (!open) {
+ setSummary("");
+ setDetails("");
+ setSelectedOutput("github");
+ setCopiedOutput(null);
+ setCopyError(null);
+ return;
+ }
+ let active = true;
+ void collectReportProblemDiagnostics().then((nextDiagnostics) => {
+ if (active) setDiagnostics(nextDiagnostics);
+ });
+ return () => {
+ active = false;
+ };
+ }, [open]);
+
+ const input = { summary, details };
+ const draft = formatReportProblemDraft(input, diagnostics, selectedOutput);
+ const destination = DESTINATIONS.find((option) => option.value === selectedOutput) ?? DESTINATIONS[0];
+ const canCopy = summary.trim().length > 0;
+
+ const clearStatus = () => {
+ setCopiedOutput(null);
+ setCopyError(null);
+ };
+
+ const copyDraft = async () => {
+ if (!canCopy) return;
+ setCopyError(null);
+ const output = selectedOutput;
+ try {
+ await aoBridge.clipboard.writeText(draft);
+ const destinationUrl = reportProblemDestinationUrl(input, diagnostics, output);
+ if (destinationUrl) {
+ await aoBridge.app.openExternal(destinationUrl);
+ }
+ setCopiedOutput(output);
+ setSummary("");
+ setDetails("");
+ setSelectedOutput("github");
+ } catch (err) {
+ setCopyError(err instanceof Error ? err.message : "Could not copy report draft");
+ setCopiedOutput(null);
+ }
+ };
+
+ return (
+
+
+
+ {
+ event.preventDefault();
+ titleRef.current?.focus();
+ }}
+ onKeyDown={(event) => {
+ // Only Cmd/Ctrl+Enter submits — a plain Enter in the textarea
+ // must keep inserting newlines.
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
+ event.preventDefault();
+ void copyDraft();
+ }
+ }}
+ >
+
+
+
+
+
+
+
+
+ Report a problem
+
+
+ Found an issue? Tell us what happened.
+
+
+
+
+
+
+ Title
+
+ {
+ setSummary(event.target.value);
+ clearStatus();
+ }}
+ placeholder="Brief Title"
+ />
+
+
+
+
+ What happened?
+
+
+
+
{
+ setSelectedOutput(value as ReportProblemOutput);
+ clearStatus();
+ }}
+ aria-label="Report destination"
+ className="inline-flex items-center gap-0.5 self-start rounded-(--radius-settings-action) border border-[var(--color-border-settings-input)] bg-[var(--color-bg-settings-input)] p-0.5"
+ >
+ {DESTINATIONS.map((option) => (
+
+
+ {option.label}
+
+ ))}
+
+
+ {copyError && (
+
+ {copyError}
+
+ )}
+ {copiedLabel && !copyError && (
+
{copiedLabel} draft copied.
+ )}
+
+
+
+
+
+ Cancel
+
+
+ void copyDraft()}
+ >
+ {destination.action}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/renderer/components/settings/SettingsOptionMenu.tsx b/frontend/src/renderer/components/settings/SettingsOptionMenu.tsx
new file mode 100644
index 0000000000..fa2c9ab77f
--- /dev/null
+++ b/frontend/src/renderer/components/settings/SettingsOptionMenu.tsx
@@ -0,0 +1,67 @@
+import { ChevronDown } from "lucide-react";
+import type { ReactNode } from "react";
+import { cn } from "../../lib/utils";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
+
+export type SettingsOption = {
+ value: T;
+ label: string;
+ icon?: ReactNode;
+};
+
+export function SettingsOptionMenu({
+ value,
+ options,
+ onChange,
+ disabled,
+ triggerClassName,
+ "aria-label": ariaLabel,
+}: {
+ value: T;
+ options: SettingsOption[];
+ onChange: (value: T) => void;
+ disabled?: boolean;
+ triggerClassName?: string;
+ "aria-label": string;
+}) {
+ const selected = options.find((option) => option.value === value);
+
+ return (
+
+
+
+ {selected?.label}
+
+
+
+
+ {options.map((option) => (
+ onChange(option.value)}
+ className={cn(
+ "settings-menu-item cursor-default outline-none",
+ "focus:border-settings-menu focus:bg-settings-menu-selected focus:text-settings-label",
+ "data-highlighted:border-settings-menu data-highlighted:bg-settings-menu-selected data-highlighted:text-settings-label",
+ option.value === value && "border-settings-menu bg-settings-menu-selected",
+ "[&_svg]:size-icon-lg [&_svg]:shrink-0 [&_svg]:text-settings-muted",
+ )}
+ >
+ {option.icon}
+ {option.label}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/renderer/components/settings/SettingsPageShell.tsx b/frontend/src/renderer/components/settings/SettingsPageShell.tsx
new file mode 100644
index 0000000000..0b20014653
--- /dev/null
+++ b/frontend/src/renderer/components/settings/SettingsPageShell.tsx
@@ -0,0 +1,12 @@
+import type { ReactNode } from "react";
+
+/** Outer settings frame — chrome matches sidebar (#1E1F22); inset panel is #101013. */
+export function SettingsPageShell({ children }: { children: ReactNode }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/renderer/components/settings/SettingsPanel.tsx b/frontend/src/renderer/components/settings/SettingsPanel.tsx
new file mode 100644
index 0000000000..4c06ecfcb2
--- /dev/null
+++ b/frontend/src/renderer/components/settings/SettingsPanel.tsx
@@ -0,0 +1,27 @@
+import { X } from "lucide-react";
+import type { ReactNode } from "react";
+
+/**
+ * Figma "Settings Container": centered column, max-width 768px,
+ * padding 64px 32px 80px, gap 32px between header and sections.
+ */
+export function SettingsPanel({ children, onClose }: { children: ReactNode; onClose: () => void }) {
+ return (
+
+
+
+
Settings
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/frontend/src/renderer/components/settings/SettingsRow.tsx b/frontend/src/renderer/components/settings/SettingsRow.tsx
new file mode 100644
index 0000000000..2becbd3294
--- /dev/null
+++ b/frontend/src/renderer/components/settings/SettingsRow.tsx
@@ -0,0 +1,45 @@
+import { ChevronRight, type LucideIcon } from "lucide-react";
+import type { ReactNode } from "react";
+import { cn } from "../../lib/utils";
+
+function SettingsRowLabel({ icon: Icon, label }: { icon?: LucideIcon; label: string }) {
+ return (
+
+ {Icon ? : null}
+ {label}
+
+ );
+}
+
+/** Figma row: #212121, 12px radius, 16px padding, 52px height, icon gap 12px. */
+export function SettingsRow({
+ icon,
+ label,
+ children,
+ className,
+}: {
+ icon?: LucideIcon;
+ label: string;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+export function SettingsLinkRow({ icon, label, onClick }: { icon?: LucideIcon; label: string; onClick: () => void }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/src/renderer/components/settings/SettingsSection.tsx b/frontend/src/renderer/components/settings/SettingsSection.tsx
new file mode 100644
index 0000000000..22b5cd08f6
--- /dev/null
+++ b/frontend/src/renderer/components/settings/SettingsSection.tsx
@@ -0,0 +1,11 @@
+import type { ReactNode } from "react";
+
+/** Figma section: column, gap 12px between heading and rows. */
+export function SettingsSection({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/renderer/components/settings/UpdatesSection.tsx b/frontend/src/renderer/components/settings/UpdatesSection.tsx
new file mode 100644
index 0000000000..1ee4632f74
--- /dev/null
+++ b/frontend/src/renderer/components/settings/UpdatesSection.tsx
@@ -0,0 +1,419 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useRef, useState } from "react";
+import { AlertTriangle, Check, ChevronDown, HardDriveDownload, History, Loader2, RefreshCw } from "lucide-react";
+import { aoBridge } from "../../lib/bridge";
+import type { FeatureBuild } from "../../lib/bridge";
+import { useUpdateStatus } from "../../hooks/useUpdateStatus";
+import type { UpdateChannel, UpdateSettings, UpdateState, UpdateStatus } from "../../../main/update-settings";
+import { ConfirmDialog } from "../ConfirmDialog";
+import { Badge } from "../ui/badge";
+import { Button } from "../ui/button";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
+import { Skeleton } from "../ui/skeleton";
+import { cn } from "../../lib/utils";
+import { SettingsOptionMenu } from "./SettingsOptionMenu";
+import { SettingsRow } from "./SettingsRow";
+import { SettingsSection } from "./SettingsSection";
+
+export const updateSettingsQueryKey = ["update-settings"] as const;
+
+type PrimaryValue = UpdateChannel | "feature";
+
+const ENABLED_OPTIONS = [
+ { value: "on" as const, label: "Enabled" },
+ { value: "off" as const, label: "Disabled" },
+];
+
+const CHANNEL_OPTIONS: { value: PrimaryValue; label: string }[] = [
+ { value: "latest", label: "Stable (Latest)" },
+ { value: "nightly", label: "Nightly (Pre-release)" },
+ { value: "feature", label: "Feature Releases" },
+];
+
+const DEFAULT_SETTINGS: UpdateSettings = { enabled: false, channel: "latest", nightlyAck: false, feature: null };
+
+const STALE_THRESHOLD_MS = 5 * 24 * 60 * 60 * 1000; // 5 days
+
+function relativeAge(iso: string): string {
+ const diffMs = Date.now() - new Date(iso).getTime();
+ const mins = Math.floor(diffMs / 60000);
+ if (mins < 1) return "just now";
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
+
+export function UpdatesSection() {
+ const queryClient = useQueryClient();
+ const query = useQuery({
+ queryKey: updateSettingsQueryKey,
+ queryFn: () => aoBridge.updateSettings.get(),
+ });
+
+ const [form, setForm] = useState(DEFAULT_SETTINGS);
+ const formRef = useRef(form);
+ formRef.current = form;
+
+ // Reveals the feature-build picker when the user selects "Feature Releases"
+ // but has not pinned a build yet (form.feature is still null).
+ const [showFeature, setShowFeature] = useState(false);
+ const [pendingPin, setPendingPin] = useState<{ pr: number; title: string } | null>(null);
+
+ const status = useUpdateStatus();
+ // Set only right after the user pins a build or returns to their home channel,
+ // so the check() that follows is allowed to auto-progress through download/install.
+ const autoProgressRef = useRef(false);
+ const handledStatusRef = useRef(null);
+
+ useEffect(() => {
+ if (query.data) setForm(query.data);
+ }, [query.data]);
+
+ useEffect(() => {
+ if (!autoProgressRef.current) return;
+ if (handledStatusRef.current === status.state) return;
+ handledStatusRef.current = status.state;
+ if (status.state === "available") {
+ void aoBridge.updates.download();
+ } else if (status.state === "downloaded") {
+ void aoBridge.updates.install();
+ autoProgressRef.current = false;
+ } else if (status.state === "error" || status.state === "unsupported" || status.state === "not-available") {
+ autoProgressRef.current = false;
+ }
+ }, [status]);
+
+ const save = useMutation({
+ mutationFn: async (next: UpdateSettings) => {
+ await aoBridge.updateSettings.set(next);
+ return next;
+ },
+ onSuccess: (next) => {
+ setForm(next);
+ void queryClient.invalidateQueries({ queryKey: updateSettingsQueryKey });
+ },
+ onError: () => {
+ const previous = queryClient.getQueryData(updateSettingsQueryKey);
+ if (previous) setForm(previous);
+ },
+ });
+
+ const primaryValue: PrimaryValue = form.feature != null || showFeature ? "feature" : form.channel;
+
+ const setEnabled = (enabled: boolean) => {
+ const next = { ...formRef.current, enabled };
+ setForm(next);
+ save.mutate(next);
+ };
+
+ const handlePrimaryChannel = (value: PrimaryValue) => {
+ if (!formRef.current.enabled) return;
+ if (value === "feature") {
+ setShowFeature(true);
+ return;
+ }
+ setShowFeature(false);
+ const next = {
+ ...formRef.current,
+ channel: value,
+ nightlyAck: value === "nightly",
+ feature: null,
+ };
+ setForm(next);
+ save.mutate(next);
+ };
+
+ const handlePinBuild = async (pr: number, title: string) => {
+ setPendingPin({ pr, title });
+ };
+
+ const confirmPinBuild = async () => {
+ if (!pendingPin) return;
+ const { pr } = pendingPin;
+ setPendingPin(null);
+ const next = { ...formRef.current, feature: { pr } };
+ setForm(next);
+ autoProgressRef.current = true;
+ handledStatusRef.current = null;
+ await aoBridge.updateSettings.set(next);
+ void queryClient.invalidateQueries({ queryKey: updateSettingsQueryKey });
+ void aoBridge.updates.check();
+ };
+
+ const handleReturnToHome = async () => {
+ setShowFeature(false);
+ const next = { ...formRef.current, feature: null };
+ setForm(next);
+ autoProgressRef.current = true;
+ handledStatusRef.current = null;
+ await aoBridge.updateSettings.set(next);
+ void queryClient.invalidateQueries({ queryKey: updateSettingsQueryKey });
+ void aoBridge.updates.check();
+ };
+
+ const activeQuery = useQuery({
+ queryKey: ["feature-active"],
+ queryFn: () => aoBridge.featureBuilds.getActive(),
+ });
+ const activeBuild = activeQuery.data ?? null;
+
+ return (
+ <>
+
+ {activeBuild && (
+
+
+ PR #{activeBuild.pr}
+ You are on PR #{activeBuild.pr}'s build.
+ void handleReturnToHome()}>
+ Return to {form.channel === "nightly" ? "Nightly" : "Stable"}
+
+
+
+ Automatic updates, if enabled, will return you to your home channel on the next check.
+
+
+ )}
+
+
+ setEnabled(next === "on")}
+ disabled={save.isPending}
+ />
+
+
+
+
+
+
+ {primaryValue === "feature" && (
+
+ )}
+
+ {form.channel === "nightly" && form.feature === null && form.enabled && (
+
+
+
+ Nightly builds are cut every day and can be unstable or lose data. Only use Nightly if you are comfortable
+ with that.
+
+
+ )}
+
+ {save.isError && (
+ {save.error instanceof Error ? save.error.message : "Save failed"}
+ )}
+
+
+
+
+ void confirmPinBuild()}
+ onOpenChange={(open) => {
+ if (!open) setPendingPin(null);
+ }}
+ />
+ >
+ );
+}
+
+function FeatureBuildsSelect({
+ currentPr,
+ onPin,
+}: {
+ currentPr: number | null;
+ onPin: (pr: number, title: string) => Promise;
+}) {
+ const buildsQuery = useQuery({
+ queryKey: ["feature-builds"],
+ queryFn: () => aoBridge.featureBuilds.list(),
+ });
+
+ if (buildsQuery.isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const builds = buildsQuery.data ?? [];
+
+ if (builds.length === 0) {
+ return (
+
+ Feature build
+ No live feature releases.
+
+ );
+ }
+
+ const selected = builds.find((b) => b.pr === currentPr);
+
+ return (
+
+
+
+
+
+ {selected ? `PR #${selected.pr}: ${selected.title}` : "Select a feature build..."}
+
+
+
+
+
+ {builds.map((build) => (
+ void onPin(build.pr, build.title)}
+ />
+ ))}
+
+
+
+ );
+}
+
+function FeatureBuildItem({
+ build,
+ selected,
+ onSelect,
+}: {
+ build: FeatureBuild;
+ selected: boolean;
+ onSelect: () => void;
+}) {
+ const ageMs = Date.now() - new Date(build.publishedAt).getTime();
+ const isStale = ageMs > STALE_THRESHOLD_MS;
+ const ageLabel = relativeAge(build.publishedAt);
+
+ return (
+
+
+
+ PR #{build.pr}: {build.title}
+
+
+ {build.buildId}
+ {ageLabel}
+
+
+
+ );
+}
+
+function UpdateActions({ status }: { status: UpdateStatus }) {
+ const version = useQuery({ queryKey: ["app-version"], queryFn: () => aoBridge.app.getVersion() });
+
+ const checking = status.state === "checking";
+ const downloading = status.state === "downloading";
+ const busy = checking || downloading;
+ const showStatus =
+ status.state === "checking" ||
+ status.state === "available" ||
+ status.state === "downloading" ||
+ status.state === "downloaded" ||
+ status.state === "not-available" ||
+ status.state === "unsupported" ||
+ status.state === "error";
+
+ return (
+ <>
+
+
+
+ Current version - {version.data ? `v${version.data}` : "…"}
+
+ void aoBridge.updates.check()}
+ disabled={busy}
+ >
+ {checking ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {showStatus && (
+
+ {status.state === "available" && (
+ void aoBridge.updates.download()}>
+ Update to {status.version ? `v${status.version}` : "latest"}
+
+ )}
+ {status.state === "downloaded" && (
+ void aoBridge.updates.install()}>
+ Restart & install
+
+ )}
+
+
+ )}
+ >
+ );
+}
+
+function UpdateStatusLine({ status }: { status: UpdateStatus }) {
+ switch (status.state) {
+ case "checking":
+ return Checking for updates… ;
+ case "available":
+ return (
+
+ Update available{status.version ? ` (v${status.version})` : ""}.
+
+ );
+ case "downloading":
+ return Downloading… {status.percent ?? 0}% ;
+ case "downloaded":
+ return Downloaded. Restart to finish updating. ;
+ case "not-available":
+ return You're on the latest version. ;
+ case "unsupported":
+ return {status.message ?? "Updates need the installed app."} ;
+ case "error":
+ return {status.message ?? "Update failed."} ;
+ default:
+ return null;
+ }
+}
diff --git a/frontend/src/renderer/components/ui/dialog.tsx b/frontend/src/renderer/components/ui/dialog.tsx
index 1e9d5a6630..0feff56c6e 100644
--- a/frontend/src/renderer/components/ui/dialog.tsx
+++ b/frontend/src/renderer/components/ui/dialog.tsx
@@ -27,7 +27,7 @@ function DialogOverlay({ className, ...props }: React.ComponentProps {
it("categorizes routes without exporting raw paths", () => {
expect(routeSurface("/")).toBe("home");
+ expect(routeSurface("/settings")).toBe("global_settings");
expect(routeSurface("/projects/demo")).toBe("project_board");
expect(routeSurface("/projects/demo/settings")).toBe("project_settings");
expect(routeSurface("/projects/demo/sessions/demo-1")).toBe("session_detail");
- expect(routeSurface("/prs")).toBe("pull_requests");
});
it("hashes renderer ids and drops raw route identifiers", async () => {
diff --git a/frontend/src/renderer/lib/telemetry.ts b/frontend/src/renderer/lib/telemetry.ts
index b8d2b02413..b8ea2db567 100644
--- a/frontend/src/renderer/lib/telemetry.ts
+++ b/frontend/src/renderer/lib/telemetry.ts
@@ -147,7 +147,7 @@ function normalizeException(reason: unknown): Error {
function routeSurface(pathname: string): string {
if (pathname === "/") return "home";
- if (/^\/prs(?:\/|$)/.test(pathname)) return "pull_requests";
+ if (/^\/settings(?:\/|$)/.test(pathname)) return "global_settings";
if (/^\/projects\/[^/]+\/sessions\/[^/]+$/.test(pathname)) return "session_detail";
if (/^\/projects\/[^/]+(?:\/|$)/.test(pathname)) {
if (/\/settings$/.test(pathname)) return "project_settings";
diff --git a/frontend/src/renderer/lib/theme.ts b/frontend/src/renderer/lib/theme.ts
index ecf693a8ad..b786d5dfdc 100644
--- a/frontend/src/renderer/lib/theme.ts
+++ b/frontend/src/renderer/lib/theme.ts
@@ -1,4 +1,5 @@
export type Theme = "light" | "dark";
+export type ThemePreference = Theme | "system";
export const themeStorageKey = "ao.theme";
@@ -12,18 +13,20 @@ export function systemTheme(): Theme {
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
-export function readStoredTheme(): Theme | null {
+export function readStoredThemePreference(): ThemePreference {
try {
const stored = getLocalStorage()?.getItem(themeStorageKey);
- return stored === "light" || stored === "dark" ? stored : null;
+ if (stored === "light" || stored === "dark" || stored === "system") return stored;
} catch {
- return null;
+ // ignore
}
+ return "system";
}
-/** Stored preference, else OS appearance. */
-export function resolveTheme(): Theme {
- return readStoredTheme() ?? systemTheme();
+/** Resolve the active light/dark appearance from a stored preference. */
+export function resolveTheme(preference: ThemePreference = readStoredThemePreference()): Theme {
+ if (preference === "system") return systemTheme();
+ return preference;
}
export function applyDocumentTheme(theme: Theme): void {
diff --git a/frontend/src/renderer/routeTree.gen.ts b/frontend/src/renderer/routeTree.gen.ts
index 5add28aa8e..7cbb6bc5a2 100644
--- a/frontend/src/renderer/routeTree.gen.ts
+++ b/frontend/src/renderer/routeTree.gen.ts
@@ -12,7 +12,6 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as ShellRouteImport } from './routes/_shell'
import { Route as ShellIndexRouteImport } from './routes/_shell.index'
import { Route as ShellSettingsRouteImport } from './routes/_shell.settings'
-import { Route as ShellPrsRouteImport } from './routes/_shell.prs'
import { Route as ShellSessionsSessionIdRouteImport } from './routes/_shell.sessions.$sessionId'
import { Route as ShellProjectsProjectIdRouteImport } from './routes/_shell.projects.$projectId'
import { Route as ShellProjectsProjectIdSettingsRouteImport } from './routes/_shell.projects.$projectId_.settings'
@@ -32,11 +31,6 @@ const ShellSettingsRoute = ShellSettingsRouteImport.update({
path: '/settings',
getParentRoute: () => ShellRoute,
} as any)
-const ShellPrsRoute = ShellPrsRouteImport.update({
- id: '/prs',
- path: '/prs',
- getParentRoute: () => ShellRoute,
-} as any)
const ShellSessionsSessionIdRoute = ShellSessionsSessionIdRouteImport.update({
id: '/sessions/$sessionId',
path: '/sessions/$sessionId',
@@ -62,7 +56,6 @@ const ShellProjectsProjectIdSessionsSessionIdRoute =
export interface FileRoutesByFullPath {
'/': typeof ShellIndexRoute
- '/prs': typeof ShellPrsRoute
'/settings': typeof ShellSettingsRoute
'/projects/$projectId': typeof ShellProjectsProjectIdRoute
'/sessions/$sessionId': typeof ShellSessionsSessionIdRoute
@@ -70,7 +63,6 @@ export interface FileRoutesByFullPath {
'/projects/$projectId/sessions/$sessionId': typeof ShellProjectsProjectIdSessionsSessionIdRoute
}
export interface FileRoutesByTo {
- '/prs': typeof ShellPrsRoute
'/settings': typeof ShellSettingsRoute
'/': typeof ShellIndexRoute
'/projects/$projectId': typeof ShellProjectsProjectIdRoute
@@ -81,7 +73,6 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_shell': typeof ShellRouteWithChildren
- '/_shell/prs': typeof ShellPrsRoute
'/_shell/settings': typeof ShellSettingsRoute
'/_shell/': typeof ShellIndexRoute
'/_shell/projects/$projectId': typeof ShellProjectsProjectIdRoute
@@ -93,7 +84,6 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
- | '/prs'
| '/settings'
| '/projects/$projectId'
| '/sessions/$sessionId'
@@ -101,7 +91,6 @@ export interface FileRouteTypes {
| '/projects/$projectId/sessions/$sessionId'
fileRoutesByTo: FileRoutesByTo
to:
- | '/prs'
| '/settings'
| '/'
| '/projects/$projectId'
@@ -111,7 +100,6 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/_shell'
- | '/_shell/prs'
| '/_shell/settings'
| '/_shell/'
| '/_shell/projects/$projectId'
@@ -147,13 +135,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ShellSettingsRouteImport
parentRoute: typeof ShellRoute
}
- '/_shell/prs': {
- id: '/_shell/prs'
- path: '/prs'
- fullPath: '/prs'
- preLoaderRoute: typeof ShellPrsRouteImport
- parentRoute: typeof ShellRoute
- }
'/_shell/sessions/$sessionId': {
id: '/_shell/sessions/$sessionId'
path: '/sessions/$sessionId'
@@ -186,7 +167,6 @@ declare module '@tanstack/react-router' {
}
interface ShellRouteChildren {
- ShellPrsRoute: typeof ShellPrsRoute
ShellSettingsRoute: typeof ShellSettingsRoute
ShellIndexRoute: typeof ShellIndexRoute
ShellProjectsProjectIdRoute: typeof ShellProjectsProjectIdRoute
@@ -196,7 +176,6 @@ interface ShellRouteChildren {
}
const ShellRouteChildren: ShellRouteChildren = {
- ShellPrsRoute: ShellPrsRoute,
ShellSettingsRoute: ShellSettingsRoute,
ShellIndexRoute: ShellIndexRoute,
ShellProjectsProjectIdRoute: ShellProjectsProjectIdRoute,
diff --git a/frontend/src/renderer/routes/_shell.index.tsx b/frontend/src/renderer/routes/_shell.index.tsx
index 409a536362..7566896296 100644
--- a/frontend/src/renderer/routes/_shell.index.tsx
+++ b/frontend/src/renderer/routes/_shell.index.tsx
@@ -1,12 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
-import { MigrationPopup } from "../components/MigrationPopup";
import { SessionsBoard } from "../components/SessionsBoard";
export const Route = createFileRoute("/_shell/")({
- component: () => (
- <>
-
-
- >
- ),
+ component: SessionsBoard,
});
diff --git a/frontend/src/renderer/routes/_shell.prs.tsx b/frontend/src/renderer/routes/_shell.prs.tsx
deleted file mode 100644
index bf2f3cb223..0000000000
--- a/frontend/src/renderer/routes/_shell.prs.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-import { createFileRoute } from "@tanstack/react-router";
-import { PullRequestsPage } from "../components/PullRequestsPage";
-
-export const Route = createFileRoute("/_shell/prs")({
- component: PullRequestsPage,
-});
diff --git a/frontend/src/renderer/routes/_shell.tsx b/frontend/src/renderer/routes/_shell.tsx
index 96152c350c..b6a1ea206e 100644
--- a/frontend/src/renderer/routes/_shell.tsx
+++ b/frontend/src/renderer/routes/_shell.tsx
@@ -19,7 +19,7 @@ import { addRendererExceptionStep, captureRendererEvent, captureRendererExceptio
import { ShellProvider } from "../lib/shell-context";
import { restartProjectOrchestrator } from "../lib/restart-orchestrator";
import { captureOrchestratorReplacementFailure } from "../lib/orchestrator-replacement-telemetry";
-import { applyDocumentTheme, readStoredTheme, systemTheme } from "../lib/theme";
+import { applyDocumentTheme } from "../lib/theme";
import { aoBridge } from "../lib/bridge";
import { useUiStore } from "../stores/ui-store";
import type { WorkspaceSummary } from "../types/workspace";
@@ -54,6 +54,14 @@ export function createProjectConfig(input: CreateProjectConfigInput): components
};
}
+const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
+const isWindows =
+ typeof navigator !== "undefined" &&
+ /win/i.test(
+ (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ??
+ navigator.platform ??
+ "",
+ );
const isLinux =
typeof navigator !== "undefined" &&
((navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? navigator.platform)
@@ -72,7 +80,8 @@ function ShellLayout() {
const workspaces = workspaceQuery.data ?? [];
const daemonStatus = useDaemonStatus(queryClient);
const agentCatalogPortRef = useRef(undefined);
- const { theme, setTheme, isSidebarOpen, toggleSidebar } = useUiStore();
+ const { themePreference, resolvedTheme, isSidebarOpen, toggleSidebar } = useUiStore();
+ const syncSystemTheme = useUiStore((state) => state.syncSystemTheme);
const requestNewTask = useUiStore((state) => state.requestNewTask);
const requestCreateProject = useUiStore((state) => state.requestCreateProject);
const [isKeyboardShortcutsOpen, setIsKeyboardShortcutsOpen] = useState(false);
@@ -88,6 +97,9 @@ function ShellLayout() {
const isSessionRoute =
Boolean(matchRoute({ to: "/projects/$projectId/sessions/$sessionId", fuzzy: true })) ||
Boolean(matchRoute({ to: "/sessions/$sessionId", fuzzy: true }));
+ const isSettingsRoute =
+ Boolean(matchRoute({ to: "/settings", fuzzy: true })) ||
+ Boolean(matchRoute({ to: "/projects/$projectId/settings", fuzzy: true }));
const setProjectRestarting = useUiStore((state) => state.setProjectRestarting);
const orchestratorReplacementErrors = useUiStore((state) => state.orchestratorReplacementErrors);
const setOrchestratorReplacementError = useUiStore((state) => state.setOrchestratorReplacementError);
@@ -253,8 +265,8 @@ function ShellLayout() {
);
useEffect(() => {
- applyDocumentTheme(theme);
- }, [theme]);
+ applyDocumentTheme(resolvedTheme);
+ }, [resolvedTheme]);
useEffect(() => {
if (daemonStatus.state !== "ready" || !daemonStatus.port) return;
@@ -266,15 +278,16 @@ function ShellLayout() {
void queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
}, [daemonStatus.port, daemonStatus.state, queryClient]);
- // Follow OS appearance only until the user picks a theme explicitly.
+ // Follow OS appearance while the user keeps Theme on System — updates
+ // resolvedTheme (and thus React consumers) without writing light/dark to storage.
useEffect(() => {
- if (readStoredTheme()) return;
+ if (themePreference !== "system") return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: light)");
- const handleChange = () => setTheme(systemTheme());
+ const handleChange = () => syncSystemTheme();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
- }, [setTheme]);
+ }, [themePreference, syncSystemTheme]);
// ⌘B lives in SidebarProvider (shadcn's built-in shortcut), which routes
// through onOpenChange back into the ui-store.
@@ -321,11 +334,11 @@ function ShellLayout() {
header instead of cutting through the titlebar strip. The bar lives
in the layout, not the screens, so the crumb and actions never shift
when the outlet content swaps. */}
-
+
{/* Windows-only custom title bar (logo + File/Edit/View/… menu); paints
the chrome the frameless window drops. Renders null on macOS/Linux. */}
-
+ {!isSettingsRoute ? : null}
{/* Controlled by the ui-store so TitlebarNav / Topbar toggles (which
call the store directly) stay in sync. --sidebar-width chains to
the drag-resizable --ao-sidebar-w set on :root by useResizable. */}
@@ -340,9 +353,13 @@ function ShellLayout() {
} as CSSProperties
}
>
+ {/* macOS TitlebarNav and the Windows WindowTitlebar stay in the top band on
+ every route (including settings, where ShellTopbar is hidden), so the
+ sidebar must hang below that strip on those platforms. Linux only
+ offsets under the topbar on session routes. */}
;
orchestratorReplacementErrors: Record;
orchestratorStartupErrors: Record;
@@ -28,8 +37,9 @@ type UiState = {
// when no project is in scope).
createProjectNonce: number;
setWorkbenchTab: (tab: WorkbenchTab) => void;
- setTheme: (theme: Theme) => void;
- toggleTheme: () => void;
+ setThemePreference: (theme: ThemePreference) => void;
+ /** Refresh resolvedTheme from OS without writing light/dark to storage. */
+ syncSystemTheme: () => void;
toggleSidebar: () => void;
toggleInspector: () => void;
setProjectRestarting: (projectId: string, restarting: boolean) => void;
@@ -55,26 +65,29 @@ function initialInspectorOpen() {
return getLocalStorage()?.getItem(inspectorStorageKey) !== "false";
}
+const initialThemePreference = readStoredThemePreference();
+
export const useUiStore = create((set) => ({
workbenchTab: "changes",
isSidebarOpen: initialSidebarOpen(),
isInspectorOpen: initialInspectorOpen(),
- theme: resolveTheme(),
+ themePreference: initialThemePreference,
+ resolvedTheme: resolveTheme(initialThemePreference),
restartingProjectIds: new Set(),
orchestratorReplacementErrors: {},
orchestratorStartupErrors: {},
newTaskRequest: null,
createProjectNonce: 0,
setWorkbenchTab: (workbenchTab) => set({ workbenchTab }),
- setTheme: (theme) => {
- getLocalStorage()?.setItem(themeStorageKey, theme);
- set({ theme });
+ setThemePreference: (themePreference) => {
+ getLocalStorage()?.setItem(themeStorageKey, themePreference);
+ set({ themePreference, resolvedTheme: resolveTheme(themePreference) });
},
- toggleTheme: () =>
+ syncSystemTheme: () =>
set((state) => {
- const theme = state.theme === "dark" ? "light" : "dark";
- getLocalStorage()?.setItem(themeStorageKey, theme);
- return { theme };
+ if (state.themePreference !== "system") return state;
+ const next = systemTheme();
+ return next === state.resolvedTheme ? state : { resolvedTheme: next };
}),
toggleSidebar: () =>
set((state) => {
@@ -122,3 +135,7 @@ export const useUiStore = create((set) => ({
set((state) => ({ newTaskRequest: { projectId, nonce: (state.newTaskRequest?.nonce ?? 0) + 1 } })),
requestCreateProject: () => set((state) => ({ createProjectNonce: state.createProjectNonce + 1 })),
}));
+
+export function useResolvedTheme(): Theme {
+ return useUiStore((state) => state.resolvedTheme);
+}
diff --git a/frontend/src/renderer/styles.css b/frontend/src/renderer/styles.css
index 337b3b373b..b7e488752f 100644
--- a/frontend/src/renderer/styles.css
+++ b/frontend/src/renderer/styles.css
@@ -94,17 +94,26 @@
--color-input: var(--bridge-border);
--color-sidebar: var(--color-bg-sidebar);
--color-sidebar-foreground: var(--color-text-muted);
- --color-sidebar-primary: var(--bridge-accent);
- --color-sidebar-primary-foreground: var(--bridge-accent-fg);
--color-sidebar-accent: var(--color-bg-tertiary);
--color-sidebar-accent-foreground: var(--color-text-primary);
--color-sidebar-border: var(--bridge-border);
--color-sidebar-ring: var(--bridge-accent);
--color-terminal: var(--color-bg-terminal);
- --color-terminal-foreground: var(--color-text-terminal);
--color-terminal-dim: var(--color-text-terminal-dim);
--color-scrim: var(--bridge-scrim);
+ /* Settings page (Figma) */
+ --color-settings-panel: var(--color-bg-settings-panel);
+ --color-settings-row: var(--color-bg-settings-row);
+ --color-settings-menu: var(--color-bg-settings-menu);
+ --color-settings-menu-selected: var(--color-bg-settings-menu-selected);
+ --color-settings-title: var(--color-text-settings-title);
+ --color-settings-muted: var(--color-text-settings-muted);
+ --color-settings-label: var(--color-text-settings-row);
+ --radius-settings-row: var(--radius-settings-row);
+ --radius-settings-panel: var(--radius-settings-panel);
+ --spacing-settings-row: var(--size-settings-row);
+
/* Browser static preview (light mock inside dark app) */
--color-preview: var(--color-preview-bg);
--color-preview-foreground: var(--color-preview-fg);
@@ -118,7 +127,6 @@
--color-preview-tile-border: var(--bridge-preview-tile-border);
--color-preview-terminal: var(--bridge-preview-terminal);
--color-preview-terminal-border: var(--bridge-preview-terminal-border);
- --color-preview-terminal-foreground: var(--bridge-preview-terminal-fg);
/* Icon chrome — size-icon-* utilities */
--size-icon-2xs: var(--size-icon-2xs);
@@ -194,7 +202,6 @@
--color-muted-foreground: var(--color-text-muted);
--color-passive: var(--color-text-passive);
--color-secondary: var(--color-bg-tertiary);
- --color-secondary-foreground: var(--color-text-muted);
--color-primary: var(--bridge-accent);
--color-primary-foreground: var(--bridge-accent-fg);
@@ -223,6 +230,16 @@
--animate-modal-in: modal-in 150ms ease-out;
}
+/* Full-viewport modal/sheet scrim — dim + blur everything behind pop panels. */
+@utility dialog-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: var(--z-overlay);
+ background-color: var(--color-scrim);
+ -webkit-backdrop-filter: blur(var(--blur-overlay));
+ backdrop-filter: blur(var(--blur-overlay));
+}
+
@utility w-dialog-md {
width: min(var(--size-dialog-md), calc(100vw - var(--space-8)));
}
@@ -273,6 +290,127 @@
background-color: color-mix(in srgb, var(--color-bg-primary) 35%, transparent);
}
+@utility bg-settings-panel {
+ background-color: var(--color-bg-settings-panel);
+}
+
+@utility bg-settings-row {
+ background-color: var(--color-bg-settings-row);
+}
+
+@utility bg-settings-dialog {
+ background-color: var(--color-bg-settings-dialog);
+}
+
+@utility bg-settings-menu {
+ background-color: var(--color-bg-settings-menu);
+}
+
+@utility bg-settings-menu-selected {
+ background-color: var(--color-bg-settings-menu-selected);
+}
+
+@utility bg-settings-accent {
+ background-color: var(--color-settings-accent);
+}
+
+@utility bg-settings-switch-on {
+ background-color: var(--color-settings-switch-on);
+}
+
+@utility text-settings-title {
+ color: var(--color-text-settings-title);
+}
+
+@utility text-settings-muted {
+ color: var(--color-text-settings-muted);
+}
+
+@utility text-settings-label {
+ color: var(--color-text-settings-row);
+}
+
+@utility text-settings-heading {
+ font-size: var(--font-size-settings-title);
+ line-height: 36px;
+ letter-spacing: var(--tracking-settings-title);
+}
+
+@utility tracking-settings-section {
+ letter-spacing: var(--tracking-settings-section);
+}
+
+@utility tracking-settings-mono {
+ letter-spacing: var(--tracking-settings-mono);
+}
+
+@utility rounded-settings-panel {
+ border-radius: var(--radius-settings-panel);
+}
+
+@utility border-settings {
+ border-color: var(--color-border-settings);
+}
+
+@utility border-settings-menu {
+ border-color: var(--color-border-settings-menu);
+}
+
+@utility settings-row-bar {
+ display: flex;
+ height: var(--size-settings-row);
+ min-height: var(--size-settings-row);
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--size-settings-row-icon-gap);
+ border-radius: var(--radius-settings-row);
+ background-color: var(--color-bg-settings-row);
+ padding: var(--size-settings-row-padding);
+}
+
+/* Tailwind preflight clears button backgrounds; keep settings rows filled. */
+button.settings-row-bar {
+ background-color: var(--color-bg-settings-row);
+}
+
+@utility settings-menu-item {
+ display: flex;
+ align-items: center;
+ gap: calc(var(--spacing) * 2);
+ border-radius: var(--radius-settings-row);
+ border: 1px solid transparent;
+ padding-inline: var(--space-3);
+ padding-block: var(--space-2_5);
+ font-size: var(--font-size-md);
+ line-height: var(--line-height-row);
+ color: var(--color-text-settings-row);
+}
+
+@utility settings-option-trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: calc(var(--spacing) * 1.5);
+ border-radius: var(--radius-settings-row);
+ padding-inline: var(--space-1);
+ padding-block: var(--space-0_5);
+ font-size: var(--font-size-base);
+ color: var(--color-text-settings-muted);
+ outline: none;
+ box-shadow: none;
+ transition-property: color;
+ transition-duration: var(--duration-fast);
+}
+
+@utility settings-menu-surface {
+ min-width: 11rem;
+ width: max-content;
+ border-radius: var(--radius-settings-panel);
+ border: 1px solid var(--color-border-settings-menu);
+ background-color: var(--color-bg-settings-menu);
+ padding: var(--space-2);
+ gap: var(--space-1_5);
+}
+
* {
box-sizing: border-box;
}
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
index bc8db29ba1..318d65ea7e 100644
--- a/frontend/src/styles/tokens.css
+++ b/frontend/src/styles/tokens.css
@@ -29,7 +29,7 @@
--color-bg-secondary: #15171b;
--color-bg-tertiary: #1b1d22;
--color-bg-elevated: #212329;
- --color-bg-sidebar: #08090b;
+ --color-bg-sidebar: #17181c;
--color-bg-terminal: #15171b;
/* Text */
@@ -63,8 +63,8 @@
--color-overlay-subtle: rgb(255 255 255 / 0.018);
--color-overlay-faint: rgb(255 255 255 / 0.02);
--color-scrollbar: rgb(255 255 255 / 0.12);
- --color-scrollbar-hover: rgb(255 255 255 / 0.2);
--color-scrim: rgb(0 0 0 / 0.55);
+ --blur-overlay: 1px;
/* ═══════════════════════════════════════════════════════════════
TYPOGRAPHY
@@ -232,12 +232,78 @@
--size-textarea-min: 112px;
--size-sidebar-project-actions: 84px;
+ /* Settings page (Figma) */
+ --color-bg-settings-panel: #101013;
+ --color-bg-settings-row: #18181c;
+ --color-bg-settings-menu: #212121;
+ --color-bg-settings-menu-selected: rgb(66 66 66 / 0.78);
+ --color-bg-settings-dialog: #212121;
+ --color-text-settings-title: rgb(236 236 236);
+ --color-text-settings-muted: rgb(161 161 161);
+ --color-text-settings-row: rgb(236 236 236);
+ --color-border-settings: #212121;
+ --color-border-settings-menu: rgb(66 66 66 / 0.78);
+ --font-size-settings-title: 28px;
+ --tracking-settings-title: -0.015em;
+ --tracking-settings-section: 0.06em;
+ --tracking-settings-mono: 0.08em;
+ --radius-settings-panel: 17px;
+ --radius-settings-row: 12px;
+ --size-settings-row: 52px;
+ --size-settings-page-inset: 14px;
+ --size-settings-content-width: 768px;
+ --size-settings-panel-padding-top: 64px;
+ --size-settings-panel-padding-x: 32px;
+ --size-settings-panel-padding-bottom: 80px;
+ --size-settings-section-gap: 32px;
+ --size-settings-section-inner-gap: 12px;
+ --size-settings-row-padding: 16px;
+ --size-settings-row-icon-gap: 12px;
+ --size-settings-dialog: 575px;
+ --color-border-settings-dialog: #2d2d34;
+ --color-border-settings-dialog-header: #2d2d34;
+ --color-bg-settings-input: #2a2a2a;
+ --color-border-settings-input: #2d2d34;
+ --color-text-settings-input-label: #d1d5db;
+ --color-text-settings-input: #e5e7eb;
+ --color-text-settings-placeholder: #6b7280;
+ --color-bg-settings-select: #2a2a2a;
+ /* Settings accents (Figma — distinct from app --color-accent) */
+ --color-settings-accent: #4f8afa;
+ --color-settings-switch-on: #3b82f6;
+ /* Settings layout sizes (Figma) */
+ --size-settings-social-gap-x: 300px;
+ --size-settings-social-icon: 20px;
+ --size-settings-mobile-dialog: 336px;
+ --size-settings-mobile-dialog-pad-x: 17px;
+ --size-settings-mobile-desc: 306px;
+ --size-settings-mobile-qr: 220px;
+ --size-settings-mobile-qr-code: 204px;
+ --size-settings-mobile-warning: 290px;
+ --size-settings-mobile-label: 55px;
+ --size-settings-mobile-regen-width: 179px;
+ --size-settings-mobile-switch-w: 42px;
+ --size-settings-mobile-switch-h: 26px;
+ --size-settings-mobile-details-pad-x: 19px;
+ --size-settings-mobile-switch-travel: 18px;
+ --leading-settings-mobile-title: 22px;
+ --leading-settings-mobile-hint: 14px;
+ --leading-settings-mobile-warning: 15px;
+ --size-settings-action-height: 38px;
+ --size-settings-report-select: 257px;
+ --radius-settings-action: 12px;
+ --radius-settings-input: 6px;
+ --radius-settings-dialog-lg: 16px;
+ --shadow-settings-dialog: 0px 20px 25px -5px rgba(0, 0, 0, 0.1), 0px 8px 10px -6px rgba(0, 0, 0, 0.1);
+ --shadow-settings-qr: 0px 10px 15px -3px rgba(0, 0, 0, 0.1), 0px 4px 6px -4px rgba(0, 0, 0, 0.1);
+ --shadow-settings-field: 0 1px 2px rgba(0, 0, 0, 0.05);
+ --shadow-settings-dialog-ring: 0 0 0 1px rgba(0, 0, 0, 0.05);
+
--size-empty-offset-y: 5vh; /* board empty-state vertical nudge */
/* Browser static preview (light mock page inside dark app) */
--color-preview-bg: #f7f8fb;
--color-preview-fg: #17202a;
- --color-preview-border: #dfe4ea;
--color-preview-muted: #687384;
--color-preview-link: #2f5b9d;
--color-preview-card-border: #d7dee8;
@@ -355,11 +421,32 @@
--color-overlay-subtle: rgb(0 0 0 / 0.02);
--color-overlay-faint: rgb(0 0 0 / 0.03);
--color-scrollbar: rgb(0 0 0 / 0.12);
- --color-scrollbar-hover: rgb(0 0 0 / 0.2);
--color-scrim: rgb(0 0 0 / 0.45);
--elevation-sm: 0 1px 0 rgb(0 0 0 / 0.06);
--elevation-md: 0 1px 0 rgb(0 0 0 / 0.03), 0 12px 30px rgb(20 30 50 / 0.1);
--elevation-lg: 0 16px 48px rgb(20 30 50 / 0.16);
--elevation-xl: 0 1px 0 rgb(0 0 0 / 0.03), 0 20px 50px rgb(20 30 50 / 0.14);
+
+ --color-bg-settings-panel: #ffffff;
+ --color-bg-settings-row: #f3f3f4;
+ --color-bg-settings-menu: #f3f3f4;
+ --color-bg-settings-menu-selected: rgb(0 0 0 / 0.08);
+ --color-bg-settings-dialog: #ffffff;
+ --color-text-settings-title: rgb(26 26 26);
+ --color-text-settings-muted: rgb(102 102 102);
+ --color-text-settings-row: rgb(26 26 26);
+ --color-border-settings: #d4d4d6;
+ --color-border-settings-menu: rgb(0 0 0 / 0.12);
+ --color-border-settings-dialog: #d4d4d6;
+ --color-border-settings-dialog-header: #e5e5e7;
+ --color-bg-settings-input: #ffffff;
+ --color-border-settings-input: #d4d4d6;
+ --color-text-settings-input-label: #374151;
+ --color-text-settings-input: #1a1a1a;
+ --color-text-settings-placeholder: #9ca3af;
+ --color-bg-settings-select: #ffffff;
+ /* Settings accents stay brand-blue in light mode (same Figma values). */
+ --color-settings-accent: #4f8afa;
+ --color-settings-switch-on: #3b82f6;
}