From 5e6cc2b89534a8e01772bf647b79a1f2da2f9664 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 12:33:42 -0700 Subject: [PATCH 1/9] fix(web): restore text-only draft project title (#10821) --- apps/web/src/components/chat/DraftHeroHeadline.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index b8de82ff2794..4a9421f2011f 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -115,14 +115,11 @@ export function DraftHeroHeadline({ render={ } > - {activeProjectGroup ? ( - - ) : null} - {activeProjectDisplayName ?? "Choose a project"} + {activeProjectDisplayName ?? "Choose a project"} {activeProjectDisplayName ? ( From 3faeee49ac67dfd9534369f1e1c627c0356b75ac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 14:05:20 -0700 Subject: [PATCH 2/9] refactor(web): consolidate setup wizards into shared components (#10832) --- apps/web/src/components/GitActionsControl.tsx | 695 ++++++++---------- .../cloud/ConnectOnboardingDialog.tsx | 105 ++- .../components/onboarding/WelcomeWizard.tsx | 110 +-- .../settings/AddProviderInstanceDialog.tsx | 379 +++++----- .../settings/SnapShotSetupDialog.tsx | 42 +- apps/web/src/components/ui/wizard-steps.tsx | 56 -- apps/web/src/components/ui/wizard.tsx | 74 +- 7 files changed, 690 insertions(+), 771 deletions(-) delete mode 100644 apps/web/src/components/ui/wizard-steps.tsx diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index b0e74159da89..b766a846931c 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -58,7 +58,7 @@ import { resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; -import { AnimatedHeight } from "./AnimatedHeight"; +import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard"; import { StartTruncatedPath } from "./StartTruncatedPath"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; @@ -563,419 +563,360 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { return ( - -
- - Publish repository - - Pick where to host it, then point us at a repo to push to. - -
- {publishWizardSteps.map((label, index) => { - const isComplete = index < publishWizardStep; - const isClickable = - publishWizardStep !== 2 && - index < publishWizardSteps.length - 1 && - index <= publishWizardStep; + + + + publishWizardStep === 2 || + index >= publishWizardSteps.length - 1 || + index > publishWizardStep + } + onStepChange={setPublishWizardStep} + /> + + + +
+ + Provider + + { + setSelectedPublishProvider(value as PublishProviderKind); + setPublishRepositoryOverride(null); + }} + aria-labelledby="publish-provider-cards-label" + className="grid grid-cols-2 gap-2.5" + > + {sortedPublishProviderOptions.map((option) => { + const readiness = publishProviderReadiness[option.value]; + const isSelected = publishProvider === option.value && readiness.ready; + if (!readiness.ready) { + return ( +
+ + + {option.label} + + + { + event.preventDefault(); + event.stopPropagation(); + openSourceControlSettings(); + }} + > + Setup Required + + } + /> + + {readiness.hint ?? + "Open Settings -> Source Control to configure this provider."} + + +
+ ); + } + return ( - + ); })} -
- - - - -
- - Provider + +
+ +
+
+ +
+ + + {publishHost}/ - { - setSelectedPublishProvider(value as PublishProviderKind); - setPublishRepositoryOverride(null); + { + setPublishRepositoryOverride(event.target.value); }} - aria-labelledby="publish-provider-cards-label" - className="grid grid-cols-2 gap-2.5" - > - {sortedPublishProviderOptions.map((option) => { - const readiness = publishProviderReadiness[option.value]; - const isSelected = publishProvider === option.value && readiness.ready; - if (!readiness.ready) { - return ( -
- - - {option.label} - - - { - event.preventDefault(); - event.stopPropagation(); - openSourceControlSettings(); - }} - > - Setup Required - - } - /> - - {readiness.hint ?? - "Open Settings -> Source Control to configure this provider."} - - -
- ); + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitPublishRepository(); } + }} + placeholder={publishPathPlaceholder} + disabled={publishRepositoryAction.isPending} + className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" + /> +
+
- return ( - - - +
+ + Visibility + + + setPublishVisibility(value as SourceControlRepositoryVisibility) + } + aria-labelledby="publish-visibility-cards-label" + disabled={publishRepositoryAction.isPending} + className="grid grid-cols-2 gap-2.5" + > + {[ + { + value: "private" as const, + label: "Private", + description: "Only invited people", + Icon: LockIcon, + }, + { + value: "public" as const, + label: "Public", + description: "Anyone on the web", + Icon: GlobeIcon, + }, + ].map((option) => { + const isSelected = publishVisibility === option.value; + return ( + + + + {option.label} - - ); - })} - -
+ + {option.description} + +
+
+ ); + })} + +
-
-
-
+ + + + {publishWizardStep === 2 ? ( + + ) : ( + <> + + {publishWizardStep < 1 ? ( + + ) : ( + + )} + + )} + +
); } diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index 656fb26bdcc7..b605ba3facd6 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -15,18 +15,10 @@ import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; +import { Dialog } from "../ui/dialog"; import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; -import { WizardSteps } from "../ui/wizard-steps"; +import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard"; /** * Post-sign-in onboarding wizard for T3 Connect. Opens on every in-session @@ -216,23 +208,29 @@ function ConfiguredConnectOnboardingDialog() { if (!open && !isApplying) complete(); }} > - - - Set up T3 Connect - - Mesh your devices together — publish this environment and connect the rest, all in one - place. - + + + Mesh your devices together — publish this environment and connect the rest, all in one + place. + + } + > {steps.length > 1 ? ( ({ id, label: STEP_LABELS[id] }))} - currentStep={step} - disabled={isApplying} - onStepSelect={setStep} + steps={steps.map((id) => STEP_LABELS[id])} + currentStep={steps.indexOf(step)} + isStepDisabled={() => isApplying} + onStepChange={(index) => { + const next = steps[index]; + if (next) setStep(next); + }} /> ) : null} - - + + {step === "publish" ? ( )} - - - -
- {step === "publish" ? ( - <> - - - - ) : ( - - )} -
-
-
+ + + ) : ( + + )} + + ); } diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index ca187b3475d1..5c6517e154a1 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -71,8 +71,8 @@ import { Input } from "../ui/input"; import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { ScrollArea } from "../ui/scroll-area"; import { Spinner } from "../ui/spinner"; -import { WizardPanel, WizardSteps } from "../ui/wizard"; -import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog"; +import { WizardPanel, WizardSteps, WizardPopup, WizardHeader } from "../ui/wizard"; +import { Dialog } from "../ui/dialog"; import { toastManager } from "../ui/toast"; import { cn } from "../../lib/utils"; import { formatRelativeTime } from "../../timestampFormat"; @@ -183,71 +183,71 @@ export function WelcomeWizard({ return ( event.cancel()}> - document.getElementById("onboarding-pairing-url") ?? true} > - Set up T3 Code -
- + Code
- isImporting || index >= stageIndex} - onStepChange={(index) => { - if (isImporting || index > stageIndex) return; - setStep(index === 0 ? "connection" : "agents"); + } + > + isImporting || index >= stageIndex} + onStepChange={(index) => { + if (isImporting || index > stageIndex) return; + setStep(index === 0 ? "connection" : "agents"); + }} + /> + + + + {step === "connection" ? ( + + setSelection((current) => { + const next = new Set(current ?? selectedIds); + if (checked) next.add(environmentId); + else next.delete(environmentId); + return next; + }) + } + onContinue={() => + startSetup( + environments + .filter((environment) => selectedIds.has(environment.environmentId)) + .map((environment) => environment.environmentId), + ) + } + onPaired={(environmentId) => { + setSelection(new Set([...selectedIds, environmentId])); }} /> - - - - {step === "connection" ? ( - - setSelection((current) => { - const next = new Set(current ?? selectedIds); - if (checked) next.add(environmentId); - else next.delete(environmentId); - return next; - }) - } - onContinue={() => - startSetup( - environments - .filter((environment) => selectedIds.has(environment.environmentId)) - .map((environment) => environment.environmentId), - ) - } - onPaired={(environmentId) => { - setSelection(new Set([...selectedIds, environmentId])); - }} - /> - ) : step === "agents" ? ( - setStep("import")} /> - ) : ( - - )} - - -
+ ) : step === "agents" ? ( + setStep("import")} /> + ) : ( + + )} + +
); } diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index d29d5fd04fe1..dc61f006fcab 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -15,21 +15,14 @@ import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; +import { Dialog } from "../ui/dialog"; import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; import { RadioGroup } from "../ui/radio-group"; import { toastManager } from "../ui/toast"; import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; -import { WizardPanel } from "../ui/wizard"; +import { WizardPanel, WizardPopup, WizardHeader, WizardFooter } from "../ui/wizard"; import { ADD_PROVIDER_WIZARD_STEPS, resolveWizardNavigation, @@ -230,206 +223,204 @@ export function AddProviderInstanceDialog({ return ( - -
- - Add provider instance - + + Configure an additional provider instance on {environmentLabel} — for example, a second Codex install pointed at a different workspace. - - - + + } + > + + - -
-
- Driver -
- setDriver(ProviderDriverKind.make(value))} - aria-labelledby="add-instance-driver-label" - className="grid grid-cols-1 gap-2 sm:grid-cols-2" - > - {DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - return ( - - - - {option.label} - - - - - {option.badgeLabel ? ( - - {option.badgeLabel} - - ) : null} - - ); - })} - {COMING_SOON_DRIVER_OPTIONS.map((option) => { - const IconComponent = option.icon; - return ( - +
+
+ Driver +
+ setDriver(ProviderDriverKind.make(value))} + aria-labelledby="add-instance-driver-label" + className="grid grid-cols-1 gap-2 sm:grid-cols-2" + > + {DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + return ( + + + + {option.label} + + - - - {option.label} - + + + {option.badgeLabel ? ( - Coming Soon + {option.badgeLabel} - - ); - })} - -
+ ) : null} +
+ ); + })} + {COMING_SOON_DRIVER_OPTIONS.map((option) => { + const IconComponent = option.icon; + return ( + + + + {option.label} + + + Coming Soon + + + ); + })} +
+
- -
); } diff --git a/apps/web/src/components/settings/SnapShotSetupDialog.tsx b/apps/web/src/components/settings/SnapShotSetupDialog.tsx index fdf980236395..3b4689f044b7 100644 --- a/apps/web/src/components/settings/SnapShotSetupDialog.tsx +++ b/apps/web/src/components/settings/SnapShotSetupDialog.tsx @@ -7,16 +7,8 @@ import { CircleCheckIcon } from "lucide-react"; import { useEffect, useId, useState, type ReactNode } from "react"; import { CaptureShortcutConfig } from "./CaptureShortcutConfig"; import { Button } from "../ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "../ui/dialog"; -import { WizardSteps } from "../ui/wizard-steps"; +import { Dialog, DialogDescription } from "../ui/dialog"; +import { WizardSteps, WizardPopup, WizardHeader, WizardPanel, WizardFooter } from "../ui/wizard"; import { captureSetupAccessReady, captureSetupBackend, @@ -335,21 +327,19 @@ export function SnapShotSetupDialog({ if (!open && !busy) void onClose(false); }} > - - - - {desktop ? `Set up snapshots for ${desktop}` : "Set up snapshots"} - + + ({ ...item, disabled: index > stepIndex }))} - currentStep={step} - disabled={busy} - onStepSelect={(next) => { - if (next !== step) changeStep(next); + steps={SETUP_STEPS.map((item) => item.label)} + currentStep={stepIndex} + isStepDisabled={(index) => busy || index > stepIndex} + onStepChange={(index) => { + const next = SETUP_STEPS[index]; + if (next && next.id !== step) changeStep(next.id); }} /> - - + +

