Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion apps/desktop/src/acp/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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 },
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
},
];
11 changes: 4 additions & 7 deletions apps/desktop/src/state/headerButtons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
19 changes: 14 additions & 5 deletions apps/desktop/src/state/headerButtons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HeaderButtonId> = new Set<HeaderButtonId>([
"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. */
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/state/pullMerge.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
124 changes: 124 additions & 0 deletions apps/desktop/src/state/pullMerge.ts
Original file line number Diff line number Diff line change
@@ -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<PullRequest, "state" | "draft" | "checks">): 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<string, string>();
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<PullRequest, "number" | "headRef">): Promise<void> {
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 });
}
}
2 changes: 1 addition & 1 deletion apps/desktop/src/ui/ChatBackgroundGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function ChatBackgroundGroup() {
>
<div className="flex shrink-0 items-center gap-2">
{banner.name && <Action onClick={() => void removeBanner()}>Remove</Action>}
<Action onClick={choose}>{banner.name ? "Change…" : "Choose image…"}</Action>
<Action onClick={choose}>{banner.name ? "Change image" : "Choose image"}</Action>
</div>
</Row>
{banner.name && (
Expand Down
21 changes: 11 additions & 10 deletions apps/desktop/src/ui/DiagnosticsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,17 +247,19 @@ export function DiagnosticsPage() {
<>
<Group
title="Debug report"
footer="Tokens, API keys, email addresses and your home folder are taken out. Read it before you post it."
hint={`The server, the system, the harnesses, the last ${REPORT_LOG_LINES} server log lines and recent events, as Markdown.`}
>
<Row title="Copy debug report" subtitle="This page in one block, to paste into a GitHub issue.">
<Row
title="For a GitHub issue"
subtitle="Secrets, emails and your home folder are taken out. Read it before you post."
>
<Action onClick={() => void saveReport()}>
<Download size={13} />
Save…
Save to file
</Action>
<Action prominent onClick={() => void copyReport()}>
<Copy size={13} />
Copy debug report
Copy report
</Action>
</Row>
</Group>
Expand All @@ -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 && (
<Action
Expand Down Expand Up @@ -345,7 +347,7 @@ export function DiagnosticsPage() {
/>
</Group>

<Group title="Harnesses" footer="What Settings › Harnesses last detected, found on this app's PATH.">
<Group title="Harnesses" hint="What Settings › Harnesses last detected, found on this app's PATH.">
{harnesses.length ? (
harnesses.map((h) => (
<div key={h.id}>
Expand Down Expand Up @@ -423,8 +425,7 @@ function ServerLog() {
return (
<Group
title="Server log"
footer={`The server's stderr since the app started (the last ${SERVER_LOG_LINES} lines).`}
hint="Each thread also logs its turns to its own logs/debug.log. Copy and Save keep only the lines shown, with secrets and your home folder taken out."
hint={`The server's stderr since the app started (the last ${SERVER_LOG_LINES} lines). Each thread also logs its turns to its own logs/debug.log. Copy and Save keep only the lines shown, with secrets and your home folder taken out.`}
>
<div className={cx("flex flex-wrap items-center gap-2", rowInset)}>
<Select
Expand Down Expand Up @@ -475,7 +476,7 @@ function ServerLog() {
}
>
<Download size={13} />
Save…
Save to file
</Action>
</div>
<pre
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/ui/LimitFallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ function RuleForm({
const [busy, setBusy] = useState(false);
const error = ruleError(rules, form, initial?.from);
const options = (current: string) => [
{ value: "", label: "Choose…" },
{ value: "", label: "Choose a harness" },
...harnesses.map((h) => ({ value: h.id, label: h.name })),
...(current && !harnesses.some((h) => h.id === current) ? [{ value: current, label: current }] : []),
];
Expand Down
Loading
Loading