Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
168 commits
Select commit Hold shift + click to select a range
8be19ed
fix(pi): short-lived Pi processes no longer lose rotated OAuth tokens…
astarktc Nov 3, 2026
e27e12d
feat(web): add refresh to provider status banners (#24861)
UtkarshUsername Nov 3, 2026
55ea365
fix(server): side chats no longer open with a model switch from the p…
saphid Nov 3, 2026
b7ab02e
fix(web): copied code blocks keep their blank lines in lists, quotes …
saphid Nov 3, 2026
42cf5a1
fix(web): compact model picker row indicators (#26415)
UtkarshUsername Nov 3, 2026
911212b
fix(web): open project picker from boring sidebar's header (#26495)
UtkarshUsername Nov 3, 2026
171f78b
fix(client): project removal counts only listed threads, not side cha…
saphid Nov 3, 2026
68c38b9
fix(opencode): text generation errors explain known provider failures…
saphid Nov 3, 2026
bec3f2d
fix(mobile): multi-file patches show every changed file (#29878)
saphid Nov 3, 2026
e5d0710
fix(server): handoff context names every file of a multi-file patch (…
saphid Nov 3, 2026
e86dc78
fix(server): commits and PR text see changes outside a subfolder proj…
saphid Nov 3, 2026
12ec17c
fix(web): opening a project from the command palette no longer lands …
saphid Nov 3, 2026
3ee5515
fix(web): device host checks show the error without a stack trace (#2…
saphid Nov 3, 2026
3d369d5
feat(web): open full access menu from the new thread context strip (#…
Yash-Singh1 Nov 3, 2026
e53672d
fix(web): long server lists in thread details now page 4 at a time (#…
Bil0000 Nov 3, 2026
115739c
fix(web): keep the project filter menu aligned with its trigger
Yash-Singh1 Nov 3, 2026
30724e2
fix(github): Rebase stack no longer conflicts after a lower layer is …
saphid Nov 3, 2026
abf8998
fix(server): changing a task's wake policy no longer reverts a checkp…
juliusmarminge Nov 3, 2026
a760f37
perf(server): settled PR lookups stop re-spawning git every sweep (#2…
juliusmarminge Nov 3, 2026
4a09502
fix(server): generated commit and PR text ignores git signature outpu…
saphid Nov 3, 2026
ea68f2a
fix(web): picking a branch in a subfolder project keeps the thread in…
saphid Nov 3, 2026
499727d
fix(mobile): close and reopen pull requests from the actions menu (#2…
juliusmarminge Nov 3, 2026
ac10bb4
fix(web): mouse wheel over the thread minimap glides instead of jumpi…
flamboh Nov 3, 2026
7ab237f
fix(server): managed policy covers Kiro (#29915)
juliusmarminge Nov 3, 2026
7681605
fix(server): t3 update installs from the policy's update mirror (#29911)
juliusmarminge Nov 3, 2026
a50fe10
fix(desktop): Ctrl+R on macOS reaches the preview page instead of rel…
saphid Nov 3, 2026
3f48896
fix(server): native forks after an undo no longer copy later turns (#…
saphid Nov 3, 2026
7f17f51
fix(server): idle compaction still runs after a queued message is rem…
saphid Nov 3, 2026
a9468da
fix(server): guests of a forked thread can open files on the messages…
saphid Nov 3, 2026
1a3f76e
fix(evals): resume browser verification after unavailable status (#30…
Yash-Singh1 Nov 3, 2026
bff68fb
fix(shared): saving one source control token no longer drops the othe…
yordis Nov 3, 2026
dd24b37
fix(desktop): a policy that stops startup no longer points at another…
juliusmarminge Nov 3, 2026
15707a8
docs: pin an absolute binary path under a permission cap (#29916)
juliusmarminge Nov 3, 2026
c756eba
fix(mobile): Android follow-up menu shows in full with the keyboard o…
juliusmarminge Nov 3, 2026
7239986
fix(web): deleting the open thread no longer opens a snoozed or settl…
juliusmarminge Nov 3, 2026
31eb1d0
fix(web): closing Settings or Usage no longer jumps back to a quote's…
juliusmarminge Nov 3, 2026
3040f91
fix(server): squash-merged branches no longer offer push & create PR …
juliusmarminge Nov 3, 2026
cf90825
fix(mobile): settings providers show the ACP registry icon (#28135)
juliusmarminge Nov 3, 2026
7b666f3
fix(server): Grok usage limits tell Legacy-mode xAI accounts to switc…
juliusmarminge Nov 3, 2026
58cf69c
fix(usage): label a proxied Claude account by its e-mail, and drop .j…
juliusmarminge Nov 3, 2026
9def583
fix(gitlab): a signed-out default glab host no longer hides other sig…
juliusmarminge Nov 3, 2026
550c5fa
perf(server): prune events the projections already cover (#29806)
juliusmarminge Nov 3, 2026
260297b
fix(server): GPT-6.1 Sol no longer compacts after every tool call on …
maria-rcks Nov 3, 2026
bd48d8c
feat(policy): admins set managed policy from a Group Policy template …
juliusmarminge Nov 3, 2026
2c99914
perf(server): bound streaming recovery reads by active runs (#30357)
Yash-Singh1 Nov 3, 2026
0b5600b
perf(server): read pull request metadata from a lightweight snapshot
Yash-Singh1 Nov 3, 2026
c3c15f7
fix(server): cancelling worktree setup no longer leaves its new branc…
saphid Nov 3, 2026
d1ec71e
feat(mobile): add on-device Android dictation (#26734)
PixPMusic Nov 3, 2026
a104909
feat(server): read usage limit source keys from a secret provider (#2…
yordis Nov 3, 2026
bd3cc5c
feat(device): read the EAS access token from a secret provider (#27763)
yordis Nov 3, 2026
aee76a8
fix(server): route missing read-only queries to readers
Yash-Singh1 Nov 3, 2026
96e887b
perf(server): index delegated recovery candidates (#30359)
Yash-Singh1 Nov 3, 2026
251369d
feat(server,web,mobile,desktop): Copy thread copies a link that opens…
juliusmarminge Nov 3, 2026
b183048
feat(mobile,web): copied thread links open the mobile app (#29815)
juliusmarminge Nov 3, 2026
7c22e24
fix(antigravity): rate-limited turns keep their reset time (#29594)
saphid Nov 3, 2026
da3f972
fix(server): startup no longer hangs on a shell stuck in the kernel (…
AdEx-Partners-DE Nov 3, 2026
ac97602
fix(server): reverting files before the first commit no longer leaves…
Abhishek-B-R Nov 3, 2026
eee9525
fix(server): truncated Claude task output no longer starts with a rep…
Abhishek-B-R Nov 3, 2026
65efae1
fix(shared): relative file links with routing punctuation open as fil…
Abhishek-B-R Nov 3, 2026
7a03a74
fix(server): messages cut by a restart no longer stay marked as strea…
Abhishek-B-R Nov 3, 2026
b6bd84d
fix(server): Antigravity Auto asks instead of approving every request…
parmartejass Nov 3, 2026
a445272
fix(server): Codex threads open in another client are no longer silen…
derektrimm Nov 3, 2026
ef99d3f
fix(server): WSL servers avoid ports already used by Windows (#22962)
tylerjenningsw Nov 3, 2026
d61058d
perf(web): reuse repeated inline skill lookups (#29008)
luke2x Nov 3, 2026
10cd1a5
fix(opencode): expand home-relative executable paths (#29326)
voltcrash Nov 3, 2026
476a99c
fix(server): title regeneration works after a thread's worktree is cl…
saphid Nov 3, 2026
b7add5a
fix(server): dev servers a Codex agent starts show under its thread (…
saphid Nov 3, 2026
8ee7cf9
fix(server): status no longer fetches a remote named after a local tr…
saphid Nov 3, 2026
5bdc2af
fix(server): preview snapshots no longer cut an emoji in half (#29600)
saphid Nov 3, 2026
bbaa535
fix(server): restart notes no longer split an emoji in a long backgro…
saphid Nov 3, 2026
21e4f98
fix(acp): Always allow no longer cancels the turn when an agent only …
MarcoDotIO Nov 3, 2026
9c89424
fix(server): forking a Codex thread archived outside T3 Code no longe…
saphid Nov 3, 2026
acad1d5
perf(shared): stop single-token message search at the best hit (#28930)
hieu-lee Nov 3, 2026
c185d78
fix(preview): a dropped stream withdraws its stale file picker (#29655)
saphid Nov 3, 2026
b156a7d
fix(provider-acp): ACP threads keep receiving events after a rollback…
trevoedwards Nov 3, 2026
2f8de8d
perf(opencode): provider event logs skip per-token reply deltas (#29673)
saphid Nov 3, 2026
300101f
fix(web): open files from Claude and ACP tool-call diffs (#29309)
Jo2234 Nov 3, 2026
04aed8f
fix(server): files tree labels ignored folders inside git submodules …
saphid Nov 3, 2026
eee8ed4
fix(server): server updates reclaim partial database backups left by …
MarcoDotIO Nov 3, 2026
b63adbf
fix(server): PR watch wakes no longer split an emoji in a long commen…
saphid Nov 3, 2026
30f7600
fix(server): pinning a settled thread stamps its return to the active…
saphid Nov 3, 2026
6016aac
fix(server): Claude's commands no longer run Electron apps as Node (#…
u9g Nov 3, 2026
ddca6b1
docs: keybindings guide lists the rightPanelOpen when key (#29677)
saphid Nov 3, 2026
0a86e15
perf(shared): avoid redundant visibility work on status updates (#28965)
hieu-lee Nov 3, 2026
b113eb0
fix(desktop): failed settings saves no longer leave temp files behind…
saphid Nov 3, 2026
6d80df5
fix(acp): a final agent message without a trailing newline is no long…
saphid Nov 3, 2026
594532a
fix(server): terminal history keeps output after an unterminated OSC …
saphid Nov 3, 2026
df2dfde
fix(cli): t3 up's pairing hint keeps --base-dir (#29684)
saphid Nov 3, 2026
1b6336d
fix(shared): preserve omitted components in zero-major caret ranges (…
Jo2234 Nov 3, 2026
4f58294
fix(server): thread titles use linked PR context when the URL is foll…
saphid Nov 3, 2026
2b4d6a3
fix(source-control): remotes like github.com:owner/repo reach their h…
MarcoDotIO Nov 3, 2026
663cd08
fix(server): terminal history keeps text with C1 characters (#29649)
saphid Nov 3, 2026
ce89cd1
fix(server): worktree handoff follows the project's start-from-origin…
saphid Nov 3, 2026
4461b84
fix(server): review keeps tracked changes when untracked files overfl…
saphid Nov 3, 2026
e91595b
fix(server): browser setup advice names the server's T3 home (#29646)
saphid Nov 3, 2026
48e73f6
fix: honor generic MIME aliases in attachment previews (#29360)
Jo2234 Nov 3, 2026
ee265cc
fix(client-runtime): terminals fit their pane again after a reconnect…
saphid Nov 3, 2026
c64117d
fix(server): Run now on a scheduled task says why it was refused (#29…
saphid Nov 3, 2026
8ca8fdd
fix(server): a preview_open whose first load fails no longer leaves a…
saphid Nov 3, 2026
47057a3
fix(cursor): Cursor text generation names usage-limit failures (#29748)
saphid Nov 3, 2026
54223c3
fix(server): review diffs keep working with untracked files outside t…
saphid Nov 3, 2026
136fe43
fix(opencode): nested custom commands like /git/commit run as command…
saphid Nov 3, 2026
8bfe94a
fix(source-control): renamed files in GitHub, GitLab and Azure DevOps…
saphid Nov 3, 2026
0992f51
fix(opencode): a command that exits non-zero is stored as failed, not…
saphid Nov 3, 2026
ddaf999
fix(server): linked environments pick up the other machine's new name…
saphid Nov 3, 2026
b8fb45e
fix(server): document text skips tracked deletions and field codes (#…
saphid Nov 3, 2026
cedcc31
fix(server): OTLP export settings survive a saved token streaming mod…
saphid Nov 3, 2026
eb30336
fix(server): Word text boxes are no longer extracted twice for agents…
saphid Nov 3, 2026
9f7b2ee
fix(server): new projects are created when core.safecrlf refuses LF f…
saphid Nov 3, 2026
228e289
fix(web): drafts saved by older versions load instead of being wiped …
saphid Nov 3, 2026
9d7f771
fix(server): Antigravity text generation shows sign-in and setup erro…
saphid Nov 3, 2026
92388d5
fix(web): picking a custom model named like a Codex alias keeps that …
saphid Nov 3, 2026
940afd6
fix(server): turn checkpoints keep LF files when core.safecrlf is tru…
saphid Nov 3, 2026
31564de
fix(shared): find in thread matches a word typed with a curly apostro…
saphid Nov 3, 2026
2f2e81f
fix(server): review rows for nested repositories load in large diffs …
saphid Nov 3, 2026
0a41bbc
fix(server): t3_worktree_list lists refs after storage cleanup remove…
saphid Nov 3, 2026
2ad6712
fix(provider-core): text generation context cuts no longer split an e…
saphid Nov 3, 2026
7767a95
fix(server): html_preview no longer splits an emoji in a long console…
saphid Nov 3, 2026
0673e7e
fix(server): diffs keep submodule changes under diff.submodule=log (#…
saphid Nov 3, 2026
5e53057
fix(opencode): OpenCode 2 titles for image messages skip images the s…
saphid Nov 3, 2026
aeaf363
fix(server): orchestrator_capabilities no longer offers providers the…
saphid Nov 3, 2026
77b948e
fix(web): big terminal selections no longer break sending (#29804)
saphid Nov 3, 2026
fcad9de
test(web): file editor dismissal test waits for the editor to render …
juliusmarminge Nov 3, 2026
fb8e6d8
test(web): worked-for heading test clicks once the list is revealed (…
juliusmarminge Nov 3, 2026
19a2bad
fix(azure-devops): legacy DefaultCollection pull request URLs can be …
saphid Nov 3, 2026
8704966
fix(shared): preview_open reports about: and mailto: as unsupported i…
saphid Nov 3, 2026
67861fd
fix(server): delegated threads no longer share one provider event log…
onlydole Nov 3, 2026
6049196
fix(server): a queued provider switch no longer ends background work …
juliusmarminge Nov 3, 2026
8198b2e
fix(kiro): slow kiro-cli starts no longer show Kiro as unavailable (#…
saphid Nov 3, 2026
480eb8d
fix(client): mark unread sticks on a thread reopened within ten minut…
saphid Nov 3, 2026
d7ee7b3
fix(pi): extension errors name folder extensions instead of "index" (…
saphid Nov 3, 2026
40aba09
fix(github): Enterprise Server pull request URLs can be pasted to che…
saphid Nov 3, 2026
b7fcb7c
fix(server): a peer refusing one call no longer marks its link as fai…
saphid Nov 3, 2026
0bd447e
perf(server): Claude skill discovery resolves each repository root on…
onlydole Nov 3, 2026
99ccd47
fix(server): t3 service uninstall leaves another T3 home's service al…
saphid Nov 3, 2026
b95ff11
fix(server): PR watches wake again when a push leaves the branch conf…
saphid Nov 3, 2026
1f87f64
fix(muse): the thread error banner no longer shows unredacted failure…
saphid Nov 3, 2026
655977a
fix(server): provider settings changed mid-refresh no longer revert t…
saphid Nov 3, 2026
e1cd7f0
fix(shared): accept whitespace after engine range operators (#29730)
romil-bijarnia Nov 3, 2026
93b0db1
perf(server): stop tracing per-event live coalescer work (#29758)
onlydole Nov 3, 2026
ece461e
test(desktop): the Windows shell kill test waits for each spawn befor…
maria-rcks Nov 3, 2026
7f92cb0
fix(shared): ignore unsupported command output control parameters (#2…
romil-bijarnia Nov 3, 2026
2b37449
fix(cli): t3 update no longer says a service awaiting restart runs th…
saphid Nov 3, 2026
76f2966
fix(mcp): accept CR and mixed line endings in response streams (#29727)
romil-bijarnia Nov 3, 2026
350dcf4
fix(server): preview tabs that fail during setup no longer leave thei…
saphid Nov 3, 2026
86b7dc6
fix(provider-core): long subagent titles no longer end in a broken ch…
saphid Nov 3, 2026
16ca47b
fix(shared): ignore unsupported command output erase modes (#29737)
romil-bijarnia Nov 3, 2026
741bcae
fix(server): a failed scheduled run shows its error without a stack t…
saphid Nov 3, 2026
ebfca09
perf(web): long threads stream with 70% less work-row derivation (#29…
Entrpi Nov 3, 2026
5c72faa
fix(pi): send SIGTERM before closing Pi's stdin on teardown (#30025)
yaacovcorcos Nov 3, 2026
b6307ee
fix(server): clean up child worktrees after their parent settles (#30…
voltcrash Nov 3, 2026
7fd859a
fix(server): Claude context meter shows the window for models outside…
hahhforest Nov 3, 2026
ece6322
fix(server): quitting no longer waits on an unreachable telemetry end…
levelorbit Nov 3, 2026
dd8c122
fix(client): keep snoozed failures hidden without completion times (#…
voltcrash Nov 3, 2026
ffa39e2
perf(server): PR sync stops reading pull requests closed over a day a…
advaitpaliwal Nov 3, 2026
ddd36a5
fix(acp): file changes show the patch of every diff in a tool call (#…
Abhishek-B-R Nov 3, 2026
d075bed
fix(server): Antigravity sign-in no longer blames the network while t…
wenshen18643 Nov 3, 2026
4a23e9e
fix(relay): read migrations in checkout paths with spaces (#30080)
domvox Nov 3, 2026
bf046e3
fix(cli): validate npm runtime before caching it (#30099)
voltcrash Nov 3, 2026
b9d7239
fix(server): SSH device host no longer reconnects on a single slow he…
gyurix Nov 3, 2026
8bc344b
fix(server): read continued core.bare values during onboarding (#30174)
imharjot Nov 3, 2026
6ea23bd
fix(server): published repos fall back to HTTPS when SSH is refused (…
saphid Nov 3, 2026
5ad00e9
fix(opencode): fail stalled event subscriptions promptly (#30201)
voltcrash Nov 3, 2026
c609500
fix(server): a link card the user moved past no longer blocks auto-se…
saphid Nov 3, 2026
2863ede
fix(server): deleting a thread also removes its agent preview recordi…
saphid Nov 3, 2026
e1cf10b
fix(server): review guides no longer fail on the repository's checkou…
saphid Nov 3, 2026
ad4bb6d
fix(desktop): bump Electron to 44.10.1 for October Chromium security …
valuin Nov 3, 2026
4be957c
merge upstream/main into fork
adampeterhiggins Nov 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
23 changes: 14 additions & 9 deletions .agents/skills/test-t3-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ description: Test T3 Code's web and desktop UI through its built-in Browser pane

# Test T3 web and desktop

Use T3's built-in Browser panel for verification. If its tools are absent or
the panel reports unavailable, explain the blocker and stop verification.
Do not install or switch to another automation system. The one exception is
screenshots the panel cannot capture, covered in
[references/screenshots.md](references/screenshots.md). For native mobile
testing, use [test-t3-mobile](../test-t3-mobile/SKILL.md).
Use T3's built-in Browser panel for verification. Follow the recovery steps
below before declaring it unavailable. Do not install or switch to another
automation system. The one exception is screenshots the panel cannot capture, covered in
[references/screenshots.md](references/screenshots.md). For native mobile testing, use
[test-t3-mobile](../test-t3-mobile/SKILL.md).

For desktop-only Electron behaviour, use [test-t3-desktop](../test-t3-desktop/SKILL.md).

Expand Down Expand Up @@ -40,9 +39,15 @@ model, such as Haiku for Claude or Luna for Codex.

## Use the Browser panel

Call `preview_status`, then `preview_open` if the Browser panel is
closed. Open localhost dev servers with `profileId: "incognito"`: the default
profile's cookies from other dev servers cause HTTP 431. Navigate to the complete startup pairing URL once with
Call `preview_status`. If no tab can be automated, call `preview_open`, including
when status reports `available: false` or `tabId: null`. Status can report
unavailable simply because no tab exists yet; opening a tab needs no user action.
Stop verification and explain the blocker only if the tools are absent or
`preview_open` reports unavailable or fails.

Open localhost dev servers with `profileId: "incognito"`: the default
profile's cookies from other dev servers cause HTTP 431.
Navigate to the complete startup pairing URL once with
`preview_navigate`, then use `preview_snapshot` and T3's interaction tools.
If the token was consumed or expired, run `node apps/server/src/bin.ts pair`
for a fresh one. Keep using the same tab.
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"@t3tools/tailscale": "workspace:*",
"dbus-next": "0.10.2",
"effect": "catalog:",
"electron": "44.10.0",
"electron": "44.10.1",
"electron-store": "^8.2.0",
"electron-updater": "^6.8.9",
"electron-webauthn": "catalog:",
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/app/DesktopStartupRecovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ describe("resolveStartupRecoveryOffer", () => {
).toEqual({ _tag: "Reinstall", version: "0.1.2" });
});

it("blames the managed policy unless a newer version can read it", () => {
expect(
resolveStartupRecoveryOffer(
updateState({ status: "available", availableVersion: "0.1.4" }),
"0.1.2",
true,
),
).toEqual({ _tag: "Update", version: "0.1.4" });
expect(
resolveStartupRecoveryOffer(updateState({ status: "up-to-date" }), "0.1.2", true),
).toEqual({ _tag: "ManagedPolicy" });
});

it("falls back to the releases page when this version never started", () => {
expect(resolveStartupRecoveryOffer(updateState({ status: "disabled" }), "0.1.3")).toEqual({
_tag: "Releases",
Expand Down Expand Up @@ -168,6 +181,7 @@ it.effect.each([
yield* SubscriptionRef.set(state.primaryBackendStartup, {
_tag: "Failing",
reason: "Server failed before readiness",
managedPolicy: false,
output: "Failure details",
});
yield* Deferred.await(checked);
Expand Down
24 changes: 20 additions & 4 deletions apps/desktop/src/app/DesktopStartupRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export type StartupRecoveryOffer =
| { readonly _tag: "Update"; readonly version: string }
| { readonly _tag: "Downloading"; readonly version: string; readonly percent: number | null }
| { readonly _tag: "Reinstall"; readonly version: string }
| { readonly _tag: "Releases" };
| { readonly _tag: "Releases" }
| { readonly _tag: "ManagedPolicy" };

const STARTUP_RECOVERY_ACTIONS = ["update", "reinstall", "releases", "copy", "quit"] as const;
type StartupRecoveryAction = (typeof STARTUP_RECOVERY_ACTIONS)[number];
Expand All @@ -41,6 +42,7 @@ type StartupRecoveryAction = (typeof STARTUP_RECOVERY_ACTIONS)[number];
export function resolveStartupRecoveryOffer(
update: DesktopUpdateState,
lastStartedVersion: string | null,
managedPolicy = false,
): StartupRecoveryOffer {
if (update.status === "downloading" && update.availableVersion !== null) {
return {
Expand All @@ -55,6 +57,8 @@ export function resolveStartupRecoveryOffer(
if (updateVersion !== null && updateVersion !== update.notAppliedVersion) {
return { _tag: "Update", version: updateVersion };
}
// A newer release can know a key this one refuses; an older one or the releases page cannot help.
if (managedPolicy) return { _tag: "ManagedPolicy" };
if (update.enabled && (update.status === "idle" || update.status === "checking")) {
return { _tag: "Checking" };
}
Expand Down Expand Up @@ -98,6 +102,11 @@ function describeOffer(offer: StartupRecoveryOffer): { text: string; action: str
text: "Get a different version from the releases page.",
action: actionLink("releases", "Open releases", true),
};
case "ManagedPolicy":
return {
text: "Your organization's managed policy stopped T3 Code from starting. Send the details below to your IT administrator.",
action: "",
};
}
}

Expand Down Expand Up @@ -171,8 +180,14 @@ export const listen: Effect.Effect<
const { latest, changes: updateChanges } = yield* updates.subscribe;
const updateStateRef = yield* Ref.make(latest);

const offer = Ref.get(updateStateRef).pipe(
Effect.map((state) => resolveStartupRecoveryOffer(state, lastStartedVersion)),
const offer = Effect.all([Ref.get(updateStateRef), Ref.get(failureRef)]).pipe(
Effect.map(([state, failure]) =>
resolveStartupRecoveryOffer(
state,
lastStartedVersion,
Option.exists(failure, ({ managedPolicy }) => managedPolicy),
),
),
);

const render = Effect.gen(function* () {
Expand Down Expand Up @@ -332,7 +347,8 @@ export const listen: Effect.Effect<
}
if (startup._tag !== "Failing") return;
if (Option.isSome(yield* Ref.get(failureRef))) return;
yield* Ref.set(failureRef, Option.some({ reason: startup.reason, output: startup.output }));
const { _tag, ...failure } = startup;
yield* Ref.set(failureRef, Option.some(failure));
// The backend manager already logs each failure's reason.
yield* logWarning("primary backend failing to start");
yield* show();
Expand Down
100 changes: 54 additions & 46 deletions apps/desktop/src/backend/DesktopBackendManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
SERVER_EXIT_CODE_MANAGED_POLICY,
SERVER_EXIT_CODE_STATE_DIR_OWNED,
DesktopBackendBootstrap,
type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
Expand Down Expand Up @@ -623,56 +624,63 @@ describe("DesktopBackendManager", () => {
),
);

it.effect("reports a crash loop with the server output on the third restart", () =>
Effect.scoped(
Effect.gen(function* () {
const message = "Error: Cannot find module 'zod/v4' in /Users/René";
let exitCount = 0;
const firstExit = yield* Deferred.make<void>();
const reports = yield* Queue.unbounded<{
readonly exitCount: number;
readonly failure: DesktopBackendManager.BackendStartupFailure;
}>();
const instance = yield* makeTestInstance({
spawnerLayer: Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(
makeProcess({
// "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut
// lands inside the emoji's surrogate pair.
stderr: Stream.make(
new TextEncoder().encode(
`😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`,
// A policy refusal is reported on the first exit, since retrying cannot fix it.
it.effect.each([
{ code: 1, exits: 3, managedPolicy: false },
{ code: SERVER_EXIT_CODE_MANAGED_POLICY, exits: 1, managedPolicy: true },
])(
"reports exit $code with the server output after $exits exits",
({ code, exits, managedPolicy }) =>
Effect.scoped(
Effect.gen(function* () {
const message = "Error: Cannot find module 'zod/v4' in /Users/René";
let exitCount = 0;
const firstExit = yield* Deferred.make<void>();
const reports = yield* Queue.unbounded<{
readonly exitCount: number;
readonly failure: DesktopBackendManager.BackendStartupFailure;
}>();
const instance = yield* makeTestInstance({
spawnerLayer: Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(
makeProcess({
// "é" split across chunks must not decode as U+FFFD, and the 4,000-char cut
// lands inside the emoji's surrogate pair.
stderr: Stream.make(
new TextEncoder().encode(
`😀${"x".repeat(3_999 - message.length)}${message.slice(0, -1)}`,
),
new Uint8Array([0xc3]),
new Uint8Array([0xa9]),
),
new Uint8Array([0xc3]),
new Uint8Array([0xa9]),
),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
}),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(code)),
}),
),
),
),
),
httpClientLayer: layerHttpClient(() => Effect.never),
backendOutputLog: {
persistFailure: () => {
exitCount += 1;
return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid);
httpClientLayer: layerHttpClient(() => Effect.never),
backendOutputLog: {
persistFailure: () => {
exitCount += 1;
return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid);
},
},
},
onStartupFailure: (failure) =>
Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid),
});
yield* instance.start;
yield* Deferred.await(firstExit);
yield* TestClock.adjust(Duration.minutes(1));
const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports);
assert.equal(exitsWhenReported, 3);
assert.equal(failure.reason, "The server could not start (code=1).");
assert.isTrue(failure.output.endsWith(message));
assert.isTrue(failure.output.isWellFormed());
}).pipe(Effect.provide(TestClock.layer())),
),
onStartupFailure: (failure) =>
Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid),
});
yield* instance.start;
yield* Deferred.await(firstExit);
yield* TestClock.adjust(Duration.minutes(1));
const { exitCount: exitsWhenReported, failure } = yield* Queue.take(reports);
assert.equal(exitsWhenReported, exits);
assert.equal(failure.reason, `The server could not start (code=${code}).`);
assert.equal(failure.managedPolicy, managedPolicy);
assert.isTrue(failure.output.endsWith(message));
assert.isTrue(failure.output.isWellFormed());
}).pipe(Effect.provide(TestClock.layer())),
),
);

it.effect("waits out a slow startup and reports one that never becomes ready", () =>
Expand Down
18 changes: 13 additions & 5 deletions apps/desktop/src/backend/DesktopBackendManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { HttpClient } from "effect/http";
import { ChildProcess, ChildProcessSpawner } from "effect/process";

import {
SERVER_EXIT_CODE_MANAGED_POLICY,
SERVER_EXIT_CODE_STATE_DIR_OWNED,
DesktopBackendBootstrap,
type DesktopBackendBootstrap as DesktopBackendBootstrapValue,
Expand Down Expand Up @@ -223,6 +224,8 @@ export const PRIMARY_INSTANCE_ID: BackendInstanceId = BackendInstanceId(

export interface BackendStartupFailure {
readonly reason: string;
/** The server refused to start under this machine's managed policy. */
readonly managedPolicy: boolean;
/** Tail of the last run's stdout and stderr. */
readonly output: string;
}
Expand Down Expand Up @@ -635,9 +638,11 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
// One streaming decoder per stream keeps UTF-8 characters split across chunks intact.
const makeOutputDecoders = () => ({ stdout: new TextDecoder(), stderr: new TextDecoder() });
let outputDecoders = makeOutputDecoders();
const reportStartupFailure = (reason: string) =>
const reportStartupFailure = (reason: string, managedPolicy = false) =>
Ref.get(outputTail).pipe(
Effect.flatMap((output) => spec.onStartupFailure?.({ reason, output }) ?? Effect.void),
Effect.flatMap(
(output) => spec.onStartupFailure?.({ reason, managedPolicy, output }) ?? Effect.void,
),
);

const { logWarning: logInstanceWarning, logError: logInstanceError } =
Expand Down Expand Up @@ -880,7 +885,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
yield* (spec.onStateDirOwned?.() ?? Effect.void).pipe(Effect.ignore);
return;
}
yield* scheduleRestart(reason);
yield* scheduleRestart(reason, exitCode);
}
}),
);
Expand Down Expand Up @@ -980,6 +985,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (

const scheduleRestart = Effect.fn("desktop.backendInstance.scheduleRestart")(function* (
reason: string,
exitCode?: number,
) {
const scheduled = yield* SubscriptionRef.modify(state, (latest) => {
if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) {
Expand All @@ -1003,8 +1009,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
reason,
delayMs: Duration.toMillis(delay),
});
if (attempt >= STARTUP_FAILURE_RESTART_ATTEMPTS) {
yield* reportStartupFailure(`The server could not start (${reason}).`);
// Retrying cannot fix a policy refusal, so say so at once; retries still pick up a fix.
const managedPolicy = exitCode === SERVER_EXIT_CODE_MANAGED_POLICY;
if (managedPolicy || attempt >= STARTUP_FAILURE_RESTART_ATTEMPTS) {
yield* reportStartupFailure(`The server could not start (${reason}).`, managedPolicy);
}
const restartFiber = yield* Effect.forkIn(
Effect.sleep(delay).pipe(
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,33 @@ describe("PreviewManager", () => {
),
);

effectIt.effect.each([
["darwin", true, false, true],
["darwin", false, true, false],
["linux", false, true, true],
["win32", true, false, false],
] as const)(
"reloads the preview only on the platform refresh chord: %s meta=%s ctrl=%s",
([platform, meta, control, expected]) =>
withManager(
(manager) =>
Effect.gen(function* () {
const preview = makeFaviconWebContents();
fromId.mockReturnValue(preview.webContents);
yield* manager.createTab("tab_refresh");
yield* manager.registerWebview("tab_refresh", 42);
const preventDefault = vi.fn();
preview.listeners.get("before-input-event")!(
{ preventDefault } as never,
{ type: "keyDown", key: "r", meta, control, shift: false, alt: false } as never,
);
expect(preventDefault).toHaveBeenCalledTimes(expected ? 1 : 0);
expect(preview.reload).toHaveBeenCalledTimes(expected ? 1 : 0);
}),
platform,
),
);

effectIt.effect("keeps editing and forwarded shortcuts in whichever tab holds focus", () =>
withManager((manager) =>
Effect.gen(function* () {
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,10 +464,10 @@ export const previewWindowOpenAction = (details: {
: "navigate";
};

const isPreviewRefreshShortcut = (input: Electron.Input): boolean =>
const isPreviewRefreshShortcut = (input: Electron.Input, platform: NodeJS.Platform): boolean =>
input.type === "keyDown" &&
input.key.toLowerCase() === "r" &&
(input.meta || input.control) &&
(platform === "darwin" ? input.meta && !input.control : input.control && !input.meta) &&
!input.shift &&
!input.alt;

Expand Down Expand Up @@ -1488,7 +1488,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
}
return;
}
if (isPreviewRefreshShortcut(input)) {
if (isPreviewRefreshShortcut(input, hostPlatform)) {
event.preventDefault();
runFork(
attempt({ operation: "shortcut.refresh", tabId, webContentsId: wc.id }, () =>
Expand Down
Loading