{title}

@@ -479,8 +469,8 @@ export function SnapShotSetupDialog({ ) : null}
- - + + {step !== "access" ? ( ) : null} - - + + ); } diff --git a/apps/web/src/components/ui/wizard-steps.tsx b/apps/web/src/components/ui/wizard-steps.tsx deleted file mode 100644 index ccb639dd0fbc..000000000000 --- a/apps/web/src/components/ui/wizard-steps.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { CheckIcon } from "lucide-react"; -import { cn } from "~/lib/utils"; - -export function WizardSteps({ - steps, - currentStep, - disabled = false, - onStepSelect, -}: { - steps: readonly { id: Step; label: string; disabled?: boolean }[]; - currentStep: Step; - disabled?: boolean; - onStepSelect: (step: Step) => void; -}) { - const currentIndex = steps.findIndex((step) => step.id === currentStep); - return ( -
    - {steps.map((step, index) => ( -
  1. - -
  2. - ))} -
- ); -} diff --git a/apps/web/src/components/ui/wizard.tsx b/apps/web/src/components/ui/wizard.tsx index 8843e265e038..925b263f4530 100644 --- a/apps/web/src/components/ui/wizard.tsx +++ b/apps/web/src/components/ui/wizard.tsx @@ -1,19 +1,75 @@ import { CheckIcon } from "lucide-react"; -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { cn } from "../../lib/utils"; import { AnimatedHeight } from "../AnimatedHeight"; +import { DialogPopup, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./dialog"; + +/** Compose a wizard from its header, panel, and footer; flow logic stays with the caller. */ +export function WizardPopup({ + children, + ...props +}: Omit, "className" | "style">) { + return ( + +
{children}
+
+ ); +} + +export function WizardHeader({ + title, + description, + identity, + children, +}: { + readonly title: ReactNode; + readonly description?: ReactNode; + /** Optional branding shown in place of the visible title. The title remains accessible. */ + readonly identity?: ReactNode; + readonly children?: ReactNode; +}) { + return ( + + {title} + {identity} + {description ? {description} : null} + {children} + + ); +} + +export function WizardFooter({ + children, + leading, +}: { + readonly children: ReactNode; + readonly leading?: ReactNode; +}) { + return ( + + {leading} + {leading ? ( +
{children}
+ ) : ( + children + )} +
+ ); +} export function WizardSteps({ steps, currentStep, summaries, + showSummaries = false, onStepChange, isStepDisabled, }: { readonly steps: readonly string[]; readonly currentStep: number; readonly summaries?: readonly (string | null)[]; + readonly showSummaries?: boolean; readonly isStepDisabled?: (step: number) => boolean; readonly onStepChange?: (step: number) => void; }) { @@ -61,6 +117,9 @@ export function WizardSteps({ )} > {step} + {showSummaries && index < currentStep && summaries?.[index] + ? `: ${summaries[index]}` + : null}
@@ -70,19 +129,16 @@ export function WizardSteps({ } export function WizardPanel({ - className, children, holdHeight = false, - ...props -}: ComponentProps<"div"> & { readonly holdHeight?: boolean }) { +}: { + readonly children: ReactNode; + readonly holdHeight?: boolean; +}) { return (
{children}
From de545c41737aca08359244b7778ab52e55934f17 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 8 Sep 2026 14:40:54 -0700 Subject: [PATCH 3/9] fix(web): stop the bar under the composer popping in after threads load (#10727) Co-authored-by: Claude Fable 5.1 --- apps/web/src/components/BranchToolbar.tsx | 4 ++-- apps/web/src/components/BranchToolbarBranchSelector.tsx | 4 ++-- apps/web/src/components/GitActionsControl.tsx | 6 ++---- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index d0207b901158..3e3f834658ea 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -12,7 +12,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; +import { useProject, useThreadShell, useThreadShellsForProjectRefs } from "../state/entities"; import { type EnvMode, type EnvironmentOption, @@ -465,7 +465,7 @@ export const BranchToolbar = memo(function BranchToolbar({ const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); - const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + const serverThread = useThreadShell(threadRef); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 0dad406985a7..e9b5360cbc45 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -27,7 +27,7 @@ import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches"; import { usePaginatedBranches } from "../state/queries"; -import { useProject, useThread } from "../state/entities"; +import { useProject, useThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; @@ -119,7 +119,7 @@ export function BranchToolbarBranchSelector({ const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); - const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); + const serverThread = useThreadShell(threadRef); const serverSession = serverThread?.session ?? null; const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index b766a846931c..c575f270f0bc 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -87,7 +87,7 @@ import { useVcsInitAction, useVcsPullAction, } from "~/lib/sourceControlActions"; -import { useThread } from "~/state/entities"; +import { useThreadShell } from "~/state/entities"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; import { sourceControlEnvironment } from "~/state/sourceControl"; @@ -950,9 +950,7 @@ export default function GitActionsControl({ ? store.getDraftThreadByRef(activeThreadRef) : null, ); - const activeServerThread = useThread(activeThreadRef, { - waitForShell: activeDraftThread !== null, - }); + const activeServerThread = useThreadShell(activeThreadRef); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); From 7fbc545ae8c7866ac2b39648120cb2a17250d8b4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 8 Sep 2026 14:41:03 -0700 Subject: [PATCH 4/9] fix(web): keep the composer footer still while thread data loads (#10768) Co-authored-by: Claude Fable 5.1 --- .../src/provider/Layers/ClaudeProvider.ts | 1 + .../src/provider/Layers/CodexProvider.ts | 1 + apps/server/src/provider/providerSnapshot.ts | 4 ++ .../web/src/components/ChatView.logic.test.ts | 62 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 43 +++++++++++++ apps/web/src/components/ChatView.tsx | 17 ++++- apps/web/src/components/chat/ChatComposer.tsx | 61 +++++++++++++++--- .../chat/ContextWindowMeter.logic.test.ts | 50 +++++++++++++++ .../chat/ContextWindowMeter.logic.ts | 27 ++++++++ .../components/chat/ContextWindowMeter.tsx | 5 ++ packages/contracts/src/server.ts | 3 + 11 files changed, 263 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index e3d2c6ab565d..62d7444c6968 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -55,6 +55,7 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabili const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, + reportsContextWindow: true, } as const; function toTitleCaseWords(value: string): string { const parts: Array = []; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 48f67c993e15..1971913f1f98 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -62,6 +62,7 @@ const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; const CODEX_PRESENTATION = { displayName: "Codex", showInteractionModeToggle: true, + reportsContextWindow: true, } as const; export interface CodexAppServerProviderSnapshot { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 8c94b8bb977d..d53367a90640 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -64,6 +64,7 @@ export interface ServerProviderPresentation { readonly displayName: string; readonly badgeLabel?: string; readonly showInteractionModeToggle?: boolean; + readonly reportsContextWindow?: boolean; readonly requiresNewThreadForModelChange?: boolean; } @@ -212,6 +213,9 @@ export function buildServerProvider(input: { ...(typeof input.presentation.showInteractionModeToggle === "boolean" ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } : {}), + ...(typeof input.presentation.reportsContextWindow === "boolean" + ? { reportsContextWindow: input.presentation.reportsContextWindow } + : {}), ...(typeof input.presentation.requiresNewThreadForModelChange === "boolean" ? { requiresNewThreadForModelChange: input.presentation.requiresNewThreadForModelChange } : {}), diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index b754c9408904..821044f2e774 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -47,6 +47,8 @@ import { isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + recallCheckoutIsRepo, + rememberCheckoutIsRepo, resolveBackgroundDraftWorkspaceOptions, resolveComposerInteractionMode, resolveComposerProviderSelection, @@ -55,6 +57,7 @@ import { resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + threadShellHasStarted, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, @@ -1958,3 +1961,62 @@ describe("shouldRefocusComposerOnWindowFocus", () => { expect(shouldRefocusComposerOnWindowFocus(element("BUTTON", { within: "-popup" }))).toBe(false); }); }); + +describe("checkout Git memory", () => { + it("answers from the last status seen for the same checkout", () => { + rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", false); + expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(false); + rememberCheckoutIsRepo(environmentId, "/repo/plain-folder", true); + expect(recallCheckoutIsRepo(environmentId, "/repo/plain-folder")).toBe(true); + }); + + it("does not answer for a checkout it has not seen", () => { + expect(recallCheckoutIsRepo(environmentId, "/repo/never-opened")).toBeUndefined(); + expect(recallCheckoutIsRepo(environmentId, null)).toBeUndefined(); + }); + + it("keeps environments apart", () => { + rememberCheckoutIsRepo(environmentId, "/repo/shared-path", false); + expect( + recallCheckoutIsRepo(EnvironmentId.make("env-other"), "/repo/shared-path"), + ).toBeUndefined(); + }); + + it("does not confuse an environment id containing the separator with a path", () => { + rememberCheckoutIsRepo(EnvironmentId.make("env"), "a:b", false); + expect(recallCheckoutIsRepo(EnvironmentId.make("env:a"), "b")).toBeUndefined(); + }); +}); + +describe("threadShellHasStarted", () => { + it("counts a thread that has a user message but no latest turn", () => { + expect( + threadShellHasStarted({ latestTurn: null, latestUserMessageAt: now, session: null }), + ).toBe(true); + }); + + it("counts a thread with a live session and nothing else", () => { + expect( + threadShellHasStarted({ + latestTurn: null, + latestUserMessageAt: null, + session: { + threadId, + status: "starting", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ).toBe(true); + }); + + it("does not count a thread that never sent anything", () => { + expect( + threadShellHasStarted({ latestTurn: null, latestUserMessageAt: null, session: null }), + ).toBe(false); + expect(threadShellHasStarted(null)).toBe(false); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index c251e8ffba32..950a9c73fa91 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -794,12 +794,55 @@ export function isBranchMismatchDismissedForSession(key: string | null): boolean return key !== null && sessionDismissedBranchMismatchKeys.has(key); } +// Git status for a checkout arrives after the composer paints, and the branch +// strip mounts on the assumption that a project is a Git repo. Without a +// memory, a non-Git project would mount the strip and drop it on every visit. +// Keyed by environment and checkout for the session; never persisted. +const sessionCheckoutIsRepo = new Map(); + +function checkoutIsRepoKey(environmentId: EnvironmentId, cwd: string): string { + return JSON.stringify([environmentId, cwd]); +} + +export function rememberCheckoutIsRepo( + environmentId: EnvironmentId, + cwd: string, + isRepo: boolean, +): void { + sessionCheckoutIsRepo.set(checkoutIsRepoKey(environmentId, cwd), isRepo); +} + +export function recallCheckoutIsRepo( + environmentId: EnvironmentId, + cwd: string | null, +): boolean | undefined { + return cwd === null + ? undefined + : sessionCheckoutIsRepo.get(checkoutIsRepoKey(environmentId, cwd)); +} + export function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean( thread && (thread.latestTurn !== null || thread.messages.length > 0 || thread.session !== null), ); } +/** + * Whether a thread ran at least one turn, judged from its shell alone. + * + * `threadHasStarted` needs the detail: a thread whose latest turn was cleared + * still has messages, and the loading shell carries none. The shell records + * when the last user message landed, which every started thread has. + */ +export function threadShellHasStarted( + shell: Pick | null | undefined, +): boolean { + return Boolean( + shell && + (shell.latestTurn !== null || shell.latestUserMessageAt !== null || shell.session !== null), + ); +} + // Imported history has no session until its first prompt. Resolve its instance // through the environment's provider catalog before locking to a driver. export function deriveLockedProvider(input: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5f339b0147dc..0fbef88c81e1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -403,6 +403,8 @@ import { readFileAsDataUrl, resolveFileAttachmentUrl, reconcileMountedTerminalThreadIds, + recallCheckoutIsRepo, + rememberCheckoutIsRepo, resolveBackgroundDraftWorkspaceOptions, resolveComposerInteractionMode, resolveComposerProviderSelection, @@ -3327,8 +3329,17 @@ export default function ChatView(props: ChatViewProps) { const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; - // Default true while loading to avoid toolbar flicker. - const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + // Git status arrives after the composer paints. A checkout seen earlier in + // this session answers from memory, so a non-Git project does not mount the + // branch strip and then drop it. A never-seen checkout assumes Git, which + // is what nearly every project is. + const liveIsGitRepo = gitStatusQuery.data?.isRepo; + useEffect(() => { + if (gitStatusCwd !== null && liveIsGitRepo !== undefined) { + rememberCheckoutIsRepo(environmentId, gitStatusCwd, liveIsGitRepo); + } + }, [environmentId, gitStatusCwd, liveIsGitRepo]); + const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; // Keep a hidden, off-flow strip mounted for existing threads so the composer // can measure whether its relocated controls fit. The visible chrome remains // content-driven: Git/environment context or controls that actually fit. @@ -8341,6 +8352,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadId={activeThreadId} activeThreadEnvironmentId={activeThread?.environmentId} activeThread={activeThread} + activeThreadShell={routeServerThreadShell} promptHistoryMessages={timelineMessages} isServerThread={isServerThread} isLocalDraftThread={isLocalDraftThread} @@ -8386,6 +8398,7 @@ export default function ChatView(props: ChatViewProps) { interactionMode={interactionMode} lockedProvider={lockedProvider} providerStatuses={providerStatuses as ServerProvider[]} + providerCatalogKnown={serverConfig !== null} activeProjectDefaultModelSelection={activeProjectDefaultModelSelection} activeThreadModelSelection={activeThread?.modelSelection} activeContextWindow={activeContextWindow} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index bff16c88bbcc..7b2e65f8bf3e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -63,6 +63,7 @@ import { readFileAsDataUrl, resolveComposerInteractionMode, resolveComposerProviderSelection, + threadShellHasStarted, } from "../ChatView.logic"; import { dataTransferHasComposerMention, @@ -198,10 +199,11 @@ import { renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./composerProviderState"; -import { ContextWindowMeter } from "./ContextWindowMeter"; +import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWindowMeter"; import { providerSupportsManualCompaction, resolveContextWindowModelDisplayName, + shouldReserveContextWindowMeter, } from "./ContextWindowMeter.logic"; import { attachVideoThumbnail, @@ -866,7 +868,13 @@ import { } from "../../providerInstances"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; -import { type ChatMessage, type SessionPhase, type Thread, videoMimeType } from "../../types"; +import { + type ChatMessage, + type SessionPhase, + type Thread, + type ThreadShell, + videoMimeType, +} from "../../types"; import { buildComposerPromptHistoryEntries, stepComposerPromptHistory, @@ -1107,6 +1115,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; activeContextWindow: ContextWindowSnapshot | null; + reserveContextWindowMeter: boolean; activeThreadModelDisplayName: string | null; isPreparingWorktree: boolean; pendingAction: { @@ -1143,6 +1152,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( compactDisabled={props.compactDisabled} compactDisabledReason={props.compactDisabledReason} /> + ) : props.reserveContextWindowMeter ? ( + ) : null} ; isServerThread: boolean; @@ -1298,6 +1311,8 @@ export interface ChatComposerProps { // Provider / model lockedProvider: ProviderDriverKind | null; providerStatuses: ServerProvider[]; + /** False until the environment's server config has arrived at least once. */ + providerCatalogKnown: boolean; activeProjectDefaultModelSelection: ModelSelection | null | undefined; activeThreadModelSelection: ModelSelection | null | undefined; @@ -1416,6 +1431,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode: requestedInteractionMode, lockedProvider, providerStatuses, + providerCatalogKnown, activeProjectDefaultModelSelection, activeThreadModelSelection, activeContextWindow, @@ -1712,6 +1728,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const selectedInstanceId = selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; const noProviderAvailable = selectedProviderEntry === undefined; + // Before the catalog arrives, every thread resolves to "no provider". Send + // stays blocked either way; only the chrome waits, keeping the picker with + // the thread's own selection instead of swapping in the setup button and + // back once the catalog lands. + const providerCatalogPending = noProviderAvailable && !providerCatalogKnown; + const showProviderUnavailable = noProviderAvailable && !providerCatalogPending; const providerSetupInstanceId = noProviderAvailable ? (unavailableProviderInstanceId ?? (lockedProvider === null @@ -1887,6 +1909,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), [activeThreadModelSelection, modelOptionsByInstance], ); + const reserveContextWindowMeter = shouldReserveContextWindowMeter({ + meterEnabled: settings.contextWindowMeterEnabled, + detailLoading: props.threadSyncPhase === "loading", + threadStarted: threadShellHasStarted(props.activeThreadShell), + providerReportsContextWindow: selectedProviderStatus + ? selectedProviderStatus.reportsContextWindow === true + : null, + }); // ------------------------------------------------------------------ // Composer-local state @@ -3843,7 +3873,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isStashMenuOpen || isDragOverComposer || isPreparingWorktree || - noProviderAvailable || + showProviderUnavailable || projectSelectionRequired || environmentUnavailable !== null || composerSubmissionError !== null || @@ -4088,7 +4118,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const hiddenRestingBlockIds = restingBlockDefs .slice(restingBlockDefs.length - restingHiddenBlockCount) .map((def) => def.id); - const composerControls = noProviderAvailable ? ( + const composerControls = showProviderUnavailable ? (