diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 251b07688121..7571c356b59a 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,9 +1,17 @@ -import { EnvironmentId, type VcsRef } from "@t3tools/contracts"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type VcsRef, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import { deriveProviderInstanceEntries } from "../providerInstances"; import { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, resolveEnvironmentOptionLabel, + buildEnvironmentOption, resolveBranchSelectionTarget, resolveCurrentWorkspaceLabel, resolveDraftEnvModeAfterBranchChange, @@ -14,6 +22,7 @@ import { resolveBranchToolbarValue, resolveLockedWorkspaceLabel, resolveLocalCheckoutBranchMismatch, + resolveMobileRunContextPopupClass, resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, sanitizeNewRefName, @@ -25,6 +34,159 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); const remoteEnvironmentId = EnvironmentId.make("environment-remote"); +function provider(input: { + instanceId: string; + driver: "codex" | "claudeAgent"; + enabled: boolean; + defaultModel: string; + displayName?: string; + modelName?: string; + modelShortName?: string; +}): ServerProvider { + return { + instanceId: ProviderInstanceId.make(input.instanceId), + driver: ProviderDriverKind.make(input.driver), + enabled: input.enabled, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + ...(input.displayName ? { displayName: input.displayName } : {}), + models: [ + { + slug: input.defaultModel, + name: input.modelName ?? input.defaultModel, + ...(input.modelShortName ? { shortName: input.modelShortName } : {}), + isCustom: false, + isDefault: true, + capabilities: {}, + }, + ], + slashCommands: [], + skills: [], + }; +} + +describe("buildEnvironmentOption", () => { + it("joins physical project defaults with the saved machine profile", () => { + const option = buildEnvironmentOption({ + environmentId: remoteEnvironmentId, + projectId: "project-remote" as never, + label: "Mini PC", + isPrimary: false, + workspaceRoot: "C:/repo", + defaultModelSelection: { instanceId: "codex" as never, model: "gpt-5.4" }, + profile: { + environmentId: remoteEnvironmentId, + projectId: "project-remote" as never, + branch: "feature/remote", + worktreePath: "C:/repo/.t3/worktrees/remote", + envMode: "worktree", + startFromOrigin: true, + runtimeMode: "full-access", + interactionMode: "default", + activeProvider: "codex" as never, + modelSelectionByProvider: { + ["codex" as never]: { instanceId: "codex" as never, model: "o3" }, + }, + }, + connectionStatusLabel: "Connected", + isAvailable: true, + }); + + expect(option).toMatchObject({ + label: "Mini PC", + workspaceRoot: "C:/repo", + connection: "connected", + profile: { + branchLabel: "feature/remote", + workspaceLabel: "C:/repo/.t3/worktrees/remote", + modelLabel: "o3", + executionLabel: "Full access · Build · origin", + }, + }); + }); + + it("marks an unavailable remote without hiding its profile", () => { + expect( + buildEnvironmentOption({ + environmentId: remoteEnvironmentId, + projectId: "project-remote" as never, + label: "Mini PC", + isPrimary: false, + workspaceRoot: "C:/repo", + defaultModelSelection: null, + profile: null, + connectionStatusLabel: "Reconnecting...", + isAvailable: false, + }), + ).toMatchObject({ + connection: "unavailable", + connectionStatusLabel: "Reconnecting...", + profile: { branchLabel: "Current checkout" }, + }); + }); + + it("does not preview a saved model from a disabled target provider", () => { + const staleInstanceId = ProviderInstanceId.make("codex_disabled"); + const fallbackInstanceId = ProviderInstanceId.make("claudeAgent"); + const option = buildEnvironmentOption({ + environmentId: remoteEnvironmentId, + projectId: "project-remote" as never, + label: "Mini PC", + isPrimary: false, + workspaceRoot: "C:/repo", + defaultModelSelection: { instanceId: fallbackInstanceId, model: "claude-sonnet-4" }, + profile: { + environmentId: remoteEnvironmentId, + projectId: "project-remote" as never, + branch: null, + worktreePath: null, + envMode: "local", + startFromOrigin: false, + runtimeMode: "full-access", + interactionMode: "default", + activeProvider: staleInstanceId, + modelSelectionByProvider: { + [staleInstanceId]: { instanceId: staleInstanceId, model: "stale-model" }, + }, + }, + providerEntries: deriveProviderInstanceEntries([ + provider({ + instanceId: staleInstanceId, + driver: "codex", + enabled: false, + defaultModel: "gpt-5.4", + }), + provider({ + instanceId: fallbackInstanceId, + driver: "claudeAgent", + enabled: true, + defaultModel: "claude-sonnet-4", + displayName: "Remote Claude", + modelName: "Claude Sonnet 4", + modelShortName: "Sonnet 4", + }), + ]), + connectionStatusLabel: "Connected", + isAvailable: true, + }); + + expect(option.profile).toMatchObject({ + providerLabel: "Remote Claude", + modelLabel: "Sonnet 4", + }); + }); +}); + +describe("resolveMobileRunContextPopupClass", () => { + it("uses the detail width only when machine profiles are rendered", () => { + expect(resolveMobileRunContextPopupClass(true)).toBe("w-[min(28rem,calc(100vw-2rem))]"); + expect(resolveMobileRunContextPopupClass(false)).toBe("w-64"); + }); +}); + describe("resolvePreviousWorktreeSeed", () => { it("picks the most recently updated worktree thread", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 0a8e07d1958b..9a23dfffbbb3 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,16 +1,134 @@ -import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentId, ModelSelection, ProjectId, VcsRef } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { toSortableTimestamp } from "../lib/threadSort"; +import { + resolveSelectableProviderInstanceEntry, + type ProviderInstanceEntry, +} from "../providerInstances"; +import { + resolveMachineProfileSummary, + type MachineDraftProfile, + type MachineProfileSummary, +} from "../machineDraftProfile"; +import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; export { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, } from "@t3tools/shared/git"; +export { resolveMachineProfileSummary } from "../machineDraftProfile"; +export type { MachineDraftProfile, MachineProfileSummary } from "../machineDraftProfile"; + +export const MACHINE_PROFILE_POPUP_CLASS = "w-[min(28rem,calc(100vw-2rem))]"; + +export function resolveMobileRunContextPopupClass(showMachineProfiles: boolean): string { + return showMachineProfiles ? MACHINE_PROFILE_POPUP_CLASS : "w-64"; +} + +export type EnvironmentConnectionState = "connected" | "unavailable"; + export interface EnvironmentOption { environmentId: EnvironmentId; projectId: ProjectId; label: string; isPrimary: boolean; + workspaceRoot: string; + connection: EnvironmentConnectionState; + connectionStatusLabel: string; + profile: MachineProfileSummary; +} + +function resolveEnvironmentModelPreview(input: { + defaultModelSelection: ModelSelection | null | undefined; + profile: MachineDraftProfile | null | undefined; + providerEntries: readonly ProviderInstanceEntry[]; +}): { selection: ModelSelection; providerLabel: string; modelLabel: string } | null { + const profile = input.profile ?? null; + const candidateInstanceIds = [profile?.activeProvider, input.defaultModelSelection?.instanceId]; + let selectedEntry: ProviderInstanceEntry | undefined; + for (const candidateInstanceId of candidateInstanceIds) { + if (!candidateInstanceId) continue; + selectedEntry = input.providerEntries.find( + (entry) => entry.instanceId === candidateInstanceId && entry.enabled && entry.isAvailable, + ); + if (selectedEntry) break; + } + selectedEntry ??= resolveSelectableProviderInstanceEntry(input.providerEntries, undefined); + if (!selectedEntry) return null; + + const savedSelection = profile?.modelSelectionByProvider[selectedEntry.instanceId]; + let selection = + savedSelection ?? + (input.defaultModelSelection?.instanceId === selectedEntry.instanceId + ? input.defaultModelSelection + : null); + if (!selection) { + const defaultModel = + selectedEntry.models.find((model) => model.isDefault && !model.isCustom)?.slug ?? + selectedEntry.models.find((model) => !model.isCustom)?.slug ?? + selectedEntry.models[0]?.slug; + selection = defaultModel ? { instanceId: selectedEntry.instanceId, model: defaultModel } : null; + } + if (!selection) return null; + + const selectedModel = selectedEntry.models.find((model) => model.slug === selection.model); + return { + selection, + providerLabel: selectedEntry.displayName, + modelLabel: selectedModel ? getTriggerDisplayModelLabel(selectedModel) : selection.model, + }; +} + +export function buildEnvironmentOption(input: { + environmentId: EnvironmentId; + projectId: ProjectId; + label: string; + isPrimary: boolean; + workspaceRoot: string; + defaultModelSelection: ModelSelection | null | undefined; + profile: MachineDraftProfile | null | undefined; + fallbackExecutionProfile?: Pick< + MachineDraftProfile, + "runtimeMode" | "interactionMode" | "startFromOrigin" + > | null; + providerEntries?: readonly ProviderInstanceEntry[] | null; + isAvailable: boolean; + connectionStatusLabel: string; +}): EnvironmentOption { + const modelPreview = input.providerEntries + ? resolveEnvironmentModelPreview({ + defaultModelSelection: input.defaultModelSelection, + profile: input.profile, + providerEntries: input.providerEntries, + }) + : undefined; + const summary = resolveMachineProfileSummary({ + workspaceRoot: input.workspaceRoot, + defaultModelSelection: input.defaultModelSelection, + profile: input.profile, + ...(modelPreview !== undefined + ? { modelSelectionOverride: modelPreview?.selection ?? null } + : {}), + ...(input.fallbackExecutionProfile !== undefined + ? { fallbackExecutionProfile: input.fallbackExecutionProfile } + : {}), + }); + return { + environmentId: input.environmentId, + projectId: input.projectId, + label: input.label, + isPrimary: input.isPrimary, + workspaceRoot: input.workspaceRoot, + connection: input.isAvailable ? "connected" : "unavailable", + connectionStatusLabel: input.connectionStatusLabel, + profile: modelPreview + ? { + ...summary, + providerLabel: modelPreview.providerLabel, + modelLabel: modelPreview.modelLabel, + } + : summary, + }; } export const EnvMode = Schema.Literals(["local", "worktree"]); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 5d11cce11fbe..d899643aa029 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -21,6 +21,7 @@ import { resolveEnvModeLabel, resolveEffectiveEnvMode, resolveLockedWorkspaceLabel, + resolveMobileRunContextPopupClass, resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, shouldShowEnvironmentIndicator, @@ -28,6 +29,7 @@ import { import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; +import { MachineProfileRow } from "./MachineProfileRow"; import { Button } from "./ui/button"; import { Menu, @@ -123,6 +125,10 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ); + const showMachineProfiles = + showEnvironmentPicker && + availableEnvironments !== undefined && + onEnvironmentChange !== undefined; if (isLocked) { return ( @@ -141,8 +147,12 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ {triggerContent} - - {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( + + {showMachineProfiles ? ( <> Run on @@ -151,17 +161,13 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} > {availableEnvironments.map((env) => { - const Icon = env.isPrimary ? MonitorIcon : CloudIcon; return ( - - - {env.label} - + ); })} diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index b5d5751a280b..797d98f85258 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -2,7 +2,8 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { CloudIcon, MonitorIcon } from "lucide-react"; import { memo, useMemo } from "react"; -import type { EnvironmentOption } from "./BranchToolbar.logic"; +import { MACHINE_PROFILE_POPUP_CLASS, type EnvironmentOption } from "./BranchToolbar.logic"; +import { MachineProfileRow } from "./MachineProfileRow"; import { Select, SelectGroup, @@ -103,19 +104,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir - + Run on {availableEnvironments.map((env) => ( - - - {env.isPrimary ? ( - - ) : ( - - )} - {env.label} - + + ))} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 5c026c94a138..fd98d0110e30 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -27,6 +27,7 @@ import { reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, + resolveEnvironmentSwitch, resolveSendEnvMode, scheduleEnvironmentReconnectWarning, startNewThreadForProject, @@ -39,6 +40,30 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("resolveEnvironmentSwitch", () => { + it("does not switch a started thread", () => { + expect( + resolveEnvironmentSwitch({ + envLocked: true, + draftId: "draft-1", + nextEnvironmentId: "env-2" as never, + environments: [{ environmentId: "env-2" as never, projectId: "project-2" as never }], + }), + ).toBeNull(); + }); + + it("resolves a target physical project for an unlocked draft", () => { + expect( + resolveEnvironmentSwitch({ + envLocked: false, + draftId: "draft-1", + nextEnvironmentId: "env-2" as never, + environments: [{ environmentId: "env-2" as never, projectId: "project-2" as never }], + }), + ).toEqual({ environmentId: "env-2", projectId: "project-2" }); + }); +}); + describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04561b507c3e..262822090aee 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -51,6 +51,21 @@ export function startNewThreadForProject( return true; } +export function resolveEnvironmentSwitch(input: { + envLocked: boolean; + draftId: string | null; + nextEnvironmentId: EnvironmentId; + environments: ReadonlyArray>; +}): ScopedProjectRef | null { + if (input.envLocked || input.draftId === null) { + return null; + } + const target = input.environments.find( + (environment) => environment.environmentId === input.nextEnvironmentId, + ); + return target ? { environmentId: target.environmentId, projectId: target.projectId } : null; +} + export function resolveThreadMetadataUpdateForNextTurn(input: { currentModelSelection: ModelSelection; nextModelSelection?: ModelSelection; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0a5b7bb8c601..df15e407a48f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -23,6 +23,7 @@ import { TerminalOpenInput, } from "@t3tools/contracts"; import { + connectionStatusText, connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; @@ -183,7 +184,12 @@ import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { useBrowserHistoryStore } from "~/browserHistoryStore"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; -import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + NO_PROVIDER_MODEL_SELECTION, + sortProviderInstanceEntries, +} from "../providerInstances"; import { useClientSettings, useClientSettingsHydrated, @@ -208,6 +214,7 @@ import { useComposerDraftStore, type DraftId, } from "../composerDraftStore"; +import { physicalProjectProfileKey, type MachineDraftProfile } from "../machineDraftProfile"; import { appendTerminalContextsToPrompt, formatTerminalContextLabel, @@ -259,6 +266,8 @@ import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayo import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { + buildEnvironmentOption, + type EnvironmentOption, resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, shouldShowComposerContextStrip, @@ -316,6 +325,7 @@ import { readFileAsDataUrl, reconcileMountedTerminalThreadIds, resolveThreadMetadataUpdateForNextTurn, + resolveEnvironmentSwitch, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -358,6 +368,7 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial> = {}; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); const composerAnchorRef = useRef(null); @@ -1309,6 +1320,11 @@ function ChatViewContent(props: ChatViewProps) { const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); + const composerModelSelectionByProvider = useComposerDraftStore( + (store) => + store.getComposerDraft(composerDraftTarget)?.modelSelectionByProvider ?? + EMPTY_MODEL_SELECTION_BY_PROVIDER, + ); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const setComposerDraftTerminalContexts = useComposerDraftStore( @@ -1328,6 +1344,22 @@ function ChatViewContent(props: ChatViewProps) { ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const switchDraftProject = useComposerDraftStore((store) => store.switchDraftProject); + const activeDraftMachineProfile = useMemo(() => { + if (!draftThread) return null; + return { + environmentId: draftThread.environmentId, + projectId: draftThread.projectId, + branch: draftThread.branch, + worktreePath: draftThread.worktreePath, + envMode: draftThread.envMode, + startFromOrigin: draftThread.startFromOrigin, + runtimeMode: draftThread.runtimeMode, + interactionMode: draftThread.interactionMode, + modelSelectionByProvider: composerModelSelectionByProvider, + activeProvider: composerActiveProvider, + }; + }, [composerActiveProvider, composerModelSelectionByProvider, draftThread]); const getDraftSessionByLogicalProjectKey = useComposerDraftStore( (store) => store.getDraftSessionByLogicalProjectKey, ); @@ -1866,23 +1898,48 @@ function ChatViewContent(props: ChatViewProps) { (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, ); const seen = new Set(); - const envs: Array<{ - environmentId: EnvironmentId; - projectId: ProjectId; - label: string; - isPrimary: boolean; - }> = []; + const activeDraftProfileKey = draftThread + ? physicalProjectProfileKey(draftThread.environmentId, draftThread.projectId) + : null; + const envs: EnvironmentOption[] = []; for (const p of memberProjects) { if (seen.has(p.environmentId)) continue; seen.add(p.environmentId); const isPrimary = p.environmentId === primaryEnvironmentId; - const label = environmentById.get(p.environmentId)?.label ?? p.environmentId; - envs.push({ - environmentId: p.environmentId, - projectId: p.id, - label, - isPrimary, - }); + const environment = environmentById.get(p.environmentId); + const label = environment?.label ?? p.environmentId; + const profileKey = physicalProjectProfileKey(p.environmentId, p.id); + const profile = + profileKey === activeDraftProfileKey + ? activeDraftMachineProfile + : (draftThread?.machineProfilesByProjectKey[profileKey] ?? null); + const connectionPhase = environment?.connection.phase; + const targetServerConfig = environment?.serverConfig; + const providerEntries = targetServerConfig + ? sortProviderInstanceEntries( + applyProviderInstanceSettings( + deriveProviderInstanceEntries(targetServerConfig.providers), + targetServerConfig.settings, + ), + ) + : null; + envs.push( + buildEnvironmentOption({ + environmentId: p.environmentId, + projectId: p.id, + label, + isPrimary, + workspaceRoot: p.workspaceRoot, + defaultModelSelection: p.defaultModelSelection, + profile, + fallbackExecutionProfile: activeDraftMachineProfile, + providerEntries, + isAvailable: connectionPhase === "connected", + connectionStatusLabel: environment + ? connectionStatusText(environment.connection) + : "Unavailable", + }), + ); } // Sort: primary first, then alphabetical envs.sort((a, b) => { @@ -1890,7 +1947,15 @@ function ChatViewContent(props: ChatViewProps) { return a.label.localeCompare(b.label); }); return envs; - }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); + }, [ + activeDraftMachineProfile, + activeProject, + allProjects, + draftThread, + environmentById, + primaryEnvironmentId, + projectGroupingSettings, + ]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; const activeEnvironmentOption = logicalProjectEnvironments.find( @@ -2741,21 +2806,22 @@ function ChatViewContent(props: ChatViewProps) { (activeThread.session !== null && activeThread.session.status !== "stopped")), ); - // Handle environment change for draft threads. When the user picks a - // different environment we update the draft context to point at the physical - // project in that environment while keeping the same logical project. + // Handle environment change for draft threads. The store snapshots and + // restores the full machine profile atomically so each physical project + // keeps its own checkout, model, and execution choices. const onEnvironmentChange = useCallback( (nextEnvironmentId: EnvironmentId) => { - if (envLocked || !draftId) return; - const target = logicalProjectEnvironments.find( - (env) => env.environmentId === nextEnvironmentId, - ); - if (!target) return; - setDraftThreadContext(draftId, { - projectRef: scopeProjectRef(target.environmentId, target.projectId), + if (!draftId) return; + const target = resolveEnvironmentSwitch({ + envLocked, + draftId, + nextEnvironmentId, + environments: logicalProjectEnvironments, }); + if (!target) return; + switchDraftProject(draftId, target); }, - [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], + [draftId, envLocked, logicalProjectEnvironments, switchDraftProject], ); const activeTerminalGroup = diff --git a/apps/web/src/components/MachineProfileRow.test.tsx b/apps/web/src/components/MachineProfileRow.test.tsx new file mode 100644 index 000000000000..b033e4aba880 --- /dev/null +++ b/apps/web/src/components/MachineProfileRow.test.tsx @@ -0,0 +1,63 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { MachineProfileRow } from "./MachineProfileRow"; + +describe("MachineProfileRow", () => { + it("renders the machine path and draft execution details", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Mini PC"); + expect(markup).toContain("C:/Users/lucas/Projects/t3code"); + expect(markup).toContain("feature/remote"); + expect(markup).toContain("gpt-5.4"); + expect(markup).toContain("Full access"); + expect(markup.match(/data-slot="tooltip-trigger"/gu)).toHaveLength(7); + expect(markup).not.toContain("title="); + expect(markup).not.toContain("aria-label="); + }); + + it("keeps disconnected machine details hoverable while marking the machine unavailable", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Reconnecting..."); + expect(markup).not.toContain(">Unavailable<"); + expect(markup.match(/pointer-events-auto/gu)).toHaveLength(8); + }); +}); diff --git a/apps/web/src/components/MachineProfileRow.tsx b/apps/web/src/components/MachineProfileRow.tsx new file mode 100644 index 000000000000..330adf6606da --- /dev/null +++ b/apps/web/src/components/MachineProfileRow.tsx @@ -0,0 +1,70 @@ +import { CloudIcon, MonitorIcon } from "lucide-react"; +import { memo } from "react"; + +import type { EnvironmentOption } from "./BranchToolbar.logic"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +function TruncatedMachineValue({ value, className }: { value: string; className: string }) { + return ( + + }> + {value} + + + {value} + + + ); +} + +export interface MachineProfileRowProps { + environment: Pick< + EnvironmentOption, + "label" | "isPrimary" | "workspaceRoot" | "connection" | "connectionStatusLabel" | "profile" + >; +} + +export const MachineProfileRow = memo(function MachineProfileRow({ + environment, +}: MachineProfileRowProps) { + const EnvironmentIcon = environment.isPrimary ? MonitorIcon : CloudIcon; + const unavailable = environment.connection !== "connected"; + const profile = environment.profile; + + return ( + + + + + Path + + + Checkout + + + + Workspace + + + Model + + + Provider + + + + Settings + + + + + ); +}); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d7285d7490e6..e61d83327a65 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -94,13 +94,18 @@ import { buildSidebarProjectSnapshots, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; +import { + acknowledgeProjectGroupingPrompt, + findProjectGroupingPromptCandidate, + separateProjectGroup, +} from "../projectGroupingPrompt.logic"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; @@ -161,6 +166,7 @@ import { type SnoozePreset, } from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; +import { ProjectGroupingPrompt } from "./sidebar/ProjectGroupingPrompt"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { @@ -193,6 +199,9 @@ const SETTLED_TAIL_PAGE_COUNT = 25; // Keep the v2 key so existing preferences survive the v2-to-default rename. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; +const PROJECT_GROUPING_PROMPT_ACKNOWLEDGED_KEY = "t3code:sidebar:project-grouping-prompts"; +const PROJECT_GROUPING_PROMPT_ACKNOWLEDGEMENTS_SCHEMA = Schema.Array(Schema.String); +const EMPTY_PROJECT_GROUPING_PROMPT_ACKNOWLEDGEMENTS: string[] = []; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -1708,6 +1717,13 @@ export default function Sidebar() { const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const updateClientSettings = useUpdateClientSettings(); + const [acknowledgedProjectGroupingPrompts, setAcknowledgedProjectGroupingPrompts] = + useLocalStorage( + PROJECT_GROUPING_PROMPT_ACKNOWLEDGED_KEY, + EMPTY_PROJECT_GROUPING_PROMPT_ACKNOWLEDGEMENTS, + PROJECT_GROUPING_PROMPT_ACKNOWLEDGEMENTS_SCHEMA, + ); const { settleThread, unsettleThread, @@ -1858,6 +1874,43 @@ export default function Sidebar() { () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), [sidebarProjectSortOrder, threads, unsortedProjectGroups], ); + const acknowledgedProjectGroupingPromptKeys = useMemo( + () => new Set(acknowledgedProjectGroupingPrompts), + [acknowledgedProjectGroupingPrompts], + ); + const projectGroupingPromptCandidate = useMemo( + () => findProjectGroupingPromptCandidate(projectGroups, acknowledgedProjectGroupingPromptKeys), + [acknowledgedProjectGroupingPromptKeys, projectGroups], + ); + const acknowledgeProjectGroupingPromptCandidate = useCallback( + (projectKey: string) => { + setAcknowledgedProjectGroupingPrompts((current) => + acknowledgeProjectGroupingPrompt(current, projectKey), + ); + }, + [setAcknowledgedProjectGroupingPrompts], + ); + const handleGroupProjectPrompt = useCallback(() => { + const candidate = projectGroupingPromptCandidate; + if (!candidate) return; + acknowledgeProjectGroupingPromptCandidate(candidate.projectKey); + }, [acknowledgeProjectGroupingPromptCandidate, projectGroupingPromptCandidate]); + const handleKeepSeparateProjectPrompt = useCallback(() => { + const candidate = projectGroupingPromptCandidate; + if (!candidate) return; + updateClientSettings({ + sidebarProjectGroupingOverrides: separateProjectGroup( + candidate, + projectGroupingSettings.sidebarProjectGroupingOverrides, + ), + }); + acknowledgeProjectGroupingPromptCandidate(candidate.projectKey); + }, [ + acknowledgeProjectGroupingPromptCandidate, + projectGroupingPromptCandidate, + projectGroupingSettings.sidebarProjectGroupingOverrides, + updateClientSettings, + ]); const serverProviders = useAtomValue(primaryServerProvidersAtom); const providerEntryByInstanceId = useMemo( () => @@ -3554,6 +3607,13 @@ export default function Sidebar() { ) : null} + {projectGroupingPromptCandidate ? ( + + ) : null} } > diff --git a/apps/web/src/components/sidebar/ProjectGroupingPrompt.test.tsx b/apps/web/src/components/sidebar/ProjectGroupingPrompt.test.tsx new file mode 100644 index 000000000000..16e481aad0c3 --- /dev/null +++ b/apps/web/src/components/sidebar/ProjectGroupingPrompt.test.tsx @@ -0,0 +1,69 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; +import type { Project } from "../../types"; +import { ProjectGroupingPrompt } from "./ProjectGroupingPrompt"; + +function renderPrompt(): string { + const primaryEnvironmentId = EnvironmentId.make("laptop"); + const project = (environmentId: EnvironmentId, id: string): Project => ({ + id: ProjectId.make(id), + environmentId, + title: "t3code", + workspaceRoot: `C:/${id}/t3code`, + repositoryIdentity: { + canonicalKey: "github.com/pingdotgg/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/pingdotgg/t3code.git", + }, + }, + defaultModelSelection: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [], + }); + const [group] = buildSidebarProjectSnapshots({ + projects: [ + project(primaryEnvironmentId, "project-laptop"), + project(EnvironmentId.make("mini-pc"), "project-mini-pc"), + ], + settings: { + sidebarProjectGroupingMode: "repository", + sidebarProjectGroupingOverrides: {}, + }, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => + environmentId === primaryEnvironmentId ? "Laptop" : "Mini PC", + }); + + return renderToStaticMarkup( + undefined} + onKeepSeparate={() => undefined} + />, + ); +} + +describe("ProjectGroupingPrompt", () => { + it("stacks both actions with the description instead of the alert action column", () => { + const markup = renderPrompt(); + + expect(markup).toContain("Keep separate"); + expect(markup).toContain("Group projects"); + expect(markup).not.toContain('data-slot="alert-action"'); + }); + + it("uses the shared info alert colors without call-site overrides", () => { + const markup = renderPrompt(); + + expect(markup).toContain("border-info/32"); + expect(markup).toContain("bg-info/4"); + expect(markup).not.toContain("border-info/30"); + expect(markup).not.toContain("bg-info/6"); + }); +}); diff --git a/apps/web/src/components/sidebar/ProjectGroupingPrompt.tsx b/apps/web/src/components/sidebar/ProjectGroupingPrompt.tsx new file mode 100644 index 000000000000..8bab96b0910a --- /dev/null +++ b/apps/web/src/components/sidebar/ProjectGroupingPrompt.tsx @@ -0,0 +1,48 @@ +import { GitMergeIcon } from "lucide-react"; + +import { resolveProjectGroupingEnvironmentLabels } from "../../projectGroupingPrompt.logic"; +import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; +import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { Button } from "../ui/button"; + +interface ProjectGroupingPromptProps { + readonly group: SidebarProjectSnapshot; + readonly onGroup: () => void; + readonly onKeepSeparate: () => void; +} + +export function ProjectGroupingPrompt({ + group, + onGroup, + onKeepSeparate, +}: ProjectGroupingPromptProps) { + const environmentLabels = resolveProjectGroupingEnvironmentLabels(group); + const environmentDescription = + environmentLabels.length > 1 ? environmentLabels.join(" and ") : "multiple environments"; + + return ( + + + Group this repository? + + + {group.displayName} is available on {environmentDescription}. Grouping keeps one project + and lets new threads choose where to run. + +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 3e4106c583f1..ed0b9478221b 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -787,6 +787,45 @@ describe("composerDraftStore project draft thread mapping", () => { } }); + it("rehomes a grouped draft when its active environment is removed", () => { + const store = useComposerDraftStore.getState(); + const logicalProjectKey = "github.com/pingdotgg/t3code"; + store.setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, sharedDraftId, { + threadId, + branch: "feature/local", + worktreePath: "/repo/.t3/worktrees/local", + envMode: "worktree", + runtimeMode: "full-access", + interactionMode: "plan", + startFromOrigin: true, + }); + store.setPrompt(sharedDraftId, "keep this grouped draft"); + store.setModelSelection(sharedDraftId, modelSelection(CODEX_DRIVER, "gpt-5.4")); + store.switchDraftProject(sharedDraftId, remoteProjectRef); + + clearComposerDraftsEnvironment(OTHER_TEST_ENVIRONMENT_ID); + + const next = useComposerDraftStore.getState(); + expect(next.getDraftSessionByLogicalProjectKey(logicalProjectKey)).toMatchObject({ + draftId: sharedDraftId, + environmentId: TEST_ENVIRONMENT_ID, + projectId, + branch: "feature/local", + worktreePath: "/repo/.t3/worktrees/local", + envMode: "worktree", + runtimeMode: "full-access", + interactionMode: "plan", + startFromOrigin: true, + }); + expect(next.getComposerDraft(sharedDraftId)).toMatchObject({ + prompt: "keep this grouped draft", + activeProvider: CODEX_INSTANCE, + modelSelectionByProvider: { + [CODEX_INSTANCE]: { model: "gpt-5.4" }, + }, + }); + }); + it("stores and reads project draft thread ids via actions", () => { const store = useComposerDraftStore.getState(); expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); @@ -1238,6 +1277,57 @@ describe("composerDraftStore project draft thread mapping", () => { startFromOrigin: true, }); }); + + it("restores a draft's branch, worktree, model, and modes per machine", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + branch: "feature/laptop", + worktreePath: "/repo/.t3/worktrees/laptop", + envMode: "worktree", + runtimeMode: "full-access", + interactionMode: "default", + startFromOrigin: true, + }); + store.setModelSelection(draftId, modelSelection(CODEX_DRIVER, "gpt-5.4")); + + store.switchDraftProject(draftId, remoteProjectRef); + + expect(store.getDraftSession(draftId)).toMatchObject({ + environmentId: OTHER_TEST_ENVIRONMENT_ID, + projectId, + branch: null, + worktreePath: null, + envMode: "worktree", + startFromOrigin: true, + }); + expect(store.getComposerDraft(draftId)?.modelSelectionByProvider ?? {}).toEqual({}); + + store.setDraftThreadContext(draftId, { + branch: "feature/minipc", + worktreePath: "/repo/.t3/worktrees/minipc", + envMode: "worktree", + runtimeMode: "approval-required", + interactionMode: "plan", + startFromOrigin: false, + }); + store.setModelSelection(draftId, modelSelection(CLAUDE_AGENT_DRIVER, "sonnet")); + store.switchDraftProject(draftId, projectRef); + + expect(store.getDraftSession(draftId)).toMatchObject({ + environmentId: TEST_ENVIRONMENT_ID, + projectId, + branch: "feature/laptop", + worktreePath: "/repo/.t3/worktrees/laptop", + envMode: "worktree", + runtimeMode: "full-access", + interactionMode: "default", + startFromOrigin: true, + }); + expect(store.getComposerDraft(draftId)?.modelSelectionByProvider).toMatchObject({ + [CODEX_INSTANCE]: { model: "gpt-5.4" }, + }); + }); }); describe("composerDraftStore modelSelection", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3fe6681e09ed..40aace205e85 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -53,6 +53,11 @@ import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; import { getDefaultServerModel } from "./providerModels"; import { UnifiedSettings } from "@t3tools/contracts/settings"; import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewCommentContext"; +import { + physicalProjectProfileKey, + type MachineDraftProfile, + type MachineDraftProfileMap, +} from "./machineDraftProfile"; const isRuntimeMode = Schema.is(RuntimeMode); const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); @@ -225,9 +230,23 @@ const PersistedDraftThreadState = Schema.Struct({ }), ), ), + // Profiles are normalized at the draft boundary so legacy and readonly + // model-option payloads can be recovered safely. + machineProfilesByProjectKey: Schema.optionalKey(Schema.Unknown), }); type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; +function serializeMachineDraftProfiles(profiles: MachineDraftProfileMap): unknown { + const serialized: Record = {}; + for (const [key, profile] of Object.entries(profiles)) { + serialized[key] = { + ...profile, + modelSelectionByProvider: compactModelSelectionByProvider(profile.modelSelectionByProvider), + }; + } + return serialized; +} + const PersistedComposerDraftStoreState = Schema.Struct({ draftsByThreadKey: Schema.Record(Schema.String, PersistedComposerThreadDraftState), draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState), @@ -321,11 +340,32 @@ export interface DraftSessionState { worktreePath: string | null; envMode: DraftThreadEnvMode; startFromOrigin: boolean; + machineProfilesByProjectKey: MachineDraftProfileMap; promotedTo?: ScopedThreadRef | null; } export type DraftThreadState = DraftSessionState; +function snapshotMachineDraftProfile( + draftThread: DraftSessionState, + composerDraft: ComposerThreadDraftState | undefined, +): MachineDraftProfile { + return { + environmentId: draftThread.environmentId, + projectId: draftThread.projectId, + branch: draftThread.branch, + worktreePath: draftThread.worktreePath, + envMode: draftThread.envMode, + startFromOrigin: draftThread.startFromOrigin, + runtimeMode: draftThread.runtimeMode, + interactionMode: draftThread.interactionMode, + modelSelectionByProvider: cloneModelSelectionByProvider( + composerDraft?.modelSelectionByProvider ?? {}, + ), + activeProvider: composerDraft?.activeProvider ?? null, + }; +} + /** * Draft session metadata paired with its stable draft-session identity. */ @@ -416,6 +456,7 @@ interface ComposerDraftStoreState { interactionMode?: ProviderInteractionMode; }, ) => void; + switchDraftProject: (draftId: DraftId, projectRef: ScopedProjectRef) => void; clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; clearProjectDraftThreadById: ( projectRef: ScopedProjectRef, @@ -1239,6 +1280,79 @@ function logicalProjectDraftKey(logicalProjectKey: string): string { return logicalProjectKey.trim(); } +function cloneModelSelectionByProvider( + selections: Partial>, +): Partial> { + const cloned: Partial> = {}; + for (const [instanceId, selection] of Object.entries(selections)) { + if (!selection) continue; + cloned[instanceId as ProviderInstanceId] = { + ...selection, + ...(selection.options ? { options: selection.options.map((option) => ({ ...option })) } : {}), + }; + } + return cloned; +} + +function normalizeMachineDraftProfiles(value: unknown): MachineDraftProfileMap { + if (!value || typeof value !== "object") { + return {}; + } + + const normalized: MachineDraftProfileMap = {}; + for (const rawProfile of Object.values(value as Record)) { + if (!rawProfile || typeof rawProfile !== "object") continue; + const candidate = rawProfile as Record; + const environmentId = candidate.environmentId; + const projectId = candidate.projectId; + if ( + typeof environmentId !== "string" || + environmentId.length === 0 || + typeof projectId !== "string" || + projectId.length === 0 + ) { + continue; + } + if (!isRuntimeMode(candidate.runtimeMode)) continue; + if (candidate.interactionMode !== "plan" && candidate.interactionMode !== "default") { + continue; + } + if (candidate.envMode !== "local" && candidate.envMode !== "worktree") continue; + + const modelSelectionByProvider: Partial> = {}; + if ( + candidate.modelSelectionByProvider && + typeof candidate.modelSelectionByProvider === "object" + ) { + for (const [instanceId, rawSelection] of Object.entries( + candidate.modelSelectionByProvider as Record, + )) { + const selection = normalizeModelSelection(rawSelection, { provider: instanceId }); + if (selection) { + modelSelectionByProvider[selection.instanceId] = selection; + } + } + } + + const activeProvider = normalizeProviderInstanceId(candidate.activeProvider); + const profile: MachineDraftProfile = { + environmentId: environmentId as EnvironmentId, + projectId: projectId as ProjectId, + branch: typeof candidate.branch === "string" ? candidate.branch : null, + worktreePath: typeof candidate.worktreePath === "string" ? candidate.worktreePath : null, + envMode: candidate.envMode as DraftThreadEnvMode, + startFromOrigin: candidate.startFromOrigin === true, + runtimeMode: candidate.runtimeMode, + interactionMode: candidate.interactionMode, + modelSelectionByProvider, + activeProvider: + activeProvider && modelSelectionByProvider[activeProvider] ? activeProvider : null, + }; + normalized[physicalProjectProfileKey(profile.environmentId, profile.projectId)] = profile; + } + return normalized; +} + /** * Runtime composer storage key for app-facing identities only. * @@ -1413,6 +1527,7 @@ function createDraftThreadState( envMode: options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + machineProfilesByProjectKey: existingThread?.machineProfilesByProjectKey ?? {}, promotedTo: null, }; } @@ -1445,6 +1560,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.worktreePath === right.worktreePath && left.envMode === right.envMode && left.startFromOrigin === right.startFromOrigin && + Equal.equals(left.machineProfilesByProjectKey, right.machineProfilesByProjectKey) && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) ); } @@ -1588,6 +1704,9 @@ function normalizePersistedDraftThreads( worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), startFromOrigin, + machineProfilesByProjectKey: serializeMachineDraftProfiles( + normalizeMachineDraftProfiles(candidateDraftThread.machineProfilesByProjectKey), + ), promotedTo, }; } @@ -1634,6 +1753,7 @@ function normalizePersistedDraftThreads( worktreePath: null, envMode: "local", startFromOrigin: false, + machineProfilesByProjectKey: {}, promotedTo: null, }; } else if ( @@ -1976,7 +2096,15 @@ function partializeComposerDraftStoreState( if (!keptSessionKeys.has(threadKey)) { continue; } - persistedDraftThreadsByThreadKey[threadKey] = draftThread; + const { machineProfilesByProjectKey, ...persistedDraftThread } = draftThread; + persistedDraftThreadsByThreadKey[threadKey] = { + ...persistedDraftThread, + ...(Object.keys(machineProfilesByProjectKey).length > 0 + ? { + machineProfilesByProjectKey: serializeMachineDraftProfiles(machineProfilesByProjectKey), + } + : {}), + } as unknown as never; } return { draftsByThreadKey: persistedDraftsByThreadKey, @@ -2237,6 +2365,9 @@ function toHydratedDraftThreadState( worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, startFromOrigin: persistedDraftThread.startFromOrigin, + machineProfilesByProjectKey: normalizeMachineDraftProfiles( + persistedDraftThread.machineProfilesByProjectKey, + ), promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2471,6 +2602,7 @@ const composerDraftStore = create()( envMode: options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + machineProfilesByProjectKey: existing.machineProfilesByProjectKey ?? {}, promotedTo: existing.promotedTo ?? null, }; const isUnchanged = @@ -2496,6 +2628,83 @@ const composerDraftStore = create()( }; }); }, + switchDraftProject: (draftId, projectRef) => { + const threadKey = resolveComposerDraftKey(get(), draftId) ?? ""; + if ( + threadKey.length === 0 || + projectRef.environmentId.length === 0 || + projectRef.projectId.length === 0 + ) { + return; + } + set((state) => { + const existingThread = state.draftThreadsByThreadKey[threadKey]; + if (!existingThread) { + return state; + } + if ( + existingThread.environmentId === projectRef.environmentId && + existingThread.projectId === projectRef.projectId + ) { + return state; + } + + const currentComposerDraft = state.draftsByThreadKey[threadKey]; + const currentProfileKey = physicalProjectProfileKey( + existingThread.environmentId, + existingThread.projectId, + ); + const targetProfileKey = physicalProjectProfileKey( + projectRef.environmentId, + projectRef.projectId, + ); + const nextProfiles: MachineDraftProfileMap = { + ...existingThread.machineProfilesByProjectKey, + [currentProfileKey]: snapshotMachineDraftProfile( + existingThread, + currentComposerDraft, + ), + }; + const targetProfile = nextProfiles[targetProfileKey]; + const nextDraftThread: DraftThreadState = { + ...existingThread, + environmentId: projectRef.environmentId, + projectId: projectRef.projectId, + branch: targetProfile?.branch ?? null, + worktreePath: targetProfile?.worktreePath ?? null, + envMode: targetProfile?.envMode ?? existingThread.envMode, + startFromOrigin: targetProfile?.startFromOrigin ?? existingThread.startFromOrigin, + runtimeMode: targetProfile?.runtimeMode ?? existingThread.runtimeMode, + interactionMode: targetProfile?.interactionMode ?? existingThread.interactionMode, + machineProfilesByProjectKey: nextProfiles, + }; + + const baseComposerDraft = currentComposerDraft ?? createEmptyThreadDraft(); + const nextComposerDraft: ComposerThreadDraftState = { + ...baseComposerDraft, + modelSelectionByProvider: targetProfile + ? cloneModelSelectionByProvider(targetProfile.modelSelectionByProvider) + : {}, + activeProvider: targetProfile?.activeProvider ?? null, + runtimeMode: nextDraftThread.runtimeMode, + interactionMode: nextDraftThread.interactionMode, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextComposerDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextComposerDraft; + } + + return { + draftsByThreadKey: nextDraftsByThreadKey, + draftThreadsByThreadKey: { + ...state.draftThreadsByThreadKey, + [threadKey]: nextDraftThread, + }, + }; + }); + }, clearProjectDraftThreadId: (projectRef) => { set((state) => { // A project can own several sessions (invested drafts survive @@ -3581,21 +3790,84 @@ export const useComposerDraftStore = composerDraftStore; export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { useComposerDraftStore.setState((state) => { const removedThreadKeys = new Set(); + const rehomedThreadKeys = new Set(); + const nextDraftThreads = { ...state.draftThreadsByThreadKey }; + const nextDrafts = { ...state.draftsByThreadKey }; for (const [threadKey, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { - if (draftThread.environmentId === environmentId) { + const survivingProfiles = Object.values(draftThread.machineProfilesByProjectKey).filter( + (profile) => profile.environmentId !== environmentId, + ); + const survivingProfilesByProjectKey: MachineDraftProfileMap = {}; + for (const profile of survivingProfiles) { + survivingProfilesByProjectKey[ + physicalProjectProfileKey(profile.environmentId, profile.projectId) + ] = profile; + } + + if (draftThread.environmentId !== environmentId) { + if ( + survivingProfiles.length !== Object.keys(draftThread.machineProfilesByProjectKey).length + ) { + nextDraftThreads[threadKey] = { + ...draftThread, + machineProfilesByProjectKey: survivingProfilesByProjectKey, + }; + } + continue; + } + + const fallbackProfile = survivingProfiles[0]; + if (!fallbackProfile) { removedThreadKeys.add(threadKey); + continue; + } + + const nextDraftThread: DraftThreadState = { + ...draftThread, + environmentId: fallbackProfile.environmentId, + projectId: fallbackProfile.projectId, + branch: fallbackProfile.branch, + worktreePath: fallbackProfile.worktreePath, + envMode: fallbackProfile.envMode, + startFromOrigin: fallbackProfile.startFromOrigin, + runtimeMode: fallbackProfile.runtimeMode, + interactionMode: fallbackProfile.interactionMode, + machineProfilesByProjectKey: survivingProfilesByProjectKey, + }; + nextDraftThreads[threadKey] = nextDraftThread; + rehomedThreadKeys.add(threadKey); + + const nextComposerDraft: ComposerThreadDraftState = { + ...(state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft()), + modelSelectionByProvider: cloneModelSelectionByProvider( + fallbackProfile.modelSelectionByProvider, + ), + activeProvider: fallbackProfile.activeProvider, + runtimeMode: fallbackProfile.runtimeMode, + interactionMode: fallbackProfile.interactionMode, + }; + if (shouldRemoveDraft(nextComposerDraft)) { + delete nextDrafts[threadKey]; + } else { + nextDrafts[threadKey] = nextComposerDraft; } } for (const threadKey of Object.keys(state.draftsByThreadKey)) { - if (parseScopedThreadKey(threadKey)?.environmentId === environmentId) { + if ( + parseScopedThreadKey(threadKey)?.environmentId === environmentId && + !rehomedThreadKeys.has(threadKey) + ) { removedThreadKeys.add(threadKey); } } for (const [logicalProjectKey, threadKey] of Object.entries( state.logicalProjectDraftThreadKeyByLogicalProjectKey, )) { - if (parseScopedProjectKey(logicalProjectKey)?.environmentId === environmentId) { + if ( + parseScopedProjectKey(logicalProjectKey)?.environmentId === environmentId && + !rehomedThreadKeys.has(threadKey) + ) { removedThreadKeys.add(threadKey); } } @@ -3603,25 +3875,19 @@ export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): vo const nextLogicalMappings = Object.fromEntries( Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter( ([logicalProjectKey, threadKey]) => - parseScopedProjectKey(logicalProjectKey)?.environmentId !== environmentId && + (parseScopedProjectKey(logicalProjectKey)?.environmentId !== environmentId || + rehomedThreadKeys.has(threadKey)) && !removedThreadKeys.has(threadKey), ), ) as Record; - const nextDraftThreads = Object.fromEntries( - Object.entries(state.draftThreadsByThreadKey).filter( - ([threadKey, draftThread]) => - draftThread.environmentId !== environmentId && !removedThreadKeys.has(threadKey), - ), - ) as Record; - const nextDrafts = Object.fromEntries( - Object.entries(state.draftsByThreadKey).filter(([threadKey, draft]) => { - if (!removedThreadKeys.has(threadKey)) { - return true; - } - revokeDraftThreadPreviewUrls(draft); - return false; - }), - ) as Record; + for (const threadKey of removedThreadKeys) { + const removedDraft = nextDrafts[threadKey]; + if (removedDraft) { + revokeDraftThreadPreviewUrls(removedDraft); + } + delete nextDrafts[threadKey]; + delete nextDraftThreads[threadKey]; + } return { draftsByThreadKey: nextDrafts, diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 9029f1204d36..b2d900be8857 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -13,6 +13,12 @@ import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, } from "./sidebarProjectGrouping"; +import { + acknowledgeProjectGroupingPrompt, + findProjectGroupingPromptCandidate, + resolveProjectGroupingEnvironmentLabels, + separateProjectGroup, +} from "./projectGroupingPrompt.logic"; import { orderItemsByPreferredIds } from "./components/Sidebar.logic"; import { legacyProjectCwdPreferenceKey } from "./uiStateStore"; import type { Project } from "./types"; @@ -51,6 +57,88 @@ function makeProject(overrides: Partial = {}): Project { } describe("environment grouping", () => { + it("offers a prompt for a repository that spans environments", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const [group] = buildSidebarProjectSnapshots({ + projects: [primary, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => + environmentId === primaryEnvironmentId ? "This device" : "MiniPC", + }); + + expect(findProjectGroupingPromptCandidate([group!], new Set())).toBe(group); + }); + + it("does not prompt for an acknowledged repository", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const [group] = buildSidebarProjectSnapshots({ + projects: [primary, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + + expect(findProjectGroupingPromptCandidate([group!], new Set([group!.projectKey]))).toBeNull(); + }); + + it("separates every physical checkout when requested", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const [group] = buildSidebarProjectSnapshots({ + projects: [primary, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + + expect(separateProjectGroup(group!, { existing: "repository" })).toEqual({ + existing: "repository", + "env-primary:/tmp/shared-repo": "separate", + "env-remote:/tmp/shared-repo": "separate", + }); + }); + + it("acknowledges a prompt key only once", () => { + expect(acknowledgeProjectGroupingPrompt(["existing"], "repo")).toEqual(["existing", "repo"]); + expect(acknowledgeProjectGroupingPrompt(["existing", "repo"], "repo")).toEqual([ + "existing", + "repo", + ]); + }); + + it("deduplicates environment labels for the prompt", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const [group] = buildSidebarProjectSnapshots({ + projects: [primary, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => + environmentId === primaryEnvironmentId ? "This device" : "MiniPC", + }); + + expect(resolveProjectGroupingEnvironmentLabels(group!)).toEqual(["This device", "MiniPC"]); + }); + it("groups matching repository identities across environments", () => { const primary = makeProject({ repositoryIdentity }); const remote = makeProject({ diff --git a/apps/web/src/machineDraftProfile.test.ts b/apps/web/src/machineDraftProfile.test.ts new file mode 100644 index 000000000000..797a3101a520 --- /dev/null +++ b/apps/web/src/machineDraftProfile.test.ts @@ -0,0 +1,102 @@ +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + type ModelSelection, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + physicalProjectProfileKey, + resolveMachineProfileSummary, + type MachineDraftProfile, +} from "./machineDraftProfile"; + +const remoteEnvironmentId = EnvironmentId.make("env-remote"); +const remoteProjectId = ProjectId.make("project-remote"); +const codexInstanceId = ProviderInstanceId.make("codex"); + +const remoteModelSelection: ModelSelection = { + instanceId: codexInstanceId, + model: "gpt-5.4", +}; + +const remoteProfile: MachineDraftProfile = { + environmentId: remoteEnvironmentId, + projectId: remoteProjectId, + branch: "feature/remote", + worktreePath: "C:/repo/.t3/worktrees/remote", + envMode: "worktree", + startFromOrigin: true, + runtimeMode: "approval-required", + interactionMode: "plan", + modelSelectionByProvider: { [codexInstanceId]: remoteModelSelection }, + activeProvider: codexInstanceId, +}; + +describe("machine draft profiles", () => { + it("keys profiles by physical environment and project", () => { + expect(physicalProjectProfileKey(remoteEnvironmentId, remoteProjectId)).toBe( + '["env-remote","project-remote"]', + ); + }); + + it("does not collide when environment and project ids contain separators", () => { + const left = physicalProjectProfileKey( + EnvironmentId.make("environment:project"), + ProjectId.make("repo"), + ); + const right = physicalProjectProfileKey( + EnvironmentId.make("environment"), + ProjectId.make("project:repo"), + ); + + expect(left).not.toBe(right); + }); + + it("summarizes a saved profile without leaking another machine's path", () => { + expect( + resolveMachineProfileSummary({ + workspaceRoot: "C:/repo", + defaultModelSelection: null, + profile: remoteProfile, + }), + ).toMatchObject({ + branchLabel: "feature/remote", + workspaceLabel: "C:/repo/.t3/worktrees/remote", + modelLabel: "gpt-5.4", + executionLabel: "Approval required · Plan · origin", + }); + }); + + it("previews the execution settings preserved on a first visit", () => { + expect( + resolveMachineProfileSummary({ + workspaceRoot: "C:/repo", + defaultModelSelection: { instanceId: codexInstanceId, model: "sonnet" }, + profile: null, + fallbackExecutionProfile: remoteProfile, + }), + ).toMatchObject({ + branchLabel: "Current checkout", + workspaceLabel: "Current checkout", + modelLabel: "sonnet", + executionLabel: "Approval required · Plan · origin", + startFromOrigin: true, + }); + }); + + it("does not show a stale project default when no target provider is selectable", () => { + expect( + resolveMachineProfileSummary({ + workspaceRoot: "C:/repo", + defaultModelSelection: { instanceId: codexInstanceId, model: "stale-default" }, + profile: null, + modelSelectionOverride: null, + }), + ).toMatchObject({ + providerLabel: "No provider available", + modelLabel: "No model available", + }); + }); +}); diff --git a/apps/web/src/machineDraftProfile.ts b/apps/web/src/machineDraftProfile.ts new file mode 100644 index 000000000000..9bab5219b102 --- /dev/null +++ b/apps/web/src/machineDraftProfile.ts @@ -0,0 +1,106 @@ +import type { + EnvironmentId, + ModelSelection, + ProjectId, + ProviderInstanceId, + ProviderInteractionMode, + RuntimeMode, + ThreadEnvMode, +} from "@t3tools/contracts"; + +export interface MachineDraftProfile { + environmentId: EnvironmentId; + projectId: ProjectId; + branch: string | null; + worktreePath: string | null; + envMode: ThreadEnvMode; + startFromOrigin: boolean; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + modelSelectionByProvider: Partial>; + activeProvider: ProviderInstanceId | null; +} + +export type MachineDraftProfileMap = Record; + +export interface MachineProfileSummary { + branchLabel: string; + workspaceLabel: string; + providerLabel: string; + modelLabel: string; + executionLabel: string; + startFromOrigin: boolean; +} + +const RUNTIME_MODE_LABELS: Record = { + "approval-required": "Approval required", + auto: "Auto", + "auto-accept-edits": "Auto accept edits", + "full-access": "Full access", +}; + +const INTERACTION_MODE_LABELS: Record = { + default: "Build", + plan: "Plan", +}; + +export function physicalProjectProfileKey( + environmentId: EnvironmentId, + projectId: ProjectId, +): string { + return JSON.stringify([environmentId, projectId]); +} + +function resolveProfileModel(profile: MachineDraftProfile): ModelSelection | null { + if (profile.activeProvider) { + const activeSelection = profile.modelSelectionByProvider[profile.activeProvider]; + if (activeSelection) return activeSelection; + } + return Object.values(profile.modelSelectionByProvider)[0] ?? null; +} + +export function resolveMachineProfileSummary(input: { + workspaceRoot: string; + defaultModelSelection: ModelSelection | null | undefined; + profile: MachineDraftProfile | null | undefined; + modelSelectionOverride?: ModelSelection | null; + fallbackExecutionProfile?: Pick< + MachineDraftProfile, + "runtimeMode" | "interactionMode" | "startFromOrigin" + > | null; +}): MachineProfileSummary { + const profile = input.profile ?? null; + const executionProfile = profile ?? input.fallbackExecutionProfile ?? null; + const hasModelSelectionOverride = input.modelSelectionOverride !== undefined; + const modelSelection = hasModelSelectionOverride + ? input.modelSelectionOverride + : profile + ? resolveProfileModel(profile) + : null; + const defaultModelSelection = hasModelSelectionOverride ? null : input.defaultModelSelection; + const branchLabel = profile?.branch ?? "Current checkout"; + const workspaceLabel = profile + ? (profile.worktreePath ?? + (profile.envMode === "worktree" ? "New worktree" : "Current checkout")) + : "Current checkout"; + const modelLabel = + modelSelection?.model ?? + defaultModelSelection?.model ?? + (hasModelSelectionOverride ? "No model available" : "Project default"); + const providerLabel = + modelSelection?.instanceId ?? + defaultModelSelection?.instanceId ?? + (hasModelSelectionOverride ? "No provider available" : "Project default"); + const executionLabel = executionProfile + ? `${RUNTIME_MODE_LABELS[executionProfile.runtimeMode]} \u00b7 ${INTERACTION_MODE_LABELS[executionProfile.interactionMode]}${executionProfile.startFromOrigin ? " \u00b7 origin" : ""}` + : "Project defaults"; + + return { + branchLabel, + workspaceLabel, + providerLabel, + modelLabel, + executionLabel, + startFromOrigin: executionProfile?.startFromOrigin ?? false, + }; +} diff --git a/apps/web/src/projectGroupingPrompt.logic.ts b/apps/web/src/projectGroupingPrompt.logic.ts new file mode 100644 index 000000000000..86f4da97b93e --- /dev/null +++ b/apps/web/src/projectGroupingPrompt.logic.ts @@ -0,0 +1,51 @@ +import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; + +import { deriveProjectGroupingOverrideKey } from "./logicalProject"; +import type { SidebarProjectSnapshot } from "./sidebarProjectGrouping"; + +export function hasMultipleProjectEnvironments( + group: Pick, +): boolean { + return new Set(group.memberProjects.map((member) => member.environmentId)).size > 1; +} + +export function resolveProjectGroupingEnvironmentLabels( + group: Pick, +): string[] { + return Array.from( + new Set( + group.memberProjects.map((member) => member.environmentLabel ?? String(member.environmentId)), + ), + ); +} + +export function findProjectGroupingPromptCandidate( + groups: ReadonlyArray, + acknowledgedKeys: ReadonlySet, +): SidebarProjectSnapshot | null { + return ( + groups.find( + (group) => hasMultipleProjectEnvironments(group) && !acknowledgedKeys.has(group.projectKey), + ) ?? null + ); +} + +export function separateProjectGroup( + group: Pick, + existingOverrides: Readonly>, +): Record { + const nextOverrides = { ...existingOverrides }; + for (const member of group.memberProjects) { + nextOverrides[deriveProjectGroupingOverrideKey(member)] = "separate"; + } + return nextOverrides; +} + +export function acknowledgeProjectGroupingPrompt( + acknowledgedKeys: ReadonlyArray, + projectKey: string, +): string[] { + return acknowledgedKeys.includes(projectKey) + ? [...acknowledgedKeys] + : [...acknowledgedKeys, projectKey]; +} diff --git a/docs/superpowers/plans/2026-08-18-machine-project-profiles.md b/docs/superpowers/plans/2026-08-18-machine-project-profiles.md new file mode 100644 index 000000000000..8de5c00085d6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-machine-project-profiles.md @@ -0,0 +1,642 @@ +# Machine-aware grouped project profiles Implementation Plan + +> **For agentic workers:** Use this plan task-by-task with the test-first +> checkpoints below. Keep the current working-tree changes intact and do not +> commit or publish them unless Lucas explicitly asks. + +**Goal:** Turn the grouped-project environment picker into a complete draft-only machine profile switcher that previews each machine's path, checkout/worktree, model, and execution settings and restores those choices per machine. + +**Architecture:** Keep the existing logical-project grouping and environment +picker. Add a serializable profile map to each draft session, keyed by the + +> physical `environmentId:projectId`; the draft store atomically snapshots the +> current profile and restores the selected target profile. The UI receives +> already-derived profile summaries from `ChatView`, while environment/project +> selection continues to be the only cross-machine operation. + +**Tech Stack:** React/TypeScript, Effect Schema persistence in +`apps/web/src/composerDraftStore.ts`, Base UI Select, Vitest through `vp`, and +the existing T3 Code isolated browser workflow. + +**Spec:** `docs/superpowers/specs/2026-08-18-machine-project-profiles-design.md` + +## Global Constraints + +- Environment selection remains draft-only; started threads stay pinned to + their existing environment and project. +- Profiles are keyed by `environmentId:projectId`, never by labels or paths. +- Switching never copies a branch/worktree path to another physical project + without a profile that belongs to that exact target key. +- Legacy draft payloads decode with an empty profile map. +- No server/WebSocket contract changes are needed. +- No live `C:\Users\lucas\.t3\userdata` access; browser verification uses the + existing disposable test bases. +- Use focused tests/typecheck/lint only; do not run repo-wide checks. +- Write each production behavior only after its focused test has failed for the + expected reason. + +--- + +### Task 1: Add the pure machine-profile model and summary helpers + +**Files:** + +- Create: `apps/web/src/machineDraftProfile.ts` +- Test: `apps/web/src/machineDraftProfile.test.ts` + +**Interfaces:** + +- Consumes: `EnvironmentId`, `ProjectId`, `ModelSelection`, `ProviderInstanceId`, + `RuntimeMode`, and `ProviderInteractionMode` from `@t3tools/contracts`. +- Produces: `MachineDraftProfile`, `MachineDraftProfileMap`, + `physicalProjectProfileKey`, and `resolveMachineProfileSummary` for the + draft store and toolbar. + +- [ ] **Step 1: Write the failing tests** + +Add tests that define the pure behavior before implementation: + +```ts +import { describe, expect, it } from "vitest"; + +import { + physicalProjectProfileKey, + resolveMachineProfileSummary, + type MachineDraftProfile, +} from "./machineDraftProfile"; + +const profile: MachineDraftProfile = { + environmentId: "env-remote" as never, + projectId: "project-remote" as never, + branch: "feature/remote", + worktreePath: "C:/repo/.t3/worktrees/remote", + envMode: "worktree", + startFromOrigin: true, + runtimeMode: "approval-required", + interactionMode: "plan", + modelSelectionByProvider: { + codex: { instanceId: "codex" as never, model: "gpt-5.4" }, + }, + activeProvider: "codex" as never, +}; + +describe("machine draft profiles", () => { + it("keys profiles by physical environment and project", () => { + expect(physicalProjectProfileKey("env-remote" as never, "project-remote" as never)).toBe( + "env-remote:project-remote", + ); + }); + + it("summarizes a saved profile without leaking another machine's path", () => { + expect( + resolveMachineProfileSummary({ + workspaceRoot: "C:/repo", + defaultModelSelection: null, + profile, + }), + ).toMatchObject({ + branchLabel: "feature/remote", + workspaceLabel: "C:/repo/.t3/worktrees/remote", + modelLabel: "gpt-5.4", + executionLabel: "approval-required · plan · origin", + }); + }); + + it("falls back to physical project defaults on a first visit", () => { + expect( + resolveMachineProfileSummary({ + workspaceRoot: "C:/repo", + defaultModelSelection: { instanceId: "claudeAgent" as never, model: "sonnet" }, + profile: null, + }), + ).toMatchObject({ + branchLabel: "Current checkout", + workspaceLabel: "Current checkout", + modelLabel: "sonnet", + executionLabel: "Project defaults", + }); + }); +}); +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```powershell +$env:VP="$env:USERPROFILE\.vite-plus\bin\vp.exe" +& $env:VP test run apps/web/src/machineDraftProfile.test.ts +``` + +Expected: FAIL because `apps/web/src/machineDraftProfile.ts` does not yet +export the profile key and summary helpers. + +- [ ] **Step 3: Implement the minimal pure model** + +Create the model with branded contract types and a map type: + +```ts +export interface MachineDraftProfile { + environmentId: EnvironmentId; + projectId: ProjectId; + branch: string | null; + worktreePath: string | null; + envMode: DraftThreadEnvMode; + startFromOrigin: boolean; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + modelSelectionByProvider: Partial>; + activeProvider: ProviderInstanceId | null; +} + +export type MachineDraftProfileMap = Record; + +export function physicalProjectProfileKey( + environmentId: EnvironmentId, + projectId: ProjectId, +): string { + return `${environmentId}:${projectId}`; +} +``` + +Implement `resolveMachineProfileSummary` so a saved profile supplies branch, +worktree, model, and execution labels; a first visit uses `Current checkout`, +the physical project's default model, and `Project defaults`. Keep labels plain +and short so the toolbar can render them in a compact row. + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run the same command. Expected: all tests pass with no warnings. + +- [ ] **Step 5: Refactor only after green** + +Use one shared physical-key helper and keep profile summary formatting outside +React components so the UI tests can assert real values without mounting the +whole chat view. + +--- + +### Task 2: Persist profiles and atomically switch draft contexts + +**Files:** + +- Modify: `apps/web/src/composerDraftStore.ts` +- Test: `apps/web/src/composerDraftStore.test.ts` + +**Interfaces:** + +- Consumes: `MachineDraftProfile`, `MachineDraftProfileMap`, and + `physicalProjectProfileKey` from Task 1. +- Produces: `machineProfilesByProjectKey` on `DraftSessionState`, a persisted + optional schema field, and `switchDraftProject(draftId, projectRef)` on the + store. + +- [ ] **Step 1: Write failing store tests** + +Add tests beside the existing `setDraftThreadContext` coverage: + +```ts +it("restores a draft's branch, worktree, model, and modes per machine", () => { + const store = useComposerDraftStore.getState(); + const draftId = DraftId.make("draft-machine-profile"); + const laptopRef = scopeProjectRef(TEST_ENVIRONMENT_ID, ProjectId.make("laptop-project")); + const miniPcRef = scopeProjectRef(OTHER_TEST_ENVIRONMENT_ID, ProjectId.make("minipc-project")); + + store.setLogicalProjectDraftThreadId("github.com/pingdotgg/t3code", laptopRef, draftId, { + branch: "feature/laptop", + worktreePath: "/repo/.t3/worktrees/laptop", + envMode: "worktree", + runtimeMode: "full-access", + interactionMode: "default", + }); + store.setModelSelection(draftId, { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }); + + store.switchDraftProject(draftId, miniPcRef); + expect(store.getDraftSession(draftId)).toMatchObject({ + environmentId: OTHER_TEST_ENVIRONMENT_ID, + projectId: "minipc-project", + branch: null, + worktreePath: null, + }); + expect(store.getComposerDraft(draftId)?.modelSelectionByProvider).toEqual({}); + + store.setDraftThreadContext(draftId, { + branch: "feature/minipc", + worktreePath: "/repo/.t3/worktrees/minipc", + envMode: "worktree", + }); + store.setModelSelection(draftId, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "sonnet", + }); + store.switchDraftProject(draftId, laptopRef); + + expect(store.getDraftSession(draftId)).toMatchObject({ + environmentId: TEST_ENVIRONMENT_ID, + projectId: "laptop-project", + branch: "feature/laptop", + worktreePath: "/repo/.t3/worktrees/laptop", + envMode: "worktree", + }); + expect(store.getComposerDraft(draftId)?.modelSelectionByProvider).toMatchObject({ + codex: { model: "gpt-5.4" }, + }); +}); + +it("ignores malformed legacy profile entries and keeps drafts readable", () => { + const decoded = decodePersistedComposerDraftStore({ + version: 4, + state: { ...legacyState, draftThreadsByThreadKey: { ...legacyState.draftThreadsByThreadKey } }, + }); + expect(decoded.draftThreadsByThreadKey["draft-1"]?.machineProfilesByProjectKey).toEqual({}); +}); +``` + +Use the existing test fixtures and reset helpers rather than creating a second +store implementation in the test. + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```powershell +& $env:VP test run apps/web/src/composerDraftStore.test.ts +``` + +Expected: TypeScript/test failures for the missing profile field and +`switchDraftProject` method. + +- [ ] **Step 3: Extend persistence with backward-compatible defaults** + +Add an optional `machineProfilesByProjectKey` record to +`PersistedDraftThreadState`. Define the nested schema with nullable strings, +`DraftThreadEnvModeSchema`, `RuntimeMode`, `ProviderInteractionMode`, and the +existing model-selection record. Normalize entries during decode: require a +non-empty `environmentId`, `projectId`, `envMode`, and valid runtime/interaction +values; discard invalid entries. Use `{}` when the field is absent. + +Add `machineProfilesByProjectKey: MachineDraftProfileMap` to `DraftSessionState` +and all draft constructors/normalizers. Existing payload version handling must +continue to decode old drafts without the field. + +- [ ] **Step 4: Implement `switchDraftProject` atomically** + +Inside the store's single `set` callback: + +1. Resolve the draft by `DraftId`. +2. Build the current physical key from the draft's environment/project. +3. Snapshot current context plus the current composer draft's model map and + active provider into `machineProfilesByProjectKey[currentKey]`. +4. Look up the target key. If present, restore its branch/worktree/env mode, + start-from-origin, runtime/interaction values, and model map/active provider. +5. If absent, set target environment/project, clear branch/worktree and model + overrides, preserve runtime/interaction/start-from-origin, and use the + current env mode only when it is still meaningful. +6. Update the `logicalProjectDraftThreadKeyByLogicalProjectKey` mapping only + through the existing draft identity path; do not create a second draft. + +The method must no-op for an empty ref or a missing draft. Keep +`setDraftThreadContext` for ordinary same-machine edits, but make it update the +active profile snapshot so a later switch captures the latest branch/worktree +and mode choices. + +- [ ] **Step 5: Ensure model/mode setters update the active profile source** + +Do not duplicate every profile field in a second mutable store. The switch +method reads the current `DraftSessionState` and `ComposerThreadDraftState` +when it snapshots. Existing setters therefore remain the source of truth; add +only the helper needed to write the restored model map/active provider in the +same atomic state update. + +- [ ] **Step 6: Run the focused tests and verify GREEN** + +Run: + +```powershell +& $env:VP test run apps/web/src/composerDraftStore.test.ts +``` + +Expected: all existing composer-draft tests plus the new profile tests pass. + +- [ ] **Step 7: Refactor after green** + +Extract snapshot/restore functions if the store callback becomes difficult to +read. Keep them pure and covered by the profile tests; do not broaden the +store's public API beyond the one switch method and the persisted field. + +--- + +### Task 3: Build profile summary data in `ChatView` + +**Files:** + +- Modify: `apps/web/src/components/ChatView.tsx` +- Modify: `apps/web/src/components/BranchToolbar.logic.ts` +- Test: `apps/web/src/components/BranchToolbar.logic.test.ts` + +**Interfaces:** + +- Consumes: `logicalProjectEnvironments`, active `DraftSessionState`, the + selected environment's provider statuses/settings, and physical project + `workspaceRoot`/`defaultModelSelection`. +- Produces: enriched `EnvironmentOption` entries containing `workspaceRoot`, + connection state, and a `MachineProfileSummary` suitable for rendering. + +- [ ] **Step 1: Write failing summary tests** + +Add pure tests for primary/remote labels, a saved profile, a first-visit +physical project default, and an unavailable environment: + +```ts +it("builds a profile row from the physical project and draft profile", () => { + const result = resolveMachineProfileSummary({ + workspaceRoot: "/repo", + defaultModelSelection: { instanceId: "codex" as never, model: "gpt-5.4" }, + profile: { + branch: "feature/a", + worktreePath: "/repo/.t3/worktrees/a", + envMode: "worktree", + runtimeMode: "full-access", + interactionMode: "default", + startFromOrigin: false, + activeProvider: "codex" as never, + modelSelectionByProvider: { + codex: { instanceId: "codex" as never, model: "o3" }, + }, + }, + }); + + expect(result).toEqual({ + path: "/repo", + checkout: "feature/a", + workspace: "/repo/.t3/worktrees/a", + model: "o3", + execution: "Full access · Build", + startFromOrigin: false, + }); +}); +``` + +- [ ] **Step 2: Run the focused logic test and verify RED** + +Run: + +```powershell +& $env:VP test run apps/web/src/components/BranchToolbar.logic.test.ts +``` + +Expected: FAIL because the summary builder and enriched option type do not yet +exist. + +- [ ] **Step 3: Implement summary derivation** + +Add a pure `MachineProfileSummary` type and `resolveMachineProfileSummary` to +`BranchToolbar.logic.ts`. Use the active provider's selected model first, then +the physical project's default model, then `Project default`. Keep path values +raw for the `title`/accessible description and expose short labels for the +row. Use `Current checkout` when no branch/worktree profile exists. + +In `ChatView`, derive profile summaries for each member project. For the active +target, use the live draft context. For previously visited targets, use the +persisted profile. For an unvisited target, use its physical project default +model and default execution labels. Pass a connection status string without +including credentials or server URLs. + +- [ ] **Step 4: Run the focused logic test and verify GREEN** + +Run the same command and expect all tests to pass. + +- [ ] **Step 5: Refactor after green** + +Keep `ChatView` responsible for joining environment/project entities with draft +state, and keep formatting/label decisions in the pure logic file. + +--- + +### Task 4: Render the detailed machine popup + +**Files:** + +- Modify: `apps/web/src/components/BranchToolbarEnvironmentSelector.tsx` +- Modify: `apps/web/src/components/BranchToolbar.tsx` +- Create: `apps/web/src/components/MachineProfileRow.tsx` +- Test: `apps/web/src/components/MachineProfileRow.test.tsx` + +**Interfaces:** + +- Consumes: enriched `EnvironmentOption` and `MachineProfileSummary` from Task + 3, plus `envLocked` and `onEnvironmentChange`. +- Produces: accessible profile rows inside the existing Base UI `SelectPopup`. + +- [ ] **Step 1: Write failing component/row tests** + +Test the real summary component with a small render harness used by existing +web component tests: + +```tsx +import { renderToStaticMarkup } from "react-dom/server"; + +it("renders path, checkout, worktree, model, and execution details", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Mini PC"); + expect(markup).toContain("C:/Users/lucas/Projects/t3code"); + expect(markup).toContain("feature/remote"); + expect(markup).toContain("gpt-5.4"); + expect(markup).toContain("Full access"); +}); +``` + +Add an accessibility assertion that the path is available through `title` or +an equivalent accessible description when CSS truncates it. + +- [ ] **Step 2: Run the focused component test and verify RED** + +Run the test file through `vp test run`. Expected: FAIL because the row does +not exist. + +- [ ] **Step 3: Implement the compact row** + +Create `MachineProfileRow` with no card-heavy chrome: icon/label on the first +line and two compact metadata lines below. Use `title` on the path/worktree +spans, `data-slot` hooks only where needed, and visually muted labels. Show +`Unavailable` and disable the row when connection state is not connected. + +Update `BranchToolbarEnvironmentSelector` to render the row as the +`SelectItem` content. Keep the existing `aria-label="Run on"`, primary/remote +icons, selected value, and `SelectPopup` collision behavior. Do not put branch +editing or model controls inside the row. + +Update the mobile `MobileRunContextSelector` path to use the same row content +when the popup is open, while keeping its explicit `side="top"` menu behavior. + +- [ ] **Step 4: Run the focused component test and verify GREEN** + +Run the same test file and expect all row assertions to pass. + +- [ ] **Step 5: Refactor after green** + +Keep the row presentational. It must not read from stores or issue environment +queries; `ChatView` remains the data owner. + +--- + +### Task 5: Wire atomic switching and machine-specific restoration + +**Files:** + +- Modify: `apps/web/src/components/ChatView.tsx` +- Modify: `apps/web/src/components/BranchToolbar.tsx` +- Modify: `apps/web/src/components/BranchToolbarEnvironmentSelector.tsx` +- Test: `apps/web/src/components/ChatView.logic.test.ts` + +**Interfaces:** + +- Consumes: `switchDraftProject` from Task 2 and enriched environment options + from Task 3. +- Produces: a draft-only `onEnvironmentChange` callback that restores the + selected machine profile and keeps `envLocked` as the single lock guard. + +- [ ] **Step 1: Write failing callback tests** + +Add tests for the pure callback decision helper (or extract one if the current +inline callback is not testable): + +```ts +it("does not switch a started thread", () => { + expect( + resolveEnvironmentSwitch({ + envLocked: true, + draftId: "draft-1" as never, + nextEnvironmentId: "env-2" as never, + environments: [{ environmentId: "env-2" as never, projectId: "project-2" as never }], + }), + ).toBeNull(); +}); + +it("resolves a target physical project for an unlocked draft", () => { + expect( + resolveEnvironmentSwitch({ + envLocked: false, + draftId: "draft-1" as never, + nextEnvironmentId: "env-2" as never, + environments: [{ environmentId: "env-2" as never, projectId: "project-2" as never }], + }), + ).toEqual({ environmentId: "env-2", projectId: "project-2" }); +}); +``` + +- [ ] **Step 2: Run the focused logic test and verify RED** + +Run: + +```powershell +& $env:VP test run apps/web/src/components/ChatView.logic.test.ts +``` + +Expected: FAIL because the helper does not exist. + +- [ ] **Step 3: Implement and wire the callback** + +Move the target lookup into a pure helper. In `ChatView`, call +`switchDraftProject(draftId, scopeProjectRef(target.environmentId, target.projectId))` +only when the helper returns a target. Keep the existing early return for +`envLocked` and missing `draftId`. + +Pass the active draft's profile map to the summary derivation and ensure the +toolbar receives the currently selected environment id after the store update. +The current `activeProject`, `settings`, provider status, git status, model +options, terminal context, and file operations should all rebind through the +existing `environmentId`/`activeProjectRef` paths. + +- [ ] **Step 4: Run the focused logic and store tests and verify GREEN** + +```powershell +& $env:VP test run apps/web/src/components/ChatView.logic.test.ts apps/web/src/composerDraftStore.test.ts +``` + +Expected: all tests pass, including existing environment-switch behavior. + +- [ ] **Step 5: Refactor after green** + +Keep one switch callback and one source of truth. Do not add a second client +settings preference for the active machine; the draft profile map is scoped to +the logical project draft. + +--- + +### Task 6: Focused verification and browser integration pass + +**Files:** + +- Modify only if failures require it: files from Tasks 1–5 +- Test artifacts: disposable bases under `C:\Users\lucas\Desktop\Projects\__t3code-browser-test-*` + +- [ ] **Step 1: Run the focused automated suite** + +Run: + +```powershell +& $env:VP test run ` + apps/web/src/machineDraftProfile.test.ts ` + apps/web/src/composerDraftStore.test.ts ` + apps/web/src/components/BranchToolbar.logic.test.ts ` + apps/web/src/components/MachineProfileRow.test.tsx ` + apps/web/src/components/ChatView.logic.test.ts ` + apps/web/src/environmentGrouping.test.ts +& $env:VP run --filter @t3tools/web typecheck +& $env:VP exec prettier --check apps/web/src/machineDraftProfile.ts apps/web/src/composerDraftStore.ts apps/web/src/components/BranchToolbar.logic.ts apps/web/src/components/BranchToolbarEnvironmentSelector.tsx apps/web/src/components/MachineProfileRow.tsx apps/web/src/components/BranchToolbar.tsx apps/web/src/components/ChatView.tsx +``` + +Run targeted lint on the changed files and `git diff --check`. Do not run the +repository-wide suite. + +- [ ] **Step 2: Prepare two isolated environments** + +Reuse the existing disposable browser-test bases if still healthy. Otherwise +stop only the exact processes started for this task, create fresh bases, add +the same repository to each with distinct titles, and start primary/secondary +servers with explicit `--home-dir` values. Never point either server at +`C:\Users\lucas\.t3\userdata`. + +- [ ] **Step 3: Verify the complete popup in the T3 preview browser** + +Use `preview_status` first, then `preview_open`/`preview_navigate` as needed. +Verify through DOM interaction and state inspection: + +1. Same repository across two environments shows one grouped project. +2. `Run on` opens an anchored popup with both machine rows. +3. Each row includes path, checkout/worktree labels, model, execution settings, + and primary/remote icon. +4. Select laptop, set a branch/worktree/model/mode, select Mini PC, set a + different branch/worktree/model/mode, then select laptop again and verify its + original values return. +5. Verify the draft store/local persistence contains separate profile keys and + the eventual start payload points at the selected environment/project. +6. Navigate to a started thread and verify `Run on` is static and cannot switch. + +- [ ] **Step 4: Leave the isolated browser/server state available** + +Keep the successful disposable servers and browser tab alive for Lucas to +inspect. Report their localhost ports without pairing secrets. Confirm the +working tree contains only the intended implementation, tests, and plan/spec +files; do not commit or push. diff --git a/docs/superpowers/specs/2026-08-18-machine-project-profiles-design.md b/docs/superpowers/specs/2026-08-18-machine-project-profiles-design.md new file mode 100644 index 000000000000..6aea82675b24 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-machine-project-profiles-design.md @@ -0,0 +1,158 @@ +# Machine-aware grouped project profiles + +## Status + +Approved in chat on 2026-08-18. This specification expands the existing +grouped-project environment picker into a complete draft-only machine profile +flow. + +## Problem + +T3 Code can now recognize matching repository projects across environments and +lets a new draft switch between them. The current switch only changes the +physical project reference. It does not expose the target machine's path or +execution context, and it drops branch/worktree context instead of remembering +one context per machine. A user who works on the same repository from a laptop +and a Mini PC needs one predictable project row while retaining each machine's +own checkout, worktree, model, and composer choices. + +## Goals + +1. Present each matching environment as an explicit machine profile in the + bottom-left `Run on` picker. +2. Show the target project's workspace path, checkout/branch, worktree state, + provider/model, and draft execution settings before switching. +3. Keep draft context isolated by physical environment/project so switching + from machine A to machine B and back restores each machine's choices. +4. Use the selected environment and physical project as the source of truth + when the new thread is created. +5. Keep environment selection available only for new, unstarted threads. +6. Preserve existing grouped-project behavior, separate-project overrides, + single-environment behavior, and remote/tunnel connection behavior. + +## Non-goals + +- Do not add server or WebSocket contracts. Profiles are draft-local client + state; the created server thread already stores its selected environment, + project, model, runtime mode, interaction mode, branch, and worktree. +- Do not expose credentials, environment variables, or the full Settings page + inside the picker. “Settings” here means the execution settings already + attached to a draft: provider/model, runtime mode, interaction mode, and + start-from-origin. +- Do not move an existing server thread between machines. Its environment and + workspace remain immutable once the thread has started. +- Do not change mobile navigation in this pass. Mobile already has a grouped + environment/workspace menu; the shared draft/profile logic must remain + compatible with it. + +## User experience + +### Machine picker + +The desktop composer context strip retains the existing `Run on` trigger. Its +anchored popup contains one compact row per environment in the logical project: + +- environment icon and label, with a primary/remote distinction; +- repository path (`workspaceRoot`), truncated visually with the full value in + the title/accessible description; +- checkout row: the saved branch when this draft has visited the machine, + otherwise `Current checkout`; +- workspace row: `Current checkout`, `New worktree`, or the saved worktree path; +- provider and model row, falling back to the physical project's default model; +- execution row with runtime mode, interaction mode, and start-from-origin; +- connection state for a remote environment. Unavailable environments remain + visible but are disabled and labeled unavailable. + +The popup is collision-aware and remains anchored to the bottom composer. The +mobile menu continues to open upward and may use the same profile row content +where its width permits. + +The existing adjacent workspace selector and branch selector remain usable. +Selecting a machine updates both controls to the selected profile. The machine +row is a summary, not a second independent branch/worktree editor. + +### Switching and locking + +When the current route is a draft and the thread has not started, selecting a +machine atomically: + +1. snapshots the current physical project's draft context and composer model + choices into its profile; +2. selects the target environment/project reference; +3. restores the target profile if one exists; +4. otherwise clears machine-local branch/worktree/model overrides so the target + project's checkout and defaults are used, while retaining the user's + intentional runtime/interaction mode where no target override exists. + +Once the active thread has messages or an active session, the selector becomes +static. The existing `envLocked` behavior remains the guard used by both the UI +and the callback. + +## Data model and persistence + +Add a `MachineDraftProfile` value to the persisted draft-session state. Profiles +are keyed by a stable physical project key (`environmentId:projectId`), not by a +machine label or path, so labels can change without losing state. + +```ts +type MachineDraftProfile = { + environmentId: EnvironmentId; + projectId: ProjectId; + branch: string | null; + worktreePath: string | null; + envMode: DraftThreadEnvMode; + startFromOrigin: boolean; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + modelSelectionByProvider: Partial>; + activeProvider: ProviderInstanceId | null; +}; +``` + +The active `DraftSessionState` remains the fast path for existing consumers. +The profile map is updated atomically by the draft-store context switch and by +normal branch/model/mode changes. Existing persisted drafts decode with an empty +profile map and continue to behave as before until the user switches machines. +When restoring a profile, model selections are filtered through the selected +environment's provider list; unavailable provider instances fall back to the +physical project's default model or the normal provider default. + +## Data flow + +1. `ChatView` derives the logical project's environment/project members as it + does today. +2. It passes each member plus its draft profile and project default model to + `BranchToolbar`. +3. `BranchToolbarEnvironmentSelector` renders the profile summary and calls a + single draft context-switch callback. +4. `ChatView` refuses the callback when `envLocked` is true and otherwise calls + the draft-store atomic switch method. +5. The active environment/project changes, so existing environment queries, + provider statuses, git status, model options, file operations, and terminal + controls rebind to that physical environment. +6. Starting the draft sends the selected environment/project and restored + execution context through the existing thread bootstrap path. + +## Error handling + +- Missing project/profile data uses explicit labels (`Current checkout`, + `Project default`, `Unavailable`) rather than fabricated values. +- A disconnected remote remains selectable only after it reconnects; clicking + it while unavailable leaves the current profile unchanged and surfaces the + existing connection status. +- Malformed/legacy profile entries are ignored during decode; the current draft + remains usable and the next persisted write stores only valid entries. +- Switching never copies a branch or worktree path from one environment into + another unless that target profile explicitly owns the path. + +## Testing strategy + +- Draft-store logic tests cover profile snapshot/restore, first visit fallback, + per-machine model isolation, branch/worktree restoration, legacy payload + decoding, and the started-thread lock. +- Branch-toolbar logic/component tests cover profile summaries, path truncation + labels, unavailable entries, primary/remote icons, and popup selection wiring. +- Existing grouped-project and branch-toolbar suites remain green. +- Run focused web tests, web typecheck, targeted lint/format, then one browser + pass with two isolated servers verifying the popup details, switch A → B → A, + start-thread payload context, and locked existing-thread behavior. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 5993fca5b352..66d95be5eb0d 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -122,6 +122,18 @@ npx t3 serve --tailscale-serve --tailscale-serve-port 8443 Once paired, add projects normally: open the Command Palette and choose **Add Project**, then pick the environment the project lives on. Every saved environment is offered, not only the local one. +### Run one repository across machines + +When the same repository is added on more than one connected environment, T3 Code can group those +projects into one sidebar project. The first time this is detected, choose **Group projects** in the +sidebar prompt, or enable **Project grouping** in Settings → General. + +New threads show a **Run on** control beside the workspace and branch controls. Open it to choose a +machine and review its path, checkout, workspace, model, provider, and execution settings. Each +machine keeps its own draft choices, so switching away and back restores that machine's context. +Machine selection is available only before a thread starts; started threads remain attached to the +environment that ran them. + ### Option 3: Desktop-Managed SSH Launch Use this when you want the desktop app to start or reuse T3 Code on another machine over SSH.