diff --git a/AGENTS.md b/AGENTS.md index 968d8a71f1f..76e5330820f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,12 @@ T3 Code is a minimal web GUI for using coding agents like Codex and Claude. This repository is a VERY EARLY WIP. Proposing sweeping changes that improve long-term maintainability is encouraged. +This is a fork of [`pingdotgg/t3code`](https://github.com/pingdotgg/t3code). See +[`UPSTREAM_DIVERGENCE.md`](./UPSTREAM_DIVERGENCE.md) for the running log of which upstream +changes we've pulled in, which we've deliberately skipped, and why. Update it whenever you +review or merge upstream commits. Standing policy: **do not pull Android / native-mobile +changes** — we are not investing in the mobile app on this fork. + ## Core Priorities 1. Performance first. diff --git a/UPSTREAM_DIVERGENCE.md b/UPSTREAM_DIVERGENCE.md new file mode 100644 index 00000000000..e418bd459f1 --- /dev/null +++ b/UPSTREAM_DIVERGENCE.md @@ -0,0 +1,81 @@ +# Upstream Divergence Log + +This fork (`DanielGGordon/t3code`, tracked as `origin`) is based on +[`pingdotgg/t3code`](https://github.com/pingdotgg/t3code) (`upstream`). We periodically +review upstream commits and pull in the ones we want. This file is the running record of +**what we pulled, what we skipped, and why** — so future syncs don't re-litigate decisions +already made, and so anyone reading the fork understands how it diverges. + +## How to use this log + +- When reviewing a batch of upstream commits, add a dated section below. +- Record the upstream review point (last upstream commit considered), what was pulled, + and — just as important — what was **deliberately skipped** and the reasoning. +- Keep decisions durable: if we skip something now but might revisit it, say so explicitly. + +## Standing policy + +- **Android / native mobile: do not pull.** We are not investing in the mobile app on this + fork. Skip all upstream Android and native-mobile changes (the app scaffolding, native + Kotlin modules, mobile persistence layers, mobile UI polish) unless they are a prerequisite + for a web/server change we actually want. This supersedes any of our own earlier hand-rolled + Android commits — we are not maintaining them going forward either. + +--- + +## 2026-07-12 — Review of upstream through `c1ec1915f` + +**Upstream review point:** `c1ec1915f` (2026-07-12) — 15 upstream commits ahead of our last +sync base `dad0889` (2026-07-07). + +### Pulled (cherry-picked with `-x`) + +- `3201e00ad` — [codex] Preserve worktree metadata during branch sync (#3822). **The priority + pull.** Adds an `expectedBranch` optimistic-concurrency guard so a stale git-status sync can't + regress a freshly-generated branch back to a temporary worktree branch; stops needlessly + rewriting `worktreePath` during branch reconcile. **Conflict in + `apps/web/src/components/GitActionsControl.tsx`:** our fork's `persistThreadBranchSync` calls + `updateThreadMetadata({ input: { threadId, branch, worktreePath } })`, so upstream's switch to + `resolveThreadBranchMetadataPatch` collided. Resolved by keeping our fork's + `updateThreadMetadata` structure but spreading + `resolveThreadBranchMetadataPatch(branch, activeServerThread.branch)` into the input (so we gain + the `expectedBranch` guard) while still writing `worktreePath` back. `decider.ts`, + `contracts/orchestration.ts`, and both test files auto-merged cleanly. +- `619b0ece9` — fix(marketing): platform-appropriate commit shortcut on the website (#3644). + Clean. +- `ef943a26a` — Fix truncated chat error alert layout (#3899). Applied against current main + (which already carries the ``-wrapped banner), so this landed as the full upstream fix: + the container layout `mx-auto w-fit max-w-[min(48rem,calc(100%-2rem))]` that stops truncation, + keeping the existing Tooltip. + +### Skipped — Android / native mobile (per standing policy) + +- `c1ec1915f` — Add Android mobile support (#3579). Full official Android port (native Ghostty + terminal, native review-diff view, Android dialogs/menus, embedded fonts). We don't want + Android work. +- `843cf176e` — fix(mobile): embed fonts and render project favicons reliably (#3823). Mobile-only. +- `2250e3ee7` — feat(client): persist offline environment data and mobile preferences (#3795). + Primarily a mobile persistence/preferences layer; touches `client-runtime` but not worth the + merge cost for our web/server focus right now. Revisit only if we want the offline state model. +- `8619ef22e` — Show compact PR number badges in mobile thread rows (#3827). Mobile-only. +- `f61fa9499` — Expose mobile PR indicator labels to accessibility (#3828). Mobile-only. +- `7778a1cea` — Use rounded depth logo for production splash screen (#3780). Mobile splash asset + (`apps/mobile/assets/splash-icon-prod.png`) — our fork deleted it, so it came in as a + modify/delete conflict. Dropped per the no-mobile policy. + +### Skipped — depends on newer Codex schema (revisit after a Codex bump) + +- `ca1e08b5a` — [codex] Label max and ultra reasoning (#3824). Cherry-picked cleanly but **fails + typecheck** on our fork: upstream types `REASONING_EFFORT_LABELS` as `Record`, + whereas our fork tightened it to `Record`, and our + vendored generated schema (`packages/effect-codex-app-server/.../schema.gen.ts`) only goes up to + `xhigh` — no `max`/`ultra`. Those effort levels don't exist in the Codex app-server version we + vendor, so the labels would be dead code anyway. Dropped; pull when we next regenerate/bump the + Codex schema. + +### Skipped — for now (revisit) + +- `e9127658a` (#3821) + `e775bc622` (#3785) — Clerk stack upgrade. Deferred; take as a block + when we next touch auth/toolchain so it doesn't drift too far. +- `18a41388e`, `0c6656585`, `03ac1f0cd` — desktop / electron-builder + pnpm-11 asar packaging + fixes. Only relevant if we ship the desktop build; pull alongside the Clerk block. diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 69de2088d43..477daeb3cb0 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -254,7 +254,7 @@ const mobileEndorsementRows = [ @@ -403,12 +403,14 @@ const mobileEndorsementRows = [ const label = document.getElementById("download-label"); const ctaBtn = document.getElementById("cta-download-btn") as HTMLAnchorElement | null; const ctaLabel = document.getElementById("cta-download-label"); + const kbd = document.getElementById("pr-button-kbd"); const platform = detectPlatform(); if (!platform) return; document.documentElement.dataset.platform = platform.os; if (label) label.textContent = platform.label; if (ctaLabel) ctaLabel.textContent = platform.label; + if (kbd && platform.os !== "mac") kbd.textContent = "Ctrl ⏎"; try { const release = await fetchLatestRelease(); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index edfda7c837a..405103450e8 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -477,6 +477,112 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + it("does not regress a generated branch to a stale temporary worktree branch", async () => { + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = now(); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-branch-race-project-create"), + projectId: asProjectId("project-branch-race"), + title: "Branch Race Project", + workspaceRoot: "/tmp/project-branch-race", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-branch-race-thread-create"), + threadId: ThreadId.make("thread-branch-race"), + projectId: asProjectId("project-branch-race"), + title: "Branch Race Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: "t3code/generated-branch-name", + worktreePath: "/tmp/project-branch-race-worktree", + createdAt, + }), + ); + + await system.run( + engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-stale-temporary-branch-sync"), + threadId: ThreadId.make("thread-branch-race"), + branch: "t3code/1234abcd", + expectedBranch: "t3code/1234abcd", + }), + ); + + const snapshot = await system.readModel(); + expect(snapshot.threads[0]?.branch).toBe("t3code/generated-branch-name"); + await system.dispose(); + }); + + it("allows authoritative worktree bootstrap to assign a temporary branch", async () => { + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = now(); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-worktree-bootstrap-project-create"), + projectId: asProjectId("project-worktree-bootstrap"), + title: "Worktree Bootstrap Project", + workspaceRoot: "/tmp/project-worktree-bootstrap", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-worktree-bootstrap-thread-create"), + threadId: ThreadId.make("thread-worktree-bootstrap"), + projectId: asProjectId("project-worktree-bootstrap"), + title: "Worktree Bootstrap Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: "main", + worktreePath: null, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-authoritative-worktree-bootstrap"), + threadId: ThreadId.make("thread-worktree-bootstrap"), + branch: "t3code/1234abcd", + worktreePath: "/tmp/project-worktree-bootstrap-worktree", + }), + ); + + const snapshot = await system.readModel(); + expect(snapshot.threads[0]?.branch).toBe("t3code/1234abcd"); + expect(snapshot.threads[0]?.worktreePath).toBe("/tmp/project-worktree-bootstrap-worktree"); + await system.dispose(); + }); + it("records command ack duration using the first committed event type", async () => { const system = await createOrchestrationSystem(); const { engine } = system; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index f6e4be96941..c31ea66f429 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -313,11 +313,17 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.meta.update": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + const branch = + command.branch !== undefined && + command.expectedBranch !== undefined && + thread.branch !== command.expectedBranch + ? thread.branch + : command.branch; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -333,7 +339,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), - ...(command.branch !== undefined ? { branch: command.branch } : {}), + ...(branch !== undefined ? { branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), updatedAt: occurredAt, }, diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 3781fad8336..f302e976ca7 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -9,6 +9,7 @@ import { resolveLiveThreadBranchUpdate, resolveQuickAction, resolveThreadBranchUpdate, + resolveThreadBranchMetadataPatch, } from "./GitActionsControl.logic"; function status(overrides: Partial = {}): VcsStatusResult { @@ -1111,6 +1112,18 @@ describe("resolveLiveThreadBranchUpdate", () => { }); }); +describe("resolveThreadBranchMetadataPatch", () => { + it("does not overwrite worktree metadata while reconciling a branch", () => { + assert.deepEqual( + resolveThreadBranchMetadataPatch("feature/current-ref", "feature/previous-ref"), + { + branch: "feature/current-ref", + expectedBranch: "feature/previous-ref", + }, + ); + }); +}); + describe("resolveAutoFeatureBranchName", () => { it("uses semantic preferred ref names when available", () => { const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 3f6bae61cdd..96f7af794ac 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -373,6 +373,16 @@ export function resolveThreadBranchUpdate( }; } +export function resolveThreadBranchMetadataPatch( + branch: string | null, + expectedBranch: string | null, +): { + branch: string | null; + expectedBranch: string | null; +} { + return { branch, expectedBranch }; +} + export function resolveLiveThreadBranchUpdate(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index c9816719452..de2634db74b 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -45,6 +45,7 @@ import { requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, + resolveThreadBranchMetadataPatch, resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; @@ -1038,7 +1039,9 @@ export default function GitActionsControl({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadRef.threadId, - branch, + // Carry expectedBranch so a stale git-status sync can't regress a + // freshly-generated branch back to a temporary worktree branch (upstream #3822). + ...resolveThreadBranchMetadataPatch(branch, activeServerThread.branch), worktreePath, }, }); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index d0b3ac80b8a..3ed2e59169c 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -13,7 +13,7 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ }) { if (!error) return null; return ( -
+
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index d4ab7310174..708434ff192 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -556,6 +556,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), });