From d1f0a917f817b6b04140f9915234aad0d426f38b Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 1 May 2026 16:55:57 +0530 Subject: [PATCH 01/46] fix(server): walk parent dirs in isGitRepository so subdirectories of a git repo are detected Previously isGitRepository only checked cwd/.git, so opening t3code at a sub-package of a monorepo (e.g. apps/server) was treated as non-git, silently disabling checkpoints and the diff panel. Walk up to the filesystem root to match git's own behavior. Fixes #2441 --- apps/server/src/git/Utils.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index 6faf3e99c77..ee1e5c141a8 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -1,6 +1,20 @@ import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join, parse } from "node:path"; export function isGitRepository(cwd: string): boolean { - return existsSync(join(cwd, ".git")); + const root = parse(cwd).root; + let current = cwd; + while (true) { + if (existsSync(join(current, ".git"))) { + return true; + } + if (current === root) { + return false; + } + const parent = dirname(current); + if (parent === current) { + return false; + } + current = parent; + } } From 9a042e5b121eeaa62b540112434322f92e488d66 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 1 May 2026 17:53:19 +0530 Subject: [PATCH 02/46] refactor(server): simplify isGitRepository per review Apply review suggestion: collapse the parent-walk into a single while loop using the dirname-equals-self termination check, and drop the redundant parse(cwd).root guard. Behavior is unchanged for every cwd the codebase actually passes in (always a sub-path of the repo). --- apps/server/src/git/Utils.ts | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index ee1e5c141a8..3315d139bda 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -1,20 +1,9 @@ import { existsSync } from "node:fs"; -import { dirname, join, parse } from "node:path"; +import { dirname, join } from "node:path"; -export function isGitRepository(cwd: string): boolean { - const root = parse(cwd).root; - let current = cwd; - while (true) { - if (existsSync(join(current, ".git"))) { - return true; - } - if (current === root) { - return false; - } - const parent = dirname(current); - if (parent === current) { - return false; - } - current = parent; +export function isGitRepository(current: string): boolean { + while (current !== (current = dirname(current))) { + if (existsSync(join(current, ".git"))) return true; } + return false; } From 147aadccb5128c5a2f2a9af74d7d357cd8d09d68 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 1 May 2026 17:57:50 +0530 Subject: [PATCH 03/46] fix(server): check input dir in isGitRepository before walking parents The previous while-loop form advanced current to its parent in the loop condition before the existsSync check ran, so the input directory was never tested. Calling isGitRepository on a path that itself contains .git returned false. Switch to do...while so the check runs before the advance, without duplicating the existsSync call. --- apps/server/src/git/Utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index 3315d139bda..b594559b5b0 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -2,8 +2,8 @@ import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; export function isGitRepository(current: string): boolean { - while (current !== (current = dirname(current))) { + do { if (existsSync(join(current, ".git"))) return true; - } + } while (current !== (current = dirname(current))); return false; } From ab662ce7002c53fee7da32859a7f9f3eeed4062a Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Tue, 5 May 2026 16:17:32 +0530 Subject: [PATCH 04/46] Rename app stage label from Alpha to AA - Update product names and titles across web and desktop - Add "AA" to DesktopAppStageLabel type --- apps/desktop/package.json | 2 +- apps/desktop/src/appBranding.ts | 2 +- apps/web/index.html | 2 +- apps/web/src/branding.ts | 2 +- packages/contracts/src/ipc.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cd20211bfef..401d1117f6d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -32,5 +32,5 @@ "typescript": "catalog:", "vitest": "catalog:" }, - "productName": "T3 Code (Alpha)" + "productName": "T3 Code (AA)" } diff --git a/apps/desktop/src/appBranding.ts b/apps/desktop/src/appBranding.ts index 3cb1539f761..00a6fcd1ee1 100644 --- a/apps/desktop/src/appBranding.ts +++ b/apps/desktop/src/appBranding.ts @@ -12,7 +12,7 @@ export function resolveDesktopAppStageLabel(input: { return "Dev"; } - return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; + return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "AA"; } export function resolveDesktopAppBranding(input: { diff --git a/apps/web/index.html b/apps/web/index.html index 88e1c8b4f23..034663fde7f 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -89,7 +89,7 @@ href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap" rel="stylesheet" /> - T3 Code (Alpha) + T3 Code (AA)
diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 99775a4c55d..db046e38e95 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -12,7 +12,7 @@ const injectedDesktopAppBranding = readInjectedDesktopAppBranding(); export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; export const APP_STAGE_LABEL = - injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "Alpha"); + injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "AA"); export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f480912920f..59f16ad6219 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -93,7 +93,7 @@ export type DesktopUpdateStatus = export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; export type DesktopUpdateChannel = "latest" | "nightly"; -export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; +export type DesktopAppStageLabel = "AA" | "Alpha" | "Dev" | "Nightly"; export interface DesktopAppBranding { baseName: string; From 4dac9bdd71c027f33a0c92127ebfc8e4fa4c2a7a Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Tue, 5 May 2026 16:34:05 +0530 Subject: [PATCH 05/46] Add setting to disable auto-create PR on push - Add client setting `autoCreatePrOnPush` (default on) - Downgrade feature-branch quick actions to commit/push only when off - Expose toggle in General settings --- .../GitActionsControl.logic.test.ts | 76 +++++++++++++++++++ .../src/components/GitActionsControl.logic.ts | 7 +- apps/web/src/components/GitActionsControl.tsx | 12 ++- .../components/settings/SettingsPanels.tsx | 26 +++++++ packages/contracts/src/settings.ts | 1 + 5 files changed, 117 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 7950753330e..55511ceb3f1 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1131,3 +1131,79 @@ describe("resolveAutoFeatureBranchName", () => { assert.equal(ref, "feature/update"); }); }); + +describe("when: autoCreatePr is disabled", () => { + it("downgrades feature-branch commit+push from PR to plain commit & push", () => { + const quick = resolveQuickAction( + status({ hasWorkingTreeChanges: true }), + false, + false, + true, + false, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push", + label: "Commit & push", + disabled: false, + }); + }); + + it("downgrades feature-branch ahead-with-upstream from create PR to plain push", () => { + const quick = resolveQuickAction( + status({ aheadCount: 2 }), + false, + false, + true, + false, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "push", + label: "Push", + disabled: false, + }); + }); + + it("downgrades feature-branch ahead-without-upstream from create PR to plain push", () => { + const quick = resolveQuickAction( + status({ aheadCount: 1, hasUpstream: false }), + false, + false, + true, + false, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "push", + label: "Push", + disabled: false, + }); + }); + + it("preserves Commit, push & PR when autoCreatePr is true (default)", () => { + const quick = resolveQuickAction( + status({ hasWorkingTreeChanges: true }), + false, + false, + true, + true, + ); + assert.deepInclude(quick, { + kind: "run_action", + action: "commit_push_pr", + label: "Commit, push & PR", + disabled: false, + }); + }); + + it("preserves Push & create PR when autoCreatePr is true (default)", () => { + const quick = resolveQuickAction(status({ aheadCount: 2 }), false, false, true, true); + assert.deepInclude(quick, { + kind: "run_action", + action: "create_pr", + label: "Push & create PR", + disabled: false, + }); + }); +}); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 3f6bae61cdd..9e75697c788 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -169,6 +169,7 @@ export function resolveQuickAction( isBusy: boolean, isDefaultRef = false, hasPrimaryRemote = true, + autoCreatePr = true, ): GitQuickAction { if (isBusy) { return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; @@ -205,7 +206,7 @@ export function resolveQuickAction( if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; } - if (hasOpenPr || isDefaultRef) { + if (hasOpenPr || isDefaultRef || !autoCreatePr) { return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; } return { @@ -238,7 +239,7 @@ export function resolveQuickAction( hint: "No local commits to push.", }; } - if (hasOpenPr || isDefaultRef) { + if (hasOpenPr || isDefaultRef || !autoCreatePr) { return { label: "Push", disabled: false, @@ -272,7 +273,7 @@ export function resolveQuickAction( } if (isAhead) { - if (hasOpenPr || isDefaultRef) { + if (hasOpenPr || isDefaultRef || !autoCreatePr) { return { label: "Push", disabled: false, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 01b84bd94a5..55c22357170 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -44,6 +44,7 @@ import { resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; import { AnimatedHeight } from "./AnimatedHeight"; +import { useSettings } from "../hooks/useSettings"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; import { @@ -1136,10 +1137,17 @@ export default function GitActionsControl({ () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasPrimaryRemote), [gitStatusForActions, hasPrimaryRemote, isGitActionRunning], ); + const autoCreatePrOnPush = useSettings((s) => s.autoCreatePrOnPush); const quickAction = useMemo( () => - resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote), - [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], + resolveQuickAction( + gitStatusForActions, + isGitActionRunning, + isDefaultRef, + hasPrimaryRemote, + autoCreatePrOnPush, + ), + [autoCreatePrOnPush, gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8fc36d4a32b..632929e9029 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -972,6 +972,32 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + autoCreatePrOnPush: DEFAULT_UNIFIED_SETTINGS.autoCreatePrOnPush, + }) + } + /> + ) : null + } + control={ + + updateSettings({ autoCreatePrOnPush: Boolean(checked) }) + } + aria-label="Auto-create PR on push" + /> + } + /> + Date: Tue, 5 May 2026 17:14:10 +0530 Subject: [PATCH 06/46] Replace 'ref' terminology with 'branch' in git actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update dialog titles and descriptions to use "branch" - Clarify progress messages: "feature ref" → "feature branch" - Update error messages and hints for consistency - Change test expectations to match new terminology --- .../src/components/GitActionsControl.logic.test.ts | 14 +++++++------- apps/web/src/components/GitActionsControl.logic.ts | 14 +++++++------- apps/web/src/components/GitActionsControl.tsx | 12 ++++++------ 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 7950753330e..18e7270dfda 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -874,7 +874,7 @@ describe("when: ref has no upstream configured", () => { }); describe("requiresDefaultBranchConfirmation", () => { - it("requires confirmation for push actions on default ref", () => { + it("requires confirmation for push actions on default branch", () => { assert.isFalse(requiresDefaultBranchConfirmation("commit", true)); assert.isTrue(requiresDefaultBranchConfirmation("push", true)); assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true)); @@ -894,9 +894,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Push to default ref?", + title: "Push to default branch?", description: - 'This action will push local commits on "main". You can continue on this ref or create a feature ref and run the same action there.', + 'This action will push local commits on "main". You can continue on this branch or create a feature branch and run the same action there.', continueLabel: "Push to main", }); }); @@ -909,9 +909,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Push & create PR from default ref?", + title: "Push & create PR from default branch?", description: - 'This action will push local commits and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', + 'This action will push local commits and create a pull request on "main". You can continue on this branch or create a feature branch and run the same action there.', continueLabel: "Push & create PR", }); }); @@ -924,9 +924,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Commit, push & create PR from default ref?", + title: "Commit, push & create PR from default branch?", description: - 'This action will commit, push, and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', + 'This action will commit, push, and create a pull request on "main". You can continue on this branch or create a feature branch and run the same action there.', continueLabel: "Commit, push & create PR", }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 3f6bae61cdd..10fec03b8cf 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -61,7 +61,7 @@ export function buildGitActionProgressStages(input: { terminology?: ChangeRequestTerminology; }): string[] { const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; - const branchStages = input.featureBranch ? ["Preparing feature ref..."] : []; + const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; const prStages = [ `Preparing ${terminology.shortLabel}...`, @@ -197,7 +197,7 @@ export function resolveQuickAction( label: "Commit", disabled: true, kind: "show_hint", - hint: `Create and checkout a ref before pushing or opening a ${terminology.singular}.`, + hint: `Create and checkout a branch before pushing or opening a ${terminology.singular}.`, }; } @@ -329,19 +329,19 @@ export function resolveDefaultBranchActionDialogCopy(input: { terminology?: ChangeRequestTerminology; }): DefaultBranchActionDialogCopy { const branchLabel = input.branchName; - const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`; + const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; if (input.action === "push" || input.action === "commit_push") { if (input.includesCommit) { return { - title: "Commit & push to default ref?", + title: "Commit & push to default branch?", description: `This action will commit and push changes${suffix}`, continueLabel: `Commit & push to ${branchLabel}`, }; } return { - title: "Push to default ref?", + title: "Push to default branch?", description: `This action will push local commits${suffix}`, continueLabel: `Push to ${branchLabel}`, }; @@ -349,13 +349,13 @@ export function resolveDefaultBranchActionDialogCopy(input: { if (input.includesCommit) { return { - title: `Commit, push & create ${terminology.shortLabel} from default ref?`, + title: `Commit, push & create ${terminology.shortLabel} from default branch?`, description: `This action will commit, push, and create a ${terminology.singular}${suffix}`, continueLabel: `Commit, push & create ${terminology.shortLabel}`, }; } return { - title: `Push & create ${terminology.shortLabel} from default ref?`, + title: `Push & create ${terminology.shortLabel} from default branch?`, description: `This action will push local commits and create a ${terminology.singular}${suffix}`, continueLabel: `Push & create ${terminology.shortLabel}`, }; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 01b84bd94a5..fcf2024bae8 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -262,7 +262,7 @@ function getMenuActionDisabledReason({ if (item.id === "push") { if (!hasBranch) { - return "Detached HEAD: checkout a refName before pushing."; + return "Detached HEAD: checkout a branch before pushing."; } if (hasChanges) { return "Commit or stash local changes before pushing."; @@ -283,7 +283,7 @@ function getMenuActionDisabledReason({ return `View ${terminology.singular} is currently unavailable.`; } if (!hasBranch) { - return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`; + return `Detached HEAD: checkout a branch before creating a ${terminology.singular}.`; } if (hasChanges) { return `Commit local changes before creating a ${terminology.singular}.`; @@ -1742,7 +1742,7 @@ export default function GitActionsControl({ ) : null} {gitStatusForActions?.refName === null && (

- Detached HEAD: create and checkout a refName to enable push and pull request + Detached HEAD: create and checkout a branch to enable push and pull request actions.

)} @@ -1789,7 +1789,7 @@ export default function GitActionsControl({ {isDefaultRef && ( - Warning: default refName + Warning: default branch )} @@ -1923,7 +1923,7 @@ export default function GitActionsControl({ disabled={noneSelected} onClick={runDialogActionOnNewBranch} > - Commit on new refName + Commit on new branch
@@ -729,6 +748,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ? "overflow-auto whitespace-pre-wrap wrap-break-word" : "overflow-auto", )} + style={ + diffFontFamilyValue ? { fontFamily: diffFontFamilyValue } : undefined + } > {renderablePatch.text} diff --git a/apps/web/src/components/settings/DiffFontPicker.tsx b/apps/web/src/components/settings/DiffFontPicker.tsx new file mode 100644 index 00000000000..b2a0fc0056b --- /dev/null +++ b/apps/web/src/components/settings/DiffFontPicker.tsx @@ -0,0 +1,205 @@ +import { ChevronsUpDownIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { cn } from "../../lib/utils"; +import { isMonospaceFamily, loadSystemFonts, type SystemFont } from "../../lib/systemFonts"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxStatus, + ComboboxTrigger, + useComboboxFilter, +} from "../ui/combobox"; +import { Switch } from "../ui/switch"; + +const FONT_PICKER_TRIGGER_CLASS_NAME = + "relative inline-flex cursor-pointer select-none items-center justify-between gap-2 border rounded-lg text-left text-base outline-none transition-[color,box-shadow,background-color] data-disabled:pointer-events-none data-disabled:opacity-64 sm:text-sm w-full min-w-36 border-input bg-background not-dark:bg-clip-padding text-foreground shadow-xs/5 ring-ring/24 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-popup-open:before:shadow-[0_1px_--theme(--color-black/4%)] focus-visible:border-ring focus-visible:ring-[3px] dark:bg-input/32 dark:not-data-popup-open:before:shadow-[0_-1px_--theme(--color-white/6%)] data-popup-open:shadow-none min-h-9 px-[calc(--spacing(3)-1px)] sm:min-h-8"; + +const FONT_PICKER_TRIGGER_ICON_CLASS_NAME = + "-me-1 size-4.5 opacity-80 sm:size-4 shrink-0 pointer-events-none"; + +const SYSTEM_DEFAULT_VALUE = "__system_default__"; +const SYSTEM_DEFAULT_LABEL = "System default"; + +interface DiffFontPickerProps { + readonly value: string; + readonly onValueChange: (next: string) => void; + readonly className?: string; +} + +interface PickerItem { + readonly value: string; + readonly label: string; + readonly fontFamily: string | null; + readonly isMonospace: boolean; +} + +function buildItems(fonts: ReadonlyArray, currentValue: string): PickerItem[] { + const items: PickerItem[] = [ + { + value: SYSTEM_DEFAULT_VALUE, + label: SYSTEM_DEFAULT_LABEL, + fontFamily: null, + isMonospace: true, + }, + ]; + const seen = new Set(); + for (const font of fonts) { + if (seen.has(font.family)) continue; + seen.add(font.family); + items.push({ + value: font.family, + label: font.family, + fontFamily: font.family, + isMonospace: font.isMonospace, + }); + } + if (currentValue && !seen.has(currentValue)) { + items.push({ + value: currentValue, + label: currentValue, + fontFamily: currentValue, + isMonospace: isMonospaceFamily(currentValue), + }); + } + return items; +} + +export function DiffFontPicker({ value, onValueChange, className }: DiffFontPickerProps) { + const [open, setOpen] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [fonts, setFonts] = useState>([]); + const [source, setSource] = useState<"local-fonts" | "fallback" | null>(null); + const [query, setQuery] = useState(""); + const [monospaceOnly, setMonospaceOnly] = useState(true); + const filter = useComboboxFilter(); + + useEffect(() => { + if (!open || hasLoaded) return; + let cancelled = false; + void loadSystemFonts() + .then((result) => { + if (cancelled) return; + setFonts(result.fonts); + setSource(result.source); + setHasLoaded(true); + }) + .catch((error) => { + if (cancelled) return; + console.warn("[DiffFontPicker] failed to load system fonts", error); + setHasLoaded(true); + }); + return () => { + cancelled = true; + }; + }, [open, hasLoaded]); + + const items = useMemo(() => buildItems(fonts, value), [fonts, value]); + + const filteredItems = useMemo(() => { + const base = monospaceOnly + ? items.filter((item) => item.value === SYSTEM_DEFAULT_VALUE || item.isMonospace) + : items; + const trimmedQuery = query.trim(); + if (trimmedQuery.length === 0) return base; + return base.filter((item) => filter.contains(item.label, trimmedQuery)); + }, [items, monospaceOnly, query, filter]); + + const triggerLabel = value ? value : SYSTEM_DEFAULT_LABEL; + const selectedValue = value ? value : SYSTEM_DEFAULT_VALUE; + + const handleValueChange = (next: string | null) => { + if (next === null) return; + if (next === SYSTEM_DEFAULT_VALUE) { + onValueChange(""); + } else { + onValueChange(next); + } + setOpen(false); + setQuery(""); + }; + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setQuery(""); + } + }; + + const statusText = !hasLoaded + ? "Loading fonts..." + : source === "fallback" + ? "Showing common monospace fonts." + : null; + + return ( + item.value)} + filter={null} + value={selectedValue} + onValueChange={handleValueChange} + open={open} + onOpenChange={handleOpenChange} + > + + + {triggerLabel} + + + + +
+ setQuery(event.target.value)} + /> +
+
+ +
+ No fonts found. + + {filteredItems.map((item, index) => ( + + + {item.label} + + + ))} + + {statusText ? {statusText} : null} +
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 632929e9029..208b6d873ad 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -56,6 +56,7 @@ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; +import { DiffFontPicker } from "./DiffFontPicker"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic"; @@ -389,6 +390,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), + ...(settings.diffFontFamily !== DEFAULT_UNIFIED_SETTINGS.diffFontFamily ? ["Diff font"] : []), ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? ["Auto-open task panel"] : []), @@ -418,6 +420,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadDelete, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, + settings.diffFontFamily, settings.diffIgnoreWhitespace, settings.diffWordWrap, settings.enableAssistantStreaming, @@ -919,6 +922,28 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ diffFontFamily: DEFAULT_UNIFIED_SETTINGS.diffFontFamily }) + } + /> + ) : null + } + control={ + updateSettings({ diffFontFamily: next })} + className="w-full sm:w-64" + /> + } + /> + ; + readonly source: "local-fonts" | "fallback"; +} + +interface FontData { + readonly family: string; + readonly fullName?: string; + readonly postscriptName?: string; + readonly style?: string; +} + +interface QueryLocalFontsWindow { + queryLocalFonts?: () => Promise>; +} + +const KNOWN_MONOSPACE_FAMILIES: ReadonlyArray = [ + "Andale Mono", + "Cascadia Code", + "Cascadia Mono", + "Consolas", + "Courier", + "Courier New", + "DejaVu Sans Mono", + "Fira Code", + "Fira Mono", + "Hack", + "IBM Plex Mono", + "Inconsolata", + "JetBrains Mono", + "Liberation Mono", + "Menlo", + "Monaco", + "MonoLisa", + "Noto Sans Mono", + "Operator Mono", + "PT Mono", + "Roboto Mono", + "SF Mono", + "Source Code Pro", + "Ubuntu Mono", + "Victor Mono", +]; + +const MONOSPACE_NAME_HINTS: ReadonlyArray = [ + "mono", + "code", + "console", + "courier", + "terminal", + "typewriter", +]; + +const KNOWN_MONOSPACE_SET = new Set(KNOWN_MONOSPACE_FAMILIES.map((name) => name.toLowerCase())); + +function isLikelyMonospace(family: string): boolean { + const lower = family.toLowerCase(); + if (KNOWN_MONOSPACE_SET.has(lower)) return true; + return MONOSPACE_NAME_HINTS.some((hint) => lower.includes(hint)); +} + +function buildFallbackFontList(): SystemFontList { + const fonts = KNOWN_MONOSPACE_FAMILIES.map((family) => ({ + family, + isMonospace: true, + })); + return { fonts, source: "fallback" }; +} + +let cachedFontsPromise: Promise | null = null; + +async function loadSystemFontsUncached(): Promise { + if (typeof window === "undefined") { + return buildFallbackFontList(); + } + const queryLocalFonts = (window as QueryLocalFontsWindow).queryLocalFonts; + if (typeof queryLocalFonts !== "function") { + return buildFallbackFontList(); + } + + try { + const fontData = await queryLocalFonts(); + const familyMap = new Map(); + for (const entry of fontData) { + const family = entry.family?.trim(); + if (!family) continue; + if (familyMap.has(family)) continue; + familyMap.set(family, { + family, + isMonospace: isLikelyMonospace(family), + }); + } + if (familyMap.size === 0) { + return buildFallbackFontList(); + } + const fonts = [...familyMap.values()].sort((left, right) => + left.family.localeCompare(right.family, undefined, { sensitivity: "base" }), + ); + return { fonts, source: "local-fonts" }; + } catch (error) { + console.warn("[systemFonts] queryLocalFonts() failed; using fallback", error); + return buildFallbackFontList(); + } +} + +export function loadSystemFonts(): Promise { + if (!cachedFontsPromise) { + cachedFontsPromise = loadSystemFontsUncached().catch((error) => { + cachedFontsPromise = null; + throw error; + }); + } + return cachedFontsPromise; +} + +export function isMonospaceFamily(family: string): boolean { + return isLikelyMonospace(family); +} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 89a80782edc..e672bbeaaef 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -33,6 +33,7 @@ export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + diffFontFamily: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Model favorites. Historically keyed by provider kind, now @@ -453,6 +454,7 @@ export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), + diffFontFamily: Schema.optionalKey(Schema.String), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffWordWrap: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey( From 1066e6c58bbffbf0524d94c410ad1b16c1178541 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Tue, 5 May 2026 23:18:38 +0530 Subject: [PATCH 08/46] Add terminal font customization - Generalize DiffFontPicker to FontPicker for reuse - Add terminalFontFamily setting to contracts schema - Apply custom font selection in TerminalViewport --- .../src/components/ThreadTerminalDrawer.tsx | 17 +++++++++++- .../{DiffFontPicker.tsx => FontPicker.tsx} | 6 ++--- .../components/settings/SettingsPanels.tsx | 26 +++++++++++++++++-- packages/contracts/src/settings.ts | 2 ++ 4 files changed, 45 insertions(+), 6 deletions(-) rename apps/web/src/components/settings/{DiffFontPicker.tsx => FontPicker.tsx} (97%) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 6c71e5eb334..b0a13272427 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -48,10 +48,13 @@ import { import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; +import { useSettings } from "../hooks/useSettings"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; +const DEFAULT_TERMINAL_FONT_FAMILY = + '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace'; function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; @@ -280,6 +283,11 @@ export function TerminalViewport({ drawerHeight, keybindings, }: TerminalViewportProps) { + const settings = useSettings(); + const customTerminalFont = settings.terminalFontFamily?.trim() ?? ""; + const terminalFontFamily = customTerminalFont + ? `"${customTerminalFont}", ${DEFAULT_TERMINAL_FONT_FAMILY}` + : DEFAULT_TERMINAL_FONT_FAMILY; const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); @@ -320,7 +328,7 @@ export function TerminalViewport({ lineHeight: 1.2, fontSize: 12, scrollback: 5_000, - fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + fontFamily: terminalFontFamily, theme: terminalThemeFromApp(mount), }); terminal.loadAddon(fitAddon); @@ -754,6 +762,13 @@ export function TerminalViewport({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd, environmentId, runtimeEnv, terminalId, threadId]); + useEffect(() => { + const terminal = terminalRef.current; + if (!terminal) return; + terminal.options.fontFamily = terminalFontFamily; + fitAddonRef.current?.fit(); + }, [terminalFontFamily]); + useEffect(() => { if (!autoFocus) return; const terminal = terminalRef.current; diff --git a/apps/web/src/components/settings/DiffFontPicker.tsx b/apps/web/src/components/settings/FontPicker.tsx similarity index 97% rename from apps/web/src/components/settings/DiffFontPicker.tsx rename to apps/web/src/components/settings/FontPicker.tsx index b2a0fc0056b..c059eb5c217 100644 --- a/apps/web/src/components/settings/DiffFontPicker.tsx +++ b/apps/web/src/components/settings/FontPicker.tsx @@ -25,7 +25,7 @@ const FONT_PICKER_TRIGGER_ICON_CLASS_NAME = const SYSTEM_DEFAULT_VALUE = "__system_default__"; const SYSTEM_DEFAULT_LABEL = "System default"; -interface DiffFontPickerProps { +interface FontPickerProps { readonly value: string; readonly onValueChange: (next: string) => void; readonly className?: string; @@ -69,7 +69,7 @@ function buildItems(fonts: ReadonlyArray, currentValue: string): Pic return items; } -export function DiffFontPicker({ value, onValueChange, className }: DiffFontPickerProps) { +export function FontPicker({ value, onValueChange, className }: FontPickerProps) { const [open, setOpen] = useState(false); const [hasLoaded, setHasLoaded] = useState(false); const [fonts, setFonts] = useState>([]); @@ -90,7 +90,7 @@ export function DiffFontPicker({ value, onValueChange, className }: DiffFontPick }) .catch((error) => { if (cancelled) return; - console.warn("[DiffFontPicker] failed to load system fonts", error); + console.warn("[FontPicker] failed to load system fonts", error); setHasLoaded(true); }); return () => { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 208b6d873ad..1715ed5f8b1 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -56,7 +56,7 @@ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; -import { DiffFontPicker } from "./DiffFontPicker"; +import { FontPicker } from "./FontPicker"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic"; @@ -936,7 +936,7 @@ export function GeneralSettingsPanel() { ) : null } control={ - updateSettings({ diffFontFamily: next })} className="w-full sm:w-64" @@ -944,6 +944,28 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ terminalFontFamily: DEFAULT_UNIFIED_SETTINGS.terminalFontFamily }) + } + /> + ) : null + } + control={ + updateSettings({ terminalFontFamily: next })} + className="w-full sm:w-64" + /> + } + /> + Date: Tue, 5 May 2026 23:25:15 +0530 Subject: [PATCH 09/46] Reorganize settings into categorized sections - Rename General to Appearance for UI-related settings (fonts, theme, diff display) - Move chat/task settings to Threads & tasks section - Move project config to Projects section - Move git settings to Version control section --- .../components/settings/SettingsPanels.tsx | 224 +++++++++--------- 1 file changed, 115 insertions(+), 109 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 1715ed5f8b1..298fb8d20d2 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -796,7 +796,7 @@ export function GeneralSettingsPanel() { return ( - + - - updateSettings({ - diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, - }) - } - /> - ) : null - } - control={ - updateSettings({ diffWordWrap: Boolean(checked) })} - aria-label="Wrap diff lines by default" - /> - } - /> - - - updateSettings({ - diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, - }) - } - /> - ) : null - } - control={ - - updateSettings({ diffIgnoreWhitespace: Boolean(checked) }) - } - aria-label="Hide whitespace changes by default" - /> - } - /> - - updateSettings({ - enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, - }) - } - /> - ) : null - } - control={ - - updateSettings({ enableAssistantStreaming: Boolean(checked) }) - } - aria-label="Stream assistant messages" - /> - } - /> - - updateSettings({ - autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, + diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, }) } /> @@ -1010,25 +933,23 @@ export function GeneralSettingsPanel() { } control={ - updateSettings({ autoOpenPlanSidebar: Boolean(checked) }) - } - aria-label="Open the task panel automatically" + checked={settings.diffWordWrap} + onCheckedChange={(checked) => updateSettings({ diffWordWrap: Boolean(checked) })} + aria-label="Wrap diff lines by default" /> } /> updateSettings({ - autoCreatePrOnPush: DEFAULT_UNIFIED_SETTINGS.autoCreatePrOnPush, + diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, }) } /> @@ -1036,15 +957,17 @@ export function GeneralSettingsPanel() { } control={ - updateSettings({ autoCreatePrOnPush: Boolean(checked) }) + updateSettings({ diffIgnoreWhitespace: Boolean(checked) }) } - aria-label="Auto-create PR on push" + aria-label="Hide whitespace changes by default" /> } /> + + updateSettings({ - addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, }) } /> ) : null } control={ - updateSettings({ addProjectBaseDirectory: next })} - placeholder="~/" - spellCheck={false} - aria-label="Add project base directory" + + updateSettings({ autoOpenPlanSidebar: Boolean(checked) }) + } + aria-label="Open the task panel automatically" + /> + } + /> + + + updateSettings({ + enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, + }) + } + /> + ) : null + } + control={ + + updateSettings({ enableAssistantStreaming: Boolean(checked) }) + } + aria-label="Stream assistant messages" /> } /> @@ -1165,6 +1113,64 @@ export function GeneralSettingsPanel() { /> } /> + + + + + updateSettings({ + addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, + }) + } + /> + ) : null + } + control={ + updateSettings({ addProjectBaseDirectory: next })} + placeholder="~/" + spellCheck={false} + aria-label="Add project base directory" + /> + } + /> + + + + + updateSettings({ + autoCreatePrOnPush: DEFAULT_UNIFIED_SETTINGS.autoCreatePrOnPush, + }) + } + /> + ) : null + } + control={ + + updateSettings({ autoCreatePrOnPush: Boolean(checked) }) + } + aria-label="Auto-create PR on push" + /> + } + /> Date: Wed, 6 May 2026 10:14:08 +0530 Subject: [PATCH 10/46] Format resolveQuickAction call in GitActionsControl test --- apps/web/src/components/GitActionsControl.logic.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 55511ceb3f1..2cc8913cb60 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1150,13 +1150,7 @@ describe("when: autoCreatePr is disabled", () => { }); it("downgrades feature-branch ahead-with-upstream from create PR to plain push", () => { - const quick = resolveQuickAction( - status({ aheadCount: 2 }), - false, - false, - true, - false, - ); + const quick = resolveQuickAction(status({ aheadCount: 2 }), false, false, true, false); assert.deepInclude(quick, { kind: "run_action", action: "push", From 9a7fedcee919a2a459a362a3cf0941b326cd31e8 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Wed, 6 May 2026 11:09:08 +0530 Subject: [PATCH 11/46] Remove CLAUDE.md symlink --- CLAUDE.md | 1 - apps/web/src/components/GitActionsControl.tsx | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) delete mode 120000 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index c3170642553..00000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index fcf2024bae8..1e88fcdea77 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1788,9 +1788,7 @@ export default function GitActionsControl({ {gitStatusForActions?.refName ?? "(detached HEAD)"} {isDefaultRef && ( - - Warning: default branch - + Warning: default branch )} From 957a62607404607fdadcb4fc27f985b6a272e761 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Wed, 6 May 2026 11:21:03 +0530 Subject: [PATCH 12/46] Add autoCreatePrOnPush to test clientSettings fixture Adds default value for new setting to keep tests passing --- apps/desktop/src/clientPersistence.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index d4c4768d2c8..6c41085c3f6 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -49,6 +49,7 @@ function makeSecretStorage(available: boolean): DesktopSecretStorage { } const clientSettings: ClientSettings = { + autoCreatePrOnPush: true, autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, From cd3500b26671965d282f5389f8259863d5a1299b Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Wed, 6 May 2026 11:32:34 +0530 Subject: [PATCH 13/46] Add autoCreatePrOnPush to test clientSettings fixtures --- apps/web/src/localApi.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 895163cf109..1d25de6a727 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -570,6 +570,7 @@ describe("wsApi", () => { it("reads and writes persistence through the desktop bridge when available", async () => { const clientSettings = { + autoCreatePrOnPush: true, autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, @@ -631,6 +632,7 @@ describe("wsApi", () => { const { createLocalApi } = await import("./localApi"); const api = createLocalApi(rpcClientMock as never); const clientSettings = { + autoCreatePrOnPush: true, autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, From 0b5db8c52def60e774602425b61403c1afd161a6 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Wed, 6 May 2026 12:00:30 +0530 Subject: [PATCH 14/46] Fix clientSettings fixture and apply formatter after merge --- apps/desktop/src/clientPersistence.test.ts | 2 ++ apps/web/src/components/DiffPanel.tsx | 4 +--- .../web/src/components/settings/FontPicker.tsx | 18 +++--------------- .../src/components/settings/SettingsPanels.tsx | 4 +++- 4 files changed, 9 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 6c41085c3f6..561f5f88100 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -53,6 +53,7 @@ const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + diffFontFamily: "", diffIgnoreWhitespace: true, diffWordWrap: true, favorites: [], @@ -63,6 +64,7 @@ const clientSettings: ClientSettings = { }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", + terminalFontFamily: "", timestampFormat: "24-hour", }; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 4767abdbb13..c8d4fa97aeb 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -748,9 +748,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ? "overflow-auto whitespace-pre-wrap wrap-break-word" : "overflow-auto", )} - style={ - diffFontFamilyValue ? { fontFamily: diffFontFamilyValue } : undefined - } + style={diffFontFamilyValue ? { fontFamily: diffFontFamilyValue } : undefined} > {renderablePatch.text} diff --git a/apps/web/src/components/settings/FontPicker.tsx b/apps/web/src/components/settings/FontPicker.tsx index c059eb5c217..831c2870052 100644 --- a/apps/web/src/components/settings/FontPicker.tsx +++ b/apps/web/src/components/settings/FontPicker.tsx @@ -146,10 +146,7 @@ export function FontPicker({ value, onValueChange, className }: FontPickerProps) onOpenChange={handleOpenChange} > - + {triggerLabel} @@ -179,19 +176,10 @@ export function FontPicker({ value, onValueChange, className }: FontPickerProps) No fonts found. {filteredItems.map((item, index) => ( - + {item.label} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 298fb8d20d2..670178eed1c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -902,7 +902,9 @@ export function GeneralSettingsPanel() { - updateSettings({ terminalFontFamily: DEFAULT_UNIFIED_SETTINGS.terminalFontFamily }) + updateSettings({ + terminalFontFamily: DEFAULT_UNIFIED_SETTINGS.terminalFontFamily, + }) } /> ) : null From 594ed43e4fc7c02e9566fc19afae18f9c16bbd9f Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Wed, 6 May 2026 12:12:18 +0530 Subject: [PATCH 15/46] Link CLAUDE.md to agents.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000000..47d29cb5ec2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +agents.md \ No newline at end of file From 9d09998c930ae4d46dbfdc2e689a01bdecab8efe Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Wed, 6 May 2026 13:37:35 +0530 Subject: [PATCH 16/46] Fix clipboard write in packaged desktop build Extend the Electron permission allowlist so navigator.clipboard.writeText calls from the renderer ("Copy" buttons in messages, sidebar, etc.) work in production. Without this, the permission handler installed alongside the local-fonts grant denied clipboard-sanitized-write, surfacing "Failed to copy" toasts. Dev mode was unaffected. --- apps/desktop/src/electron.d.ts | 2 +- apps/desktop/src/main.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/electron.d.ts b/apps/desktop/src/electron.d.ts index a6445988626..0b5b72c1606 100644 --- a/apps/desktop/src/electron.d.ts +++ b/apps/desktop/src/electron.d.ts @@ -4,7 +4,7 @@ declare namespace Electron { handler: | (( webContents: WebContents, - permission: "local-fonts", + permission: "local-fonts" | "clipboard-sanitized-write" | "clipboard-read", callback: (permissionGranted: boolean) => void, details: PermissionRequest, ) => void) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 012f9e0e172..673197a33e4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -2241,7 +2241,11 @@ app writeDesktopLogHeader("app ready"); configureAppIdentity(); session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { - if (permission === "local-fonts") { + if ( + permission === "local-fonts" || + permission === "clipboard-sanitized-write" || + permission === "clipboard-read" + ) { callback(true); return; } From 82e3c03a93657bc94670978510f4a4c2905a2c98 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Wed, 6 May 2026 14:03:49 +0530 Subject: [PATCH 17/46] Include build timestamp in desktop artifact filenames - Adds formatBuildTimestamp utility for UTC YYYYMMDD-HHMM format - Updates artifactName to include timestamp in builds - Helps distinguish builds for debugging --- scripts/build-desktop-artifact.test.ts | 8 ++++++++ scripts/build-desktop-artifact.ts | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 86452693d09..9bcd240d4d2 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -3,6 +3,7 @@ import { assert, it } from "@effect/vitest"; import { ConfigProvider, Effect, Option } from "effect"; import { + formatBuildTimestamp, resolveBuildOptions, resolveDesktopBuildIconAssets, resolveDesktopProductName, @@ -37,6 +38,13 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); }); + it("formats build timestamps as zero-padded UTC YYYYMMDD-HHMM", () => { + assert.equal(formatBuildTimestamp(new Date(Date.UTC(2026, 4, 6, 14, 30))), "20260506-1430"); + assert.equal(formatBuildTimestamp(new Date(Date.UTC(2026, 0, 1, 0, 0))), "20260101-0000"); + assert.equal(formatBuildTimestamp(new Date(Date.UTC(2026, 11, 31, 23, 59))), "20261231-2359"); + assert.match(formatBuildTimestamp(new Date()), /^\d{8}-\d{4}$/); + }); + it("falls back to the default mock update port when the configured port is blank", () => { assert.equal(resolveMockUpdateServerUrl(undefined), "http://localhost:3000"); assert.equal(resolveMockUpdateServerUrl(4123), "http://localhost:4123"); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 74e8bed0cb8..4a57cad0472 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -150,6 +150,16 @@ const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRo return hash.toLowerCase(); }); +export const formatBuildTimestamp = (date: Date): string => { + const pad = (n: number) => n.toString().padStart(2, "0"); + const yyyy = date.getUTCFullYear(); + const mm = pad(date.getUTCMonth() + 1); + const dd = pad(date.getUTCDate()); + const hh = pad(date.getUTCHours()); + const mi = pad(date.getUTCMinutes()); + return `${yyyy}${mm}${dd}-${hh}${mi}`; +}; + const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -565,11 +575,12 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( signed: boolean, mockUpdates: boolean, mockUpdateServerPort: number | undefined, + buildTimestamp: string, ) { const buildConfig: Record = { appId: "com.t3tools.t3code", productName: resolveDesktopProductName(version), - artifactName: "T3-Code-${version}-${arch}.${ext}", + artifactName: `T3-Code-\${version}-\${arch}-${buildTimestamp}.\${ext}`, directories: { buildResources: "apps/desktop/resources", }, @@ -777,6 +788,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // electron-builder is filtering out stageResourcesDir directory in the AppImage for production yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources")); + const buildTimestamp = formatBuildTimestamp(new Date()); + const stagePackageJson: StagePackageJson = { name: "t3code", version: appVersion, @@ -793,6 +806,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.signed, options.mockUpdates, options.mockUpdateServerPort, + buildTimestamp, ), dependencies: { ...resolvedServerDependencies, @@ -845,7 +859,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( } yield* Effect.log( - `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion})...`, + `[desktop-artifact] Building ${options.platform}/${options.target} (arch=${options.arch}, version=${appVersion}, timestamp=${buildTimestamp})...`, ); yield* runCommand( ChildProcess.make({ From 90a454b023fb0b54a63e504685b9c0525a2ca9ec Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 8 May 2026 12:07:53 +0530 Subject: [PATCH 18/46] Restore message text and attachments when reverting edits - Add chatImageAttachmentsToComposerImages helper to restore images - Track user messages by ID for lookup during revert - Make onRevertToTurnCount async and return success status - Populate composer draft with reverted message content and attachments --- apps/web/src/components/ChatView.tsx | 78 +++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9ef221e262a..425411996b5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -90,6 +90,7 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, + type ChatAttachment, type SessionPhase, type Thread, type TurnDiffSummary, @@ -600,6 +601,34 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); }); +async function chatImageAttachmentsToComposerImages( + attachments: ReadonlyArray, +): Promise { + const restoredImages: ComposerImageAttachment[] = []; + for (const attachment of attachments) { + if (attachment.type !== "image" || !attachment.previewUrl) { + continue; + } + try { + const response = await fetch(attachment.previewUrl); + const blob = await response.blob(); + const file = new File([blob], attachment.name, { type: attachment.mimeType }); + restoredImages.push({ + type: "image", + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: attachment.previewUrl, + file, + }); + } catch { + continue; + } + } + return restoredImages; +} + export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -1604,6 +1633,17 @@ export default function ChatView(props: ChatViewProps) { return byUserMessageId; }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); + const userMessageById = useMemo(() => { + const byMessageId = new Map(); + for (const entry of timelineEntries) { + if (!entry || entry.kind !== "message" || entry.message.role !== "user") { + continue; + } + byMessageId.set(entry.message.id, entry.message); + } + return byMessageId; + }, [timelineEntries]); + const completionSummary = useMemo(() => { if (!latestTurnSettled) return null; if (!activeLatestTurn?.startedAt) return null; @@ -2549,21 +2589,21 @@ export default function ChatView(props: ChatViewProps) { ]); const onRevertToTurnCount = useCallback( - async (turnCount: number) => { + async (turnCount: number): Promise => { const api = readEnvironmentApi(environmentId); const localApi = readLocalApi(); - if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; + if (!api || !localApi || !activeThread || isRevertingCheckpoint) return false; if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { setThreadError( activeThread.id, `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, ); - return; + return false; } if (phase === "running" || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); - return; + return false; } const confirmed = await localApi.dialogs.confirm( [ @@ -2573,7 +2613,7 @@ export default function ChatView(props: ChatViewProps) { ].join("\n"), ); if (!confirmed) { - return; + return false; } setIsRevertingCheckpoint(true); @@ -2593,6 +2633,7 @@ export default function ChatView(props: ChatViewProps) { ); } setIsRevertingCheckpoint(false); + return true; }, [ activeThread, @@ -3480,14 +3521,37 @@ export default function ChatView(props: ChatViewProps) { // the callback reference is fully stable and never busts context identity. const revertTurnCountRef = useRef(revertTurnCountByUserMessageId); revertTurnCountRef.current = revertTurnCountByUserMessageId; + const userMessageByIdRef = useRef(userMessageById); + userMessageByIdRef.current = userMessageById; const onRevertToTurnCountRef = useRef(onRevertToTurnCount); onRevertToTurnCountRef.current = onRevertToTurnCount; - const onRevertUserMessage = useCallback((messageId: MessageId) => { + const setComposerDraftPromptRef = useRef(setComposerDraftPrompt); + setComposerDraftPromptRef.current = setComposerDraftPrompt; + const addComposerDraftImagesRef = useRef(addComposerDraftImages); + addComposerDraftImagesRef.current = addComposerDraftImages; + const composerDraftTargetRef = useRef(composerDraftTarget); + composerDraftTargetRef.current = composerDraftTarget; + + const onRevertUserMessage = useCallback(async (messageId: MessageId) => { const targetTurnCount = revertTurnCountRef.current.get(messageId); if (typeof targetTurnCount !== "number") { return; } - void onRevertToTurnCountRef.current(targetTurnCount); + const didProceed = await onRevertToTurnCountRef.current(targetTurnCount); + if (!didProceed) { + return; + } + + const userMessage = userMessageByIdRef.current.get(messageId); + if (userMessage) { + setComposerDraftPromptRef.current(composerDraftTargetRef.current, userMessage.text); + if (userMessage.attachments && userMessage.attachments.length > 0) { + const restoredImages = await chatImageAttachmentsToComposerImages(userMessage.attachments); + if (restoredImages.length > 0) { + addComposerDraftImagesRef.current(composerDraftTargetRef.current, restoredImages); + } + } + } }, []); // Empty state: no active thread From 561a448da590c8e263a40b1eb8c3831cf87778b1 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 8 May 2026 17:56:05 +0530 Subject: [PATCH 19/46] update electron permissions --- apps/desktop/src/electron/ElectronPermissions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/electron/ElectronPermissions.ts b/apps/desktop/src/electron/ElectronPermissions.ts index a07043f631e..209d74045d4 100644 --- a/apps/desktop/src/electron/ElectronPermissions.ts +++ b/apps/desktop/src/electron/ElectronPermissions.ts @@ -23,7 +23,7 @@ const install = Effect.acquireRelease( }), ); -export const layer = Layer.scopedDiscard( +export const layer = Layer.effectDiscard( Effect.gen(function* () { const app = yield* ElectronApp.ElectronApp; yield* app.whenReady; From 76934ad804da198356b59c83a7a4f19329005c8b Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 8 May 2026 18:09:14 +0530 Subject: [PATCH 20/46] Implement fallback plan capture for text-only plan-mode responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a heuristic to detect and capture plan-like text when Claude completes a plan-mode turn without calling ExitPlanMode. This makes the Implement button appear for structured text plans that don't explicitly use the SDK tool. The fallback gates on all of: - Turn ran in plan mode - No ExitPlanMode already captured - No pending user input request - Turn completed successfully The heuristic checks for: - Text length >= 200 chars (filters trivial responses) - Structural markers (headings, bullet/numbered lists) - Not starting with refusal stems - Either 3+ total list items OR a plan-related heading keyword Captures via the same emitProposedPlanCompleted path, with rawMethod "claude/text-fallback" for observability. Deduplication prevents double-firing when ExitPlanMode already captured the turn. No web/client changes required—the capture flows through existing turn.proposed.completed → activeProposedPlan → Implement button path. --- .../src/provider/Layers/ClaudeAdapter.ts | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 556504d6cf4..c995b1c14d9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -117,6 +117,7 @@ interface ClaudeTurnState { readonly assistantTextBlocks: Map; readonly assistantTextBlockOrder: Array; readonly capturedProposedPlanKeys: Set; + readonly interactionMode: "plan" | "default"; nextSyntheticAssistantBlockIndex: number; } @@ -785,6 +786,49 @@ function extractExitPlanModePlan(value: unknown): string | undefined { : undefined; } +function looksLikePlan(text: string): boolean { + const trimmed = text.trim(); + + if (trimmed.length < 200) { + return false; + } + + const headingMatch = /^#{1,4} \S/m.test(trimmed); + const orderedListMatch = /^\s*\d+\.\s+\S/m.test(trimmed); + const bulletedListMatch = /^\s*[-*]\s+\S/m.test(trimmed); + + if (!headingMatch && !orderedListMatch && !bulletedListMatch) { + return false; + } + + const firstLineChars = trimmed.substring(0, 120).replace(/^#+\s*/, ""); + const refusalStems = [ + /^i can't/i, + /^i cannot/i, + /^i'm not able/i, + /^i am unable/i, + /^i don't have enough/i, + /^could you clarify/i, + /^can you clarify/i, + /^before i /i, + /^to help you, i need/i, + ]; + + if (refusalStems.some((stem) => stem.test(firstLineChars))) { + return false; + } + + const orderedListCount = (trimmed.match(/^\s*\d+\.\s+\S/gm) || []).length; + const bulletedListCount = (trimmed.match(/^\s*[-*]\s+\S/gm) || []).length; + const totalListItems = orderedListCount + bulletedListCount; + + const planHeadingMatch = /^#{1,4} [^\n]*(?:plan|approach|steps?|implementation|proposal)/i.test( + trimmed, + ); + + return totalListItems >= 3 || planHeadingMatch; +} + function exitPlanCaptureKey(input: { readonly toolUseId?: string | undefined; readonly planMarkdown: string; @@ -1359,7 +1403,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: { readonly planMarkdown: string; readonly toolUseId?: string | undefined; - readonly rawSource: "claude.sdk.message" | "claude.sdk.permission"; + readonly rawSource: + | "claude.sdk.message" + | "claude.sdk.permission" + | "claude.sdk.text-fallback"; readonly rawMethod: string; readonly rawPayload: unknown; }, @@ -1543,6 +1590,33 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } + if ( + turnState.interactionMode === "plan" && + turnState.capturedProposedPlanKeys.size === 0 && + context.pendingUserInputs.size === 0 && + status === "completed" && + result?.subtype === "success" + ) { + let lastNonEmptyBlock: AssistantTextBlockState | undefined; + for (let i = turnState.assistantTextBlockOrder.length - 1; i >= 0; i--) { + const block = turnState.assistantTextBlockOrder[i]; + const blockText = block.fallbackText.trim(); + if (blockText.length > 0) { + lastNonEmptyBlock = block; + break; + } + } + + if (lastNonEmptyBlock && looksLikePlan(lastNonEmptyBlock.fallbackText)) { + yield* emitProposedPlanCompleted(context, { + planMarkdown: lastNonEmptyBlock.fallbackText, + rawSource: "claude.sdk.text-fallback", + rawMethod: "claude/text-fallback", + rawPayload: result, + }); + } + } + const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "turn.completed", @@ -3093,6 +3167,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const turnId = TurnId.make(yield* Random.nextUUIDv4); + const resolvedInteractionMode = + input.interactionMode ?? context.session.interactionMode ?? "default"; const turnState: ClaudeTurnState = { turnId, startedAt: yield* nowIso, @@ -3100,6 +3176,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), + interactionMode: resolvedInteractionMode, nextSyntheticAssistantBlockIndex: -1, }; From 3f4e22e9752d7be056836d42927a28ffe83edb17 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 8 May 2026 18:33:28 +0530 Subject: [PATCH 21/46] Track interaction mode state in Claude adapter - Store currentInteractionMode in context to properly reflect plan/default mode - Use tracked mode instead of input-based derivation for resolvedInteractionMode - Add null safety check for text block access - Support claude.sdk.text-fallback event source --- apps/server/src/provider/Layers/ClaudeAdapter.ts | 14 ++++++++++---- packages/contracts/src/providerRuntime.ts | 1 + 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index c995b1c14d9..1000f682ee3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -174,6 +174,7 @@ interface ClaudeSessionContext { lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; + currentInteractionMode: "plan" | "default"; stopped: boolean; } @@ -1600,8 +1601,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( let lastNonEmptyBlock: AssistantTextBlockState | undefined; for (let i = turnState.assistantTextBlockOrder.length - 1; i >= 0; i--) { const block = turnState.assistantTextBlockOrder[i]; - const blockText = block.fallbackText.trim(); - if (blockText.length > 0) { + if (!block) { + continue; + } + if (block.fallbackText.trim().length > 0) { lastNonEmptyBlock = block; break; } @@ -2030,6 +2033,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), + interactionMode: "default", nextSyntheticAssistantBlockIndex: -1, }; context.session = { @@ -3047,6 +3051,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( lastKnownTokenUsage: undefined, lastAssistantUuid: resumeState?.resumeSessionAt, lastThreadStartedId: undefined, + currentInteractionMode: permissionMode === "plan" ? "plan" : "default", stopped: false, }; yield* Ref.set(contextRef, context); @@ -3159,16 +3164,17 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( try: () => context.query.setPermissionMode("plan"), catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), }); + context.currentInteractionMode = "plan"; } else if (input.interactionMode === "default") { yield* Effect.tryPromise({ try: () => context.query.setPermissionMode(context.basePermissionMode ?? "default"), catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), }); + context.currentInteractionMode = "default"; } const turnId = TurnId.make(yield* Random.nextUUIDv4); - const resolvedInteractionMode = - input.interactionMode ?? context.session.interactionMode ?? "default"; + const resolvedInteractionMode = context.currentInteractionMode; const turnState: ClaudeTurnState = { turnId, startedAt: yield* nowIso, diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 5032dc4eb41..77491639e0d 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -24,6 +24,7 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("codex.eventmsg"), Schema.Literal("claude.sdk.message"), Schema.Literal("claude.sdk.permission"), + Schema.Literal("claude.sdk.text-fallback"), Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), Schema.Literal("acp.jsonrpc"), From 087a96bd5c7cf7b66668e5aca1aca5afbef138fb Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 8 May 2026 18:50:21 +0530 Subject: [PATCH 22/46] Track interaction mode state in Claude adapter - Initialize interactionMode from context instead of hardcoding - Fix plan detection regex to support multiline matching with 'm' flag --- apps/server/src/provider/Layers/ClaudeAdapter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 1000f682ee3..d5e1541463c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -823,7 +823,7 @@ function looksLikePlan(text: string): boolean { const bulletedListCount = (trimmed.match(/^\s*[-*]\s+\S/gm) || []).length; const totalListItems = orderedListCount + bulletedListCount; - const planHeadingMatch = /^#{1,4} [^\n]*(?:plan|approach|steps?|implementation|proposal)/i.test( + const planHeadingMatch = /^#{1,4} [^\n]*(?:plan|approach|steps?|implementation|proposal)/im.test( trimmed, ); @@ -2033,7 +2033,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), - interactionMode: "default", + interactionMode: context.currentInteractionMode, nextSyntheticAssistantBlockIndex: -1, }; context.session = { From 53de8b841b9110ac8644581270b64e09e38a9330 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 8 May 2026 19:51:36 +0530 Subject: [PATCH 23/46] Rebrand to A3 and add build timestamp tracking - Change stage label from Alpha to A3 - Add buildTimestamp parameter throughout environment setup - Extract formatBuildTimestamp to @t3tools/shared - Update displayVersion format: pkgVersion-fork-timestamp --- apps/desktop/package.json | 2 +- apps/desktop/scripts/electron-launcher.mjs | 2 +- .../src/app/DesktopAppIdentity.test.ts | 7 +++--- apps/desktop/src/app/DesktopAppIdentity.ts | 3 ++- .../src/app/DesktopEnvironment.test.ts | 1 + apps/desktop/src/app/DesktopEnvironment.ts | 11 +++++++- .../src/app/DesktopObservability.test.ts | 1 + apps/desktop/src/app/forkBranding.ts | 8 ++++++ .../DesktopBackendConfiguration.test.ts | 1 + .../src/backend/DesktopServerExposure.test.ts | 1 + .../src/electron/ElectronPermissions.ts | 8 +++--- apps/desktop/src/main.ts | 25 ++++++++++++++++--- .../src/settings/DesktopAppSettings.test.ts | 1 + .../settings/DesktopClientSettings.test.ts | 1 + .../settings/DesktopSavedEnvironments.test.ts | 1 + .../src/updates/DesktopUpdates.test.ts | 1 + .../src/window/DesktopApplicationMenu.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 1 + apps/web/src/branding.test.ts | 2 ++ apps/web/src/branding.ts | 5 +++- apps/web/src/forkBranding.ts | 5 ++++ apps/web/src/versionSkew.ts | 4 +-- packages/contracts/src/ipc.ts | 8 ++++-- packages/shared/package.json | 4 +++ packages/shared/src/buildTimestamp.ts | 9 +++++++ scripts/build-desktop-artifact.test.ts | 4 +-- scripts/build-desktop-artifact.ts | 14 +++-------- 27 files changed, 101 insertions(+), 30 deletions(-) create mode 100644 apps/desktop/src/app/forkBranding.ts create mode 100644 apps/web/src/forkBranding.ts create mode 100644 packages/shared/src/buildTimestamp.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 843209363f3..f448a2f7e21 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -34,5 +34,5 @@ "typescript": "catalog:", "vitest": "catalog:" }, - "productName": "T3 Code (Alpha)" + "productName": "T3 Code (A3)" } diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 1453cbe666e..942e3be1969 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -17,7 +17,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); -const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; +const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (A3)"; const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; const LAUNCHER_VERSION = 2; diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index f95fd1bef71..bd290eeb40c 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -23,6 +23,7 @@ const defaultEnvironmentInput = { isPackaged: true, resourcesPath: "/Applications/T3 Code.app/Contents/Resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; type TestEnvironmentInput = Partial & { @@ -156,9 +157,9 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; yield* identity.configure; - assert.deepEqual(calls.setName, ["T3 Code (Alpha)"]); - assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (Alpha)"); - assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); + assert.deepEqual(calls.setName, ["T3 Code (A3)"]); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "T3 Code (A3)"); + assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3-a3-20260508-1430"); assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); assert.deepEqual(calls.setDockIcon, ["/icon.png"]); }), diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 0b9b8196651..5f008be331c 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -15,6 +15,7 @@ const COMMIT_HASH_DISPLAY_LENGTH = 12; const AppPackageMetadata = Schema.Struct({ t3codeCommitHash: Schema.optional(Schema.String), + t3codeBuildTimestamp: Schema.optional(Schema.String), }); export interface DesktopAppIdentityShape { @@ -97,7 +98,7 @@ const make = Effect.gen(function* () { yield* electronApp.setName(environment.displayName); yield* electronApp.setAboutPanelOptions({ applicationName: environment.displayName, - applicationVersion: environment.appVersion, + applicationVersion: environment.displayVersion, version: Option.getOrElse(commitHash, () => "unknown"), }); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 427b8848833..fb6a30fa25a 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -17,6 +17,7 @@ const defaultInput = { isPackaged: false, resourcesPath: "/Applications/T3 Code.app/Contents/Resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; const makeEnvironmentLayer = ( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index a5212f25358..391489c3197 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -17,6 +17,7 @@ import { } from "../settings/DesktopAppSettings.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import { isNightlyDesktopVersion } from "../updates/updateChannels.ts"; +import { FORK_STAGE_LABEL, formatForkDisplayVersion } from "./forkBranding.ts"; export interface MakeDesktopEnvironmentInput { readonly dirname: string; @@ -28,6 +29,7 @@ export interface MakeDesktopEnvironmentInput { readonly isPackaged: boolean; readonly resourcesPath: string; readonly runningUnderArm64Translation: boolean; + readonly buildTimestamp: string; } export interface DesktopEnvironmentShape { @@ -73,6 +75,7 @@ export interface DesktopEnvironmentShape { readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option; readonly resolveResourcePathCandidates: (fileName: string) => readonly string[]; readonly developmentDockIconPath: string; + readonly displayVersion: string; } export class DesktopEnvironment extends Context.Service< @@ -90,18 +93,21 @@ function resolveDesktopAppStageLabel(input: { return "Dev"; } - return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; + return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : FORK_STAGE_LABEL; } function resolveDesktopAppBranding(input: { readonly isDevelopment: boolean; readonly appVersion: string; + readonly displayVersion: string; }): DesktopAppBranding { const stageLabel = resolveDesktopAppStageLabel(input); return { baseName: APP_BASE_NAME, stageLabel, displayName: `${APP_BASE_NAME} (${stageLabel})`, + displayVersion: input.displayVersion, + appVersion: input.appVersion, }; } @@ -154,9 +160,11 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3")); const rootDir = path.resolve(input.dirname, "../../.."); const appRoot = input.isPackaged ? input.appPath : rootDir; + const displayVersion = formatForkDisplayVersion(input.appVersion, input.buildTimestamp); const branding = resolveDesktopAppBranding({ isDevelopment, appVersion: input.appVersion, + displayVersion, }); const displayName = branding.displayName; const stateDir = path.join(baseDir, isDevelopment ? "dev" : "userdata"); @@ -242,6 +250,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( path.join(resourcesPath, fileName), ], developmentDockIconPath: path.join(rootDir, "assets", "dev", "blueprint-macos-1024.png"), + displayVersion, }); }); diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index a78de48d5e1..16c85df8221 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -47,6 +47,7 @@ const environmentInput = (baseDir: string) => isPackaged: false, resourcesPath: "/repo/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; const makeEnvironmentLayer = (baseDir: string) => diff --git a/apps/desktop/src/app/forkBranding.ts b/apps/desktop/src/app/forkBranding.ts new file mode 100644 index 00000000000..b84719605fb --- /dev/null +++ b/apps/desktop/src/app/forkBranding.ts @@ -0,0 +1,8 @@ +import type { DesktopAppStageLabel } from "@t3tools/contracts"; + +export const FORK_TAG = "a3"; +export const FORK_STAGE_LABEL: DesktopAppStageLabel = "A3"; + +export function formatForkDisplayVersion(pkgVersion: string, buildTimestamp: string): string { + return `${pkgVersion}-${FORK_TAG}-${buildTimestamp}`; +} diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 96e56a87c9d..c79cdd4ca8c 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -53,6 +53,7 @@ function makeEnvironmentLayer( isPackaged: options?.isPackaged ?? true, resourcesPath: "/missing/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", }).pipe( Layer.provide( Layer.mergeAll( diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 0f3e9eaeb45..9512d692407 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -75,6 +75,7 @@ function makeEnvironmentLayer(baseDir: string, env: Record([ const install = Effect.acquireRelease( Effect.sync(() => { - Electron.session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { - callback(ALLOWED_PERMISSIONS.has(permission)); - }); + Electron.session.defaultSession.setPermissionRequestHandler( + (_webContents, permission, callback) => { + callback(ALLOWED_PERMISSIONS.has(permission)); + }, + ); }), () => Effect.sync(() => { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 42162cb9339..b5d1d9eed29 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -3,10 +3,13 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Electron from "electron"; +import { formatBuildTimestamp } from "@t3tools/shared/buildTimestamp"; import * as NetService from "@t3tools/shared/Net"; import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; @@ -47,14 +50,30 @@ import * as DesktopWindow from "./window/DesktopWindow.ts"; const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { - const metadata = yield* Effect.service(ElectronApp.ElectronApp).pipe( - Effect.flatMap((app) => app.metadata), - ); + const electronApp = yield* Effect.service(ElectronApp.ElectronApp); + const metadata = yield* electronApp.metadata; + let buildTimestamp = process.env.T3CODE_BUILD_TIMESTAMP; + if (!buildTimestamp && metadata.isPackaged) { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const packagePath = path.join(metadata.appPath, "package.json"); + const fileContent = yield* fs.readFileString(packagePath).pipe(Effect.option); + if (Option.isSome(fileContent)) { + try { + const packageJson = JSON.parse(fileContent.value); + if (typeof packageJson?.t3codeBuildTimestamp === "string") { + buildTimestamp = packageJson.t3codeBuildTimestamp; + } + } catch {} + } + } + buildTimestamp = buildTimestamp || formatBuildTimestamp(new Date()); return DesktopEnvironment.layer({ dirname: __dirname, homeDirectory: NodeOS.homedir(), platform: process.platform, processArch: process.arch, + buildTimestamp, ...metadata, }); }), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index db6194cf8f7..92e9d33ddfe 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -37,6 +37,7 @@ function makeEnvironmentLayer(baseDir: string, appVersion = "0.0.17") { isPackaged: true, resourcesPath: "/missing/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", }).pipe( Layer.provide( Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 511c9d6e05f..1bed085efd1 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -49,6 +49,7 @@ function makeLayer(baseDir: string) { isPackaged: true, resourcesPath: "/missing/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", }).pipe( Layer.provide( Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index d1d37b96e11..a5bcba2180f 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -102,6 +102,7 @@ function makeLayer( isPackaged: true, resourcesPath: "/missing/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", }).pipe( Layer.provide( Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 9838fe10747..004d48b7928 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -124,6 +124,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { isPackaged: true, resourcesPath: "/missing/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", }).pipe( Layer.provide( Layer.mergeAll( diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index fc589b3e39b..18ebffeccff 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -26,6 +26,7 @@ const environmentInput = { isPackaged: false, resourcesPath: "/repo/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index fbcc60934aa..d633f631503 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -29,6 +29,7 @@ const environmentInput = { isPackaged: false, resourcesPath: "/repo/resources", runningUnderArm64Translation: false, + buildTimestamp: "20260508-1430", } satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; function makeFakeBrowserWindow() { diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index b6643d2a72f..77f3ca95ef3 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -23,6 +23,8 @@ describe("branding", () => { baseName: "T3 Code", stageLabel: "Nightly", displayName: "T3 Code (Nightly)", + displayVersion: "1.2.3-a3-20260508-1430", + appVersion: "1.2.3", }), }, }, diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index db046e38e95..6523f5a35d7 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -15,4 +15,7 @@ export const APP_STAGE_LABEL = injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "AA"); export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; -export const APP_VERSION = import.meta.env.APP_VERSION || "0.0.0"; +export const APP_VERSION = + injectedDesktopAppBranding?.displayVersion ?? import.meta.env.APP_VERSION ?? "0.0.0"; +export const APP_PKG_VERSION = + injectedDesktopAppBranding?.appVersion ?? import.meta.env.APP_VERSION ?? "0.0.0"; diff --git a/apps/web/src/forkBranding.ts b/apps/web/src/forkBranding.ts new file mode 100644 index 00000000000..31b00e4e224 --- /dev/null +++ b/apps/web/src/forkBranding.ts @@ -0,0 +1,5 @@ +export const FORK_TAG = "a3"; + +export function formatForkDisplayVersion(pkgVersion: string, buildTimestamp: string): string { + return `${pkgVersion}-${FORK_TAG}-${buildTimestamp}`; +} diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index cb0116c8550..94424a99a10 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -1,7 +1,7 @@ import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { APP_VERSION } from "./branding"; +import { APP_PKG_VERSION } from "./branding"; import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; export interface VersionMismatch { @@ -26,7 +26,7 @@ function normalizeVersion(version: string | null | undefined): string | null { export function resolveVersionMismatch( serverVersion: string | null | undefined, ): VersionMismatch | null { - const normalizedClientVersion = normalizeVersion(APP_VERSION); + const normalizedClientVersion = normalizeVersion(APP_PKG_VERSION); const normalizedServerVersion = normalizeVersion(serverVersion); if ( !normalizedClientVersion || diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 79eb99fe55f..38c91886755 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -116,7 +116,7 @@ export type DesktopUpdateStatus = export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; export type DesktopUpdateChannel = "latest" | "nightly"; -export type DesktopAppStageLabel = "AA" | "Alpha" | "Dev" | "Nightly"; +export type DesktopAppStageLabel = "A3" | "AA" | "Alpha" | "Dev" | "Nightly"; export const DesktopUpdateStatusSchema = Schema.Literals([ "disabled", @@ -131,18 +131,22 @@ export const DesktopUpdateStatusSchema = Schema.Literals([ export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other"]); export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); -export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); +export const DesktopAppStageLabelSchema = Schema.Literals(["A3", "Alpha", "Dev", "Nightly"]); export interface DesktopAppBranding { baseName: string; stageLabel: DesktopAppStageLabel; displayName: string; + displayVersion: string; + appVersion: string; } export const DesktopAppBrandingSchema = Schema.Struct({ baseName: Schema.String, stageLabel: DesktopAppStageLabelSchema, displayName: Schema.String, + displayVersion: Schema.String, + appVersion: Schema.String, }); export interface DesktopRuntimeInfo { diff --git a/packages/shared/package.json b/packages/shared/package.json index 5e785efc4d7..ed5b2d913b2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -83,6 +83,10 @@ "./keybindings": { "types": "./src/keybindings.ts", "import": "./src/keybindings.ts" + }, + "./buildTimestamp": { + "types": "./src/buildTimestamp.ts", + "import": "./src/buildTimestamp.ts" } }, "scripts": { diff --git a/packages/shared/src/buildTimestamp.ts b/packages/shared/src/buildTimestamp.ts new file mode 100644 index 00000000000..534d98f2d33 --- /dev/null +++ b/packages/shared/src/buildTimestamp.ts @@ -0,0 +1,9 @@ +export function formatBuildTimestamp(date: Date): string { + const pad = (n: number) => n.toString().padStart(2, "0"); + const yyyy = date.getUTCFullYear(); + const mm = pad(date.getUTCMonth() + 1); + const dd = pad(date.getUTCDate()); + const hh = pad(date.getUTCHours()); + const mi = pad(date.getUTCMinutes()); + return `${yyyy}${mm}${dd}-${hh}${mi}`; +} diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 9bcd240d4d2..09196c6f852 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -3,7 +3,6 @@ import { assert, it } from "@effect/vitest"; import { ConfigProvider, Effect, Option } from "effect"; import { - formatBuildTimestamp, resolveBuildOptions, resolveDesktopBuildIconAssets, resolveDesktopProductName, @@ -11,6 +10,7 @@ import { resolveMockUpdateServerPort, resolveMockUpdateServerUrl, } from "./build-desktop-artifact.ts"; +import { formatBuildTimestamp } from "@t3tools/shared/buildTimestamp"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { @@ -20,7 +20,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); it("switches desktop packaging product names to nightly for nightly builds", () => { - assert.equal(resolveDesktopProductName("0.0.17"), "T3 Code (Alpha)"); + assert.equal(resolveDesktopProductName("0.0.17"), "T3 Code (A3)"); assert.equal(resolveDesktopProductName("0.0.17-nightly.20260413.42"), "T3 Code (Nightly)"); }); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 4a57cad0472..9ec2e4f9d2b 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -7,6 +7,7 @@ import serverPackageJson from "../apps/server/package.json" with { type: "json" import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; +import { formatBuildTimestamp } from "@t3tools/shared/buildTimestamp"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -150,16 +151,6 @@ const resolveGitCommitHash = Effect.fn("resolveGitCommitHash")(function* (repoRo return hash.toLowerCase(); }); -export const formatBuildTimestamp = (date: Date): string => { - const pad = (n: number) => n.toString().padStart(2, "0"); - const yyyy = date.getUTCFullYear(); - const mm = pad(date.getUTCMonth() + 1); - const dd = pad(date.getUTCDate()); - const hh = pad(date.getUTCHours()); - const mi = pad(date.getUTCMinutes()); - return `${yyyy}${mm}${dd}-${hh}${mi}`; -}; - const resolvePythonForNodeGyp = Effect.fn("resolvePythonForNodeGyp")(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -584,6 +575,9 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* ( directories: { buildResources: "apps/desktop/resources", }, + extraMetadata: { + t3codeBuildTimestamp: buildTimestamp, + }, }; const updateChannel = resolveDesktopUpdateChannel(version); const publishConfig = resolveGitHubPublishConfig(updateChannel); From 40c95783b65e40de2c06843873ee8be2b60b0efb Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Sat, 9 May 2026 13:20:21 +0530 Subject: [PATCH 24/46] Rename stage label from AA to A3 - Update app branding and desktop IPC contracts - Suppress effect-ts diagnostics warnings for Date and JSON operations --- apps/desktop/src/main.ts | 3 +++ apps/web/index.html | 2 +- apps/web/src/branding.ts | 2 +- packages/contracts/src/ipc.ts | 2 +- scripts/build-desktop-artifact.test.ts | 4 ++++ scripts/build-desktop-artifact.ts | 1 + 6 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 98438e80b9b..c7391ec4e11 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -60,7 +60,9 @@ const desktopEnvironmentLayer = Layer.unwrap( const packagePath = path.join(metadata.appPath, "package.json"); const fileContent = yield* fs.readFileString(packagePath).pipe(Effect.option); if (Option.isSome(fileContent)) { + // @effect-diagnostics-next-line tryCatchInEffectGen:off try { + // @effect-diagnostics-next-line preferSchemaOverJson:off const packageJson = JSON.parse(fileContent.value); if (typeof packageJson?.t3codeBuildTimestamp === "string") { buildTimestamp = packageJson.t3codeBuildTimestamp; @@ -68,6 +70,7 @@ const desktopEnvironmentLayer = Layer.unwrap( } catch {} } } + // @effect-diagnostics-next-line globalDateInEffect:off buildTimestamp = buildTimestamp || formatBuildTimestamp(new Date()); return DesktopEnvironment.layer({ dirname: __dirname, diff --git a/apps/web/index.html b/apps/web/index.html index 034663fde7f..33ada2e35b7 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -89,7 +89,7 @@ href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap" rel="stylesheet" /> - T3 Code (AA) + T3 Code (A3)
diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index d60e4745eb5..833705c43b6 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -13,7 +13,7 @@ const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLower export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; export const APP_STAGE_LABEL = - injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "AA"); + injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "A3"); export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; export const APP_VERSION = diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1ae882615c0..830bd288401 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -117,7 +117,7 @@ export type DesktopUpdateStatus = export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; export type DesktopUpdateChannel = "latest" | "nightly"; -export type DesktopAppStageLabel = "A3" | "AA" | "Alpha" | "Dev" | "Nightly"; +export type DesktopAppStageLabel = "A3" | "Alpha" | "Dev" | "Nightly"; export const DesktopUpdateStatusSchema = Schema.Literals([ "disabled", diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 126a7b58314..55321388411 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -41,9 +41,13 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); it("formats build timestamps as zero-padded UTC YYYYMMDD-HHMM", () => { + // @effect-diagnostics-next-line globalDate:off assert.equal(formatBuildTimestamp(new Date(Date.UTC(2026, 4, 6, 14, 30))), "20260506-1430"); + // @effect-diagnostics-next-line globalDate:off assert.equal(formatBuildTimestamp(new Date(Date.UTC(2026, 0, 1, 0, 0))), "20260101-0000"); + // @effect-diagnostics-next-line globalDate:off assert.equal(formatBuildTimestamp(new Date(Date.UTC(2026, 11, 31, 23, 59))), "20261231-2359"); + // @effect-diagnostics-next-line globalDate:off assert.match(formatBuildTimestamp(new Date()), /^\d{8}-\d{4}$/); }); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index c6bbb740ec5..66773398440 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -780,6 +780,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // electron-builder is filtering out stageResourcesDir directory in the AppImage for production yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources")); + // @effect-diagnostics-next-line globalDateInEffect:off const buildTimestamp = formatBuildTimestamp(new Date()); const stagePackageJson: StagePackageJson = { From 30d31cf4cc850b68858c598f0a4ad8a41207b4f4 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Sat, 9 May 2026 16:30:48 +0530 Subject: [PATCH 25/46] Add manual plan promotion/revert commands - Introduce thread.proposed-plan.promote to allow users to promote assistant messages to proposed plans - Introduce thread.proposed-plan.revert to allow reverting promoted plans back to messages - Remove automatic text-fallback plan capture (looksLikePlan logic) - Update backend event handling, persistence layer, and frontend UI for plan mode interaction --- .../Layers/ProjectionPipeline.ts | 7 ++ apps/server/src/orchestration/Schemas.ts | 2 + apps/server/src/orchestration/decider.ts | 79 ++++++++++++++++++ apps/server/src/orchestration/projector.ts | 26 ++++++ .../Layers/ProjectionThreadProposedPlans.ts | 17 ++++ .../Services/ProjectionThreadProposedPlans.ts | 9 +++ .../src/provider/Layers/ClaudeAdapter.ts | 74 +---------------- apps/server/src/ws.ts | 2 + apps/web/src/components/ChatView.tsx | 81 +++++++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 40 +++++++++ .../CompactComposerControlsMenu.browser.tsx | 4 + .../chat/CompactComposerControlsMenu.tsx | 13 ++- .../chat/ComposerPrimaryActions.tsx | 12 +++ apps/web/src/session-logic.ts | 21 +++-- apps/web/src/store.ts | 15 ++++ packages/contracts/src/orchestration.ts | 31 +++++++ packages/contracts/src/providerRuntime.ts | 2 +- 17 files changed, 354 insertions(+), 81 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 3ef8b38d642..2af3cb3fd71 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -683,6 +683,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.message-sent": case "thread.proposed-plan-upserted": + case "thread.proposed-plan-removed": case "thread.activity-appended": case "thread.approval-response-requested": case "thread.user-input-response-requested": { @@ -871,6 +872,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; + case "thread.proposed-plan-removed": + yield* projectionThreadProposedPlanRepository.deleteByPlanId({ + planId: event.payload.planId, + }); + return; + case "thread.reverted": { const existingRows = yield* projectionThreadProposedPlanRepository.listByThreadId({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index f7ebf693440..8294d5412bb 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -11,6 +11,7 @@ import { ThreadUnarchivedPayload as ContractsThreadUnarchivedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, + ThreadProposedPlanRemovedPayload as ContractsThreadProposedPlanRemovedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema, ThreadRevertedPayload as ContractsThreadRevertedPayloadSchema, @@ -37,6 +38,7 @@ export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; +export const ThreadProposedPlanRemovedPayload = ContractsThreadProposedPlanRemovedPayloadSchema; export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema; export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema; export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 05ae5b0eb00..062e11ec066 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -653,6 +653,85 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.proposed-plan.promote": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const message = thread.messages.find((entry) => entry.id === command.messageId); + if (!message) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message '${command.messageId}' not found in thread '${command.threadId}'.`, + }); + } + if (message.role !== "assistant") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message '${command.messageId}' is not an assistant message.`, + }); + } + const planMarkdown = message.text.trim(); + if (planMarkdown.length === 0) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message '${command.messageId}' has no text to promote.`, + }); + } + const planId = `plan:${command.threadId}:promoted:${command.messageId}`; + const existingPlan = thread.proposedPlans.find((entry) => entry.id === planId); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.proposed-plan-upserted", + payload: { + threadId: command.threadId, + proposedPlan: { + id: planId, + turnId: message.turnId ?? null, + planMarkdown, + implementedAt: existingPlan?.implementedAt ?? null, + implementationThreadId: existingPlan?.implementationThreadId ?? null, + createdAt: existingPlan?.createdAt ?? command.createdAt, + updatedAt: command.createdAt, + }, + }, + }; + } + + case "thread.proposed-plan.revert": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const existingPlan = thread.proposedPlans.find((entry) => entry.id === command.planId); + if (!existingPlan) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Proposed plan '${command.planId}' not found in thread '${command.threadId}'.`, + }); + } + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.proposed-plan-removed", + payload: { + threadId: command.threadId, + planId: command.planId, + }, + }; + } + case "thread.turn.diff.complete": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index deb8a6d44d7..68142a85b39 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -20,6 +20,7 @@ import { ThreadInteractionModeSetPayload, ThreadMetaUpdatedPayload, ThreadProposedPlanUpsertedPayload, + ThreadProposedPlanRemovedPayload, ThreadRuntimeModeSetPayload, ThreadUnarchivedPayload, ThreadRevertedPayload, @@ -499,6 +500,31 @@ export function projectEvent( }; }); + case "thread.proposed-plan-removed": + return Effect.gen(function* () { + const payload = yield* decodeForEvent( + ThreadProposedPlanRemovedPayload, + event.payload, + event.type, + "payload", + ); + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + const proposedPlans = thread.proposedPlans.filter((entry) => entry.id !== payload.planId); + if (proposedPlans.length === thread.proposedPlans.length) { + return nextBase; + } + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + proposedPlans, + updatedAt: event.occurredAt, + }), + }; + }); + case "thread.turn-diff-completed": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts index ccd322feb23..605eb88c7e5 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -4,6 +4,7 @@ import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { toPersistenceSqlError } from "../Errors.ts"; import { + DeleteProjectionThreadProposedPlanByIdInput, DeleteProjectionThreadProposedPlansInput, ListProjectionThreadProposedPlansInput, ProjectionThreadProposedPlan, @@ -76,6 +77,14 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { `, }); + const deleteProjectionThreadProposedPlanRowById = SqlSchema.void({ + Request: DeleteProjectionThreadProposedPlanByIdInput, + execute: ({ planId }) => sql` + DELETE FROM projection_thread_proposed_plans + WHERE plan_id = ${planId} + `, + }); + const upsert: ProjectionThreadProposedPlanRepositoryShape["upsert"] = (row) => upsertProjectionThreadProposedPlanRow(row).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")), @@ -97,10 +106,18 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { ), ); + const deleteByPlanId: ProjectionThreadProposedPlanRepositoryShape["deleteByPlanId"] = (input) => + deleteProjectionThreadProposedPlanRowById(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.deleteByPlanId:query"), + ), + ); + return { upsert, listByThreadId, deleteByThreadId, + deleteByPlanId, } satisfies ProjectionThreadProposedPlanRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts index a68bedb8c37..aba35bafdef 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -34,6 +34,12 @@ export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ export type DeleteProjectionThreadProposedPlansInput = typeof DeleteProjectionThreadProposedPlansInput.Type; +export const DeleteProjectionThreadProposedPlanByIdInput = Schema.Struct({ + planId: OrchestrationProposedPlanId, +}); +export type DeleteProjectionThreadProposedPlanByIdInput = + typeof DeleteProjectionThreadProposedPlanByIdInput.Type; + export interface ProjectionThreadProposedPlanRepositoryShape { readonly upsert: ( proposedPlan: ProjectionThreadProposedPlan, @@ -44,6 +50,9 @@ export interface ProjectionThreadProposedPlanRepositoryShape { readonly deleteByThreadId: ( input: DeleteProjectionThreadProposedPlansInput, ) => Effect.Effect; + readonly deleteByPlanId: ( + input: DeleteProjectionThreadProposedPlanByIdInput, + ) => Effect.Effect; } export class ProjectionThreadProposedPlanRepository extends Context.Service< diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d5e1541463c..6ff7f7855c2 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -787,49 +787,6 @@ function extractExitPlanModePlan(value: unknown): string | undefined { : undefined; } -function looksLikePlan(text: string): boolean { - const trimmed = text.trim(); - - if (trimmed.length < 200) { - return false; - } - - const headingMatch = /^#{1,4} \S/m.test(trimmed); - const orderedListMatch = /^\s*\d+\.\s+\S/m.test(trimmed); - const bulletedListMatch = /^\s*[-*]\s+\S/m.test(trimmed); - - if (!headingMatch && !orderedListMatch && !bulletedListMatch) { - return false; - } - - const firstLineChars = trimmed.substring(0, 120).replace(/^#+\s*/, ""); - const refusalStems = [ - /^i can't/i, - /^i cannot/i, - /^i'm not able/i, - /^i am unable/i, - /^i don't have enough/i, - /^could you clarify/i, - /^can you clarify/i, - /^before i /i, - /^to help you, i need/i, - ]; - - if (refusalStems.some((stem) => stem.test(firstLineChars))) { - return false; - } - - const orderedListCount = (trimmed.match(/^\s*\d+\.\s+\S/gm) || []).length; - const bulletedListCount = (trimmed.match(/^\s*[-*]\s+\S/gm) || []).length; - const totalListItems = orderedListCount + bulletedListCount; - - const planHeadingMatch = /^#{1,4} [^\n]*(?:plan|approach|steps?|implementation|proposal)/im.test( - trimmed, - ); - - return totalListItems >= 3 || planHeadingMatch; -} - function exitPlanCaptureKey(input: { readonly toolUseId?: string | undefined; readonly planMarkdown: string; @@ -1407,7 +1364,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( readonly rawSource: | "claude.sdk.message" | "claude.sdk.permission" - | "claude.sdk.text-fallback"; + | "client.user-promoted"; readonly rawMethod: string; readonly rawPayload: unknown; }, @@ -1591,35 +1548,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } - if ( - turnState.interactionMode === "plan" && - turnState.capturedProposedPlanKeys.size === 0 && - context.pendingUserInputs.size === 0 && - status === "completed" && - result?.subtype === "success" - ) { - let lastNonEmptyBlock: AssistantTextBlockState | undefined; - for (let i = turnState.assistantTextBlockOrder.length - 1; i >= 0; i--) { - const block = turnState.assistantTextBlockOrder[i]; - if (!block) { - continue; - } - if (block.fallbackText.trim().length > 0) { - lastNonEmptyBlock = block; - break; - } - } - - if (lastNonEmptyBlock && looksLikePlan(lastNonEmptyBlock.fallbackText)) { - yield* emitProposedPlanCompleted(context, { - planMarkdown: lastNonEmptyBlock.fallbackText, - rawSource: "claude.sdk.text-fallback", - rawMethod: "claude/text-fallback", - rawPayload: result, - }); - } - } - const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "turn.completed", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 476140dd3ae..fba294defba 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -84,6 +84,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< type: | "thread.message-sent" | "thread.proposed-plan-upserted" + | "thread.proposed-plan-removed" | "thread.activity-appended" | "thread.turn-diff-completed" | "thread.reverted" @@ -93,6 +94,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< return ( event.type === "thread.message-sent" || event.type === "thread.proposed-plan-upserted" || + event.type === "thread.proposed-plan-removed" || event.type === "thread.activity-appended" || event.type === "thread.turn-diff-completed" || event.type === "thread.reverted" || diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6b84aa11ca6..9a7aaea6f2d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3366,6 +3366,83 @@ export default function ChatView(props: ChatViewProps) { environmentId, ]); + const latestPromotableAssistantMessageId = useMemo(() => { + const messages = activeThread?.messages; + if (!messages || messages.length === 0) return undefined; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message?.role === "assistant" && message.text.trim().length > 0) { + return message.id; + } + } + return undefined; + }, [activeThread?.messages]); + + const canPromoteToPlan = + interactionMode === "plan" && + activeProposedPlan === null && + latestPromotableAssistantMessageId !== undefined && + isServerThread && + !isSendBusy && + !isConnecting && + !activeEnvironmentUnavailable; + + const onPromoteToPlan = useCallback(() => { + if (!activeThread || !latestPromotableAssistantMessageId) return; + const api = readEnvironmentApi(activeThread.environmentId); + if (!api) return; + void api.orchestration + .dispatchCommand({ + type: "thread.proposed-plan.promote", + commandId: newCommandId(), + threadId: activeThread.id, + messageId: latestPromotableAssistantMessageId, + createdAt: new Date().toISOString(), + }) + .catch((err: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not promote message to plan", + description: + err instanceof Error ? err.message : "An error occurred while promoting the message.", + }), + ); + }); + }, [activeThread, latestPromotableAssistantMessageId]); + + const canRevertPlan = + activeProposedPlan !== null && + /:promoted:/.test(activeProposedPlan.id) && + isServerThread && + !isSendBusy && + !isConnecting && + !activeEnvironmentUnavailable; + + const onRevertPlan = useCallback(() => { + if (!activeThread || !activeProposedPlan) return; + const api = readEnvironmentApi(activeThread.environmentId); + if (!api) return; + void api.orchestration + .dispatchCommand({ + type: "thread.proposed-plan.revert", + commandId: newCommandId(), + threadId: activeThread.id, + planId: activeProposedPlan.id, + createdAt: new Date().toISOString(), + }) + .catch((err: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not revert plan", + description: + err instanceof Error ? err.message : "An error occurred while reverting the plan.", + }), + ); + }); + }, [activeThread, activeProposedPlan]); + const onProviderModelSelect = useCallback( (instanceId: ProviderInstanceId, model: string) => { if (!activeThread) return; @@ -3655,6 +3732,10 @@ export default function ChatView(props: ChatViewProps) { composerTerminalContextsRef={composerTerminalContextsRef} shouldAutoScrollRef={isAtEndRef} scheduleStickToBottom={scrollToEnd} + canPromoteToPlan={canPromoteToPlan} + onPromoteToPlan={onPromoteToPlan} + canRevertPlan={canRevertPlan} + onRevertPlan={onRevertPlan} onSend={onSend} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2c4743de3c6..9b0b059396f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -88,6 +88,7 @@ import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, + CornerRightUpIcon, ListTodoIcon, type LucideIcon, LockIcon, @@ -186,6 +187,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop showPlanToggle: boolean; planSidebarLabel: string; planSidebarOpen: boolean; + canPromoteToPlan: boolean; + onPromoteToPlan: () => void; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; onTogglePlanSidebar: () => void; @@ -256,6 +259,23 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop + {props.interactionMode === "plan" && props.canPromoteToPlan ? ( + <> + + + + ) : null} + {props.showPlanToggle ? ( <> @@ -304,9 +324,11 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isEnvironmentUnavailable: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + canRevertPlan?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + onRevertPlan?: () => void; }) { return ( <> @@ -326,9 +348,11 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} + canRevertPlan={props.canRevertPlan ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} + onRevertPlan={props.onRevertPlan} /> ); @@ -454,6 +478,12 @@ export interface ChatComposerProps { shouldAutoScrollRef: React.MutableRefObject; scheduleStickToBottom: () => void; + // Promote-to-plan + canPromoteToPlan: boolean; + onPromoteToPlan: () => void; + canRevertPlan: boolean; + onRevertPlan: () => void; + // Callbacks onSend: (e?: { preventDefault: () => void }) => void; onInterrupt: () => void; @@ -539,6 +569,10 @@ export const ChatComposer = memo( composerTerminalContextsRef, shouldAutoScrollRef, scheduleStickToBottom, + canPromoteToPlan, + onPromoteToPlan, + canRevertPlan, + onRevertPlan, onSend, onInterrupt, onImplementPlanInNewThread, @@ -2353,6 +2387,8 @@ export const ChatComposer = memo( runtimeMode={runtimeMode} showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} traitsMenuContent={providerTraitsMenuContent} + canPromoteToPlan={canPromoteToPlan} + onPromoteToPlan={onPromoteToPlan} onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} onRuntimeModeChange={handleRuntimeModeChange} @@ -2377,6 +2413,8 @@ export const ChatComposer = memo( showPlanToggle={showPlanSidebarToggle} planSidebarLabel={planSidebarLabel} planSidebarOpen={planSidebarOpen} + canPromoteToPlan={canPromoteToPlan} + onPromoteToPlan={onPromoteToPlan} onToggleInteractionMode={toggleInteractionMode} onRuntimeModeChange={handleRuntimeModeChange} onTogglePlanSidebar={togglePlanSidebar} @@ -2408,9 +2446,11 @@ export const ChatComposer = memo( isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} + canRevertPlan={canRevertPlan} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + onRevertPlan={onRevertPlan} />
diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 49eb5fbb94b..4056a7ee533 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -152,6 +152,8 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str onPromptChange={onPromptChange} /> } + canPromoteToPlan={false} + onPromoteToPlan={vi.fn()} onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} onRuntimeModeChange={vi.fn()} @@ -303,6 +305,8 @@ describe("CompactComposerControlsMenu", () => { planSidebarOpen={false} runtimeMode="approval-required" showInteractionModeToggle={false} + canPromoteToPlan={false} + onPromoteToPlan={vi.fn()} onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} onRuntimeModeChange={vi.fn()} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index f1fbd193a63..f6a32c0443a 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,6 +1,6 @@ import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; import { memo, type ReactNode } from "react"; -import { EllipsisIcon, ListTodoIcon } from "lucide-react"; +import { CornerRightUpIcon, EllipsisIcon, ListTodoIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Menu, @@ -20,6 +20,8 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls runtimeMode: RuntimeMode; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; + canPromoteToPlan: boolean; + onPromoteToPlan: () => void; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; @@ -73,6 +75,15 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls Auto-accept edits Full access + {props.interactionMode === "plan" && props.canPromoteToPlan ? ( + <> + + + + Promote to plan + + + ) : null} {props.activePlan ? ( <> diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index fbeb9de30b8..5b62b7bedc8 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -24,9 +24,11 @@ interface ComposerPrimaryActionsProps { isPreparingWorktree: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + canRevertPlan?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + onRevertPlan?: () => void; } export const formatPendingPrimaryActionLabel = (input: { @@ -63,9 +65,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isPreparingWorktree, hasSendableContent, preserveComposerFocusOnPointerDown = false, + canRevertPlan = false, onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, + onRevertPlan, }: ComposerPrimaryActionsProps) { const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } @@ -186,6 +190,14 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ > Implement in a new thread + {canRevertPlan && onRevertPlan ? ( + void onRevertPlan()} + > + Revert plan to message + + ) : null} diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a7767672fa1..8f735bba0e6 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1161,12 +1161,21 @@ export function deriveTimelineEntries( proposedPlans: ProposedPlan[], workEntries: WorkLogEntry[], ): TimelineEntry[] { - const messageRows: TimelineEntry[] = messages.map((message) => ({ - id: message.id, - kind: "message", - createdAt: message.createdAt, - message, - })); + const promotedSourceMessageIds = new Set(); + for (const proposedPlan of proposedPlans) { + const match = /:promoted:(.+)$/.exec(proposedPlan.id); + if (match) { + promotedSourceMessageIds.add(match[1]); + } + } + const messageRows: TimelineEntry[] = messages + .filter((message) => !promotedSourceMessageIds.has(message.id)) + .map((message) => ({ + id: message.id, + kind: "message", + createdAt: message.createdAt, + message, + })); const proposedPlanRows: TimelineEntry[] = proposedPlans.map((proposedPlan) => ({ id: proposedPlan.id, kind: "proposed-plan", diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 921054df34f..7a7b8647a26 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1512,6 +1512,21 @@ function applyEnvironmentOrchestrationEvent( }; }); + case "thread.proposed-plan-removed": + return updateThreadState(state, event.payload.threadId, (thread) => { + const proposedPlans = thread.proposedPlans.filter( + (entry) => entry.id !== event.payload.planId, + ); + if (proposedPlans.length === thread.proposedPlans.length) { + return thread; + } + return { + ...thread, + proposedPlans, + updatedAt: event.occurredAt, + }; + }); + case "thread.turn-diff-completed": return updateThreadState(state, event.payload.threadId, (thread) => { const checkpoint = mapTurnDiffSummary({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 44d840d1499..2acb3e407fe 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -644,6 +644,22 @@ const ThreadSessionStopCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadProposedPlanPromoteCommand = Schema.Struct({ + type: Schema.Literal("thread.proposed-plan.promote"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + createdAt: IsoDateTime, +}); + +const ThreadProposedPlanRevertCommand = Schema.Struct({ + type: Schema.Literal("thread.proposed-plan.revert"), + commandId: CommandId, + threadId: ThreadId, + planId: OrchestrationProposedPlanId, + createdAt: IsoDateTime, +}); + const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, @@ -661,6 +677,8 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + ThreadProposedPlanPromoteCommand, + ThreadProposedPlanRevertCommand, ]); export type DispatchableClientOrchestrationCommand = typeof DispatchableClientOrchestrationCommand.Type; @@ -682,6 +700,8 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + ThreadProposedPlanPromoteCommand, + ThreadProposedPlanRevertCommand, ]); export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; @@ -788,6 +808,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.session-stop-requested", "thread.session-set", "thread.proposed-plan-upserted", + "thread.proposed-plan-removed", "thread.turn-diff-completed", "thread.activity-appended", ]); @@ -948,6 +969,11 @@ export const ThreadProposedPlanUpsertedPayload = Schema.Struct({ proposedPlan: OrchestrationProposedPlan, }); +export const ThreadProposedPlanRemovedPayload = Schema.Struct({ + threadId: ThreadId, + planId: OrchestrationProposedPlanId, +}); + export const ThreadTurnDiffCompletedPayload = Schema.Struct({ threadId: ThreadId, turnId: TurnId, @@ -1086,6 +1112,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.proposed-plan-upserted"), payload: ThreadProposedPlanUpsertedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.proposed-plan-removed"), + payload: ThreadProposedPlanRemovedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.turn-diff-completed"), diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 77491639e0d..f5312df2304 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -24,7 +24,7 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("codex.eventmsg"), Schema.Literal("claude.sdk.message"), Schema.Literal("claude.sdk.permission"), - Schema.Literal("claude.sdk.text-fallback"), + Schema.Literal("client.user-promoted"), Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), Schema.Literal("acp.jsonrpc"), From c23432f17627d1fc0ed0512516311fb7fef75466 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Sat, 9 May 2026 16:33:05 +0530 Subject: [PATCH 26/46] Reformat rawSource type to single line --- apps/server/src/provider/Layers/ClaudeAdapter.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6ff7f7855c2..92cc1471fdb 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -1361,10 +1361,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input: { readonly planMarkdown: string; readonly toolUseId?: string | undefined; - readonly rawSource: - | "claude.sdk.message" - | "claude.sdk.permission" - | "client.user-promoted"; + readonly rawSource: "claude.sdk.message" | "claude.sdk.permission" | "client.user-promoted"; readonly rawMethod: string; readonly rawPayload: unknown; }, From d713c12aa072f49454de1304d43e12c537131079 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Sun, 10 May 2026 01:42:47 +0530 Subject: [PATCH 27/46] Support reverting and re-promoting proposed plans Track revertedAt on proposed plans so revert keeps the plan record and promote can restore the latest reverted plan instead of requiring an assistant message id. --- .../Layers/ProjectionPipeline.ts | 5 +- .../Layers/ProjectionSnapshotQuery.test.ts | 32 ++ .../Layers/ProjectionSnapshotQuery.ts | 51 +- .../Layers/ProviderRuntimeIngestion.ts | 2 + .../decider.proposedPlan.test.ts | 444 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 63 ++- .../Layers/ProjectionThreadProposedPlans.ts | 4 + apps/server/src/persistence/Migrations.ts | 2 + ..._ProjectionThreadProposedPlanRevertedAt.ts | 17 + .../Services/ProjectionThreadProposedPlans.ts | 1 + apps/web/src/components/ChatView.tsx | 12 +- apps/web/src/components/chat/ChatComposer.tsx | 2 +- apps/web/src/session-logic.test.ts | 14 + apps/web/src/session-logic.ts | 30 +- apps/web/src/store.test.ts | 3 + apps/web/src/store.ts | 1 + apps/web/src/types.ts | 1 + packages/contracts/src/orchestration.ts | 2 +- 18 files changed, 648 insertions(+), 38 deletions(-) create mode 100644 apps/server/src/orchestration/decider.proposedPlan.test.ts create mode 100644 apps/server/src/persistence/Migrations/031_ProjectionThreadProposedPlanRevertedAt.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 312fb0d21d4..2db90cfca22 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -170,11 +170,11 @@ function deriveHasActionableProposedPlan(input: { } } if (latestForTurn !== null) { - return latestForTurn.implementedAt === null; + return latestForTurn.implementedAt === null && latestForTurn.revertedAt === null; } const latestPlan = sorted.at(-1) ?? null; - return latestPlan !== null && latestPlan.implementedAt === null; + return latestPlan !== null && latestPlan.implementedAt === null && latestPlan.revertedAt === null; } function retainProjectionMessagesAfterRevert( @@ -872,6 +872,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti planMarkdown: event.payload.proposedPlan.planMarkdown, implementedAt: event.payload.proposedPlan.implementedAt, implementationThreadId: event.payload.proposedPlan.implementationThreadId, + revertedAt: event.payload.proposedPlan.revertedAt, createdAt: event.payload.proposedPlan.createdAt, updatedAt: event.payload.proposedPlan.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 01d7c295746..63e75cae4c6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -326,6 +326,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { planMarkdown: "# Ship it", implementedAt: "2026-02-24T00:00:05.500Z", implementationThreadId: ThreadId.make("thread-2"), + revertedAt: null, createdAt: "2026-02-24T00:00:05.000Z", updatedAt: "2026-02-24T00:00:05.500Z", }, @@ -1159,6 +1160,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { yield* sql`DELETE FROM projection_projects`; yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_thread_messages`; yield* sql`DELETE FROM projection_turns`; yield* sql`DELETE FROM projection_state`; @@ -1278,6 +1280,31 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ) `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + is_streaming, + created_at, + updated_at, + attachments_json + ) + VALUES ( + 'message-assistant-1', + 'thread-1', + 'turn-completed', + 'assistant', + 'persisted assistant answer', + 0, + '2026-04-03T00:00:20.000Z', + '2026-04-03T00:00:20.000Z', + NULL + ) + `; + yield* sql` INSERT INTO projection_state (projector, last_applied_sequence, updated_at) VALUES @@ -1293,6 +1320,11 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { const commandReadModel = yield* snapshotQuery.getCommandReadModel(); assert.equal(commandReadModel.threads[0]?.latestTurn?.turnId, asTurnId("turn-running")); assert.equal(commandReadModel.threads[0]?.latestTurn?.state, "running"); + assert.equal( + commandReadModel.threads[0]?.messages[0]?.id, + asMessageId("message-assistant-1"), + ); + assert.equal(commandReadModel.threads[0]?.messages[0]?.text, "persisted assistant answer"); const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); assert.equal(shellSnapshot.threads[0]?.latestTurn?.turnId, asTurnId("turn-running")); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index f326a524f91..58fd0e17f81 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -232,6 +232,7 @@ function mapProposedPlanRow( planMarkdown: row.planMarkdown, implementedAt: row.implementedAt, implementationThreadId: row.implementationThreadId, + revertedAt: row.revertedAt, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -419,6 +420,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { plan_markdown AS "planMarkdown", implemented_at AS "implementedAt", implementation_thread_id AS "implementationThreadId", + reverted_at AS "revertedAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_proposed_plans @@ -783,6 +785,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { plan_markdown AS "planMarkdown", implemented_at AS "implementedAt", implementation_thread_id AS "implementationThreadId", + reverted_at AS "revertedAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_proposed_plans @@ -1018,6 +1021,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { planMarkdown: row.planMarkdown, implementedAt: row.implementedAt, implementationThreadId: row.implementationThreadId, + revertedAt: row.revertedAt, createdAt: row.createdAt, updatedAt: row.updatedAt, }); @@ -1187,6 +1191,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listThreadMessageRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listThreadMessages:query", + "ProjectionSnapshotQuery.getCommandReadModel:listThreadMessages:decodeRows", + ), + ), + ), listThreadProposedPlanRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -1223,7 +1235,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ) .pipe( Effect.flatMap( - ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => + ([ + projectRows, + threadRows, + messageRows, + proposedPlanRows, + sessionRows, + latestTurnRows, + stateRows, + ]) => Effect.sync(() => { let updatedAt: string | null = null; const projects: OrchestrationProject[] = []; @@ -1260,6 +1280,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } updatedAt = maxIso(updatedAt, row.updatedAt); } + for (let index = 0; index < messageRows.length; index += 1) { + const row = messageRows[index]; + if (!row) { + continue; + } + updatedAt = maxIso(updatedAt, row.updatedAt); + } for (let index = 0; index < sessionRows.length; index += 1) { const row = sessionRows[index]; if (!row) { @@ -1296,9 +1323,29 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } latestTurnByThread.set(row.threadId, mapLatestTurn(row)); } + const messagesByThread = new Map>(); const proposedPlansByThread = new Map>(); const sessionByThread = new Map(); + for (let index = 0; index < messageRows.length; index += 1) { + const row = messageRows[index]; + if (!row) { + continue; + } + const threadMessages = messagesByThread.get(row.threadId) ?? []; + threadMessages.push({ + id: row.messageId, + role: row.role, + text: row.text, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + messagesByThread.set(row.threadId, threadMessages); + } + for (let index = 0; index < sessionRows.length; index += 1) { const row = sessionRows[index]; if (!row) { @@ -1336,7 +1383,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updatedAt: row.updatedAt, archivedAt: row.archivedAt, deletedAt: row.deletedAt, - messages: [], + messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], activities: [], checkpoints: [], diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 2c07ac91b1e..84470772d41 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -987,6 +987,7 @@ const make = Effect.gen(function* () { createdAt: string; implementedAt: string | null; implementationThreadId: ThreadId | null; + revertedAt?: string | null; }>; planId: string; turnId?: TurnId; @@ -1011,6 +1012,7 @@ const make = Effect.gen(function* () { planMarkdown, implementedAt: existingPlan?.implementedAt ?? null, implementationThreadId: existingPlan?.implementationThreadId ?? null, + revertedAt: existingPlan?.revertedAt ?? null, createdAt: existingPlan?.createdAt ?? input.createdAt, updatedAt: input.updatedAt, }, diff --git a/apps/server/src/orchestration/decider.proposedPlan.test.ts b/apps/server/src/orchestration/decider.proposedPlan.test.ts new file mode 100644 index 00000000000..361c682625f --- /dev/null +++ b/apps/server/src/orchestration/decider.proposedPlan.test.ts @@ -0,0 +1,444 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationCommand, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "vitest"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const asCommandId = (value: string): CommandId => CommandId.make(value); +const asEventId = (value: string): EventId => EventId.make(value); +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asThreadId = (value: string): ThreadId => ThreadId.make(value); +const asMessageId = (value: string): MessageId => MessageId.make(value); +const asTurnId = (value: string): TurnId => TurnId.make(value); + +async function seedReadModelWithProject(): Promise { + const now = "2026-01-01T00:00:00.000Z"; + const initial = createEmptyReadModel(now); + return await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-proposedplan"), + type: "project.created", + occurredAt: now, + commandId: asCommandId("cmd-project-create"), + causationEventId: null, + correlationId: asCommandId("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-proposedplan"), + title: "Project ProposedPlan", + workspaceRoot: "/tmp/project-proposedplan", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); +} + +async function seedReadModelWithThreadAndMessages( + projectReadModel: OrchestrationReadModel, +): Promise { + const now = "2026-01-01T00:00:00.000Z"; + let readModel = projectReadModel; + let sequence = projectReadModel.snapshotSequence + 1; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-thread-create"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-proposedplan"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-create"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-create"), + metadata: {}, + payload: { + threadId: asThreadId("thread-proposedplan"), + projectId: asProjectId("project-proposedplan"), + title: "Thread ProposedPlan", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + sequence += 1; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-message-sent-1"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-proposedplan"), + type: "thread.message-sent", + occurredAt: now, + commandId: asCommandId("cmd-message-send-1"), + causationEventId: null, + correlationId: asCommandId("cmd-message-send-1"), + metadata: {}, + payload: { + threadId: asThreadId("thread-proposedplan"), + messageId: asMessageId("msg-user-1"), + role: "user", + text: "What should we do?", + attachments: [], + turnId: asTurnId("turn-1"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + sequence += 1; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-message-sent-2"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-proposedplan"), + type: "thread.message-sent", + occurredAt: now, + commandId: asCommandId("cmd-message-send-2"), + causationEventId: null, + correlationId: asCommandId("cmd-message-send-2"), + metadata: {}, + payload: { + threadId: asThreadId("thread-proposedplan"), + messageId: asMessageId("msg-assistant-1"), + role: "assistant", + text: "Here's a plan:\n1. Do this\n2. Then that", + attachments: [], + turnId: asTurnId("turn-1"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + sequence += 1; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-message-sent-3"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-proposedplan"), + type: "thread.message-sent", + occurredAt: now, + commandId: asCommandId("cmd-message-send-3"), + causationEventId: null, + correlationId: asCommandId("cmd-message-send-3"), + metadata: {}, + payload: { + threadId: asThreadId("thread-proposedplan"), + messageId: asMessageId("msg-user-2"), + role: "user", + text: "Can we improve it?", + attachments: [], + turnId: asTurnId("turn-2"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + sequence += 1; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-message-sent-4"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-proposedplan"), + type: "thread.message-sent", + occurredAt: now, + commandId: asCommandId("cmd-message-send-4"), + causationEventId: null, + correlationId: asCommandId("cmd-message-send-4"), + metadata: {}, + payload: { + threadId: asThreadId("thread-proposedplan"), + messageId: asMessageId("msg-assistant-2"), + role: "assistant", + text: "Updated plan:\n1. Do this first\n2. Then that\n3. Finally this", + attachments: [], + turnId: asTurnId("turn-2"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + + return readModel; +} + +describe("proposed plan promote command", () => { + it("promotes the latest eligible assistant message", async () => { + const projectReadModel = await seedReadModelWithProject(); + const readModel = await seedReadModelWithThreadAndMessages(projectReadModel); + + const promoteCommand: OrchestrationCommand = { + type: "thread.proposed-plan.promote", + commandId: asCommandId("cmd-promote"), + threadId: asThreadId("thread-proposedplan"), + createdAt: "2026-01-01T00:00:01.000Z", + }; + + const decided = await Effect.runPromise( + decideOrchestrationCommand({ + command: promoteCommand, + readModel, + }), + ); + + const event = Array.isArray(decided) ? decided[0] : decided; + expect(event).toBeDefined(); + expect(event.type).toBe("thread.proposed-plan-upserted"); + expect(event.aggregateKind).toBe("thread"); + expect(event.aggregateId).toBe(asThreadId("thread-proposedplan")); + + if (event.type === "thread.proposed-plan-upserted") { + const plan = event.payload.proposedPlan; + expect(plan.planMarkdown).toBe( + "Updated plan:\n1. Do this first\n2. Then that\n3. Finally this", + ); + expect(plan.turnId).toBe(asTurnId("turn-2")); + expect(plan.id).toMatch(/^plan:thread-proposedplan:promoted:msg-assistant-2$/); + } + }); + + it("returns error when no eligible assistant message exists", async () => { + const projectReadModel = await seedReadModelWithProject(); + let readModel = projectReadModel; + let sequence = projectReadModel.snapshotSequence + 1; + const now = "2026-01-01T00:00:00.000Z"; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-thread-create"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-empty"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-create"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-create"), + metadata: {}, + payload: { + threadId: asThreadId("thread-empty"), + projectId: asProjectId("project-proposedplan"), + title: "Thread Empty", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + const promoteCommand: OrchestrationCommand = { + type: "thread.proposed-plan.promote", + commandId: asCommandId("cmd-promote-empty"), + threadId: asThreadId("thread-empty"), + createdAt: "2026-01-01T00:00:01.000Z", + }; + + await expect( + Effect.runPromise( + decideOrchestrationCommand({ + command: promoteCommand, + readModel, + }), + ), + ).rejects.toThrow("No assistant message available to promote"); + }); + + it("returns error when no assistant message has non-empty text", async () => { + const projectReadModel = await seedReadModelWithProject(); + let readModel = projectReadModel; + let sequence = projectReadModel.snapshotSequence + 1; + const now = "2026-01-01T00:00:00.000Z"; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-thread-create-2"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-empty-messages"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-create-2"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-create-2"), + metadata: {}, + payload: { + threadId: asThreadId("thread-empty-messages"), + projectId: asProjectId("project-proposedplan"), + title: "Thread Empty Messages", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + sequence += 1; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-message-sent-user"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-empty-messages"), + type: "thread.message-sent", + occurredAt: now, + commandId: asCommandId("cmd-message-send-user"), + causationEventId: null, + correlationId: asCommandId("cmd-message-send-user"), + metadata: {}, + payload: { + threadId: asThreadId("thread-empty-messages"), + messageId: asMessageId("msg-user-empty"), + role: "user", + text: "Hello?", + attachments: [], + turnId: asTurnId("turn-3"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + sequence += 1; + + readModel = await Effect.runPromise( + projectEvent(readModel, { + sequence, + eventId: asEventId("evt-message-sent-assistant-empty"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-empty-messages"), + type: "thread.message-sent", + occurredAt: now, + commandId: asCommandId("cmd-message-send-assistant-empty"), + causationEventId: null, + correlationId: asCommandId("cmd-message-send-assistant-empty"), + metadata: {}, + payload: { + threadId: asThreadId("thread-empty-messages"), + messageId: asMessageId("msg-assistant-empty"), + role: "assistant", + text: " \n\t ", + attachments: [], + turnId: asTurnId("turn-3"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + + const promoteCommand: OrchestrationCommand = { + type: "thread.proposed-plan.promote", + commandId: asCommandId("cmd-promote-empty-text"), + threadId: asThreadId("thread-empty-messages"), + createdAt: "2026-01-01T00:00:02.000Z", + }; + + await expect( + Effect.runPromise( + decideOrchestrationCommand({ + command: promoteCommand, + readModel, + }), + ), + ).rejects.toThrow("No assistant message available to promote"); + }); + + it("picks M1 when a revert retains only turn T1 (M2 from T2 has been pruned)", async () => { + const projectReadModel = await seedReadModelWithProject(); + const fullReadModel = await seedReadModelWithThreadAndMessages(projectReadModel); + + const fullThread = fullReadModel.threads.find( + (t) => t.id === asThreadId("thread-proposedplan"), + ); + if (!fullThread) throw new Error("expected seeded thread"); + + const retainedMessages = fullThread.messages.filter( + (entry) => entry.turnId === asTurnId("turn-1"), + ); + const readModel: OrchestrationReadModel = { + ...fullReadModel, + threads: fullReadModel.threads.map((entry) => + entry.id === fullThread.id ? { ...entry, messages: retainedMessages } : entry, + ), + }; + + const promoteCommand: OrchestrationCommand = { + type: "thread.proposed-plan.promote", + commandId: asCommandId("cmd-promote-after-revert"), + threadId: asThreadId("thread-proposedplan"), + createdAt: "2026-01-01T00:00:02.000Z", + }; + + const decided = await Effect.runPromise( + decideOrchestrationCommand({ + command: promoteCommand, + readModel, + }), + ); + + const event = Array.isArray(decided) ? decided[0] : decided; + expect(event).toBeDefined(); + expect(event.type).toBe("thread.proposed-plan-upserted"); + + if (event.type === "thread.proposed-plan-upserted") { + expect(event.payload.proposedPlan.id).toBe( + "plan:thread-proposedplan:promoted:msg-assistant-1", + ); + expect(event.payload.proposedPlan.turnId).toBe(asTurnId("turn-1")); + expect(event.payload.proposedPlan.planMarkdown).toBe( + "Here's a plan:\n1. Do this\n2. Then that", + ); + } + }); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 2ce66390871..d7b39873d02 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -660,27 +660,51 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - const message = thread.messages.find((entry) => entry.id === command.messageId); - if (!message) { - return yield* new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `Message '${command.messageId}' not found in thread '${command.threadId}'.`, - }); + + const latestRevertedPlan = [...thread.proposedPlans] + .filter((entry) => entry.revertedAt !== null) + .toSorted( + (left, right) => + (left.revertedAt ?? "").localeCompare(right.revertedAt ?? "") || + left.id.localeCompare(right.id), + ) + .at(-1); + if (latestRevertedPlan) { + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.proposed-plan-upserted", + payload: { + threadId: command.threadId, + proposedPlan: { + ...latestRevertedPlan, + revertedAt: null, + updatedAt: command.createdAt, + }, + }, + }; } - if (message.role !== "assistant") { - return yield* new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `Message '${command.messageId}' is not an assistant message.`, - }); + + let message: (typeof thread.messages)[number] | undefined; + for (let i = thread.messages.length - 1; i >= 0; i -= 1) { + const entry = thread.messages[i]; + if (entry?.role === "assistant" && entry.text.trim().length > 0) { + message = entry; + break; + } } - const planMarkdown = message.text.trim(); - if (planMarkdown.length === 0) { + if (!message) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, - detail: `Message '${command.messageId}' has no text to promote.`, + detail: `No assistant message available to promote in thread '${command.threadId}'.`, }); } - const planId = `plan:${command.threadId}:promoted:${command.messageId}`; + const planMarkdown = message.text.trim(); + const planId = `plan:${command.threadId}:promoted:${message.id}`; const existingPlan = thread.proposedPlans.find((entry) => entry.id === planId); return { ...withEventBase({ @@ -698,6 +722,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" planMarkdown, implementedAt: existingPlan?.implementedAt ?? null, implementationThreadId: existingPlan?.implementationThreadId ?? null, + revertedAt: null, createdAt: existingPlan?.createdAt ?? command.createdAt, updatedAt: command.createdAt, }, @@ -725,10 +750,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" occurredAt: command.createdAt, commandId: command.commandId, }), - type: "thread.proposed-plan-removed", + type: "thread.proposed-plan-upserted", payload: { threadId: command.threadId, - planId: command.planId, + proposedPlan: { + ...existingPlan, + revertedAt: command.createdAt, + updatedAt: command.createdAt, + }, }, }; } diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts index 52536e89ab1..90b82cdde73 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -26,6 +26,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { plan_markdown, implemented_at, implementation_thread_id, + reverted_at, created_at, updated_at ) @@ -36,6 +37,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { ${row.planMarkdown}, ${row.implementedAt}, ${row.implementationThreadId}, + ${row.revertedAt}, ${row.createdAt}, ${row.updatedAt} ) @@ -46,6 +48,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { plan_markdown = excluded.plan_markdown, implemented_at = excluded.implemented_at, implementation_thread_id = excluded.implementation_thread_id, + reverted_at = excluded.reverted_at, created_at = excluded.created_at, updated_at = excluded.updated_at `, @@ -62,6 +65,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { plan_markdown AS "planMarkdown", implemented_at AS "implementedAt", implementation_thread_id AS "implementationThreadId", + reverted_at AS "revertedAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_proposed_plans diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index cc5024d5f51..d499a81d7db 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -43,6 +43,7 @@ import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts" import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; +import Migration0031 from "./Migrations/031_ProjectionThreadProposedPlanRevertedAt.ts"; /** * Migration loader with all migrations defined inline. @@ -85,6 +86,7 @@ export const migrationEntries = [ [28, "ProjectionThreadSessionInstanceId", Migration0028], [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], [30, "ProjectionThreadShellArchiveIndexes", Migration0030], + [31, "ProjectionThreadProposedPlanRevertedAt", Migration0031], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/031_ProjectionThreadProposedPlanRevertedAt.ts b/apps/server/src/persistence/Migrations/031_ProjectionThreadProposedPlanRevertedAt.ts new file mode 100644 index 00000000000..82d3241f0b3 --- /dev/null +++ b/apps/server/src/persistence/Migrations/031_ProjectionThreadProposedPlanRevertedAt.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const columns = yield* sql<{ name: string }>` + PRAGMA table_info(projection_thread_proposed_plans) + `; + + if (!columns.some((column) => column.name === "reverted_at")) { + yield* sql` + ALTER TABLE projection_thread_proposed_plans + ADD COLUMN reverted_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts index cb1ec970a93..e5b04f2cefe 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -18,6 +18,7 @@ export const ProjectionThreadProposedPlan = Schema.Struct({ planMarkdown: TrimmedNonEmptyString, implementedAt: Schema.NullOr(IsoDateTime), implementationThreadId: Schema.NullOr(ThreadId), + revertedAt: Schema.NullOr(IsoDateTime), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e741b04bb76..b778d8c3e7e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3419,17 +3419,22 @@ export default function ChatView(props: ChatViewProps) { return undefined; }, [activeThread?.messages]); + const hasRevertedPromotablePlan = useMemo( + () => (activeThread?.proposedPlans ?? []).some((entry) => entry.revertedAt !== null), + [activeThread?.proposedPlans], + ); + const canPromoteToPlan = interactionMode === "plan" && activeProposedPlan === null && - latestPromotableAssistantMessageId !== undefined && + (latestPromotableAssistantMessageId !== undefined || hasRevertedPromotablePlan) && isServerThread && !isSendBusy && !isConnecting && !activeEnvironmentUnavailable; const onPromoteToPlan = useCallback(() => { - if (!activeThread || !latestPromotableAssistantMessageId) return; + if (!activeThread) return; const api = readEnvironmentApi(activeThread.environmentId); if (!api) return; void api.orchestration @@ -3437,7 +3442,6 @@ export default function ChatView(props: ChatViewProps) { type: "thread.proposed-plan.promote", commandId: newCommandId(), threadId: activeThread.id, - messageId: latestPromotableAssistantMessageId, createdAt: new Date().toISOString(), }) .catch((err: unknown) => { @@ -3450,7 +3454,7 @@ export default function ChatView(props: ChatViewProps) { }), ); }); - }, [activeThread, latestPromotableAssistantMessageId]); + }, [activeThread]); const canRevertPlan = activeProposedPlan !== null && diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9b0b059396f..6ba2488f56d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -352,7 +352,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} - onRevertPlan={props.onRevertPlan} + {...(props.onRevertPlan !== undefined && { onRevertPlan: props.onRevertPlan })} /> ); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index baf384d6af2..570c717d95d 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -379,6 +379,7 @@ describe("findLatestProposedPlan", () => { planMarkdown: "# Older", implementedAt: null, implementationThreadId: null, + revertedAt: null, createdAt: "2026-02-23T00:00:01.000Z", updatedAt: "2026-02-23T00:00:01.000Z", }, @@ -388,6 +389,7 @@ describe("findLatestProposedPlan", () => { planMarkdown: "# Latest", implementedAt: null, implementationThreadId: null, + revertedAt: null, createdAt: "2026-02-23T00:00:01.000Z", updatedAt: "2026-02-23T00:00:02.000Z", }, @@ -397,6 +399,7 @@ describe("findLatestProposedPlan", () => { planMarkdown: "# Different turn", implementedAt: null, implementationThreadId: null, + revertedAt: null, createdAt: "2026-02-23T00:00:03.000Z", updatedAt: "2026-02-23T00:00:03.000Z", }, @@ -409,6 +412,7 @@ describe("findLatestProposedPlan", () => { planMarkdown: "# Latest", implementedAt: null, implementationThreadId: null, + revertedAt: null, createdAt: "2026-02-23T00:00:01.000Z", updatedAt: "2026-02-23T00:00:02.000Z", }); @@ -423,6 +427,7 @@ describe("findLatestProposedPlan", () => { planMarkdown: "# First", implementedAt: null, implementationThreadId: null, + revertedAt: null, createdAt: "2026-02-23T00:00:01.000Z", updatedAt: "2026-02-23T00:00:01.000Z", }, @@ -432,6 +437,7 @@ describe("findLatestProposedPlan", () => { planMarkdown: "# Latest", implementedAt: null, implementationThreadId: null, + revertedAt: null, createdAt: "2026-02-23T00:00:02.000Z", updatedAt: "2026-02-23T00:00:03.000Z", }, @@ -452,6 +458,7 @@ describe("hasActionableProposedPlan", () => { planMarkdown: "# Plan", implementedAt: null, implementationThreadId: null, + revertedAt: null, createdAt: "2026-02-23T00:00:00.000Z", updatedAt: "2026-02-23T00:00:01.000Z", }), @@ -466,6 +473,7 @@ describe("hasActionableProposedPlan", () => { planMarkdown: "# Plan", implementedAt: "2026-02-23T00:00:02.000Z", implementationThreadId: ThreadId.make("thread-implement"), + revertedAt: null, createdAt: "2026-02-23T00:00:00.000Z", updatedAt: "2026-02-23T00:00:02.000Z", }), @@ -487,6 +495,7 @@ describe("findSidebarProposedPlan", () => { planMarkdown: "# Source plan", implementedAt: "2026-02-23T00:00:03.000Z", implementationThreadId: ThreadId.make("thread-2"), + revertedAt: null, createdAt: "2026-02-23T00:00:01.000Z", updatedAt: "2026-02-23T00:00:02.000Z", }, @@ -501,6 +510,7 @@ describe("findSidebarProposedPlan", () => { planMarkdown: "# Latest elsewhere", implementedAt: null, implementationThreadId: null, + revertedAt: null, createdAt: "2026-02-23T00:00:04.000Z", updatedAt: "2026-02-23T00:00:05.000Z", }, @@ -541,6 +551,7 @@ describe("findSidebarProposedPlan", () => { planMarkdown: "# Older", implementedAt: null, implementationThreadId: null, + revertedAt: null, createdAt: "2026-02-23T00:00:01.000Z", updatedAt: "2026-02-23T00:00:02.000Z", }, @@ -550,6 +561,7 @@ describe("findSidebarProposedPlan", () => { planMarkdown: "# Latest", implementedAt: null, implementationThreadId: null, + revertedAt: null, createdAt: "2026-02-23T00:00:03.000Z", updatedAt: "2026-02-23T00:00:04.000Z", }, @@ -1285,6 +1297,7 @@ describe("deriveTimelineEntries", () => { planMarkdown: "# Ship it", implementedAt: null, implementationThreadId: null, + revertedAt: null, createdAt: "2026-02-23T00:00:02.000Z", updatedAt: "2026-02-23T00:00:02.000Z", }, @@ -1306,6 +1319,7 @@ describe("deriveTimelineEntries", () => { planMarkdown: "# Ship it", implementedAt: null, implementationThreadId: null, + revertedAt: null, }, }); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 8f735bba0e6..4b0a8da8b13 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -98,6 +98,7 @@ export interface LatestProposedPlanState { planMarkdown: string; implementedAt: string | null; implementationThreadId: ThreadId | null; + revertedAt: string | null; } export type TimelineEntry = @@ -424,8 +425,9 @@ export function findLatestProposedPlan( proposedPlans: ReadonlyArray, latestTurnId: TurnId | string | null | undefined, ): LatestProposedPlanState | null { + const activePlans = proposedPlans.filter((entry) => entry.revertedAt === null); if (latestTurnId) { - const matchingTurnPlan = [...proposedPlans] + const matchingTurnPlan = [...activePlans] .filter((proposedPlan) => proposedPlan.turnId === latestTurnId) .toSorted( (left, right) => @@ -437,7 +439,7 @@ export function findLatestProposedPlan( } } - const latestPlan = [...proposedPlans] + const latestPlan = [...activePlans] .toSorted( (left, right) => left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id), @@ -475,9 +477,11 @@ export function findSidebarProposedPlan(input: { } export function hasActionableProposedPlan( - proposedPlan: LatestProposedPlanState | Pick | null, + proposedPlan: LatestProposedPlanState | Pick | null, ): boolean { - return proposedPlan !== null && proposedPlan.implementedAt === null; + return ( + proposedPlan !== null && proposedPlan.implementedAt === null && proposedPlan.revertedAt === null + ); } export function deriveWorkLogEntries( @@ -693,6 +697,7 @@ function toLatestProposedPlanState(proposedPlan: ProposedPlan): LatestProposedPl planMarkdown: proposedPlan.planMarkdown, implementedAt: proposedPlan.implementedAt, implementationThreadId: proposedPlan.implementationThreadId, + revertedAt: proposedPlan.revertedAt, }; } @@ -1163,8 +1168,9 @@ export function deriveTimelineEntries( ): TimelineEntry[] { const promotedSourceMessageIds = new Set(); for (const proposedPlan of proposedPlans) { + if (proposedPlan.revertedAt !== null) continue; const match = /:promoted:(.+)$/.exec(proposedPlan.id); - if (match) { + if (match && match[1]) { promotedSourceMessageIds.add(match[1]); } } @@ -1176,12 +1182,14 @@ export function deriveTimelineEntries( createdAt: message.createdAt, message, })); - const proposedPlanRows: TimelineEntry[] = proposedPlans.map((proposedPlan) => ({ - id: proposedPlan.id, - kind: "proposed-plan", - createdAt: proposedPlan.createdAt, - proposedPlan, - })); + const proposedPlanRows: TimelineEntry[] = proposedPlans + .filter((proposedPlan) => proposedPlan.revertedAt === null) + .map((proposedPlan) => ({ + id: proposedPlan.id, + kind: "proposed-plan", + createdAt: proposedPlan.createdAt, + proposedPlan, + })); const workRows: TimelineEntry[] = workEntries.map((entry) => ({ id: entry.id, kind: "work", diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 59ebd0cea0c..785086b6ead 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -310,6 +310,7 @@ describe("thread selection memoization", () => { planMarkdown: "plan", implementedAt: null, implementationThreadId: null, + revertedAt: null, createdAt: "2026-02-13T00:02:00.000Z", updatedAt: "2026-02-13T00:02:00.000Z", }, @@ -902,6 +903,7 @@ describe("incremental orchestration updates", () => { planMarkdown: "plan 1", implementedAt: null, implementationThreadId: null, + revertedAt: null, createdAt: "2026-02-27T00:00:00.000Z", updatedAt: "2026-02-27T00:00:00.000Z", }, @@ -911,6 +913,7 @@ describe("incremental orchestration updates", () => { planMarkdown: "plan 2", implementedAt: null, implementationThreadId: null, + revertedAt: null, createdAt: "2026-02-27T00:00:02.000Z", updatedAt: "2026-02-27T00:00:02.000Z", }, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index ab13db507e8..83bb2b712c1 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -192,6 +192,7 @@ function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): ProposedPlan planMarkdown: proposedPlan.planMarkdown, implementedAt: proposedPlan.implementedAt, implementationThreadId: proposedPlan.implementationThreadId, + revertedAt: proposedPlan.revertedAt, createdAt: proposedPlan.createdAt, updatedAt: proposedPlan.updatedAt, }; diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 7c9fd41bba1..54ab7fe971a 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -60,6 +60,7 @@ export interface ProposedPlan { planMarkdown: string; implementedAt: string | null; implementationThreadId: ThreadId | null; + revertedAt: string | null; createdAt: string; updatedAt: string; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 2b0c9815caf..9d9de70bb7b 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -236,6 +236,7 @@ export const OrchestrationProposedPlan = Schema.Struct({ implementationThreadId: Schema.NullOr(ThreadId).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + revertedAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -649,7 +650,6 @@ const ThreadProposedPlanPromoteCommand = Schema.Struct({ type: Schema.Literal("thread.proposed-plan.promote"), commandId: CommandId, threadId: ThreadId, - messageId: MessageId, createdAt: IsoDateTime, }); From 59c73da8a4f640d50384ff81088b478ef1ee35fd Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Sun, 10 May 2026 02:03:29 +0530 Subject: [PATCH 28/46] fix: show implement button for promoted Claude plans when session is ready - Remove latestTurnSettled gate from activeProposedPlan lookup so promoted plans are recognized even when latest-turn completion metadata is stale. - Switch showPlanFollowUpPrompt guard from latestTurnSettled to phase !== 'running' so the Implement/Refine composer actions appear when the thread is idle and an actionable plan exists. - Fix narrow findProposedPlanById type in ProviderRuntimeIngestion so revertedAt is preserved through plan upserts. --- .../Layers/ProviderRuntimeIngestion.ts | 15 +++++++-------- apps/web/src/components/ChatView.tsx | 7 ++----- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 84470772d41..207676e9bff 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -121,14 +121,12 @@ function findMessageById( return undefined; } -function findProposedPlanById( - proposedPlans: ReadonlyArray< - Pick - >, - planId: string, -): - | Pick - | undefined { +function findProposedPlanById< + T extends Pick< + OrchestrationProposedPlan, + "id" | "createdAt" | "implementedAt" | "implementationThreadId" + > & { revertedAt?: string | null }, +>(proposedPlans: ReadonlyArray, planId: string): T | undefined { for (let index = 0; index < proposedPlans.length; index += 1) { const proposedPlan = proposedPlans[index]; if (proposedPlan?.id === planId) { @@ -1028,6 +1026,7 @@ const make = Effect.gen(function* () { createdAt: string; implementedAt: string | null; implementationThreadId: ThreadId | null; + revertedAt?: string | null; }>; planId: string; turnId?: TurnId; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b778d8c3e7e..652cacc5370 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1343,14 +1343,11 @@ export default function ChatView(props: ChatViewProps) { ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) : false; const activeProposedPlan = useMemo(() => { - if (!latestTurnSettled) { - return null; - } return findLatestProposedPlan( activeThread?.proposedPlans ?? [], activeLatestTurn?.turnId ?? null, ); - }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); + }, [activeLatestTurn?.turnId, activeThread?.proposedPlans]); const sidebarProposedPlan = useMemo( () => findSidebarProposedPlan({ @@ -1369,7 +1366,7 @@ export default function ChatView(props: ChatViewProps) { const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && - latestTurnSettled && + phase !== "running" && hasActionableProposedPlan(activeProposedPlan); const activePendingApproval = pendingApprovals[0] ?? null; const { From fe21b8fbae29c39f377495ac762ed8e007a5a414 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Sun, 10 May 2026 11:24:51 +0530 Subject: [PATCH 29/46] Update DesktopLifecycle.ts --- apps/desktop/src/app/DesktopLifecycle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index b9a7636a411..a709c8692ae 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -127,7 +127,7 @@ function handleBeforeQuit( void runEffect( Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; - yield* electronApp.quit; + yield* electronApp.exit(0); }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")), ); }); From 3f847c68876922c8091c784b05672d336e40e271 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Sun, 10 May 2026 11:25:48 +0530 Subject: [PATCH 30/46] Create settings.json --- .zed/settings.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .zed/settings.json diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 00000000000..d5f695e30ec --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,5 @@ +{ + "ensure_final_newline_on_save": false, + "remove_trailing_whitespace_on_save": false, + "format_on_save": "off" +} From 6251b57a443b97c81daa3a6b01477e8644a37a77 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Sun, 10 May 2026 12:31:58 +0530 Subject: [PATCH 31/46] Add timeout to desktop shutdown to prevent app hangs - Adds 5-second timeout (SHUTDOWN_EXIT_TIMEOUT_MS) to shutdown sequence - Uses Promise.race() to ensure app exits even if shutdown effect hangs - Properly manages timeout handle cleanup - Prevents indefinite app hang on quit --- apps/desktop/src/app/DesktopLifecycle.ts | 61 ++++++++++++++---------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index a709c8692ae..ca0dce66f2a 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -6,6 +6,10 @@ import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off +import * as NodeTimers from "node:timers"; + +import { app as electronAppModule, type Event as ElectronEvent } from "electron"; import type * as Electron from "electron"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -97,40 +101,49 @@ const requestDesktopShutdownAndWait = Effect.fn("desktop.lifecycle.requestShutdo }, ); +const SHUTDOWN_EXIT_TIMEOUT_MS = 5000; + function handleBeforeQuit( - event: Electron.Event, + event: ElectronEvent, runEffect: (effect: Effect.Effect) => Promise, - allowQuit: () => boolean, - markQuitAllowed: () => void, + getShutdownPromise: () => Promise | null, + setShutdownPromise: (promise: Promise) => void, ): void { - if (allowQuit()) { - void runEffect( - Effect.gen(function* () { - const state = yield* DesktopState.DesktopState; - yield* Ref.set(state.quitting, true); - yield* logLifecycleInfo("before-quit received"); - }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), - ); + if (getShutdownPromise() !== null) { + event.preventDefault(); return; } event.preventDefault(); - void runEffect( + + const shutdownEffect = runEffect( Effect.gen(function* () { const state = yield* DesktopState.DesktopState; yield* Ref.set(state.quitting, true); yield* logLifecycleInfo("before-quit received"); yield* requestDesktopShutdownAndWait(); }).pipe(Effect.withSpan("desktop.lifecycle.beforeQuit")), - ).finally(() => { - markQuitAllowed(); - void runEffect( - Effect.gen(function* () { - const electronApp = yield* ElectronApp.ElectronApp; - yield* electronApp.exit(0); - }).pipe(Effect.withSpan("desktop.lifecycle.quitAfterShutdown")), - ); + ); + + let timeoutHandle: ReturnType | null = null; + const timeoutPromise = new Promise((resolve) => { + timeoutHandle = NodeTimers.setTimeout(resolve, SHUTDOWN_EXIT_TIMEOUT_MS); + }); + + const racePromise = Promise.race([ + shutdownEffect.then( + () => undefined, + () => undefined, + ), + timeoutPromise, + ]).finally(() => { + if (timeoutHandle !== null) { + NodeTimers.clearTimeout(timeoutHandle); + } + electronAppModule.exit(0); }); + + setShutdownPromise(racePromise); } function quitFromSignal( @@ -189,7 +202,7 @@ export const layer = Layer.succeed( const environment = yield* DesktopEnvironment.DesktopEnvironment; const context = yield* Effect.context(); const runEffect = Effect.runPromiseWith(context); - let quitAllowed = false; + let shutdownPromise: Promise | null = null; yield* electronTheme.onUpdated(() => { void runEffect( desktopWindow.syncAppearance.pipe(Effect.withSpan("desktop.lifecycle.themeUpdated")), @@ -199,9 +212,9 @@ export const layer = Layer.succeed( handleBeforeQuit( event, runEffect, - () => quitAllowed, - () => { - quitAllowed = true; + () => shutdownPromise, + (promise) => { + shutdownPromise = promise; }, ); }); From 56e4eeb740b4f7375f06c3c919ca731c48f01832 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Sun, 10 May 2026 14:52:35 +0530 Subject: [PATCH 32/46] Prevent plan promotion when user input is pending Add check for pending user inputs before allowing plan promotion to Claude, ensuring users cannot promote a plan while unsent messages await. --- apps/web/src/components/ChatView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 652cacc5370..b5cb9439d0a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3424,6 +3424,7 @@ export default function ChatView(props: ChatViewProps) { const canPromoteToPlan = interactionMode === "plan" && activeProposedPlan === null && + pendingUserInputs.length === 0 && (latestPromotableAssistantMessageId !== undefined || hasRevertedPromotablePlan) && isServerThread && !isSendBusy && From 6267bef535162ccfe7cbdb542fe7b8c02e03aa49 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Sun, 10 May 2026 16:40:33 +0530 Subject: [PATCH 33/46] Switch build timestamps from UTC to local time - Change formatBuildTimestamp to use local time getters - Remove -u flag from date command in release workflow - Update test expectations and comments --- .github/workflows/release.yml | 2 +- packages/shared/src/buildTimestamp.ts | 10 +++++----- scripts/build-desktop-artifact.test.ts | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35bcaaf3129..b837bdf4193 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,7 +109,7 @@ jobs: NIGHTLY_RUN_NUMBER: ${{ github.run_number }} run: | if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then - nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" + nightly_date="$(date -d "$NIGHTLY_DATE" +%Y%m%d)" node scripts/resolve-nightly-release.ts \ --date "$nightly_date" \ diff --git a/packages/shared/src/buildTimestamp.ts b/packages/shared/src/buildTimestamp.ts index 534d98f2d33..aa3b25718c0 100644 --- a/packages/shared/src/buildTimestamp.ts +++ b/packages/shared/src/buildTimestamp.ts @@ -1,9 +1,9 @@ export function formatBuildTimestamp(date: Date): string { const pad = (n: number) => n.toString().padStart(2, "0"); - const yyyy = date.getUTCFullYear(); - const mm = pad(date.getUTCMonth() + 1); - const dd = pad(date.getUTCDate()); - const hh = pad(date.getUTCHours()); - const mi = pad(date.getUTCMinutes()); + const yyyy = date.getFullYear(); + const mm = pad(date.getMonth() + 1); + const dd = pad(date.getDate()); + const hh = pad(date.getHours()); + const mi = pad(date.getMinutes()); return `${yyyy}${mm}${dd}-${hh}${mi}`; } diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 55321388411..57852275793 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -40,13 +40,13 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); }); - it("formats build timestamps as zero-padded UTC YYYYMMDD-HHMM", () => { + it("formats build timestamps as zero-padded local YYYYMMDD-HHMM", () => { // @effect-diagnostics-next-line globalDate:off - assert.equal(formatBuildTimestamp(new Date(Date.UTC(2026, 4, 6, 14, 30))), "20260506-1430"); + assert.equal(formatBuildTimestamp(new Date(2026, 4, 6, 14, 30)), "20260506-1430"); // @effect-diagnostics-next-line globalDate:off - assert.equal(formatBuildTimestamp(new Date(Date.UTC(2026, 0, 1, 0, 0))), "20260101-0000"); + assert.equal(formatBuildTimestamp(new Date(2026, 0, 1, 0, 0)), "20260101-0000"); // @effect-diagnostics-next-line globalDate:off - assert.equal(formatBuildTimestamp(new Date(Date.UTC(2026, 11, 31, 23, 59))), "20261231-2359"); + assert.equal(formatBuildTimestamp(new Date(2026, 11, 31, 23, 59)), "20261231-2359"); // @effect-diagnostics-next-line globalDate:off assert.match(formatBuildTimestamp(new Date()), /^\d{8}-\d{4}$/); }); From 7f50a22daa63caa8073718fb01cf76ee33da811e Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Tue, 12 May 2026 00:44:05 +0530 Subject: [PATCH 34/46] Merge branch 'main' into aa-2026-05-12 --- apps/web/src/branding.test.ts | 2 ++ apps/web/src/branding.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index 0d244058881..498233f62b4 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -44,6 +44,8 @@ describe("branding", () => { expect(branding.HOSTED_APP_CHANNEL).toBe("nightly"); expect(branding.HOSTED_APP_CHANNEL_LABEL).toBe("Nightly"); + expect(branding.APP_STAGE_LABEL).toBe("Nightly"); + expect(branding.APP_DISPLAY_NAME).toBe("T3 Code (Nightly)"); }); it("ignores unknown hosted app channels", async () => { diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 833705c43b6..a1bc75173e5 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -11,16 +11,18 @@ function readInjectedDesktopAppBranding(): DesktopAppBranding | null { const injectedDesktopAppBranding = readInjectedDesktopAppBranding(); const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLowerCase(); +export const HOSTED_APP_CHANNEL = + hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null; +export const HOSTED_APP_CHANNEL_LABEL = + HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null; export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; export const APP_STAGE_LABEL = - injectedDesktopAppBranding?.stageLabel ?? (import.meta.env.DEV ? "Dev" : "A3"); + injectedDesktopAppBranding?.stageLabel ?? + HOSTED_APP_CHANNEL_LABEL ?? + (import.meta.env.DEV ? "Dev" : "A3"); export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; export const APP_VERSION = injectedDesktopAppBranding?.displayVersion ?? import.meta.env.APP_VERSION ?? "0.0.0"; export const APP_PKG_VERSION = injectedDesktopAppBranding?.appVersion ?? import.meta.env.APP_VERSION ?? "0.0.0"; -export const HOSTED_APP_CHANNEL = - hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null; -export const HOSTED_APP_CHANNEL_LABEL = - HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null; From 09cfbf9f8357528496515fc14248bbd33dba681b Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Tue, 12 May 2026 00:52:22 +0530 Subject: [PATCH 35/46] Add plan implementation in new thread draft mode - New "Implement in a new thread (don't send)" menu option - Draft threads track source plan references - New useStartImplementationDraftFromPlan hook for creating implementation drafts --- apps/web/src/components/ChatView.tsx | 52 ++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 16 +++++ .../chat/ComposerPrimaryActions.tsx | 8 +++ apps/web/src/composerDraftStore.ts | 68 ++++++++++++++++++- apps/web/src/hooks/useHandleNewThread.ts | 54 ++++++++++++++- 5 files changed, 195 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6b84aa11ca6..0488e86ffff 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -96,6 +96,7 @@ import { } from "../types"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; +import { useStartImplementationDraftFromPlan } from "../hooks/useHandleNewThread"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; @@ -2869,6 +2870,9 @@ export default function ChatView(props: ChatViewProps) { runtimeMode, interactionMode, ...(bootstrap ? { bootstrap } : {}), + ...(isLocalDraftThread && draftThread?.pendingSourceProposedPlan + ? { sourceProposedPlan: draftThread.pendingSourceProposedPlan } + : {}), createdAt: messageCreatedAt, }); turnStartSucceeded = true; @@ -3366,6 +3370,53 @@ export default function ChatView(props: ChatViewProps) { environmentId, ]); + const startImplementationDraftFromPlan = useStartImplementationDraftFromPlan(); + const onImplementPlanInNewThreadDraft = useCallback(async () => { + if ( + !activeThread || + !activeProject || + !activeProposedPlan || + !isServerThread || + isSendBusy || + isConnecting || + activeEnvironmentUnavailable || + sendInFlightRef.current + ) { + return; + } + const sendCtx = composerRef.current?.getSendContext(); + if (!sendCtx) { + return; + } + const { selectedModelSelection: ctxSelectedModelSelection } = sendCtx; + const implementationPrompt = buildPlanImplementationPrompt(activeProposedPlan.planMarkdown); + const logicalProjectKey = deriveLogicalProjectKeyFromSettings( + activeProject, + projectGroupingSettings, + ); + await startImplementationDraftFromPlan({ + projectRef: scopeProjectRef(activeProject.environmentId, activeProject.id), + logicalProjectKey, + prompt: implementationPrompt, + modelSelection: ctxSelectedModelSelection, + sourceThreadId: activeThread.id, + sourcePlanId: activeProposedPlan.id, + branch: activeThreadBranch, + worktreePath: activeThread.worktreePath, + }); + }, [ + activeEnvironmentUnavailable, + activeProject, + activeProposedPlan, + activeThread, + activeThreadBranch, + isConnecting, + isSendBusy, + isServerThread, + projectGroupingSettings, + startImplementationDraftFromPlan, + ]); + const onProviderModelSelect = useCallback( (instanceId: ProviderInstanceId, model: string) => { if (!activeThread) return; @@ -3658,6 +3709,7 @@ export default function ChatView(props: ChatViewProps) { onSend={onSend} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} + onImplementPlanInNewThreadDraft={onImplementPlanInNewThreadDraft} onRespondToApproval={onRespondToApproval} onSelectActivePendingUserInputOption={onSelectActivePendingUserInputOption} onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2c4743de3c6..5992e47451b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -307,6 +307,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + onImplementPlanInNewThreadDraft: () => void; }) { return ( <> @@ -329,6 +330,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} + onImplementPlanInNewThreadDraft={props.onImplementPlanInNewThreadDraft} /> ); @@ -458,6 +460,7 @@ export interface ChatComposerProps { onSend: (e?: { preventDefault: () => void }) => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + onImplementPlanInNewThreadDraft: () => void; onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, @@ -542,6 +545,7 @@ export const ChatComposer = memo( onSend, onInterrupt, onImplementPlanInNewThread, + onImplementPlanInNewThreadDraft, onRespondToApproval, onSelectActivePendingUserInputOption, onAdvanceActivePendingUserInput, @@ -1789,6 +1793,9 @@ export const ChatComposer = memo( const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => { void onImplementPlanInNewThread(); }, [onImplementPlanInNewThread]); + const handleImplementPlanInNewThreadDraftPrimaryAction = useCallback(() => { + void onImplementPlanInNewThreadDraft(); + }, [onImplementPlanInNewThreadDraft]); const scheduleComposerCollapseCheck = useCallback(() => { if (!isMobileViewport) { return; @@ -2085,6 +2092,9 @@ export const ChatComposer = memo( onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + onImplementPlanInNewThreadDraft={ + handleImplementPlanInNewThreadDraftPrimaryAction + } /> ) : null} @@ -2295,6 +2305,9 @@ export const ChatComposer = memo( onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + onImplementPlanInNewThreadDraft={ + handleImplementPlanInNewThreadDraftPrimaryAction + } /> ) : null} @@ -2411,6 +2424,9 @@ export const ChatComposer = memo( onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} + onImplementPlanInNewThreadDraft={ + handleImplementPlanInNewThreadDraftPrimaryAction + } /> diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index fbeb9de30b8..906f03866d4 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -27,6 +27,7 @@ interface ComposerPrimaryActionsProps { onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; + onImplementPlanInNewThreadDraft: () => void; } export const formatPendingPrimaryActionLabel = (input: { @@ -66,6 +67,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, + onImplementPlanInNewThreadDraft, }: ComposerPrimaryActionsProps) { const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } @@ -186,6 +188,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ > Implement in a new thread + void onImplementPlanInNewThreadDraft()} + > + Implement in a new thread (don't send) + diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index d554ad7b0d3..d72bca3c43f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -14,6 +14,7 @@ import { type ScopedProjectRef, type ScopedThreadRef, ThreadId, + OrchestrationProposedPlanId, } from "@t3tools/contracts"; import { parseScopedProjectKey, @@ -187,6 +188,14 @@ const PersistedDraftThreadState = Schema.Struct({ }), ), ), + pendingSourceProposedPlan: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + threadId: ThreadId, + planId: OrchestrationProposedPlanId, + }), + ), + ), }); type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; @@ -249,6 +258,7 @@ export interface DraftSessionState { worktreePath: string | null; envMode: DraftThreadEnvMode; promotedTo?: ScopedThreadRef | null; + pendingSourceProposedPlan?: { threadId: ThreadId; planId: OrchestrationProposedPlanId } | null; } export type DraftThreadState = DraftSessionState; @@ -311,6 +321,10 @@ interface ComposerDraftStoreState { envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + pendingSourceProposedPlan?: { + threadId: ThreadId; + planId: OrchestrationProposedPlanId; + } | null; }, ) => void; /** Creates or updates the draft session tracked for a concrete project ref. */ @@ -325,6 +339,10 @@ interface ComposerDraftStoreState { envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + pendingSourceProposedPlan?: { + threadId: ThreadId; + planId: OrchestrationProposedPlanId; + } | null; }, ) => void; /** Updates mutable draft-session metadata without touching composer content. */ @@ -338,6 +356,10 @@ interface ComposerDraftStoreState { envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + pendingSourceProposedPlan?: { + threadId: ThreadId; + planId: OrchestrationProposedPlanId; + } | null; }, ) => void; clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; @@ -1157,6 +1179,7 @@ function createDraftThreadState( envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + pendingSourceProposedPlan?: { threadId: ThreadId; planId: OrchestrationProposedPlanId } | null; }, ): DraftThreadState { const projectChanged = @@ -1194,6 +1217,10 @@ function createDraftThreadState( ? "local" : (existingThread?.envMode ?? "local")), promotedTo: null, + pendingSourceProposedPlan: + options?.pendingSourceProposedPlan !== undefined + ? options.pendingSourceProposedPlan + : (existingThread?.pendingSourceProposedPlan ?? null), }; } @@ -1207,6 +1234,16 @@ function scopedThreadRefsEqual( return left.environmentId === right.environmentId && left.threadId === right.threadId; } +function pendingSourceProposedPlansEqual( + left: { threadId: ThreadId; planId: OrchestrationProposedPlanId } | null | undefined, + right: { threadId: ThreadId; planId: OrchestrationProposedPlanId } | null | undefined, +): boolean { + if (!left || !right) { + return left === right; + } + return left.threadId === right.threadId && left.planId === right.planId; +} + function isDraftThreadPromoting(draftThread: DraftThreadState | null | undefined): boolean { return draftThread?.promotedTo !== null && draftThread?.promotedTo !== undefined; } @@ -1224,7 +1261,8 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.branch === right.branch && left.worktreePath === right.worktreePath && left.envMode === right.envMode && - scopedThreadRefsEqual(left.promotedTo, right.promotedTo) + scopedThreadRefsEqual(left.promotedTo, right.promotedTo) && + pendingSourceProposedPlansEqual(left.pendingSourceProposedPlan, right.pendingSourceProposedPlan) ); } @@ -1335,6 +1373,22 @@ function normalizePersistedDraftThreads( promotedToRecord.threadId as ThreadId, ) : null; + const pendingSourceProposedPlanCandidate = candidateDraftThread.pendingSourceProposedPlan; + const pendingSourceProposedPlanRecord = + pendingSourceProposedPlanCandidate && typeof pendingSourceProposedPlanCandidate === "object" + ? (pendingSourceProposedPlanCandidate as Record) + : null; + const pendingSourceProposedPlan = + pendingSourceProposedPlanRecord && + typeof pendingSourceProposedPlanRecord.threadId === "string" && + pendingSourceProposedPlanRecord.threadId.length > 0 && + typeof pendingSourceProposedPlanRecord.planId === "string" && + pendingSourceProposedPlanRecord.planId.length > 0 + ? { + threadId: pendingSourceProposedPlanRecord.threadId as ThreadId, + planId: pendingSourceProposedPlanRecord.planId as OrchestrationProposedPlanId, + } + : null; if (typeof projectId !== "string" || projectId.length === 0 || environmentId === undefined) { continue; } @@ -1366,6 +1420,7 @@ function normalizePersistedDraftThreads( worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), promotedTo, + ...(pendingSourceProposedPlan ? { pendingSourceProposedPlan } : {}), }; } } @@ -1931,6 +1986,7 @@ function toHydratedDraftThreadState( persistedDraftThread.promotedTo.threadId as ThreadId, ) : null, + pendingSourceProposedPlan: persistedDraftThread.pendingSourceProposedPlan ?? null, }; } @@ -2131,6 +2187,10 @@ const composerDraftStore = create()( ? "local" : (existing.envMode ?? "local")), promotedTo: existing.promotedTo ?? null, + pendingSourceProposedPlan: + options.pendingSourceProposedPlan !== undefined + ? options.pendingSourceProposedPlan + : (existing.pendingSourceProposedPlan ?? null), }; const isUnchanged = nextDraftThread.environmentId === existing.environmentId && @@ -2142,7 +2202,11 @@ const composerDraftStore = create()( nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && - scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); + scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo) && + pendingSourceProposedPlansEqual( + nextDraftThread.pendingSourceProposedPlan, + existing.pendingSourceProposedPlan, + ); if (isUnchanged) { return state; } diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 3630171bf59..03f30adaf22 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,5 +1,11 @@ import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; -import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"; +import { + DEFAULT_RUNTIME_MODE, + type ModelSelection, + type OrchestrationProposedPlanId, + type ScopedProjectRef, + type ThreadId, +} from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { useShallow } from "zustand/react/shallow"; @@ -17,6 +23,52 @@ import { resolveThreadRouteTarget } from "../threadRoutes"; import { useUiStateStore } from "../uiStateStore"; import { useSettings } from "./useSettings"; +export function useStartImplementationDraftFromPlan() { + const router = useRouter(); + return useCallback( + async (options: { + projectRef: ScopedProjectRef; + logicalProjectKey: string; + prompt: string; + modelSelection: ModelSelection; + sourceThreadId: ThreadId; + sourcePlanId: OrchestrationProposedPlanId; + branch?: string | null; + worktreePath?: string | null; + envMode?: DraftThreadEnvMode; + }) => { + const { setLogicalProjectDraftThreadId, setPrompt, setModelSelection } = + useComposerDraftStore.getState(); + const draftId = newDraftId(); + const threadId = newThreadId(); + const createdAt = new Date().toISOString(); + const worktreePath = options.worktreePath ?? null; + const envMode: DraftThreadEnvMode = options.envMode ?? (worktreePath ? "worktree" : "local"); + + setLogicalProjectDraftThreadId(options.logicalProjectKey, options.projectRef, draftId, { + threadId, + createdAt, + branch: options.branch ?? null, + worktreePath, + envMode, + runtimeMode: DEFAULT_RUNTIME_MODE, + pendingSourceProposedPlan: { + threadId: options.sourceThreadId, + planId: options.sourcePlanId, + }, + }); + setPrompt(draftId, options.prompt); + setModelSelection(draftId, options.modelSelection); + + await router.navigate({ + to: "/draft/$draftId", + params: { draftId }, + }); + }, + [router], + ); +} + function useNewThreadState() { const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); const projectGroupingSettings = useSettings((settings) => ({ From b71664f2164cd8efb8b251bd3960cabce150f927 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Tue, 12 May 2026 11:00:01 +0530 Subject: [PATCH 36/46] Support plan reimplementation and sanitize PR bodies - Sanitize PR body generation to unwrap JSON envelopes from LLM responses - Support reimplementing already-implemented plans with updated UI labels - Add hasReimplementableProposedPlan to determine plan eligibility --- .../textGeneration/ClaudeTextGeneration.ts | 3 +- .../src/textGeneration/CodexTextGeneration.ts | 3 +- .../textGeneration/CursorTextGeneration.ts | 3 +- .../textGeneration/OpenCodeTextGeneration.ts | 3 +- .../textGeneration/TextGenerationPrompts.ts | 2 + .../src/textGeneration/TextGenerationUtils.ts | 28 ++++++++++ apps/web/src/components/ChatView.tsx | 7 ++- apps/web/src/components/chat/ChatComposer.tsx | 12 ++++- .../chat/ComposerPlanFollowUpBanner.tsx | 6 ++- .../chat/ComposerPrimaryActions.tsx | 16 ++++-- apps/web/src/session-logic.test.ts | 52 +++++++++++++++++++ apps/web/src/session-logic.ts | 6 +++ 12 files changed, 130 insertions(+), 11 deletions(-) diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 3c1e8c69673..19a54463088 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -27,6 +27,7 @@ import { import { normalizeCliError, sanitizeCommitSubject, + sanitizePrBody, sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, @@ -306,7 +307,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu return { title: sanitizePrTitle(generated.title), - body: generated.body.trim(), + body: sanitizePrBody(generated.body), }; }); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 3d1637a7fc0..86c8e57a5e8 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -29,6 +29,7 @@ import { import { normalizeCliError, sanitizeCommitSubject, + sanitizePrBody, sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, @@ -352,7 +353,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func return { title: sanitizePrTitle(generated.title), - body: generated.body.trim(), + body: sanitizePrBody(generated.body), }; }); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index c4ef1af21d1..4898ce2d0e0 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -18,6 +18,7 @@ import { } from "./TextGenerationPrompts.ts"; import { sanitizeCommitSubject, + sanitizePrBody, sanitizePrTitle, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; @@ -223,7 +224,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu return { title: sanitizePrTitle(generated.title), - body: generated.body.trim(), + body: sanitizePrBody(generated.body), }; }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index b865b2e5ef5..76c92c32115 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -26,6 +26,7 @@ import { import { type TextGenerationShape } from "./TextGeneration.ts"; import { sanitizeCommitSubject, + sanitizePrBody, sanitizePrTitle, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; @@ -412,7 +413,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" return { title: sanitizePrTitle(generated.title), - body: generated.body.trim(), + body: sanitizePrBody(generated.body), }; }); diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 6015e83b5d4..b871fb282d6 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -95,6 +95,8 @@ export function buildPrContentPrompt(input: PrContentPromptInput) { "Rules:", "- title should be concise and specific", "- body must be markdown and include headings '## Summary' and '## Testing'", + "- body must be plain markdown text only — do NOT wrap it in JSON, code fences, or repeat the title/body keys inside the body", + "- do NOT serialize the response as a string inside a field; the title and body fields receive their literal values directly", "- under Summary, provide short bullet points", "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", ...policyInstruction(input.policy?.changeRequestInstructions), diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts index a786f81b2c8..3d7d4572ff2 100644 --- a/apps/server/src/textGeneration/TextGenerationUtils.ts +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -42,6 +42,34 @@ export function sanitizePrTitle(raw: string): string { return "Update project changes"; } +export function sanitizePrBody(raw: string): string { + const trimmed = raw.trim(); + return unwrapPrBodyEnvelope(trimmed, 3); +} + +function unwrapPrBodyEnvelope(value: string, depth: number): string { + if (depth <= 0) return value; + if (!(value.startsWith("{") && value.endsWith("}"))) return value; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return value; + } + if ( + parsed !== null && + typeof parsed === "object" && + "body" in parsed && + typeof (parsed as { body: unknown }).body === "string" && + "title" in parsed && + typeof (parsed as { title: unknown }).title === "string" + ) { + const inner = (parsed as { body: string }).body.trim(); + return unwrapPrBodyEnvelope(inner, depth - 1); + } + return value; +} + /** Normalise a raw thread title to a compact single-line sidebar-safe label. */ export function sanitizeThreadTitle(raw: string): string { const normalized = raw diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index acf5497aa9e..a25bc95949d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -59,7 +59,7 @@ import { findSidebarProposedPlan, findLatestProposedPlan, deriveWorkLogEntries, - hasActionableProposedPlan, + hasReimplementableProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, @@ -1368,7 +1368,9 @@ export default function ChatView(props: ChatViewProps) { pendingUserInputs.length === 0 && interactionMode === "plan" && phase !== "running" && - hasActionableProposedPlan(activeProposedPlan); + hasReimplementableProposedPlan(activeProposedPlan); + const isPlanReimplementation = + activeProposedPlan !== null && activeProposedPlan.implementedAt !== null; const activePendingApproval = pendingApprovals[0] ?? null; const { beginLocalDispatch, @@ -3827,6 +3829,7 @@ export default function ChatView(props: ChatViewProps) { activePendingQuestionIndex={activePendingQuestionIndex} respondingRequestIds={respondingRequestIds} showPlanFollowUpPrompt={showPlanFollowUpPrompt} + isPlanReimplementation={isPlanReimplementation} activeProposedPlan={activeProposedPlan} activePlan={activePlan as { turnId?: TurnId } | null} sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8995b458eab..4915723e118 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -318,6 +318,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( } | null; isRunning: boolean; showPlanFollowUpPrompt: boolean; + isPlanReimplementation: boolean; promptHasText: boolean; isSendBusy: boolean; isConnecting: boolean; @@ -342,6 +343,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( pendingAction={props.pendingAction} isRunning={props.isRunning} showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} + isPlanReimplementation={props.isPlanReimplementation} promptHasText={props.promptHasText} isSendBusy={props.isSendBusy} isConnecting={props.isConnecting} @@ -445,6 +447,7 @@ export interface ChatComposerProps { // Plan showPlanFollowUpPrompt: boolean; + isPlanReimplementation: boolean; activeProposedPlan: Thread["proposedPlans"][number] | null; activePlan: { turnId?: TurnId } | null; sidebarProposedPlan: { turnId?: TurnId } | null; @@ -550,6 +553,7 @@ export const ChatComposer = memo( activePendingQuestionIndex, respondingRequestIds, showPlanFollowUpPrompt, + isPlanReimplementation, activeProposedPlan, activePlan, sidebarProposedPlan, @@ -2053,6 +2057,7 @@ export const ChatComposer = memo( ) : null)} @@ -2116,6 +2121,7 @@ export const ChatComposer = memo( pendingAction={pendingPrimaryAction} isRunning={false} showPlanFollowUpPrompt={false} + isPlanReimplementation={false} promptHasText={false} isSendBusy={isSendBusy} isConnecting={isConnecting} @@ -2302,7 +2308,9 @@ export const ChatComposer = memo( : activePendingProgress ? "Type your own answer, or leave this blank to use the selected option" : showPlanFollowUpPrompt && activeProposedPlan - ? "Add feedback to refine the plan, or leave this blank to implement it" + ? isPlanReimplementation + ? "Add feedback to refine the plan, or leave this blank to reimplement it" + : "Add feedback to refine the plan, or leave this blank to implement it" : environmentUnavailable ? `${environmentUnavailable.label} is ${ environmentUnavailable.connectionState === "connecting" @@ -2329,6 +2337,7 @@ export const ChatComposer = memo( pendingAction={pendingPrimaryAction} isRunning={false} showPlanFollowUpPrompt={false} + isPlanReimplementation={false} promptHasText={false} isSendBusy={isSendBusy} isConnecting={isConnecting} @@ -2452,6 +2461,7 @@ export const ChatComposer = memo( showPlanFollowUpPrompt={ pendingUserInputs.length === 0 && showPlanFollowUpPrompt } + isPlanReimplementation={isPlanReimplementation} promptHasText={prompt.trim().length > 0} isSendBusy={isSendBusy} isConnecting={isConnecting} diff --git a/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx b/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx index 49b03f7724b..ef9c24b5f49 100644 --- a/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx +++ b/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx @@ -2,13 +2,17 @@ import { memo } from "react"; export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ planTitle, + isReimplementation = false, }: { planTitle: string | null; + isReimplementation?: boolean; }) { return (
- Plan ready + + {isReimplementation ? "Plan implemented" : "Plan ready"} + {planTitle ? ( {planTitle} ) : null} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 46ef6fdf022..a780b21fb86 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -17,6 +17,7 @@ interface ComposerPrimaryActionsProps { pendingAction: PendingActionState | null; isRunning: boolean; showPlanFollowUpPrompt: boolean; + isPlanReimplementation: boolean; promptHasText: boolean; isSendBusy: boolean; isConnecting: boolean; @@ -59,6 +60,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ pendingAction, isRunning, showPlanFollowUpPrompt, + isPlanReimplementation, promptHasText, isSendBusy, isConnecting, @@ -168,7 +170,13 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ {...pointerFocusProps} disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} > - {isConnecting || isSendBusy ? "Sending..." : "Implement"} + {isConnecting || isSendBusy + ? isPlanReimplementation + ? "Reimplementing..." + : "Sending..." + : isPlanReimplementation + ? "Reimplement" + : "Implement"} void onImplementPlanInNewThread()} > - Implement in a new thread + {isPlanReimplementation ? "Reimplement in a new thread" : "Implement in a new thread"} void onImplementPlanInNewThreadDraft()} > - Implement in a new thread (don't send) + {isPlanReimplementation + ? "Reimplement in a new thread (don't send)" + : "Implement in a new thread (don't send)"} {canRevertPlan && onRevertPlan ? ( { }); }); +describe("hasReimplementableProposedPlan", () => { + it("returns true for an unimplemented proposed plan", () => { + expect( + hasReimplementableProposedPlan({ + id: "plan-1", + turnId: TurnId.make("turn-1"), + planMarkdown: "# Plan", + implementedAt: null, + implementationThreadId: null, + revertedAt: null, + createdAt: "2026-02-23T00:00:00.000Z", + updatedAt: "2026-02-23T00:00:01.000Z", + }), + ).toBe(true); + }); + + it("returns true for an already-implemented plan that has not been reverted", () => { + expect( + hasReimplementableProposedPlan({ + id: "plan-1", + turnId: TurnId.make("turn-1"), + planMarkdown: "# Plan", + implementedAt: "2026-02-23T00:00:02.000Z", + implementationThreadId: ThreadId.make("thread-implement"), + revertedAt: null, + createdAt: "2026-02-23T00:00:00.000Z", + updatedAt: "2026-02-23T00:00:02.000Z", + }), + ).toBe(true); + }); + + it("returns false for a reverted plan", () => { + expect( + hasReimplementableProposedPlan({ + id: "plan-1", + turnId: TurnId.make("turn-1"), + planMarkdown: "# Plan", + implementedAt: "2026-02-23T00:00:02.000Z", + implementationThreadId: ThreadId.make("thread-implement"), + revertedAt: "2026-02-23T00:00:05.000Z", + createdAt: "2026-02-23T00:00:00.000Z", + updatedAt: "2026-02-23T00:00:05.000Z", + }), + ).toBe(false); + }); + + it("returns false for null", () => { + expect(hasReimplementableProposedPlan(null)).toBe(false); + }); +}); + describe("findSidebarProposedPlan", () => { it("prefers the running turn source proposed plan when available on the same thread", () => { expect( diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4b0a8da8b13..e48890ef383 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -484,6 +484,12 @@ export function hasActionableProposedPlan( ); } +export function hasReimplementableProposedPlan( + proposedPlan: LatestProposedPlanState | Pick | null, +): boolean { + return proposedPlan !== null && proposedPlan.revertedAt === null; +} + export function deriveWorkLogEntries( activities: ReadonlyArray, latestTurnId: TurnId | undefined, From d522adc782b50813ee6eb371271b1b408a8c4394 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Wed, 13 May 2026 11:23:37 +0530 Subject: [PATCH 37/46] remove zed settings --- .zed/settings.json | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .zed/settings.json diff --git a/.zed/settings.json b/.zed/settings.json deleted file mode 100644 index d5f695e30ec..00000000000 --- a/.zed/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "ensure_final_newline_on_save": false, - "remove_trailing_whitespace_on_save": false, - "format_on_save": "off" -} From a145a651aa7163f255fe8a7b5d4c8e858a8e7614 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Wed, 13 May 2026 12:17:47 +0530 Subject: [PATCH 38/46] Replace ref terminology with branch in UI Change user-facing text from "ref" to "branch" for clarity. Updates branch selector labels, placeholders, error messages, and related test selectors. --- .../components/BranchToolbarBranchSelector.tsx | 18 +++++++++--------- apps/web/src/components/ChatView.browser.tsx | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 2e30edfc02c..93ea545faa7 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -70,7 +70,7 @@ function getBranchTriggerLabel(input: { }): string { const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; if (!resolvedActiveBranch) { - return "Select ref"; + return "Select branch"; } if (effectiveEnvMode === "worktree" && !activeWorktreePath) { return `From ${resolvedActiveBranch}`; @@ -295,11 +295,11 @@ export function BranchToolbarBranchSelector({ const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; const branchStatusText = isBranchesSearchPending - ? "Loading refs..." + ? "Loading branches..." : isFetchingNextPage - ? "Loading more refs..." + ? "Loading more branches..." : hasNextPage - ? `Showing ${refs.length} of ${totalBranchCount} refs` + ? `Showing ${refs.length} of ${totalBranchCount} branches` : null; // --------------------------------------------------------------------------- @@ -363,7 +363,7 @@ export function BranchToolbarBranchSelector({ toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to switch ref.", + title: "Failed to switch branch.", description: toBranchActionErrorMessage(error), }), ); @@ -395,7 +395,7 @@ export function BranchToolbarBranchSelector({ toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to create and switch ref.", + title: "Failed to create and switch branch.", description: toBranchActionErrorMessage(error), }), ); @@ -535,7 +535,7 @@ export function BranchToolbarBranchSelector({ value={itemValue} onClick={() => createRef(trimmedBranchQuery)} > - Create new ref "{trimmedBranchQuery}" + Create new branch "{trimmedBranchQuery}" ); } @@ -602,14 +602,14 @@ export function BranchToolbarBranchSelector({ setBranchQuery(event.target.value)} />
- No refs found. + No branches found. {shouldVirtualizeBranchList ? ( diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index bb62d8edbc0..25459d37e86 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2431,11 +2431,11 @@ describe("ChatView timeline estimator parity (full app)", () => { branchButton.click(); const branchInput = await waitForElement( - () => document.querySelector('input[placeholder="Search refs..."]'), + () => document.querySelector('input[placeholder="Search branches..."]'), "Unable to find ref search input.", ); branchInput.focus(); - await page.getByPlaceholder("Search refs...").fill("1359"); + await page.getByPlaceholder("Search branches...").fill("1359"); const checkoutItem = await waitForElement( () => @@ -3314,7 +3314,7 @@ describe("ChatView timeline estimator parity (full app)", () => { branchButton.click(); await waitForElement( - () => document.querySelector('input[placeholder="Search refs..."]'), + () => document.querySelector('input[placeholder="Search branches..."]'), "Unable to find ref search input.", ); From 09b88c6cc1f7353f6378fef4bc387de93c1c6a8a Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Wed, 13 May 2026 15:10:06 +0530 Subject: [PATCH 39/46] Add setting to expand changed files by default - Introduces changedFilesExpandedByDefault user setting - Updates UI state store to track overrides vs default state - Adds toggle in Settings panel to control expansion behavior --- .../settings/DesktopClientSettings.test.ts | 1 + .../src/components/chat/MessagesTimeline.tsx | 15 ++++- .../components/settings/SettingsPanels.tsx | 34 +++++++++++ apps/web/src/localApi.test.ts | 2 + apps/web/src/uiStateStore.test.ts | 56 ++++++++++++++++--- apps/web/src/uiStateStore.ts | 26 ++++++--- packages/contracts/src/settings.ts | 4 ++ 7 files changed, 118 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1bed085efd1..891413169c9 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -14,6 +14,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { autoCreatePrOnPush: true, autoOpenPlanSidebar: false, + changedFilesExpandedByDefault: false, confirmThreadArchive: true, confirmThreadDelete: false, diffFontFamily: "", diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1540d5f344a..10e94256144 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -59,6 +59,7 @@ import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; +import { useSettings } from "../../hooks/useSettings"; import { buildInlineTerminalContextText, @@ -684,8 +685,11 @@ function AssistantChangedFilesSectionInner({ resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { + const changedFilesExpandedByDefault = useSettings((s) => s.changedFilesExpandedByDefault); const allDirectoriesExpanded = useUiStateStore( - (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true, + (store) => + store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? + changedFilesExpandedByDefault, ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); const summaryStat = summarizeTurnDiffStats(checkpointFiles); @@ -709,7 +713,14 @@ function AssistantChangedFilesSectionInner({ size="xs" variant="outline" data-scroll-anchor-ignore - onClick={() => setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)} + onClick={() => + setExpanded( + routeThreadKey, + turnSummary.turnId, + !allDirectoriesExpanded, + changedFilesExpandedByDefault, + ) + } > {allDirectoriesExpanded ? "Collapse all" : "Expand all"} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ad8d2d00c06..1990081448c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -407,6 +407,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? ["Auto-open task panel"] : []), + ...(settings.changedFilesExpandedByDefault !== + DEFAULT_UNIFIED_SETTINGS.changedFilesExpandedByDefault + ? ["Expand changed files by default"] + : []), ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), @@ -431,6 +435,7 @@ export function useSettingsRestore(onRestored?: () => void) { [ isGitWritingModelDirty, settings.autoOpenPlanSidebar, + settings.changedFilesExpandedByDefault, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -463,6 +468,7 @@ export function useSettingsRestore(onRestored?: () => void) { diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, + changedFilesExpandedByDefault: DEFAULT_UNIFIED_SETTINGS.changedFilesExpandedByDefault, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, @@ -760,6 +766,34 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + changedFilesExpandedByDefault: + DEFAULT_UNIFIED_SETTINGS.changedFilesExpandedByDefault, + }) + } + /> + ) : null + } + control={ + + updateSettings({ changedFilesExpandedByDefault: Boolean(checked) }) + } + aria-label="Expand changed files by default" + /> + } + /> + { const clientSettings = { autoCreatePrOnPush: true, autoOpenPlanSidebar: false, + changedFilesExpandedByDefault: false, confirmThreadArchive: true, confirmThreadDelete: false, diffFontFamily: "", @@ -667,6 +668,7 @@ describe("wsApi", () => { const clientSettings = { autoCreatePrOnPush: true, autoOpenPlanSidebar: false, + changedFilesExpandedByDefault: false, confirmThreadArchive: true, confirmThreadDelete: false, diffFontFamily: "", diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 5d860fbb7fd..5e49a81fa1f 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -416,32 +416,70 @@ describe("uiStateStore pure functions", () => { expect(next.threadChangedFilesExpandedById).toEqual({}); }); - it("setThreadChangedFilesExpanded stores collapsed turns per thread", () => { + it("setThreadChangedFilesExpanded stores overrides that differ from the default", () => { const thread1 = ThreadId.make("thread-1"); const initialState = makeUiState(); - const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", false); - - expect(next.threadChangedFilesExpandedById).toEqual({ + const collapsedOverride = setThreadChangedFilesExpanded( + initialState, + thread1, + "turn-1", + false, + true, + ); + expect(collapsedOverride.threadChangedFilesExpandedById).toEqual({ [thread1]: { "turn-1": false, }, }); + + const expandedOverride = setThreadChangedFilesExpanded( + initialState, + thread1, + "turn-1", + true, + false, + ); + expect(expandedOverride.threadChangedFilesExpandedById).toEqual({ + [thread1]: { + "turn-1": true, + }, + }); }); - it("setThreadChangedFilesExpanded removes thread overrides when expanded again", () => { + it("setThreadChangedFilesExpanded removes thread overrides when matching the default", () => { const thread1 = ThreadId.make("thread-1"); - const initialState = makeUiState({ + const collapsedDefaultExpanded = makeUiState({ threadChangedFilesExpandedById: { [thread1]: { "turn-1": false, }, }, }); + const restoredFromCollapsed = setThreadChangedFilesExpanded( + collapsedDefaultExpanded, + thread1, + "turn-1", + true, + true, + ); + expect(restoredFromCollapsed.threadChangedFilesExpandedById).toEqual({}); - const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", true); - - expect(next.threadChangedFilesExpandedById).toEqual({}); + const expandedDefaultCollapsed = makeUiState({ + threadChangedFilesExpandedById: { + [thread1]: { + "turn-1": true, + }, + }, + }); + const restoredFromExpanded = setThreadChangedFilesExpanded( + expandedDefaultCollapsed, + thread1, + "turn-1", + false, + false, + ); + expect(restoredFromExpanded.threadChangedFilesExpandedById).toEqual({}); }); }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index b76f5f6859a..2031fde3a74 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -123,8 +123,8 @@ function sanitizePersistedThreadChangedFilesExpanded( const nextTurns: Record = {}; for (const [turnId, expanded] of Object.entries(turns)) { - if (turnId && typeof expanded === "boolean" && expanded === false) { - nextTurns[turnId] = false; + if (turnId && typeof expanded === "boolean") { + nextTurns[turnId] = expanded; } } @@ -179,7 +179,7 @@ export function persistState(state: UiState): void { const threadChangedFilesExpandedById = Object.fromEntries( Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => { const nextTurns = Object.fromEntries( - Object.entries(turns).filter(([, expanded]) => expanded === false), + Object.entries(turns).filter(([, expanded]) => typeof expanded === "boolean"), ); return Object.keys(nextTurns).length > 0 ? [[threadId, nextTurns]] : []; }), @@ -500,14 +500,15 @@ export function setThreadChangedFilesExpanded( threadId: string, turnId: string, expanded: boolean, + expandedByDefault: boolean = false, ): UiState { const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {}; - const currentExpanded = currentThreadState[turnId] ?? true; + const currentExpanded = currentThreadState[turnId] ?? expandedByDefault; if (currentExpanded === expanded) { return state; } - if (expanded) { + if (expanded === expandedByDefault) { if (!(turnId in currentThreadState)) { return state; } @@ -538,7 +539,7 @@ export function setThreadChangedFilesExpanded( ...state.threadChangedFilesExpandedById, [threadId]: { ...currentThreadState, - [turnId]: false, + [turnId]: expanded, }, }, }; @@ -628,7 +629,12 @@ interface UiStateStore extends UiState { markThreadVisited: (threadId: string, visitedAt?: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; clearThreadUi: (threadId: string) => void; - setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; + setThreadChangedFilesExpanded: ( + threadId: string, + turnId: string, + expanded: boolean, + expandedByDefault?: boolean, + ) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; toggleProject: (projectId: string) => void; setProjectExpanded: (projectId: string, expanded: boolean) => void; @@ -647,8 +653,10 @@ export const useUiStateStore = create((set) => ({ markThreadUnread: (threadId, latestTurnCompletedAt) => set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), - setThreadChangedFilesExpanded: (threadId, turnId, expanded) => - set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), + setThreadChangedFilesExpanded: (threadId, turnId, expanded, expandedByDefault) => + set((state) => + setThreadChangedFilesExpanded(state, threadId, turnId, expanded, expandedByDefault), + ), setDefaultAdvertisedEndpointKey: (key) => set((state) => setDefaultAdvertisedEndpointKey(state, key)), toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 10a9647f917..624ce518458 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -42,6 +42,9 @@ export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6 export const ClientSettingsSchema = Schema.Struct({ autoCreatePrOnPush: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + changedFilesExpandedByDefault: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffFontFamily: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -480,6 +483,7 @@ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ autoCreatePrOnPush: Schema.optionalKey(Schema.Boolean), autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), + changedFilesExpandedByDefault: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffFontFamily: Schema.optionalKey(Schema.String), From 2022f1d19cfae977ce3fa112832e53b53dc5d069 Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Thu, 14 May 2026 23:36:46 +0530 Subject: [PATCH 40/46] Strip workspace dependencies from desktop artifact Workspace protocol deps (workspace:*) are internal monorepo references and should not be included in the standalone desktop artifact --- scripts/build-desktop-artifact.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 66773398440..89dc71f2657 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -485,6 +485,10 @@ function validateBundledClientAssets(clientDir: string) { }); } +function stripWorkspaceDeps(deps: Record): Record { + return Object.fromEntries(Object.entries(deps).filter(([, v]) => v !== "workspace:*")); +} + function resolveDesktopRuntimeDependencies( dependencies: Record | undefined, catalog: Record, @@ -802,8 +806,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( buildTimestamp, ), dependencies: { - ...resolvedServerDependencies, - ...resolvedDesktopRuntimeDependencies, + ...stripWorkspaceDeps(resolvedServerDependencies), + ...stripWorkspaceDeps(resolvedDesktopRuntimeDependencies), }, devDependencies: { electron: electronVersion, From 399ae692518ebd0fff8fd70100a487d75410607d Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 15 May 2026 10:48:15 +0530 Subject: [PATCH 41/46] Add option to hide unavailable providers in model picker - New setting to hide disabled/coming-soon providers from sidebar - Includes UI toggle in provider settings panel --- .../settings/DesktopClientSettings.test.ts | 1 + .../components/chat/ModelPickerContent.tsx | 9 +++-- .../components/settings/SettingsPanels.tsx | 34 +++++++++++++++++++ apps/web/src/localApi.test.ts | 2 ++ packages/contracts/src/settings.ts | 2 ++ 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 891413169c9..7fb0e8a4e3f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -21,6 +21,7 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, diffWordWrap: true, dismissedProviderUpdateNotificationKeys: [], + hideUnavailableProviders: false, favorites: [], providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index c3468ef8c65..96ac91e9bd3 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -94,6 +94,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const listRegionRef = useRef(null); const highlightedModelKeyRef = useRef(null); const favorites = useSettings((s) => s.favorites ?? []); + const hideUnavailableProviders = useSettings((s) => s.hideUnavailableProviders); const [selectedInstanceId, setSelectedInstanceId] = useState( () => { if (props.lockedProvider !== null) { @@ -220,9 +221,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ); const showLockedInstanceSidebar = isLocked && lockedInstanceEntries.length > 1; const showSidebar = !isSearching && (!isLocked || showLockedInstanceSidebar); - const sidebarInstanceEntries = showLockedInstanceSidebar + const baseSidebarInstanceEntries = showLockedInstanceSidebar ? lockedInstanceEntries : instanceEntries; + const sidebarInstanceEntries = + !isLocked && hideUnavailableProviders + ? baseSidebarInstanceEntries.filter((entry) => entry.isAvailable && entry.status === "ready") + : baseSidebarInstanceEntries; const instanceOrder = useMemo( () => instanceEntries.map((entry) => entry.instanceId), [instanceEntries], @@ -538,7 +543,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onSelectInstance={handleSelectInstance} instanceEntries={sidebarInstanceEntries} showFavorites={!isLocked} - showComingSoon={!isLocked} + showComingSoon={!isLocked && !hideUnavailableProviders} /> )} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 1990081448c..f1dc1ffcf91 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -404,6 +404,9 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Diff whitespace changes"] : []), ...(settings.diffFontFamily !== DEFAULT_UNIFIED_SETTINGS.diffFontFamily ? ["Diff font"] : []), + ...(settings.hideUnavailableProviders !== DEFAULT_UNIFIED_SETTINGS.hideUnavailableProviders + ? ["Hide unavailable providers"] + : []), ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? ["Auto-open task panel"] : []), @@ -443,6 +446,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.diffFontFamily, settings.diffIgnoreWhitespace, settings.diffWordWrap, + settings.hideUnavailableProviders, settings.automaticGitFetchInterval, settings.enableAssistantStreaming, settings.sidebarThreadPreviewCount, @@ -469,6 +473,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, changedFilesExpandedByDefault: DEFAULT_UNIFIED_SETTINGS.changedFilesExpandedByDefault, + hideUnavailableProviders: DEFAULT_UNIFIED_SETTINGS.hideUnavailableProviders, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, @@ -1293,6 +1298,35 @@ export function ProviderSettingsPanel() { return ( + + + updateSettings({ + hideUnavailableProviders: DEFAULT_UNIFIED_SETTINGS.hideUnavailableProviders, + }) + } + /> + ) : null + } + control={ + + updateSettings({ hideUnavailableProviders: Boolean(checked) }) + } + aria-label="Hide unavailable providers from the model picker" + /> + } + /> + + { diffIgnoreWhitespace: true, diffWordWrap: true, dismissedProviderUpdateNotificationKeys: [], + hideUnavailableProviders: false, favorites: [], providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path" as const, @@ -675,6 +676,7 @@ describe("wsApi", () => { diffIgnoreWhitespace: true, diffWordWrap: true, dismissedProviderUpdateNotificationKeys: [], + hideUnavailableProviders: false, favorites: [], providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path" as const, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 624ce518458..e7ea8105182 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -53,6 +53,7 @@ export const ClientSettingsSchema = Schema.Struct({ dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), + hideUnavailableProviders: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), terminalFontFamily: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model @@ -489,6 +490,7 @@ export const ClientSettingsPatch = Schema.Struct({ diffFontFamily: Schema.optionalKey(Schema.String), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffWordWrap: Schema.optionalKey(Schema.Boolean), + hideUnavailableProviders: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({ From 40ebd040a82b2f13a77f07044791251a8bf3260e Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Fri, 15 May 2026 10:49:01 +0530 Subject: [PATCH 42/46] Add OpenCode provider filter to model picker --- .../components/chat/ModelPickerContent.tsx | 124 ++++++- .../chat/ProviderModelPicker.browser.tsx | 350 ++++++++++++++++++ 2 files changed, 473 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index c3468ef8c65..8755cc8dd57 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -1,7 +1,7 @@ import { type ProviderInstanceId, - type ProviderDriverKind, type ResolvedKeybindingsConfig, + ProviderDriverKind, } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react"; @@ -21,6 +21,7 @@ import { import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { TooltipProvider } from "../ui/tooltip"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import type { ProviderInstanceEntry } from "../../providerInstances"; import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; @@ -37,6 +38,8 @@ type ModelPickerItem = { }; const EMPTY_MODEL_JUMP_LABELS = new Map(); +const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const ALL_OPENCODE_SUB_PROVIDERS = "all"; // Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs // can contain colons (e.g. some vendor model ids), so we only split on the @@ -90,6 +93,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onInstanceModelChange, } = props; const [searchQuery, setSearchQuery] = useState(""); + const [selectedOpenCodeSubProvider, setSelectedOpenCodeSubProvider] = useState( + ALL_OPENCODE_SUB_PROVIDERS, + ); const searchInputRef = useRef(null); const listRegionRef = useRef(null); const highlightedModelKeyRef = useRef(null); @@ -223,11 +229,83 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const sidebarInstanceEntries = showLockedInstanceSidebar ? lockedInstanceEntries : instanceEntries; + const selectedInstanceEntry = + selectedInstanceId === "favorites" ? null : entryByInstanceId.get(selectedInstanceId); + const isSelectedOpenCodeInstance = selectedInstanceEntry?.driverKind === OPENCODE_DRIVER_KIND; + const isOpenCodeProviderScope = + selectedInstanceId !== "favorites" && + ((!isLocked && isSelectedOpenCodeInstance) || + (isLocked && + props.lockedProvider === OPENCODE_DRIVER_KIND && + (!showLockedInstanceSidebar || isSelectedOpenCodeInstance))); const instanceOrder = useMemo( () => instanceEntries.map((entry) => entry.instanceId), [instanceEntries], ); + const openCodeProviderOptions = useMemo(() => { + if (selectedInstanceId === "favorites") { + return []; + } + + let candidateModels: ReadonlyArray = []; + + if (!isLocked) { + if (!isSelectedOpenCodeInstance) { + return []; + } + candidateModels = flatModels.filter((model) => model.instanceId === selectedInstanceId); + } else if (props.lockedProvider === OPENCODE_DRIVER_KIND) { + if (showLockedInstanceSidebar) { + if (!isSelectedOpenCodeInstance) { + return []; + } + candidateModels = flatModels.filter((model) => model.instanceId === selectedInstanceId); + } else { + candidateModels = flatModels.filter((model) => matchesLockedProvider(model)); + } + } else { + return []; + } + + const subProviders = new Set(); + for (const model of candidateModels) { + if (model.subProvider) { + subProviders.add(model.subProvider); + } + } + + if (subProviders.size < 2) { + return []; + } + + return Array.from(subProviders).toSorted((a, b) => a.localeCompare(b)); + }, [ + flatModels, + isLocked, + isSelectedOpenCodeInstance, + matchesLockedProvider, + props.lockedProvider, + selectedInstanceId, + showLockedInstanceSidebar, + ]); + + const shouldShowOpenCodeProviderFilter = !isSearching && openCodeProviderOptions.length >= 2; + + useEffect(() => { + if (!isOpenCodeProviderScope) { + return; + } + + const selectionIsValid = + selectedOpenCodeSubProvider === ALL_OPENCODE_SUB_PROVIDERS || + openCodeProviderOptions.includes(selectedOpenCodeSubProvider); + if (selectionIsValid) { + return; + } + setSelectedOpenCodeSubProvider(ALL_OPENCODE_SUB_PROVIDERS); + }, [isOpenCodeProviderScope, openCodeProviderOptions, selectedOpenCodeSubProvider]); + // Filter models based on search query and selected instance const filteredModels = useMemo(() => { let result = flatModels; @@ -312,6 +390,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { result = result.filter((m) => m.instanceId === selectedInstanceId); } + if ( + shouldShowOpenCodeProviderFilter && + selectedOpenCodeSubProvider !== ALL_OPENCODE_SUB_PROVIDERS + ) { + result = result.filter((m) => m.subProvider === selectedOpenCodeSubProvider); + } + return sortProviderModelItems(result, { favoriteModelKeys: favoritesSet, groupFavorites: selectedInstanceId !== "favorites", @@ -326,6 +411,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { searchQuery, showLockedInstanceSidebar, selectedInstanceId, + shouldShowOpenCodeProviderFilter, + selectedOpenCodeSubProvider, ]); const handleModelSelect = useCallback( @@ -606,6 +693,41 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { />
+ {/* OpenCode provider filter */} + {shouldShowOpenCodeProviderFilter && ( +
+ +
+ )} + {/* Model list */}
( + '[aria-label="OpenCode provider"]', + ); + expect(providerSelect).not.toBeNull(); + await userEvent.click(providerSelect!); + + let providerItem: HTMLElement | undefined; + await vi.waitFor(() => { + providerItem = Array.from( + document.querySelectorAll('[data-slot="select-item"]'), + ).find((item) => item.textContent?.trim() === provider); + expect(providerItem).toBeDefined(); + }); + await userEvent.click(providerItem!); +} + describe("ProviderModelPicker", () => { beforeEach(async () => { // Reset test environment before each test @@ -1223,4 +1240,337 @@ describe("ProviderModelPicker", () => { await mounted.cleanup(); } }); + + it("shows OpenCode provider filter and filters models by provider", async () => { + const providers: ReadonlyArray = [ + buildOpenCodeProvider([ + { + slug: "github-copilot/claude-opus-4.7", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + { + slug: "github-copilot/claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + { + slug: "openai/gpt-4-turbo", + name: "GPT-4 Turbo", + subProvider: "OpenAI", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + ]), + ]; + const mounted = await mountPicker({ + activeInstanceId: OPENCODE_INSTANCE_ID, + model: "github-copilot/claude-opus-4.7", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(getVisibleModelNames()).toEqual([ + "Claude Opus 4.7", + "Claude Sonnet 4.6", + "GPT-4 Turbo", + ]); + }); + + const providerSelect = document.querySelector( + '[aria-label="OpenCode provider"]', + ); + expect(providerSelect).not.toBeNull(); + + await selectOpenCodeProviderFilter("GitHub Copilot"); + + await vi.waitFor(() => { + expect(getVisibleModelNames()).toEqual(["Claude Opus 4.7", "Claude Sonnet 4.6"]); + }); + + await selectOpenCodeProviderFilter("All providers"); + + await vi.waitFor(() => { + expect(getVisibleModelNames()).toEqual([ + "Claude Opus 4.7", + "Claude Sonnet 4.6", + "GPT-4 Turbo", + ]); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("does not show OpenCode provider filter with only one upstream provider", async () => { + const providers: ReadonlyArray = [ + buildOpenCodeProvider([ + { + slug: "github-copilot/claude-opus-4.7", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + { + slug: "github-copilot/claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + ]), + ]; + const mounted = await mountPicker({ + activeInstanceId: OPENCODE_INSTANCE_ID, + model: "github-copilot/claude-opus-4.7", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const providerSelect = document.querySelector( + '[aria-label="OpenCode provider"]', + ); + expect(providerSelect).toBeNull(); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows OpenCode filter only for OpenCode provider, not other providers", async () => { + const providers: ReadonlyArray = [ + buildCodexProvider([ + { + slug: "gpt-5-codex", + name: "GPT-5 Codex", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + ]), + buildOpenCodeProvider([ + { + slug: "github-copilot/claude-opus-4.7", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + { + slug: "openai/gpt-4-turbo", + name: "GPT-4 Turbo", + subProvider: "OpenAI", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + ]), + ]; + const mounted = await mountPicker({ + activeInstanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const providerSelect = document.querySelector( + '[aria-label="OpenCode provider"]', + ); + expect(providerSelect).toBeNull(); + expect(getVisibleModelNames()).toEqual(["GPT-5 Codex"]); + }); + + await page.getByRole("button", { name: "OpenCode", exact: true }).click(); + + await vi.waitFor(() => { + const providerSelect = document.querySelector( + '[aria-label="OpenCode provider"]', + ); + expect(providerSelect).not.toBeNull(); + expect(getVisibleModelNames()).toEqual(["Claude Opus 4.7", "GPT-4 Turbo"]); + }); + + await selectOpenCodeProviderFilter("GitHub Copilot"); + + await vi.waitFor(() => { + expect(getVisibleModelNames()).toEqual(["Claude Opus 4.7"]); + }); + + await page.getByRole("button", { name: "Codex", exact: true }).click(); + + await vi.waitFor(() => { + expect( + document.querySelector('[aria-label="OpenCode provider"]'), + ).toBeNull(); + expect(getVisibleModelNames()).toEqual(["GPT-5 Codex"]); + }); + + await page.getByRole("button", { name: "OpenCode", exact: true }).click(); + + await vi.waitFor(() => { + expect( + document.querySelector('[aria-label="OpenCode provider"]'), + ).not.toBeNull(); + expect(getVisibleModelNames()).toEqual(["Claude Opus 4.7"]); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("preserves OpenCode provider filter selection across search", async () => { + const providers: ReadonlyArray = [ + buildOpenCodeProvider([ + { + slug: "github-copilot/claude-opus-4.7", + name: "Claude Opus 4.7", + subProvider: "GitHub Copilot", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + { + slug: "openai/gpt-4-turbo", + name: "GPT-4 Turbo", + subProvider: "OpenAI", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("reasoningEffort", "Reasoning", [ + { id: "low", label: "low" }, + { id: "medium", label: "medium", isDefault: true }, + { id: "high", label: "high" }, + ]), + ], + }), + }, + ]), + ]; + const mounted = await mountPicker({ + activeInstanceId: OPENCODE_INSTANCE_ID, + model: "github-copilot/claude-opus-4.7", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + + await selectOpenCodeProviderFilter("GitHub Copilot"); + + await vi.waitFor(() => { + expect(getVisibleModelNames()).toEqual(["Claude Opus 4.7"]); + }); + + const searchInput = page.getByPlaceholder("Search models..."); + await searchInput.fill("turbo"); + + await vi.waitFor(() => { + const providerSelect = document.querySelector( + '[aria-label="OpenCode provider"]', + ); + expect(providerSelect).toBeNull(); + const text = document.body.textContent ?? ""; + expect(text).toContain("GPT-4 Turbo"); + }); + + await searchInput.fill(""); + + await vi.waitFor(() => { + const providerSelect = document.querySelector( + '[aria-label="OpenCode provider"]', + ); + expect(providerSelect).not.toBeNull(); + expect(getVisibleModelNames()).toEqual(["Claude Opus 4.7"]); + }); + } finally { + await mounted.cleanup(); + } + }); }); From bd1182ae3763028eb4a1f626c0b21561e2959c0d Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Sun, 17 May 2026 13:36:17 +0530 Subject: [PATCH 43/46] Add head repository support to change requests - Enable cross-fork PR creation by specifying head repository - Add headRepository parameter to createChangeRequest - Add repository parameter to getDefaultBranch across providers - Include GitHub CLI tests for new parameter --- apps/server/src/git/GitManager.ts | 17 ++++- .../src/sourceControl/AzureDevOpsCli.ts | 2 + .../AzureDevOpsSourceControlProvider.ts | 6 +- apps/server/src/sourceControl/BitbucketApi.ts | 2 + .../BitbucketSourceControlProvider.ts | 2 + .../src/sourceControl/GitHubCli.test.ts | 71 +++++++++++++++++++ apps/server/src/sourceControl/GitHubCli.ts | 13 +++- .../GitHubSourceControlProvider.ts | 6 +- apps/server/src/sourceControl/GitLabCli.ts | 2 + .../GitLabSourceControlProvider.ts | 6 +- .../sourceControl/SourceControlProvider.ts | 2 + 11 files changed, 123 insertions(+), 6 deletions(-) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 8dfb957b89d..7ef04eba79c 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1058,7 +1058,10 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { cwd: string, branch: string, upstreamRef: string | null, - headContext: Pick, + headContext: Pick< + BranchHeadContext, + "isCrossRepository" | "remoteName" | "headRepositoryNameWithOwner" + >, ) { const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); if (configured) return configured; @@ -1073,7 +1076,14 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } const defaultFromProvider = yield* sourceControlProvider(cwd).pipe( - Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })), + Effect.flatMap((provider) => + provider.getDefaultBranch({ + cwd, + ...(headContext.headRepositoryNameWithOwner + ? { repository: headContext.headRepositoryNameWithOwner } + : {}), + }), + ), Effect.catch(() => Effect.succeed(null)), ); if (defaultFromProvider) { @@ -1321,6 +1331,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { headSelector: headContext.preferredHeadSelector, title: generated.title, bodyFile, + ...(headContext.headRepositoryNameWithOwner + ? { headRepository: headContext.headRepositoryNameWithOwner } + : {}), }) .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index d4a4d69267b..025cdf779e6 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -80,10 +80,12 @@ export interface AzureDevOpsCliShape { readonly target?: SourceControlProvider.SourceControlRefSelector; readonly title: string; readonly bodyFile: string; + readonly headRepository?: string; }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; + readonly repository?: string; }) => Effect.Effect; readonly checkoutPullRequest: (input: { diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 8d8e081cb89..3fb9a890137 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -116,6 +116,7 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* ...(input.target !== undefined ? { target: input.target } : {}), title: input.title, bodyFile: input.bodyFile, + ...(input.headRepository !== undefined ? { headRepository: input.headRepository } : {}), }) .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); }, @@ -129,7 +130,10 @@ export const make = Effect.fn("makeAzureDevOpsSourceControlProvider")(function* .pipe(Effect.mapError((error) => providerError("createRepository", error))), getDefaultBranch: (input) => azure - .getDefaultBranch({ cwd: input.cwd }) + .getDefaultBranch({ + cwd: input.cwd, + ...(input.repository !== undefined ? { repository: input.repository } : {}), + }) .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => azure diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 748113ba04f..bd1bd4336fc 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -140,10 +140,12 @@ export interface BitbucketApiShape { readonly target?: SourceControlProvider.SourceControlRefSelector; readonly title: string; readonly bodyFile: string; + readonly headRepository?: string; }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly repository?: string; }) => Effect.Effect; readonly checkoutPullRequest: (input: { readonly cwd: string; diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index f3fd502f7fb..a7949055d53 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -82,6 +82,7 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () ...(input.target ? { target: input.target } : {}), title: input.title, bodyFile: input.bodyFile, + ...(input.headRepository !== undefined ? { headRepository: input.headRepository } : {}), }) .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); }, @@ -98,6 +99,7 @@ export const make = Effect.fn("makeBitbucketSourceControlProvider")(function* () .getDefaultBranch({ cwd: input.cwd, ...(input.context ? { context: input.context } : {}), + ...(input.repository !== undefined ? { repository: input.repository } : {}), }) .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index fb765b352c2..aa0f94b2875 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -267,6 +267,77 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("appends --repo when headRepository is provided to createPullRequest", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "aa/test", + title: "Test PR", + bodyFile: "/tmp/body.md", + headRepository: "imabdulazeez/t3code", + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "pr", + "create", + "--base", + "main", + "--head", + "aa/test", + "--title", + "Test PR", + "--body-file", + "/tmp/body.md", + "--repo", + "imabdulazeez/t3code", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("omits --repo when headRepository is not provided to createPullRequest", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "aa/test", + title: "Test PR", + bodyFile: "/tmp/body.md", + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "pr", + "create", + "--base", + "main", + "--head", + "aa/test", + "--title", + "Test PR", + "--body-file", + "/tmp/body.md", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("surfaces a friendly error when the pull request is not found", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 14d01aab2ed..cc11889f451 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -79,10 +79,12 @@ export interface GitHubCliShape { readonly headSelector: string; readonly title: string; readonly bodyFile: string; + readonly headRepository?: string; }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; + readonly repository?: string; }) => Effect.Effect; readonly checkoutPullRequest: (input: { @@ -352,12 +354,21 @@ export const make = Effect.fn("makeGitHubCli")(function* () { input.title, "--body-file", input.bodyFile, + ...(input.headRepository ? ["--repo", input.headRepository] : []), ], }).pipe(Effect.asVoid), getDefaultBranch: (input) => execute({ cwd: input.cwd, - args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], + args: [ + "repo", + "view", + "--json", + "defaultBranchRef", + "--jq", + ".defaultBranchRef.name", + ...(input.repository ? ["--repo", input.repository] : []), + ], }).pipe( Effect.map((value) => { const trimmed = value.stdout.trim(); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index cc892015fce..e9d516561ef 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -177,6 +177,7 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { headSelector: input.headSelector, title: input.title, bodyFile: input.bodyFile, + ...(input.headRepository !== undefined ? { headRepository: input.headRepository } : {}), }) .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))), getRepositoryCloneUrls: (input) => @@ -189,7 +190,10 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { .pipe(Effect.mapError((error) => providerError("createRepository", error))), getDefaultBranch: (input) => github - .getDefaultBranch(input) + .getDefaultBranch({ + cwd: input.cwd, + ...(input.repository !== undefined ? { repository: input.repository } : {}), + }) .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => github diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index faabe87263d..df4580e18dc 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -83,10 +83,12 @@ export interface GitLabCliShape { readonly target?: SourceControlProvider.SourceControlRefSelector; readonly title: string; readonly bodyFile: string; + readonly headRepository?: string; }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; + readonly repository?: string; }) => Effect.Effect; readonly checkoutMergeRequest: (input: { diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index ccab2bd1f76..fc905a37be1 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -121,6 +121,7 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { ...(input.target ? { target: input.target } : {}), title: input.title, bodyFile: input.bodyFile, + ...(input.headRepository !== undefined ? { headRepository: input.headRepository } : {}), }) .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))); }, @@ -134,7 +135,10 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { .pipe(Effect.mapError((error) => providerError("createRepository", error))), getDefaultBranch: (input) => gitlab - .getDefaultBranch(input) + .getDefaultBranch({ + cwd: input.cwd, + ...(input.repository !== undefined ? { repository: input.repository } : {}), + }) .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), checkoutChangeRequest: (input) => gitlab diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index a0465008212..df0edb19a72 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -73,6 +73,7 @@ export interface SourceControlProviderShape { readonly headSelector: string; readonly title: string; readonly bodyFile: string; + readonly headRepository?: string; }) => Effect.Effect; readonly getRepositoryCloneUrls: (input: { readonly cwd: string; @@ -87,6 +88,7 @@ export interface SourceControlProviderShape { readonly getDefaultBranch: (input: { readonly cwd: string; readonly context?: SourceControlProviderContext; + readonly repository?: string; }) => Effect.Effect; readonly checkoutChangeRequest: (input: { readonly cwd: string; From 05011232a398f95079d80707f08e350a96d72fdc Mon Sep 17 00:00:00 2001 From: imabdulazeez Date: Sun, 17 May 2026 13:49:41 +0530 Subject: [PATCH 44/46] Allow customizing prompt instructions for version control generation - Add settings UI for editing commit message, PR, and branch name instructions - Extract default instructions to contracts and wire through text generation pipeline - Instructions override built-in defaults when provided, enabling users to customize AI behavior --- apps/server/src/git/GitManager.ts | 22 +++ .../Layers/ProviderCommandReactor.ts | 5 +- .../textGeneration/ClaudeTextGeneration.ts | 3 + .../src/textGeneration/CodexTextGeneration.ts | 3 + .../textGeneration/CursorTextGeneration.ts | 3 + .../textGeneration/OpenCodeTextGeneration.ts | 3 + .../src/textGeneration/TextGeneration.ts | 6 + .../textGeneration/TextGenerationPrompts.ts | 85 ++++++++--- .../components/settings/SettingsPanels.tsx | 139 +++++++++++++++++- .../components/settings/settingsLayout.tsx | 2 +- packages/contracts/src/settings.ts | 36 +++++ 11 files changed, 281 insertions(+), 26 deletions(-) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 7ef04eba79c..43d24358439 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1120,6 +1120,16 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; } + const { commitMessagePromptInstructions } = yield* serverSettingsService.getSettings.pipe( + Effect.mapError((cause) => + gitManagerError( + "resolveCommitAndBranchSuggestion", + "Failed to get server settings.", + cause, + ), + ), + ); + const generated = yield* textGeneration .generateCommitMessage({ cwd: input.cwd, @@ -1128,6 +1138,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { stagedPatch: limitContext(context.stagedPatch, 50_000), ...(input.includeBranch ? { includeBranch: true } : {}), modelSelection: input.modelSelection, + ...(commitMessagePromptInstructions.length > 0 + ? { instructionsOverride: commitMessagePromptInstructions } + : {}), }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); @@ -1301,6 +1314,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); + const { prContentPromptInstructions } = yield* serverSettingsService.getSettings.pipe( + Effect.mapError((cause) => + gitManagerError("runPrStep", "Failed to get server settings.", cause), + ), + ); + const generated = yield* textGeneration.generatePrContent({ cwd, baseBranch, @@ -1309,6 +1328,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { diffSummary: limitContext(rangeContext.diffSummary, 20_000), diffPatch: limitContext(rangeContext.diffPatch, 60_000), modelSelection, + ...(prContentPromptInstructions.length > 0 + ? { instructionsOverride: prContentPromptInstructions } + : {}), }); const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 8b71a976808..987a1c12464 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -593,7 +593,7 @@ const make = Effect.gen(function* () { const cwd = input.worktreePath; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = + const { textGenerationModelSelection: modelSelection, branchNamePromptInstructions } = yield* serverSettingsService.getSettings; const generated = yield* textGeneration.generateBranchName({ @@ -601,6 +601,9 @@ const make = Effect.gen(function* () { message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), modelSelection, + ...(branchNamePromptInstructions.length > 0 + ? { instructionsOverride: branchNamePromptInstructions } + : {}), }); if (!generated) return; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 19a54463088..55fc32eb52f 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -267,6 +267,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runClaudeJson({ @@ -295,6 +296,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runClaudeJson({ @@ -317,6 +319,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runClaudeJson({ diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 86c8e57a5e8..e37d14de31b 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -313,6 +313,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runCodexJson({ @@ -341,6 +342,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runCodexJson({ @@ -367,6 +369,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runCodexJson({ diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 4898ce2d0e0..6cc078e9c1b 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -184,6 +184,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runCursorJson({ @@ -212,6 +213,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runCursorJson({ @@ -234,6 +236,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runCursorJson({ diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 76c92c32115..0b87a0eaad9 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -375,6 +375,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" stagedSummary: input.stagedSummary, stagedPatch: input.stagedPatch, includeBranch: input.includeBranch === true, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runOpenCodeJson({ operation: "generateCommitMessage", @@ -402,6 +403,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" commitSummary: input.commitSummary, diffSummary: input.diffSummary, diffPatch: input.diffPatch, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runOpenCodeJson({ operation: "generatePrContent", @@ -423,6 +425,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const { prompt, outputSchema } = buildBranchNamePrompt({ message: input.message, attachments: input.attachments, + ...(input.instructionsOverride ? { instructionsOverride: input.instructionsOverride } : {}), }); const generated = yield* runOpenCodeJson({ operation: "generateBranchName", diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 36a23d509db..71843f27412 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -21,6 +21,8 @@ export interface CommitMessageGenerationInput { includeBranch?: boolean; /** What model and provider to use for generation. */ modelSelection: ModelSelection; + /** Custom prompt instructions to replace the built-in ones. */ + instructionsOverride?: string | undefined; } export interface CommitMessageGenerationResult { @@ -39,6 +41,8 @@ export interface PrContentGenerationInput { diffPatch: string; /** What model and provider to use for generation. */ modelSelection: ModelSelection; + /** Custom prompt instructions to replace the built-in ones. */ + instructionsOverride?: string | undefined; } export interface PrContentGenerationResult { @@ -52,6 +56,8 @@ export interface BranchNameGenerationInput { attachments?: ReadonlyArray | undefined; /** What model and provider to use for generation. */ modelSelection: ModelSelection; + /** Custom prompt instructions to replace the built-in ones. */ + instructionsOverride?: string | undefined; } export interface BranchNameGenerationResult { diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index b871fb282d6..6d15124712f 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -17,6 +17,17 @@ function policyInstruction(instruction: string | undefined): ReadonlyArray; + instructionsOverride: string | undefined; + contractLine: string; + contextLines: ReadonlyArray; +}): string { + const override = input.instructionsOverride?.trim(); + const instructionLines = override ? [override] : input.instructions; + return [...instructionLines, input.contractLine, ...input.contextLines].join("\n"); +} + // --------------------------------------------------------------------------- // Commit message // --------------------------------------------------------------------------- @@ -27,16 +38,14 @@ export interface CommitMessagePromptInput { stagedPatch: string; includeBranch: boolean; policy?: TextGenerationPolicy | undefined; + instructionsOverride?: string | undefined; } export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { const wantsBranch = input.includeBranch; - const prompt = [ + const instructions = [ "You write concise git commit messages.", - wantsBranch - ? "Return a JSON object with keys: subject, body, branch." - : "Return a JSON object with keys: subject, body.", "Rules:", "- subject must be imperative, <= 72 chars, and no trailing period", "- body can be empty string or short bullet points", @@ -44,7 +53,14 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { ? ["- branch must be a short semantic git branch fragment for this change"] : []), "- capture the primary user-visible or developer-visible change", - ...policyInstruction(input.policy?.commitInstructions), + ]; + + const contractLine = wantsBranch + ? "Return a JSON object with keys: subject, body, branch." + : "Return a JSON object with keys: subject, body."; + + const contextLines = [ + ...policyInstruction(input.instructionsOverride ? undefined : input.policy?.commitInstructions), "", `Branch: ${input.branch ?? "(detached)"}`, "", @@ -53,7 +69,14 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { "", "Staged patch:", limitSection(input.stagedPatch, 40_000), - ].join("\n"); + ]; + + const prompt = buildPromptSections({ + instructions, + instructionsOverride: input.instructionsOverride, + contractLine, + contextLines, + }); if (wantsBranch) { return { @@ -86,12 +109,12 @@ export interface PrContentPromptInput { diffSummary: string; diffPatch: string; policy?: TextGenerationPolicy | undefined; + instructionsOverride?: string | undefined; } export function buildPrContentPrompt(input: PrContentPromptInput) { - const prompt = [ + const instructions = [ "You write GitHub pull request content.", - "Return a JSON object with keys: title, body.", "Rules:", "- title should be concise and specific", "- body must be markdown and include headings '## Summary' and '## Testing'", @@ -99,7 +122,14 @@ export function buildPrContentPrompt(input: PrContentPromptInput) { "- do NOT serialize the response as a string inside a field; the title and body fields receive their literal values directly", "- under Summary, provide short bullet points", "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", - ...policyInstruction(input.policy?.changeRequestInstructions), + ]; + + const contractLine = "Return a JSON object with keys: title, body."; + + const contextLines = [ + ...policyInstruction( + input.instructionsOverride ? undefined : input.policy?.changeRequestInstructions, + ), "", `Base branch: ${input.baseBranch}`, `Head branch: ${input.headBranch}`, @@ -112,7 +142,14 @@ export function buildPrContentPrompt(input: PrContentPromptInput) { "", "Diff patch:", limitSection(input.diffPatch, 40_000), - ].join("\n"); + ]; + + const prompt = buildPromptSections({ + instructions, + instructionsOverride: input.instructionsOverride, + contractLine, + contextLines, + }); const outputSchema = Schema.Struct({ title: Schema.String, @@ -130,6 +167,7 @@ export interface BranchNamePromptInput { message: string; attachments?: ReadonlyArray | undefined; policy?: TextGenerationPolicy | undefined; + instructionsOverride?: string | undefined; } interface PromptFromMessageInput { @@ -139,6 +177,7 @@ interface PromptFromMessageInput { message: string; attachments?: ReadonlyArray | undefined; additionalInstructions?: string | undefined; + instructionsOverride?: string | undefined; } function buildPromptFromMessage(input: PromptFromMessageInput): string { @@ -146,25 +185,26 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string { (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, ); - const promptSections = [ - input.instruction, - input.responseShape, - "Rules:", - ...input.rules.map((rule) => `- ${rule}`), + const instructions = [input.instruction, "Rules:", ...input.rules.map((rule) => `- ${rule}`)]; + const contractLine = input.responseShape; + + const contextLines = [ + ...policyInstruction(input.instructionsOverride ? undefined : input.additionalInstructions), "", "User message:", limitSection(input.message, 8_000), - ...policyInstruction(input.additionalInstructions), ]; + if (attachmentLines.length > 0) { - promptSections.push( - "", - "Attachment metadata:", - limitSection(attachmentLines.join("\n"), 4_000), - ); + contextLines.push("", "Attachment metadata:", limitSection(attachmentLines.join("\n"), 4_000)); } - return promptSections.join("\n"); + return buildPromptSections({ + instructions, + instructionsOverride: input.instructionsOverride, + contractLine, + contextLines, + }); } export function buildBranchNamePrompt(input: BranchNamePromptInput) { @@ -180,6 +220,7 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) { message: input.message, attachments: input.attachments, additionalInstructions: input.policy?.branchInstructions, + instructionsOverride: input.instructionsOverride, }); const outputSchema = Schema.Struct({ branch: Schema.String, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f1dc1ffcf91..ab464431ab2 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,7 +1,8 @@ import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { useCallback, useMemo, useRef, useState } from "react"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { defaultInstanceIdForDriver, type DesktopUpdateChannel, @@ -12,7 +13,12 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + DEFAULT_BRANCH_NAME_PROMPT_INSTRUCTIONS, + DEFAULT_COMMIT_MESSAGE_PROMPT_INSTRUCTIONS, + DEFAULT_PR_CONTENT_PROMPT_INSTRUCTIONS, + DEFAULT_UNIFIED_SETTINGS, +} from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; @@ -50,6 +56,7 @@ import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; +import { Textarea } from "../ui/textarea"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -434,9 +441,24 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Delete confirmation"] : []), ...(isGitWritingModelDirty ? ["Git writing model"] : []), + ...(settings.commitMessagePromptInstructions !== + DEFAULT_UNIFIED_SETTINGS.commitMessagePromptInstructions + ? ["Commit message instructions"] + : []), + ...(settings.prContentPromptInstructions !== + DEFAULT_UNIFIED_SETTINGS.prContentPromptInstructions + ? ["PR content instructions"] + : []), + ...(settings.branchNamePromptInstructions !== + DEFAULT_UNIFIED_SETTINGS.branchNamePromptInstructions + ? ["Branch name instructions"] + : []), ], [ isGitWritingModelDirty, + settings.branchNamePromptInstructions, + settings.commitMessagePromptInstructions, + settings.prContentPromptInstructions, settings.autoOpenPlanSidebar, settings.changedFilesExpandedByDefault, settings.confirmThreadArchive, @@ -481,6 +503,9 @@ export function useSettingsRestore(onRestored?: () => void) { confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + commitMessagePromptInstructions: DEFAULT_UNIFIED_SETTINGS.commitMessagePromptInstructions, + prContentPromptInstructions: DEFAULT_UNIFIED_SETTINGS.prContentPromptInstructions, + branchNamePromptInstructions: DEFAULT_UNIFIED_SETTINGS.branchNamePromptInstructions, }); onRestored?.(); }, [changedSettingLabels, onRestored, setTheme, updateSettings]); @@ -491,6 +516,85 @@ export function useSettingsRestore(onRestored?: () => void) { }; } +function DraftTextarea({ + value, + onCommit, + className, + ...rest +}: Omit, "value" | "onChange" | "defaultValue"> & { + readonly value: string; + readonly onCommit: (next: string) => void; +}) { + const [draft, setDraft] = useState(value); + const focusedRef = useRef(false); + + useEffect(() => { + if (!focusedRef.current) setDraft(value); + }, [value]); + + return ( +