diff --git a/apps/desktop/src/acp/mock.ts b/apps/desktop/src/acp/mock.ts index 5d67d4227..a8c3234ba 100644 --- a/apps/desktop/src/acp/mock.ts +++ b/apps/desktop/src/acp/mock.ts @@ -3817,6 +3817,8 @@ export class MockEngine { case "_rustcode/pulls/merge/cancel": return { cancelled: false }; case "_rustcode/pulls/merge": + // Long enough for the panel's "Waiting for checks" to show. + if (obj(params)?.mergeId) await new Promise((r) => setTimeout(r, 2500)); return { merge: { number: num(obj(params)?.number) ?? 2790, @@ -3991,7 +3993,8 @@ export class MockEngine { title: "feat(desktop): Tauri desktop app on every harness", author: "LHagfoss", state: "open", - draft: true, + // #2788 is the draft the list shows; any other opens ready to merge. + draft: params.number === 2788, checks: { passed: 5, failed: 0, pending: 1, skipped: 0 }, headRef: "feat/desktop-tauri-harnesses", baseRef: "main", @@ -4003,7 +4006,12 @@ export class MockEngine { body: "Adds `apps/desktop`: a Tauri shell around `rustcode --acp`.\n\n- Composer, approvals, harness pickers\n- Link chips and Smart UI", checkRuns: [ { name: "desktop / check", status: "passed" }, + { name: "lint", status: "passed" }, + { name: "label", status: "passed" }, + { name: "Desktop UI", status: "passed" }, { name: "rust / test", status: "pending" }, + // A matrix job GitHub skipped before it expanded: the template stays in its name. + { name: ["Desktop shell (", "$", "{{ matrix.name }})"].join(""), status: "pending" }, ], files: [ { path: "apps/desktop/src/ui/Composer.tsx", additions: 4, deletions: 2 }, @@ -7016,6 +7024,8 @@ export class MockTransport implements Transport { if (command === "link_meta") return mockLinkMeta(String(args.url)); if (command === "server_status") return this.serverStatus(); if (command === "system_info") return MOCK_SYSTEM_INFO; + // Settings › About › System setup: a finished install, one step not needed. + if (command === "system_setup_status" || command === "system_setup_run") return MOCK_SYSTEM_SETUP; if (command === "resolve_binaries") return (args.names as string[]).map((name) => ["codex", "claude-agent-acp", "gemini", "opencode"].includes(name) @@ -7238,3 +7248,57 @@ function mockGit(threads: readonly { worktree?: MockWorktree }[], path: string): mockGitStates.set(path, state); return state; } + +/** Settings › About › System setup in the mock: the macOS steps of a finished install. */ +const MOCK_SYSTEM_SETUP = [ + { + id: "quarantine", + title: "Clear the download quarantine flag", + about: + "macOS marks a downloaded app as quarantined, which can make its command line prompt or fail the first time. This clears the flag on RustCode.", + auto: true, + status: "done", + message: "No quarantine flag on RustCode.", + lastRun: "2026-11-14T09:00:00Z", + }, + { + id: "signature", + title: "Keep one signature across updates", + about: + "Releases are signed ad hoc, so macOS sees every update as a new app and asks again for Keychain access and Background Items. A local certificate gives updates one identity.", + auto: true, + status: "done", + message: "RustCode has a signature that stays the same across updates.", + lastRun: "2026-11-14T09:00:00Z", + }, + { + id: "cli-link", + title: "Put `rustcode` on your PATH", + about: + "Links `rustcode` into ~/.local/bin when no copy is on your PATH, so a terminal runs the same version as the app.", + auto: true, + status: "done", + message: "`rustcode` is at /Users/lars/.cargo/bin/rustcode.", + lastRun: "2026-11-14T09:00:00Z", + }, + { + id: "shell-path", + title: "Check ~/.local/bin is on your shell's PATH", + about: + "Reads your login shell's PATH. RustCode never edits your shell profile; when the folder is missing it shows the line to add.", + auto: true, + status: "skipped", + message: "`rustcode` is not linked into ~/.local/bin, so there is nothing to add.", + lastRun: "2026-11-14T09:00:00Z", + }, + { + id: "background-server", + title: "Run the RustCode server in the background", + about: + "Installs the server as a LaunchAgent, systemd user unit or logon task, so paired machines reach this computer with the app closed. See Machines.", + auto: false, + status: "done", + message: "The server starts when you log in.", + lastRun: "2026-11-14T09:00:00Z", + }, +]; diff --git a/apps/desktop/src/state/headerButtons.test.ts b/apps/desktop/src/state/headerButtons.test.ts index 44f2756f6..2d307ae04 100644 --- a/apps/desktop/src/state/headerButtons.test.ts +++ b/apps/desktop/src/state/headerButtons.test.ts @@ -11,13 +11,10 @@ import { const ids = (...list: string[]) => list as HeaderButtonId[]; describe("header buttons sanitising", () => { - test("pull requests are hidden by default: ⌘K holds that action", () => { - expect(DEFAULT_HEADER_BUTTONS).not.toContain("pulls"); - expect(DEFAULT_HEADER_BUTTONS).toEqual( - ids("status", "plan", "git", "actions", "terminal", "details", "changes"), - ); - // Still a button the setting lists, so it can be shown. - expect(HEADER_BUTTON_IDS).toContain("pulls"); + test("the hand-driven git buttons are hidden by default", () => { + expect(DEFAULT_HEADER_BUTTONS).toEqual(ids("status", "plan", "terminal", "details")); + // Still buttons the setting lists, so each can be shown. + for (const id of ids("git", "actions", "pulls", "changes")) expect(HEADER_BUTTON_IDS).toContain(id); }); test("a layout saved with Pull requests shown keeps it", () => { diff --git a/apps/desktop/src/state/headerButtons.ts b/apps/desktop/src/state/headerButtons.ts index 33725be25..d74766d80 100644 --- a/apps/desktop/src/state/headerButtons.ts +++ b/apps/desktop/src/state/headerButtons.ts @@ -22,13 +22,22 @@ export const HEADER_BUTTON_IDS = [ export type HeaderButtonId = (typeof HEADER_BUTTON_IDS)[number]; /** - * What a fresh install and a reset start from: every button in registry order but Pull requests, - * which ⌘K holds ("Open Git"; still one click away in Settings › Chat › Thread - * header buttons). A layout the user saved is never touched by this: `sanitizeHeaderButtons` keeps - * what was stored, so a user who shows the button keeps it. + * What a fresh install and a reset start from: status, plan, terminal and the + * panel. The buttons for driving git by hand stay one click away in Settings › + * Chat › Thread header buttons: Git (commit, push, open a pull request: agents + * do that), Project actions, Changes (the panel's Changes tab holds them) and + * Pull requests (⌘K "Open Git", and the panel's tab). A layout the user saved + * is never touched by this: `sanitizeHeaderButtons` keeps what was stored. */ +export const HIDDEN_BY_DEFAULT: ReadonlySet = new Set([ + "git", + "actions", + "pulls", + "changes", +]); + export const DEFAULT_HEADER_BUTTONS: readonly HeaderButtonId[] = HEADER_BUTTON_IDS.filter( - (id) => id !== "pulls", + (id) => !HIDDEN_BY_DEFAULT.has(id), ); /** What `rustcode.headerButtons` holds. */ diff --git a/apps/desktop/src/state/pullMerge.test.ts b/apps/desktop/src/state/pullMerge.test.ts new file mode 100644 index 000000000..6eb612a1e --- /dev/null +++ b/apps/desktop/src/state/pullMerge.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { checkName, checksHeadline, mergeControl } from "./pullMerge"; + +const checks = (passed = 0, failed = 0, pending = 0) => ({ passed, failed, pending, skipped: 0 }); + +describe("merge control", () => { + test("follows where the pull stands", () => { + expect(mergeControl({ state: "open", draft: false, checks: checks(6) })).toEqual({ kind: "merge" }); + expect(mergeControl({ state: "open", draft: false, checks: checks() })).toEqual({ kind: "merge" }); + expect(mergeControl({ state: "open", draft: false, checks: checks(6, 0, 3) })).toEqual({ + kind: "merge_when_green", + pending: 3, + }); + expect(mergeControl({ state: "open", draft: false, checks: checks(6, 2, 1) })).toEqual({ + kind: "blocked", + failed: 2, + }); + }); + + test("offers nothing for a draft or a finished pull", () => { + expect(mergeControl({ state: "open", draft: true, checks: checks(6) })).toEqual({ kind: "none" }); + expect(mergeControl({ state: "merged", draft: false, checks: checks(6) })).toEqual({ kind: "none" }); + expect(mergeControl({ state: "closed", draft: false, checks: checks(6) })).toEqual({ kind: "none" }); + }); +}); + +describe("check names", () => { + // GitHub's expression syntax, built so it does not read as a template literal. + const expr = (name: string) => ["$", "{{ ", name, " }}"].join(""); + test("drop an unexpanded matrix template", () => { + expect(checkName(`Desktop shell (${expr("matrix.name")})`)).toBe("Desktop shell"); + expect(checkName(`${expr("matrix.name")} portability coverage`)).toBe("portability coverage"); + expect(checkName(expr("matrix.os"))).toBe("Job"); + expect(checkName("lint")).toBe("lint"); + }); + + test("headline says what needs attention first", () => { + const run = (status: string) => ({ name: "x", status }); + expect(checksHeadline([run("passed"), run("failed"), run("pending")])).toBe("1 check failed"); + expect(checksHeadline([run("passed"), run("pending"), run("pending")])).toBe("2 checks running"); + expect(checksHeadline([run("passed"), run("skipped")])).toBe("Checks passed"); + expect(checksHeadline([])).toBe("No checks"); + }); +}); diff --git a/apps/desktop/src/state/pullMerge.ts b/apps/desktop/src/state/pullMerge.ts new file mode 100644 index 000000000..764a0619a --- /dev/null +++ b/apps/desktop/src/state/pullMerge.ts @@ -0,0 +1,124 @@ +// Merging a pull request from its view in the right panel. The engine waits for +// the checks and squash-merges (`_rustcode/pulls/merge`, the path agents use +// through `merge_when_green`), with scripts/pr-merge.sh's rules: advisory jobs +// are ignored and a failing check stops it. A merge outlives the panel: it is +// kept here, by project and number, so the view shows it again when reopened. + +import { useSyncExternalStore } from "react"; +import { type PullRequest, parseMergeOutcome, parsePullError, pullErrorTitle } from "../acp/rustcode"; +import { Method } from "../acp/types"; +import { PULL_LIMIT, readPullDetail, readPullList } from "./pullQueries"; +import { request, toast } from "./store"; + +/** How long a merge waits for checks: the same 25 minutes `merge_when_green` waits at most. */ +export const MERGE_WAIT_MS = 25 * 60_000; + +/** + * The view's one prominent control, from where the pull stands: nothing for a + * draft or a finished pull, "merge" once every check passed (or there are none), + * "merge_when_green" while checks run, and "blocked" with the failing count. + */ +export type MergeControl = + | { kind: "none" } + | { kind: "merge" } + | { kind: "merge_when_green"; pending: number } + | { kind: "blocked"; failed: number }; + +export function mergeControl(pull: Pick): MergeControl { + if (pull.state !== "open" || pull.draft) return { kind: "none" }; + if (pull.checks.failed > 0) return { kind: "blocked", failed: pull.checks.failed }; + if (pull.checks.pending > 0) return { kind: "merge_when_green", pending: pull.checks.pending }; + return { kind: "merge" }; +} + +/** + * A check's name as people read it. A job GitHub skipped before it expanded its + * matrix keeps the template in its name (`Desktop shell (${{ matrix.name }})`): + * the template goes, and a name that was only the template becomes "Job". + */ +export function checkName(name: string): string { + const cleaned = name + .replace(/\s*\(\s*\$\{\{[^}]*\}\}\s*\)/g, "") + .replace(/\$\{\{[^}]*\}\}\s*/g, "") + .replace(/\s+/g, " ") + .trim(); + return cleaned || "Job"; +} + +type CheckRun = { name: string; status: string }; + +/** The checks' headline: what needs the user first, then that everything passed. */ +export function checksHeadline(runs: readonly CheckRun[]): string { + const failed = runs.filter((c) => c.status === "failed").length; + const pending = runs.filter( + (c) => c.status !== "failed" && c.status !== "passed" && c.status !== "skipped", + ).length; + if (failed) return `${failed} ${failed === 1 ? "check failed" : "checks failed"}`; + if (pending) return `${pending} ${pending === 1 ? "check running" : "checks running"}`; + return runs.length ? "Checks passed" : "No checks"; +} + +const running = new Map(); +const listeners = new Set<() => void>(); +let version = 0; + +const keyOf = (cwd: string, number: number) => `${cwd}#${number}`; +const changed = () => { + version++; + for (const listener of listeners) listener(); +}; +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +/** Whether a merge of this pull is waiting or running. */ +export function useMerging(cwd: string, number: number): boolean { + useSyncExternalStore(subscribe, () => version); + return running.has(keyOf(cwd, number)); +} + +/** Stops a merge still waiting for checks; one already merging finishes. */ +export function cancelMerge(cwd: string, number: number): void { + const mergeId = running.get(keyOf(cwd, number)); + if (!mergeId) return; + void request(Method.pullMergeCancel, { mergeId }, 10_000).catch(() => {}); +} + +/** Waits for the pull's checks and squash-merges it, then reads it again; the outcome is a toast. */ +export async function mergePull(cwd: string, pull: Pick): Promise { + const key = keyOf(cwd, pull.number); + if (running.has(key)) return; + const mergeId = `panel-${pull.number}-${Date.now().toString(36)}`; + running.set(key, mergeId); + changed(); + try { + const result = await request( + Method.pullMerge, + { cwd, number: pull.number, branch: pull.headRef, timeoutMs: MERGE_WAIT_MS, mergeId }, + MERGE_WAIT_MS + 120_000, + ); + const error = parsePullError(result); + const outcome = error ? null : parseMergeOutcome(result); + if (error) toast(`${pullErrorTitle(error.code)}: ${error.message}`, "error", { persist: true }); + else if (!outcome) toast(`#${pull.number} did not merge: no answer`, "error", { persist: true }); + else if (outcome.merged) toast(`Merged #${pull.number}`, "success"); + else if (outcome.failing.length) + toast(`#${pull.number} not merged: ${outcome.failing.join(", ")} failed`, "error", { persist: true }); + else toast(outcome.message || `#${pull.number} not merged (${outcome.state})`, "info"); + } catch (error) { + cancelMerge(cwd, pull.number); + toast( + `#${pull.number} did not merge: ${error instanceof Error ? error.message : String(error)}`, + "error", + { + persist: true, + }, + ); + } finally { + running.delete(key); + changed(); + void readPullDetail(cwd, pull.number, { force: true }); + void readPullList(cwd, "all", { limit: PULL_LIMIT, force: true }); + } +} diff --git a/apps/desktop/src/ui/ChatBackgroundGroup.tsx b/apps/desktop/src/ui/ChatBackgroundGroup.tsx index 9dbc1be7c..ea6f0bdf2 100644 --- a/apps/desktop/src/ui/ChatBackgroundGroup.tsx +++ b/apps/desktop/src/ui/ChatBackgroundGroup.tsx @@ -66,7 +66,7 @@ export function ChatBackgroundGroup() { >
{banner.name && void removeBanner()}>Remove} - {banner.name ? "Change…" : "Choose image…"} + {banner.name ? "Change image" : "Choose image"}
{banner.name && ( diff --git a/apps/desktop/src/ui/DiagnosticsPage.tsx b/apps/desktop/src/ui/DiagnosticsPage.tsx index 1f794d429..6fb455a7a 100644 --- a/apps/desktop/src/ui/DiagnosticsPage.tsx +++ b/apps/desktop/src/ui/DiagnosticsPage.tsx @@ -247,17 +247,19 @@ export function DiagnosticsPage() { <> - + void saveReport()}> - Save… + Save to file void copyReport()}> - Copy debug report + Copy report @@ -270,8 +272,8 @@ export function DiagnosticsPage() { {SERVER_STATE_LABEL[state]} } - subtitle="Runs every thread. The app starts it again when it crashes." - status={detail ?? TRANSPORT_LABEL[transport ?? ""]} + hint="Runs every thread. The app starts it again when it crashes." + subtitle={detail ?? TRANSPORT_LABEL[transport ?? ""]} > {shell && ( - + {harnesses.length ? ( harnesses.map((h) => (
@@ -423,8 +425,7 @@ function ServerLog() { return (
)} - + void connect()}> {busy ? "Connecting…" : "Connect app"} diff --git a/apps/desktop/src/ui/OverviewDeliverables.tsx b/apps/desktop/src/ui/OverviewDeliverables.tsx index 389832552..d27352930 100644 --- a/apps/desktop/src/ui/OverviewDeliverables.tsx +++ b/apps/desktop/src/ui/OverviewDeliverables.tsx @@ -125,6 +125,9 @@ export function DeliverablesSection({ writeDeliverableMode(next); }; + // Nothing delivered yet: the section waits until there is something to show. + if (total === 0) return null; + return ( )} - {total === 0 && ( -

- Nothing yet: the pictures its replies show and the files it writes land here. -

- )}
); } diff --git a/apps/desktop/src/ui/OverviewLog.tsx b/apps/desktop/src/ui/OverviewLog.tsx index d957e59fc..48ec22ec8 100644 --- a/apps/desktop/src/ui/OverviewLog.tsx +++ b/apps/desktop/src/ui/OverviewLog.tsx @@ -1,5 +1,5 @@ // The overview's Execution log: what the thread spent and how long it worked, -// a bar for each turn, then the last few turns themselves — and, behind the +// a bar for each turn once there are a few to compare, then the last few turns themselves — and, behind the // header's expand, the whole log with each turn's own waterfall (ui/TurnTrace.tsx) // where the app watched one. The turns come from the transcript the thread // already holds, so the panel reads no transcript again (ui/overview.ts). @@ -12,13 +12,15 @@ import { formatDuration, formatTokens, type TranscriptItem } from "../state/thre import { HarnessIcon } from "./HarnessIcon"; import { OverviewSection, SectionButton } from "./OverviewSection"; import { logTotals, type TurnEntry, turnEntries, turnHasTrace } from "./overview"; -import { cx, Tooltip } from "./primitives"; +import { cx, Pill, Tooltip } from "./primitives"; import { TurnTrace } from "./TurnTrace"; /** How many turns the section lists before the header's expand shows them all. */ export const LOG_ROWS = 3; /** How many bars the strip draws: a thread with a hundred turns keeps its shape, not every bar. */ export const LOG_BARS = 24; +/** Fewer turns than this draw no bars: one or two bars compare nothing. */ +export const MIN_BARS = 4; /** The turns' rows, before the full log is opened and needs them. */ const NO_TURNS: Record = {}; @@ -167,14 +169,13 @@ export function ExecutionLogSection({ {turns.length > 0 ? ( <>
- - - 0 ? formatDuration(totals.agentMs) : "—"} /> + {totals.spent !== undefined && ( + + )} + {totals.tokens !== undefined && } + {totals.agentMs > 0 && }
- + {turns.length >= MIN_BARS && }
{shown.map((turn) => { const end = ends.get(turn.id); @@ -193,9 +194,9 @@ export function ExecutionLogSection({ })}
{!all && more && ( -

- {turns.length - shown.length} more behind the expand. -

+ setAll(true)}> + Show all {turns.length} turns + )} ) : ( diff --git a/apps/desktop/src/ui/OverviewProperties.tsx b/apps/desktop/src/ui/OverviewProperties.tsx index cd4908e6e..334ba040a 100644 --- a/apps/desktop/src/ui/OverviewProperties.tsx +++ b/apps/desktop/src/ui/OverviewProperties.tsx @@ -1,7 +1,8 @@ // The overview's first section: what this thread is and where it works // (ui/ThreadDetails.tsx draws every section). A label/value list with a mark // for each fact — the status it is in, the agent and model it runs on, the -// project and branch it works in, the effort and access it was given — and the +// project and branch it works in, the effort and access it was given when it +// chose them rather than a default — and the // rows that do something are the ones with an act behind them: the project // opens the file manager, the branch copies itself. @@ -129,28 +130,31 @@ export function PropertiesSection({ return ( - } - label="Status" - title={ - waiting - ? waitingTitle(waiting) - ? `${waitingLabel(waiting)}: ${waitingTitle(waiting)}` - : waitingLabel(waiting) - : undefined - } - value={ - status !== "idle" ? ( - - - - ) : waiting ? ( - Waiting - ) : ( - Idle - ) - } - /> + {/* An idle thread says nothing here: the header's status already does. */} + {(status !== "idle" || waiting) && ( + } + label="Status" + title={ + waiting + ? waitingTitle(waiting) + ? `${waitingLabel(waiting)}: ${waitingTitle(waiting)}` + : waitingLabel(waiting) + : undefined + } + value={ + status !== "idle" ? ( + + + + ) : waiting ? ( + Waiting + ) : ( + Idle + ) + } + /> + )} {thread.harness && ( } @@ -184,25 +188,17 @@ export function PropertiesSection({ /> )} {base && } - } - label="Effort" - // A thread that sets none runs on the harness's own default, which reads quieter. - muted={!effort} - value={effort ?? "Default"} - title={effort ? undefined : "This thread sets no effort: its harness chooses."} - /> - } - label="Access" - title={ - inheritedAccess - ? "This thread keeps the default access from Settings › General." - : "Chosen for this thread, under its composer." - } - muted={inheritedAccess} - value={inheritedAccess ? `${modeLabel(mode)} (default)` : modeLabel(mode)} - /> + {/* A thread that sets no effort runs on its harness's default: nothing to say. */} + {effort && } label="Effort" value={effort} />} + {/* Access shows when the thread chose its own; the default from Settings › General goes unsaid. */} + {!inheritedAccess && ( + } + label="Access" + title="Chosen for this thread, under its composer." + value={modeLabel(mode)} + /> + )} ); } diff --git a/apps/desktop/src/ui/PermissionsPage.tsx b/apps/desktop/src/ui/PermissionsPage.tsx index 92755fdb4..b02d93897 100644 --- a/apps/desktop/src/ui/PermissionsPage.tsx +++ b/apps/desktop/src/ui/PermissionsPage.tsx @@ -54,7 +54,7 @@ export function PermissionsPage() { {ALWAYS_ASKS.map((item) => ( @@ -230,7 +230,7 @@ function RememberedGroup() { className="text-accent hover:underline" onClick={() => openSettings("orchestration")} > - “Let delegates use my named accounts without asking” in Orchestration… + “Let delegates use my named accounts without asking” in Orchestration } diff --git a/apps/desktop/src/ui/ProjectGenerators.tsx b/apps/desktop/src/ui/ProjectGenerators.tsx index 3372bda80..35bc4a018 100644 --- a/apps/desktop/src/ui/ProjectGenerators.tsx +++ b/apps/desktop/src/ui/ProjectGenerators.tsx @@ -114,7 +114,7 @@ export function ProjectGenerators({ project }: { project: { name: string; path: return ( diff --git a/apps/desktop/src/ui/PullRequests.tsx b/apps/desktop/src/ui/PullRequests.tsx index fd88f0711..2fc6fc4c7 100644 --- a/apps/desktop/src/ui/PullRequests.tsx +++ b/apps/desktop/src/ui/PullRequests.tsx @@ -6,17 +6,22 @@ import { ArrowLeft, + Bot, + ChevronRight, CircleCheck, CircleDashed, CirclePlus, CircleX, ExternalLink, GitBranchPlus, + GitMerge, GitPullRequest, + MessageSquare, + MoreHorizontal, RefreshCw, } from "lucide-react"; -import { Dialog } from "radix-ui"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { Dialog, DropdownMenu } from "radix-ui"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { JsonObject } from "../acp/jsonrpc"; import { type PullDetail, @@ -31,6 +36,14 @@ import { useIntentRest } from "../lib/intent"; import { openExternal } from "../lib/native"; import { count } from "../lib/plural"; import { type FeedbackItem, feedbackItems, feedbackLocation, keepSelected } from "../lib/reviewFeedback"; +import { + cancelMerge, + checkName, + checksHeadline, + mergeControl, + mergePull, + useMerging, +} from "../state/pullMerge"; import { PULL_LIMIT, type PullListState, @@ -67,11 +80,14 @@ import { Cluster, ClusterButton, cx, + MenuContent, + menuItem, noTextHelpers, Pill, Segments, Skeleton, Spinner, + Tooltip, } from "./primitives"; /** @@ -312,6 +328,118 @@ function FeedbackSection({ detail, pull }: { detail: PullDetail; pull: PullReque ); } +/** How long a pressed merge waits for the second press that confirms it. */ +const CONFIRM_MS = 4000; + +/** + * The view's one prominent control (state/pullMerge.ts): Merge once the checks + * passed, Merge when checks pass while they run, nothing for a draft or a + * finished pull. The first press arms it ("Confirm merge?"), the second merges; + * a waiting merge shows here, with a way to stop it, until it ends. + */ +function MergeButton({ cwd, pull }: { cwd: string; pull: PullRequest }) { + const merging = useMerging(cwd, pull.number); + const [armed, setArmed] = useState(false); + const timer = useRef>(undefined); + useEffect(() => () => clearTimeout(timer.current), []); + const control = mergeControl(pull); + if (merging) + return ( + + + + {control.kind === "merge_when_green" ? "Waiting for checks" : "Merging"} + + cancelMerge(cwd, pull.number)}> + Cancel + + + ); + if (control.kind === "none") return null; + if (control.kind === "blocked") + return ( + + + + {count(control.failed, "check")} failed + + + + ); + const label = control.kind === "merge" ? "Merge" : "Merge when checks pass"; + const press = () => { + clearTimeout(timer.current); + if (!armed) { + setArmed(true); + timer.current = setTimeout(() => setArmed(false), CONFIRM_MS); + return; + } + setArmed(false); + void mergePull(cwd, pull); + }; + return ( + + setArmed(false)}> + {armed ? "Confirm merge?" : label} + + + ); +} + +/** + * The pull's checks under one headline. While something failed or runs, those + * rows show and the passed ones fold behind a line; when all passed, the list + * folds whole behind the headline. + */ +function ChecksSection({ runs }: { runs: PullDetail["checkRuns"] }) { + const [open, setOpen] = useState(false); + const attention = runs.filter((c) => c.status !== "passed" && c.status !== "skipped"); + const rest = runs.filter((c) => c.status === "passed" || c.status === "skipped"); + const shown = open ? [...attention, ...rest] : attention; + return ( +
+ + {shown.map((c, i) => ( +
+ {c.status === "passed" ? ( + + ) : c.status === "failed" ? ( + + ) : ( + + )} + {c.url ? ( + + ) : ( + {checkName(c.name)} + )} +
+ ))} +
+ ); +} + function PullView({ cwd, host, @@ -351,39 +479,50 @@ function PullView({

{summary.title} #{summary.number}

-
+
{STATE_LABEL[pullState(summary)]} + + + {count(summary.changedFiles, "file")}{" "} + +{summary.additions}{" "} + −{summary.deletions} + +
-

- {summary.author} wants to merge {summary.headRef} into{" "} - {summary.baseRef} · {summary.changedFiles} files{" "} - +{summary.additions}{" "} - −{summary.deletions} -

-
- {summary.url && ( - openExternal(summary.url!)}> - GitHub - - )} - askAgent(summary)}>Ask the agent to review - {!host && summary.state === "open" && ( - setWorking(true)}> - Work on this in a thread - - )} - {!reviewing && ( - setReviewing(true)} - // A review names the head commit: it waits for a read this session, - // not for a row or a saved copy that may be behind the branch. - disabled={!live || !repositoryOf(summary.url) || pullState(summary) !== "open"} - > - Review… - - )} +
+ + + + + + + + + {summary.url && ( + openExternal(summary.url!)}> + Open on GitHub + + )} + askAgent(summary)}> + Ask an agent to review + + {!host && summary.state === "open" && ( + setWorking(true)}> + Work on this in a thread + + )} + setReviewing(true)} + > + Write a review + + +
{reviewing && setReviewing(false)} />} {working && ( @@ -421,23 +560,7 @@ function PullView({
)} - {detail.checkRuns.length > 0 && ( -
-

Checks

- {detail.checkRuns.map((c, i) => ( -
- {c.status === "passed" ? ( - - ) : c.status === "failed" ? ( - - ) : ( - - )} - {c.name} -
- ))} -
- )} + {detail.checkRuns.length > 0 && } {detail.files.length > 0 && (

Files

diff --git a/apps/desktop/src/ui/RemoteAccessPage.tsx b/apps/desktop/src/ui/RemoteAccessPage.tsx index 151780354..e1df5762b 100644 --- a/apps/desktop/src/ui/RemoteAccessPage.tsx +++ b/apps/desktop/src/ui/RemoteAccessPage.tsx @@ -178,11 +178,12 @@ export function RemoteAccessPage() { - {serverLine(server)} + {server && !server.installed ? {serverLine(server)} : null} {server?.gateway.remoteEnabled || server?.gateway.running ? ( - Gateway: {gatewayLine(server.gateway, server.installed)} + Gateway: {gatewayLine(server.gateway, server.installed)} ) : null} {server?.problems.map((problem) => ( @@ -220,7 +222,8 @@ export function RemoteAccessPage() { @@ -251,7 +254,8 @@ export function RemoteAccessPage() { {busy === "pair" ? : } - Pair a device + Show pairing code @@ -309,7 +313,7 @@ export function RemoteAccessPage() { {devices.length ? ( devices.map((device) => ( @@ -317,7 +321,7 @@ export function RemoteAccessPage() { key={device.id} title={device.name} subtitle={`${deviceLabel(device.kind, device.platform)} · ${lastSeenLabel(device, now)}`} - status={connectionDirection( + hint={connectionDirection( peerOutbound(device.hostId, machines), device.connected === true, device.name, @@ -340,14 +344,14 @@ export function RemoteAccessPage() { ) : ( )} {gateway?.routes && gateway.routes.length > 0 ? ( gateway.routes.map((route) => ( @@ -410,7 +414,7 @@ export function RefusedConnectionsGroup({ refused }: { refused: RemoteRefusedCon return ( {refused.map((entry) => { const copy = refusalCopy(entry); diff --git a/apps/desktop/src/ui/Settings.tsx b/apps/desktop/src/ui/Settings.tsx index 188e91374..6c6cc92ef 100644 --- a/apps/desktop/src/ui/Settings.tsx +++ b/apps/desktop/src/ui/Settings.tsx @@ -331,18 +331,18 @@ function GeneralPage() { status={defaultCwd || "None chosen"} > void pickFolder(defaultCwd).then((p) => p && setDefaultCwd(p))}> - Choose… + {defaultCwd ? "Change folder" : "Choose folder"} - openSettings("agents")}>Harnesses… + openSettings("agents")}>Open Harnesses - openSettings("permissions")}>Permissions… + openSettings("permissions")}>Open Permissions @@ -679,7 +679,7 @@ function AgentsPage() { ) : undefined } - footer="A thread can still switch harness and model from the composer." + hint="A thread can still switch harness and model from the composer." > {scoped.project && (
@@ -1351,7 +1351,7 @@ function AppearancePage() { )} setCreating(!creating)} aria-expanded={creating}> - New theme… + New theme
} @@ -1559,7 +1559,7 @@ function RecordRequestBodiesGroup() { return ( - - - openSettings("diagnostics")}>Open Diagnostics - - - - setOnboarding(true)}>Run setup again + + setOnboarding(true)}>Run setup @@ -1628,7 +1620,7 @@ function AdvancedPage() { function AboutPage() { return ( <> - + @@ -1668,9 +1660,8 @@ function SystemSetupGroup() { return ( void run()}> {running === "all" ? : null} @@ -1688,8 +1679,8 @@ function SystemSetupGroup() { {chip.label}} > {command && ( @@ -1701,10 +1692,13 @@ function SystemSetupGroup() { Copy command )} - void run(step.id)}> - {running === step.id ? : null} - {step.auto ? "Run again" : step.status === "done" ? "Check again" : "Set up"} - + {/* A step that stands needs no button of its own: the section's Run again checks it. */} + {(step.status === "pending" || step.status === "failed" || running === step.id) && ( + void run(step.id)}> + {running === step.id ? : null} + {step.status === "failed" ? "Try again" : "Set up"} + + )} ); }) diff --git a/apps/desktop/src/ui/SettingsScope.tsx b/apps/desktop/src/ui/SettingsScope.tsx index 83ee880b7..ca6a5e70f 100644 --- a/apps/desktop/src/ui/SettingsScope.tsx +++ b/apps/desktop/src/ui/SettingsScope.tsx @@ -18,7 +18,7 @@ import { useScopeProjects, } from "../state/settingsScope"; import { Tooltip } from "./primitives"; -import { Action, Select } from "./settingsKit"; +import { Action, HintTip, Select } from "./settingsKit"; const THIS = "this"; const ALL = "all"; @@ -64,14 +64,20 @@ export function SettingsScopeBar({ machineOnly = false }: { machineOnly?: boolea
-
{machineOnly ? "Showing" : "Applying to"}
-
- {machineOnly - ? "These settings belong to the machine you pick." - : project - ? `Rows a project can set change for ${project.name} only. The rest apply to every project.` - : "Server settings change on the machine you pick. App preferences stay here."} +
+ {machineOnly ? "Showing" : "Applying to"} + + {machineOnly + ? "These settings belong to the machine you pick." + : "Server settings change on the machine you pick. App preferences stay on this computer. A project changes only the rows a project can set; the rest apply to every project."} +
+ {/* Only a picked project changes what a row means, so only then is there a line to read. */} + {!machineOnly && project && ( +
+ Project rows change for {project.name} only. +
+ )}
{!machineOnly && (