From 77c35b2b59da024d0649abc300926aabf9bd6bc4 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:45:54 -0700 Subject: [PATCH 1/2] fix(web): choose machines for projectless drafts --- apps/web/src/components/BranchToolbar.tsx | 4 +- .../BranchToolbarEnvironmentSelector.tsx | 2 +- apps/web/src/components/ChatView.tsx | 85 ++++++-- .../KeybindingsSettings.logic.test.ts | 1 + .../hooks/useScratchDraftEnvironment.test.tsx | 189 ++++++++++++++++++ .../src/hooks/useScratchDraftEnvironment.ts | 137 +++++++++++++ apps/web/src/keybindings.test.ts | 22 ++ docs/user/keybindings.md | 1 + docs/user/thread-sidebar.md | 4 +- packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 11 files changed, 427 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/hooks/useScratchDraftEnvironment.test.tsx create mode 100644 apps/web/src/hooks/useScratchDraftEnvironment.ts diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index eca4fab601d0..1576f8bbc525 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -84,7 +84,7 @@ interface BranchToolbarProps { envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; - availableEnvironments?: readonly EnvironmentOption[]; + availableEnvironments?: readonly Omit[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; composerControlsHostRef?: (element: HTMLDivElement | null) => void; contextStripVisible?: boolean; @@ -97,7 +97,7 @@ interface MobileRunContextSelectorProps { envLocked: boolean; envModeLocked: boolean; environmentId: EnvironmentId; - availableEnvironments: readonly EnvironmentOption[] | undefined; + availableEnvironments: readonly Omit[] | undefined; showEnvironmentPicker: boolean; showEnvironmentIndicator: boolean; onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 69062323ab3f..4c542f221c3f 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -21,7 +21,7 @@ interface BranchToolbarEnvironmentSelectorProps { onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; environmentId: EnvironmentId; - availableEnvironments: readonly EnvironmentOption[]; + availableEnvironments: readonly Omit[]; // Absent when there is only one environment to show: the indicator still // renders (as a static label) so remote projects are always identifiable. onEnvironmentChange?: (environmentId: EnvironmentId) => void; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a32c1473cda7..ba809dcc6b30 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -368,6 +368,7 @@ import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; +import { useScratchDraftEnvironment } from "../hooks/useScratchDraftEnvironment"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -2448,6 +2449,15 @@ export default function ChatView(props: ChatViewProps) { }, [navigate, setEnvironmentEnabled], ); + const canSwitchScratchEnvironment = useCallback( + () => !isServerThread && !sendInFlightRef.current, + [isServerThread, sendInFlightRef], + ); + const scratchDraftEnvironment = useScratchDraftEnvironment({ + draftId, + activeProject, + canSwitch: canSwitchScratchEnvironment, + }); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); @@ -2476,9 +2486,12 @@ export default function ChatView(props: ChatViewProps) { }); return envs; }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); - const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + const composerEnvironments = scratchDraftEnvironment.isScratchDraft + ? scratchDraftEnvironment.availableEnvironments + : logicalProjectEnvironments; + const hasMultipleEnvironments = composerEnvironments.length > 1; const activeEnvironmentOption = - logicalProjectEnvironments.find( + composerEnvironments.find( (environment) => environment.environmentId === activeThread?.environmentId, ) ?? null; const showComposerEnvironmentIndicator = shouldShowEnvironmentIndicator({ @@ -2635,6 +2648,7 @@ export default function ChatView(props: ChatViewProps) { clientSettingsHydrated && draftId && !envLocked && + !scratchDraftEnvironment.isScratchDraft && hasMultipleEnvironments && loadBalancingSettings.loadBalancingEnabled && draftThread?.environmentSelection !== "manual" && @@ -3890,7 +3904,11 @@ export default function ChatView(props: ChatViewProps) { // project in that environment while keeping the same logical project. const onEnvironmentChange = useCallback( (nextEnvironmentId: EnvironmentId) => { - if (envLocked || !draftId) return; + if (envLocked || !draftId || sendInFlightRef.current) return; + if (scratchDraftEnvironment.isScratchDraft) { + void scratchDraftEnvironment.selectEnvironment(nextEnvironmentId); + return; + } const target = logicalProjectEnvironments.find( (env) => env.environmentId === nextEnvironmentId, ); @@ -3901,7 +3919,14 @@ export default function ChatView(props: ChatViewProps) { loadBalancedEnvironmentId: null, }); }, - [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], + [ + draftId, + envLocked, + logicalProjectEnvironments, + setDraftThreadContext, + scratchDraftEnvironment, + sendInFlightRef, + ], ); const activeTerminalGroup = @@ -6886,6 +6911,23 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "composer.cycleHost") { + if (envLocked || !draftId || !hasMultipleEnvironments) return; + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + if (scratchDraftEnvironment.isScratchDraft) { + scratchDraftEnvironment.cycleEnvironment(); + } else { + const index = composerEnvironments.findIndex( + (env) => env.environmentId === environmentId, + ); + const next = composerEnvironments[(index + 1) % composerEnvironments.length]; + if (next) onEnvironmentChange(next.environmentId); + } + return; + } + if (command === "composer.branch") { event.preventDefault(); event.stopPropagation(); @@ -6968,6 +7010,13 @@ export default function ChatView(props: ChatViewProps) { toggleRightPanelMaximized, toggleTerminalVisibility, composerRef, + composerEnvironments, + draftId, + environmentId, + envLocked, + hasMultipleEnvironments, + onEnvironmentChange, + scratchDraftEnvironment, ]); // Paste-to-focus: the resting composer blurs on a click into the timeline, @@ -7359,7 +7408,8 @@ export default function ChatView(props: ChatViewProps) { !clientSettingsHydrated || threadDetailLoading || sendInFlightRef.current || - feedbackUploadsInFlightRef.current.has(routeThreadKey) + feedbackUploadsInFlightRef.current.has(routeThreadKey) || + scratchDraftEnvironment.requestRef.current !== null ) { notifyDirectAnnotationAttached(); return; @@ -10017,15 +10067,17 @@ export default function ChatView(props: ChatViewProps) { isSendBusy={isSendBusy} isRevertingCheckpoint={isRevertingCheckpoint} sendDisabledReason={ - isRevertingCheckpoint - ? "Rewinding conversation" - : feedbackUploading - ? "Sending feedback" - : threadDetailLoading - ? "Messages loading" - : worktreeSetupBlocksSend - ? "Preparing worktree" - : projectCloneSendBlockReason + scratchDraftEnvironment.pending + ? "Preparing machine" + : isRevertingCheckpoint + ? "Rewinding conversation" + : feedbackUploading + ? "Sending feedback" + : threadDetailLoading + ? "Messages loading" + : worktreeSetupBlocksSend + ? "Preparing worktree" + : projectCloneSendBlockReason } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} @@ -10158,11 +10210,12 @@ export default function ChatView(props: ChatViewProps) { draftId && !envLocked && hasMultipleEnvironments && - loadBalancingSettings.loadBalancingEnabled + loadBalancingSettings.loadBalancingEnabled && + !scratchDraftEnvironment.isScratchDraft ? onAutoEnvironment : undefined } - availableEnvironments={logicalProjectEnvironments} + availableEnvironments={composerEnvironments} composerControlsHostRef={setRestingComposerControlsHost} contextStripVisible={showComposerContextStrip} /> diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 15561e06be83..533b43f57ec1 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -21,6 +21,7 @@ describe("KeybindingsSettings.logic", () => { const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, ""); for (const command of [ "composer.host", + "composer.cycleHost", "composer.effort", "composer.mode", "composer.workspace", diff --git a/apps/web/src/hooks/useScratchDraftEnvironment.test.tsx b/apps/web/src/hooks/useScratchDraftEnvironment.test.tsx new file mode 100644 index 000000000000..2a6534099883 --- /dev/null +++ b/apps/web/src/hooks/useScratchDraftEnvironment.test.tsx @@ -0,0 +1,189 @@ +import { useLayoutEffect } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vite-plus/test"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { type DraftId } from "../composerDraftStore"; +import { useScratchDraftEnvironment } from "./useScratchDraftEnvironment"; + +const state = vi.hoisted(() => ({ + open: vi.fn(), + remap: vi.fn(), + context: vi.fn(), + getDraft: vi.fn(), +})); +vi.mock("./useScratchProject", () => ({ + useScratchProject: () => ({ openScratchProject: state.open }), +})); +vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); +vi.mock("../logicalProject", () => ({ + selectProjectGroupingSettings: vi.fn(), + deriveLogicalProjectKeyFromSettings: (project: EnvironmentProject) => + `${project.environmentId}:${project.id}`, +})); +vi.mock("../composerDraftStore", () => ({ + useComposerDraftStore: { + getState: () => ({ + getDraftSession: state.getDraft, + setLogicalProjectDraftThreadId: state.remap, + setDraftThreadContext: state.context, + }), + }, +})); +vi.mock("../state/environments", () => ({ + usePrimaryEnvironmentId: () => "a", + useEnvironments: () => ({ + environments: [ + { + environmentId: "a", + label: "A", + connection: { phase: "connected" }, + serverConfig: { environment: { platform: "linux" }, scratchWorkspaceRoot: "/scratch/a" }, + }, + { + environmentId: "b", + label: "B", + connection: { phase: "connected" }, + serverConfig: { environment: { platform: "linux" }, scratchWorkspaceRoot: "/scratch/b" }, + }, + { + environmentId: "c", + label: "C", + connection: { phase: "connected" }, + serverConfig: { environment: { platform: "linux" }, scratchWorkspaceRoot: "/scratch/c" }, + }, + { + environmentId: "offline", + label: "Offline", + connection: { phase: "disconnected" }, + serverConfig: { + environment: { platform: "linux" }, + scratchWorkspaceRoot: "/scratch/offline", + }, + }, + { + environmentId: "unsupported", + label: "Unsupported", + connection: { phase: "connected" }, + serverConfig: { environment: { platform: "linux" } }, + }, + ], + }), +})); +const project = (id: string) => + ({ + id: ProjectId.make(`project-${id}`), + environmentId: EnvironmentId.make(id), + workspaceRoot: `/scratch/${id}`, + }) as EnvironmentProject; +const draftId = "draft" as DraftId; +let result: ReturnType; +let renderer: ReactTestRenderer; +let canSwitch = true; +function Probe({ activeProject = project("a") }: { activeProject?: EnvironmentProject }) { + const selection = useScratchDraftEnvironment({ + draftId, + activeProject, + canSwitch: () => canSwitch, + }); + useLayoutEffect(() => { + result = selection; + }); + return null; +} +function deferred() { + let resolve!: (project: EnvironmentProject | null) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.clearAllMocks(); + canSwitch = true; + state.getDraft.mockReturnValue({ environmentId: "a", projectId: "project-a", promotedTo: null }); + act(() => { + renderer = create(); + }); +}); +afterEach(() => act(() => renderer.unmount())); + +describe("projectless machine selection", () => { + it("offers connected machines before they have scratch projects and keeps the initial target", () => { + expect(result.availableEnvironments.map((env) => env.environmentId)).toEqual(["a", "b", "c"]); + expect(state.open).not.toHaveBeenCalled(); + expect(state.remap).not.toHaveBeenCalled(); + }); + it("cycles from the current machine and wraps while resolving only the latest selection", async () => { + const b = deferred(); + const c = deferred(); + state.open.mockReturnValueOnce(b.promise).mockReturnValueOnce(c.promise); + act(() => result.cycleEnvironment()); + expect(result.pending).toBe(true); + act(() => result.cycleEnvironment()); + expect(state.open.mock.calls.map(([id]) => id)).toEqual(["b", "c"]); + await act(async () => { + b.resolve(project("b")); + await b.promise; + }); + expect(state.remap).not.toHaveBeenCalled(); + await act(async () => { + c.resolve(project("c")); + await c.promise; + }); + expect(state.remap).toHaveBeenCalledWith( + "c:project-c", + { environmentId: "c", projectId: "project-c" }, + draftId, + ); + expect(result.pending).toBe(false); + act(() => renderer.update()); + act(() => result.cycleEnvironment()); + expect(state.open).toHaveBeenLastCalledWith("a"); + }); + it("does not overwrite a project selected while the machine is being prepared", async () => { + const next = deferred(); + state.open.mockReturnValue(next.promise); + act(() => { + void result.selectEnvironment(EnvironmentId.make("b")); + }); + state.getDraft.mockReturnValue({ + environmentId: "a", + projectId: "chosen-project", + promotedTo: null, + }); + await act(async () => { + next.resolve(project("b")); + await next.promise; + }); + expect(state.remap).not.toHaveBeenCalled(); + expect(result.pending).toBe(false); + }); + it("keeps the draft on its machine after a failed resolution", async () => { + state.open.mockResolvedValue(null); + await act(async () => result.selectEnvironment(EnvironmentId.make("b"))); + expect(state.remap).not.toHaveBeenCalled(); + expect(result.pending).toBe(false); + }); + it("clears pending state when leaving and returning to the original project", async () => { + const next = deferred(); + state.open.mockReturnValue(next.promise); + act(() => { + void result.selectEnvironment(EnvironmentId.make("b")); + }); + act(() => renderer.update()); + act(() => renderer.update()); + expect(result.pending).toBe(false); + await act(async () => { + next.resolve(project("b")); + await next.promise; + }); + expect(state.remap).not.toHaveBeenCalled(); + }); + it("ignores switching once sending starts", async () => { + canSwitch = false; + await act(async () => result.selectEnvironment(EnvironmentId.make("b"))); + expect(state.open).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/useScratchDraftEnvironment.ts b/apps/web/src/hooks/useScratchDraftEnvironment.ts new file mode 100644 index 000000000000..8ce8c24e1b45 --- /dev/null +++ b/apps/web/src/hooks/useScratchDraftEnvironment.ts @@ -0,0 +1,137 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; +import { resolveEnvironmentMachineKind, type EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; + +import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; +import { + deriveLogicalProjectKeyFromSettings, + selectProjectGroupingSettings, +} from "../logicalProject"; +import { type EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { useClientSettings } from "./useSettings"; +import { useScratchProject } from "./useScratchProject"; + +export function useScratchDraftEnvironment({ + draftId, + activeProject, + canSwitch, +}: { + draftId: DraftId | null; + activeProject: EnvironmentProject | null; + canSwitch: () => boolean; +}) { + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const settings = useClientSettings(selectProjectGroupingSettings); + const { openScratchProject } = useScratchProject(); + const isScratchDraft = Boolean( + draftId && + activeProject && + isScratchProject( + activeProject, + environments.find((env) => env.environmentId === activeProject.environmentId)?.serverConfig + ?.scratchWorkspaceRoot, + ), + ); + const availableEnvironments = useMemo( + () => + environments + .filter( + (env) => env.connection.phase === "connected" && env.serverConfig?.scratchWorkspaceRoot, + ) + .map((env) => ({ + environmentId: env.environmentId, + label: env.label, + isPrimary: env.environmentId === primaryEnvironmentId, + machine: resolveEnvironmentMachineKind(env.serverConfig), + })) + .sort((a, b) => + a.isPrimary !== b.isPrimary ? (a.isPrimary ? -1 : 1) : a.label.localeCompare(b.label), + ), + [environments, primaryEnvironmentId], + ); + const sourceKey = `${draftId}:${activeProject?.environmentId}:${activeProject?.id}`; + const requestRef = useRef<{ environmentId: EnvironmentId; sourceKey: string } | null>(null); + const [pendingRequest, setPendingRequest] = useState(null); + const pending = pendingRequest?.sourceKey === sourceKey; + useLayoutEffect( + () => () => { + if (requestRef.current?.sourceKey === sourceKey) requestRef.current = null; + setPendingRequest((current) => (current?.sourceKey === sourceKey ? null : current)); + }, + [sourceKey], + ); + + const selectEnvironment = useCallback( + async (environmentId: EnvironmentId) => { + if (!isScratchDraft || !draftId || !activeProject || !canSwitch()) return; + if (!availableEnvironments.some((env) => env.environmentId === environmentId)) return; + const request = { environmentId, sourceKey }; + requestRef.current = request; + if (environmentId === activeProject.environmentId) { + requestRef.current = null; + setPendingRequest(null); + return; + } + setPendingRequest(request); + try { + const project = await openScratchProject(environmentId); + if (requestRef.current !== request || !project || !canSwitch()) return; + const store = useComposerDraftStore.getState(); + const draft = store.getDraftSession(draftId); + if ( + !draft || + draft.promotedTo || + draft.environmentId !== activeProject.environmentId || + draft.projectId !== activeProject.id + ) + return; + // Remap the logical-project index as well as the physical target. The composer + // session stays in place, preserving its prompt and model/mode selections. + store.setLogicalProjectDraftThreadId( + deriveLogicalProjectKeyFromSettings(project, settings), + scopeProjectRef(project.environmentId, project.id), + draftId, + ); + store.setDraftThreadContext(draftId, { + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + envMode: "local", + }); + } finally { + if (requestRef.current === request) { + requestRef.current = null; + } + setPendingRequest((current) => (current === request ? null : current)); + } + }, + [ + activeProject, + availableEnvironments, + canSwitch, + draftId, + isScratchDraft, + openScratchProject, + settings, + sourceKey, + ], + ); + + const cycleEnvironment = useCallback(() => { + const currentId = requestRef.current?.environmentId ?? activeProject?.environmentId; + const index = availableEnvironments.findIndex((env) => env.environmentId === currentId); + const next = availableEnvironments[(index + 1) % availableEnvironments.length]; + if (next && availableEnvironments.length > 1) void selectEnvironment(next.environmentId); + }, [activeProject?.environmentId, availableEnvironments, selectEnvironment]); + + return { + isScratchDraft, + availableEnvironments, + selectEnvironment, + cycleEnvironment, + pending, + requestRef, + }; +} diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index a5fc2374be56..4974ec79d047 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1307,6 +1307,28 @@ describe("composer and pull request shortcuts", () => { }); } + it("cycles machines on each platform and leaves terminal input alone", () => { + for (const platform of ["MacIntel", "Win32", "Linux"]) { + const input = event({ + key: "h", + altKey: true, + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + }); + assert.strictEqual( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { platform }), + "composer.cycleHost", + ); + assert.strictEqual( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { terminalFocus: true }, + }), + null, + ); + } + }); + const altEffortBindings = compileResolvedKeybindingsConfig([ { key: "mod+alt+e", command: "composer.effort", when: "!terminalFocus" }, ]); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 1844eb62c211..39dc59fa91ef 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -15,6 +15,7 @@ use `mod+Shift+Enter` for the opposite action. In a new thread, `mod+Enter` keep starting the thread in the background. Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. +In a new thread, `mod+alt+h` cycles through available machines. Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the current checkout, a new worktree, and the previous worktree when available. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 603578e8c5b7..4bbb55debc6f 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -19,7 +19,9 @@ A thread does not need a project. To start one without a project, click **or start without a project** under a new thread's heading, pick **No project** from the project menu in that heading or from **New thread in...** in the command palette, or press `mod+alt+n`. On mobile, pick **No project** from the project -list. To move a draft into a project, pick the project in the heading. +list. It starts on your current machine. Before sending, use the normal machine +dropdown or `mod+alt+h` to switch machines on web and desktop. To move a draft into +a project, pick the project in the heading. Each thread without a project works in its own folder under `~/.t3/scratch` (the `scratch` folder of your T3 data directory), named after its date, the first words diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 076c8a944933..7977365d331e 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -84,6 +84,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "themeEditor.toggle", "composer.stash", "composer.host", + "composer.cycleHost", "composer.effort", "composer.mode", "composer.workspace", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index a93409bb3cb4..8d370f316ae6 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -52,6 +52,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+alt+n", command: "chat.newWithoutProject", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, + { key: "mod+alt+h", command: "composer.cycleHost", when: "!terminalFocus" }, { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" }, { key: "mod+shift+a", command: "composer.mode", when: "!terminalFocus" }, { key: "mod+shift+x", command: "composer.workspace", when: "!terminalFocus" }, From 00356d372f4340241cd5ef0088e6d211096320d5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 3 Oct 2026 16:32:48 -0700 Subject: [PATCH 2/2] fix(web): share projectless machine switching through client-runtime Move "ensure the Scratch project, then wait for it to reach the client store" into a shared client-runtime command, openScratch, and the connected-and-offers-Scratch check into availableScratchWorkspaceRoot. Web's useScratchProject and mobile's start/switch flows now use both instead of their own copies. Stabilize useScratchDraftEnvironment's result so ChatView's keydown effect and onEnvironmentChange are no longer rebuilt on every render, and drop the request ref from its public surface. Move the machine-cycling shortcut (composer.cycleHost) out of this change into its own PR. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../threads/NewTaskContextPickerScreens.tsx | 33 ++-- .../features/threads/NewTaskRouteScreen.tsx | 48 +++--- .../threads/new-task-flow-provider.tsx | 8 +- apps/mobile/src/state/projects.ts | 4 +- apps/web/src/components/ChatView.tsx | 26 +-- .../KeybindingsSettings.logic.test.ts | 1 - .../hooks/useScratchDraftEnvironment.test.tsx | 151 ++++++++++-------- .../src/hooks/useScratchDraftEnvironment.ts | 101 +++++------- apps/web/src/hooks/useScratchProject.ts | 43 +++-- apps/web/src/keybindings.test.ts | 22 --- apps/web/src/state/projects.ts | 12 +- docs/user/keybindings.md | 1 - docs/user/thread-sidebar.md | 6 +- .../client-runtime/src/operations/projects.ts | 14 ++ .../src/state/projectCommands.test.ts | 77 +++++++++ .../src/state/projectCommands.ts | 60 ++++++- packages/contracts/src/keybindings.ts | 1 - packages/shared/src/keybindings.ts | 1 - 18 files changed, 341 insertions(+), 268 deletions(-) create mode 100644 packages/client-runtime/src/state/projectCommands.test.ts diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx index a5c0d71325a7..a1a96d9dbf3d 100644 --- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx +++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx @@ -2,8 +2,6 @@ import { MaterialListRow } from "../../components/MaterialListRow"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { VcsRef } from "@t3tools/client-runtime/state/vcs"; import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/unstable/reactivity"; import { LegendList } from "@legendapp/list/react-native"; import { isAtomCommandInterrupted, @@ -31,7 +29,7 @@ import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSym import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useServerConfigs, waitForProject } from "../../state/entities"; +import { useServerConfigs } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; import { useAtomCommand } from "../../state/use-atom-command"; import { vcsEnvironment } from "../../state/vcs"; @@ -208,7 +206,7 @@ export function NewTaskEnvironmentPickerRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const serverConfigs = useServerConfigs(); - const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { + const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false, }); const [movingToEnvironmentId, setMovingToEnvironmentId] = useState(null); @@ -218,23 +216,20 @@ export function NewTaskEnvironmentPickerRouteScreen() { async function moveScratchDraft(environmentId: EnvironmentId): Promise { setMovingToEnvironmentId(environmentId); try { - const result = await ensureScratch({ environmentId, input: {} }); - if (AsyncResult.isFailure(result)) { - const error = Cause.squash(result.cause); - Alert.alert( - "Could not switch machine", - error instanceof Error - ? error.message - : "The folder for threads without a project could not be created.", - ); - return; - } - const project = await waitForProject({ environmentId, projectId: result.value.projectId }); - if (project === null) { - Alert.alert("Could not switch machine", "It has not reached this device yet. Try again."); + const result = await openScratch({ environmentId, input: {} }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not switch machine", + error instanceof Error + ? error.message + : "The folder for threads without a project could not be created.", + ); + } return; } - flow.setProject(project); + flow.setProject(result.value); navigation.goBack(); } finally { setMovingToEnvironmentId(null); diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 1ba1409d7976..1a5323281c47 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -7,11 +7,13 @@ import { type StaticScreenProps, } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; -import { canCreateProjectInEnvironment } from "@t3tools/client-runtime/operations/projects"; +import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/unstable/reactivity"; import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -21,7 +23,7 @@ import { MaterialButton } from "../../components/MaterialButton"; import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { useProjects, useServerConfigs, waitForProject } from "../../state/entities"; +import { useProjects, useServerConfigs } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; import { useAtomCommand } from "../../state/use-atom-command"; import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; @@ -173,7 +175,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps - canCreateProjectInEnvironment(environment.connectionState) && - serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined, + availableScratchWorkspaceRoot( + environment.connectionState, + serverConfigs.get(environment.environmentId), + ) !== null, ); const scratchEnvironment = scratchEnvironments.find( @@ -230,26 +234,20 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps connectedEnvironments.filter( (environment) => - canCreateProjectInEnvironment(environment.connectionState) && - serverConfigs.get(environment.environmentId)?.scratchWorkspaceRoot !== undefined, + availableScratchWorkspaceRoot( + environment.connectionState, + serverConfigs.get(environment.environmentId), + ) !== null, ), [connectedEnvironments, serverConfigs], ); diff --git a/apps/mobile/src/state/projects.ts b/apps/mobile/src/state/projects.ts index 7a8799883281..aaad677ef9cd 100644 --- a/apps/mobile/src/state/projects.ts +++ b/apps/mobile/src/state/projects.ts @@ -5,8 +5,10 @@ import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; -export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); export const environmentProjects = createEnvironmentProjectAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: environmentSnapshotAtom, }); +export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, { + projectAtom: environmentProjects.projectAtom, +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1cecdfc694b2..b72c2cb4e2b9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7540,23 +7540,6 @@ export default function ChatView(props: ChatViewProps) { return; } - if (command === "composer.cycleHost") { - if (envLocked || !draftId || !hasMultipleEnvironments) return; - event.preventDefault(); - event.stopPropagation(); - if (event.repeat) return; - if (scratchDraftEnvironment.isScratchDraft) { - scratchDraftEnvironment.cycleEnvironment(); - } else { - const index = composerEnvironments.findIndex( - (env) => env.environmentId === environmentId, - ); - const next = composerEnvironments[(index + 1) % composerEnvironments.length]; - if (next) onEnvironmentChange(next.environmentId); - } - return; - } - if (command === "composer.branch") { event.preventDefault(); event.stopPropagation(); @@ -7646,13 +7629,6 @@ export default function ChatView(props: ChatViewProps) { toggleThreadPanel, toggleTerminalVisibility, composerRef, - composerEnvironments, - draftId, - environmentId, - envLocked, - hasMultipleEnvironments, - onEnvironmentChange, - scratchDraftEnvironment, ]); // Paste-to-focus: the resting composer blurs on a click into the timeline, @@ -8151,7 +8127,7 @@ export default function ChatView(props: ChatViewProps) { threadDetailLoading || sendInFlightRef.current || feedbackUploadsInFlightRef.current.has(routeThreadKey) || - scratchDraftEnvironment.requestRef.current !== null + scratchDraftEnvironment.pending ) { notifyDirectAnnotationAttached(); return; diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index a837c5f37269..cedcda74e6f9 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -25,7 +25,6 @@ describe("KeybindingsSettings.logic", () => { "thread.steerQueuedMessage", "thread.editQueuedMessage", "composer.host", - "composer.cycleHost", "composer.effort", "composer.mode", "composer.workspace", diff --git a/apps/web/src/hooks/useScratchDraftEnvironment.test.tsx b/apps/web/src/hooks/useScratchDraftEnvironment.test.tsx index 2a6534099883..02fd82bd17c2 100644 --- a/apps/web/src/hooks/useScratchDraftEnvironment.test.tsx +++ b/apps/web/src/hooks/useScratchDraftEnvironment.test.tsx @@ -15,7 +15,8 @@ const state = vi.hoisted(() => ({ vi.mock("./useScratchProject", () => ({ useScratchProject: () => ({ openScratchProject: state.open }), })); -vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); +const GROUPING_SETTINGS = {}; +vi.mock("./useSettings", () => ({ useClientSettings: () => GROUPING_SETTINGS })); vi.mock("../logicalProject", () => ({ selectProjectGroupingSettings: vi.fn(), deriveLogicalProjectKeyFromSettings: (project: EnvironmentProject) => @@ -30,45 +31,44 @@ vi.mock("../composerDraftStore", () => ({ }), }, })); +const ENVIRONMENTS = [ + { + environmentId: "a", + label: "A", + connection: { phase: "connected" }, + serverConfig: { environment: { platform: "linux" }, scratchWorkspaceRoot: "/scratch/a" }, + }, + { + environmentId: "b", + label: "B", + connection: { phase: "connected" }, + serverConfig: { environment: { platform: "linux" }, scratchWorkspaceRoot: "/scratch/b" }, + }, + { + environmentId: "c", + label: "C", + connection: { phase: "connected" }, + serverConfig: { environment: { platform: "linux" }, scratchWorkspaceRoot: "/scratch/c" }, + }, + { + environmentId: "offline", + label: "Offline", + connection: { phase: "disconnected" }, + serverConfig: { + environment: { platform: "linux" }, + scratchWorkspaceRoot: "/scratch/offline", + }, + }, + { + environmentId: "unsupported", + label: "Unsupported", + connection: { phase: "connected" }, + serverConfig: { environment: { platform: "linux" } }, + }, +]; vi.mock("../state/environments", () => ({ usePrimaryEnvironmentId: () => "a", - useEnvironments: () => ({ - environments: [ - { - environmentId: "a", - label: "A", - connection: { phase: "connected" }, - serverConfig: { environment: { platform: "linux" }, scratchWorkspaceRoot: "/scratch/a" }, - }, - { - environmentId: "b", - label: "B", - connection: { phase: "connected" }, - serverConfig: { environment: { platform: "linux" }, scratchWorkspaceRoot: "/scratch/b" }, - }, - { - environmentId: "c", - label: "C", - connection: { phase: "connected" }, - serverConfig: { environment: { platform: "linux" }, scratchWorkspaceRoot: "/scratch/c" }, - }, - { - environmentId: "offline", - label: "Offline", - connection: { phase: "disconnected" }, - serverConfig: { - environment: { platform: "linux" }, - scratchWorkspaceRoot: "/scratch/offline", - }, - }, - { - environmentId: "unsupported", - label: "Unsupported", - connection: { phase: "connected" }, - serverConfig: { environment: { platform: "linux" } }, - }, - ], - }), + useEnvironments: () => ({ environments: ENVIRONMENTS }), })); const project = (id: string) => ({ @@ -80,11 +80,13 @@ const draftId = "draft" as DraftId; let result: ReturnType; let renderer: ReactTestRenderer; let canSwitch = true; -function Probe({ activeProject = project("a") }: { activeProject?: EnvironmentProject }) { +const readCanSwitch = () => canSwitch; +const PROJECT_A = project("a"); +function Probe({ activeProject = PROJECT_A }: { activeProject?: EnvironmentProject }) { const selection = useScratchDraftEnvironment({ draftId, activeProject, - canSwitch: () => canSwitch, + canSwitch: readCanSwitch, }); useLayoutEffect(() => { result = selection; @@ -98,6 +100,13 @@ function deferred() { }); return { promise, resolve }; } +const select = (id: string) => { + let promise!: Promise; + act(() => { + promise = result.selectEnvironment(EnvironmentId.make(id)); + }); + return promise; +}; beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.clearAllMocks(); @@ -115,22 +124,28 @@ describe("projectless machine selection", () => { expect(state.open).not.toHaveBeenCalled(); expect(state.remap).not.toHaveBeenCalled(); }); - it("cycles from the current machine and wraps while resolving only the latest selection", async () => { + it("keeps the same result between renders while nothing changes", () => { + const first = result; + act(() => renderer.update()); + expect(result).toBe(first); + }); + it("retargets only for the latest selection", async () => { const b = deferred(); const c = deferred(); state.open.mockReturnValueOnce(b.promise).mockReturnValueOnce(c.promise); - act(() => result.cycleEnvironment()); + const toB = select("b"); expect(result.pending).toBe(true); - act(() => result.cycleEnvironment()); + const toC = select("c"); expect(state.open.mock.calls.map(([id]) => id)).toEqual(["b", "c"]); await act(async () => { b.resolve(project("b")); - await b.promise; + await toB; }); expect(state.remap).not.toHaveBeenCalled(); + expect(result.pending).toBe(true); await act(async () => { c.resolve(project("c")); - await c.promise; + await toC; }); expect(state.remap).toHaveBeenCalledWith( "c:project-c", @@ -138,52 +153,48 @@ describe("projectless machine selection", () => { draftId, ); expect(result.pending).toBe(false); - act(() => renderer.update()); - act(() => result.cycleEnvironment()); - expect(state.open).toHaveBeenLastCalledWith("a"); }); - it("does not overwrite a project selected while the machine is being prepared", async () => { + it("cancels a pending switch when the current machine is picked again", async () => { const next = deferred(); state.open.mockReturnValue(next.promise); - act(() => { - void result.selectEnvironment(EnvironmentId.make("b")); + const toB = select("b"); + await select("a"); + expect(result.pending).toBe(false); + await act(async () => { + next.resolve(project("b")); + await toB; }); + expect(state.remap).not.toHaveBeenCalled(); + }); + it.each([ + ["the draft moved to another project", { projectId: "chosen-project" }], + ["the draft was sent", { promotedTo: "thread-1" }], + ])("does not retarget when %s while the machine was prepared", async (_label, change) => { + const next = deferred(); + state.open.mockReturnValue(next.promise); + const toB = select("b"); state.getDraft.mockReturnValue({ environmentId: "a", - projectId: "chosen-project", + projectId: "project-a", promotedTo: null, + ...change, }); await act(async () => { next.resolve(project("b")); - await next.promise; + await toB; }); expect(state.remap).not.toHaveBeenCalled(); expect(result.pending).toBe(false); }); it("keeps the draft on its machine after a failed resolution", async () => { state.open.mockResolvedValue(null); - await act(async () => result.selectEnvironment(EnvironmentId.make("b"))); + await act(async () => select("b")); expect(state.remap).not.toHaveBeenCalled(); expect(result.pending).toBe(false); }); - it("clears pending state when leaving and returning to the original project", async () => { - const next = deferred(); - state.open.mockReturnValue(next.promise); - act(() => { - void result.selectEnvironment(EnvironmentId.make("b")); - }); - act(() => renderer.update()); - act(() => renderer.update()); - expect(result.pending).toBe(false); - await act(async () => { - next.resolve(project("b")); - await next.promise; - }); - expect(state.remap).not.toHaveBeenCalled(); - }); it("ignores switching once sending starts", async () => { canSwitch = false; - await act(async () => result.selectEnvironment(EnvironmentId.make("b"))); + await act(async () => select("b")); expect(state.open).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/hooks/useScratchDraftEnvironment.ts b/apps/web/src/hooks/useScratchDraftEnvironment.ts index 8ce8c24e1b45..308a66fc8052 100644 --- a/apps/web/src/hooks/useScratchDraftEnvironment.ts +++ b/apps/web/src/hooks/useScratchDraftEnvironment.ts @@ -1,18 +1,24 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; +import { type EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { resolveEnvironmentMachineKind, type EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { type EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useClientSettings } from "./useSettings"; import { useScratchProject } from "./useScratchProject"; +/** + * Moves a draft without a project to another machine's Scratch project, + * creating it there first if needed. The draft keeps its composer session, + * so the prompt and model/mode selections carry over. + */ export function useScratchDraftEnvironment({ draftId, activeProject, @@ -26,20 +32,18 @@ export function useScratchDraftEnvironment({ const primaryEnvironmentId = usePrimaryEnvironmentId(); const settings = useClientSettings(selectProjectGroupingSettings); const { openScratchProject } = useScratchProject(); - const isScratchDraft = Boolean( - draftId && - activeProject && - isScratchProject( - activeProject, - environments.find((env) => env.environmentId === activeProject.environmentId)?.serverConfig - ?.scratchWorkspaceRoot, - ), + const activeEnvironment = environments.find( + (env) => env.environmentId === activeProject?.environmentId, ); + const isScratchDraft = + draftId !== null && + activeProject !== null && + isScratchProject(activeProject, activeEnvironment?.serverConfig?.scratchWorkspaceRoot); const availableEnvironments = useMemo( () => environments .filter( - (env) => env.connection.phase === "connected" && env.serverConfig?.scratchWorkspaceRoot, + (env) => availableScratchWorkspaceRoot(env.connection.phase, env.serverConfig) !== null, ) .map((env) => ({ environmentId: env.environmentId, @@ -52,44 +56,36 @@ export function useScratchDraftEnvironment({ ), [environments, primaryEnvironmentId], ); - const sourceKey = `${draftId}:${activeProject?.environmentId}:${activeProject?.id}`; - const requestRef = useRef<{ environmentId: EnvironmentId; sourceKey: string } | null>(null); - const [pendingRequest, setPendingRequest] = useState(null); - const pending = pendingRequest?.sourceKey === sourceKey; - useLayoutEffect( - () => () => { - if (requestRef.current?.sourceKey === sourceKey) requestRef.current = null; - setPendingRequest((current) => (current?.sourceKey === sourceKey ? null : current)); - }, - [sourceKey], - ); + // The latest switch wins: a slower, earlier one must not retarget the draft. + const latestRequestRef = useRef(null); + const [pending, setPending] = useState(false); const selectEnvironment = useCallback( async (environmentId: EnvironmentId) => { if (!isScratchDraft || !draftId || !activeProject || !canSwitch()) return; - if (!availableEnvironments.some((env) => env.environmentId === environmentId)) return; - const request = { environmentId, sourceKey }; - requestRef.current = request; if (environmentId === activeProject.environmentId) { - requestRef.current = null; - setPendingRequest(null); + // Picking the current machine again cancels a switch still in flight. + latestRequestRef.current = null; + setPending(false); return; } - setPendingRequest(request); + const request = {}; + latestRequestRef.current = request; + setPending(true); try { - const project = await openScratchProject(environmentId); - if (requestRef.current !== request || !project || !canSwitch()) return; + const project = await openScratchProject(environmentId, "Could not switch machine"); + if (latestRequestRef.current !== request || !project || !canSwitch()) return; const store = useComposerDraftStore.getState(); const draft = store.getDraftSession(draftId); + // Leave the draft alone if it was sent or moved while this switch ran. if ( !draft || draft.promotedTo || draft.environmentId !== activeProject.environmentId || draft.projectId !== activeProject.id - ) + ) { return; - // Remap the logical-project index as well as the physical target. The composer - // session stays in place, preserving its prompt and model/mode selections. + } store.setLogicalProjectDraftThreadId( deriveLogicalProjectKeyFromSettings(project, settings), scopeProjectRef(project.environmentId, project.id), @@ -101,37 +97,20 @@ export function useScratchDraftEnvironment({ envMode: "local", }); } finally { - if (requestRef.current === request) { - requestRef.current = null; + if (latestRequestRef.current === request) { + latestRequestRef.current = null; + setPending(false); } - setPendingRequest((current) => (current === request ? null : current)); } }, - [ - activeProject, - availableEnvironments, - canSwitch, - draftId, - isScratchDraft, - openScratchProject, - settings, - sourceKey, - ], + [activeProject, canSwitch, draftId, isScratchDraft, openScratchProject, settings], ); - const cycleEnvironment = useCallback(() => { - const currentId = requestRef.current?.environmentId ?? activeProject?.environmentId; - const index = availableEnvironments.findIndex((env) => env.environmentId === currentId); - const next = availableEnvironments[(index + 1) % availableEnvironments.length]; - if (next && availableEnvironments.length > 1) void selectEnvironment(next.environmentId); - }, [activeProject?.environmentId, availableEnvironments, selectEnvironment]); - - return { - isScratchDraft, - availableEnvironments, - selectEnvironment, - cycleEnvironment, - pending, - requestRef, - }; + // Stable between renders so ChatView's callbacks and effects that depend on + // it are not rebuilt on every streamed update. + const visiblePending = isScratchDraft && pending; + return useMemo( + () => ({ isScratchDraft, availableEnvironments, selectEnvironment, pending: visiblePending }), + [isScratchDraft, availableEnvironments, selectEnvironment, visiblePending], + ); } diff --git a/apps/web/src/hooks/useScratchProject.ts b/apps/web/src/hooks/useScratchProject.ts index 0d25f3385855..52fdf39cc522 100644 --- a/apps/web/src/hooks/useScratchProject.ts +++ b/apps/web/src/hooks/useScratchProject.ts @@ -1,4 +1,5 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -8,17 +9,16 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback } from "react"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; -import { waitForProject } from "~/state/entities"; import { useEnvironments } from "~/state/environments"; import { projectEnvironment } from "~/state/projects"; import { useAtomCommand } from "~/state/use-atom-command"; import { useNewThreadHandler } from "./useHandleNewThread"; -function reportScratchFailure(error: unknown) { +function reportScratchFailure(title: string, error: unknown) { toastManager.add( stackedThreadToast({ type: "error", - title: "Could not start without a project", + title, description: error instanceof Error ? error.message : "An error occurred.", }), ); @@ -32,16 +32,17 @@ function reportScratchFailure(error: unknown) { */ export function useScratchProject() { const { environments } = useEnvironments(); - const ensureScratch = useAtomCommand(projectEnvironment.ensureScratch, { reportFailure: false }); + const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); const handleNewThread = useNewThreadHandler(); /** The scratch folder of a connected environment, or null when it offers none. */ const scratchWorkspaceRootFor = useCallback( (environmentId: EnvironmentId | null): string | null => { const environment = environments.find((entry) => entry.environmentId === environmentId); - return environment?.connection.phase === "connected" - ? (environment.serverConfig?.scratchWorkspaceRoot ?? null) - : null; + return availableScratchWorkspaceRoot( + environment?.connection.phase, + environment?.serverConfig, + ); }, [environments], ); @@ -62,24 +63,18 @@ export function useScratchProject() { /** Resolves to the scratch project once it is in this client's store. */ const openScratchProject = useCallback( - async (environmentId: EnvironmentId): Promise => { - const result = await ensureScratch({ environmentId, input: {} }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - reportScratchFailure(squashAtomCommandFailure(result)); - } - return null; + async ( + environmentId: EnvironmentId, + failureTitle = "Could not start without a project", + ): Promise => { + const result = await openScratch({ environmentId, input: {} }); + if (result._tag === "Success") return result.value; + if (!isAtomCommandInterrupted(result)) { + reportScratchFailure(failureTitle, squashAtomCommandFailure(result)); } - // Drafts key off the project's stored path and settings, so wait for - // the create event to reach the store before targeting one. - return waitForProject(scopeProjectRef(environmentId, result.value.projectId)).catch( - (error: unknown) => { - reportScratchFailure(error); - return null; - }, - ); + return null; }, - [ensureScratch], + [openScratch], ); const startScratchThread = useCallback( @@ -87,7 +82,7 @@ export function useScratchProject() { const project = await openScratchProject(environmentId); if (project) { await handleNewThread(scopeProjectRef(project.environmentId, project.id)).catch( - reportScratchFailure, + (error: unknown) => reportScratchFailure("Could not start without a project", error), ); } }, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 7c09c71f70a7..18604e44e19d 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1373,28 +1373,6 @@ describe("composer and pull request shortcuts", () => { }); } - it("cycles machines on each platform and leaves terminal input alone", () => { - for (const platform of ["MacIntel", "Win32", "Linux"]) { - const input = event({ - key: "h", - altKey: true, - metaKey: platform === "MacIntel", - ctrlKey: platform !== "MacIntel", - }); - assert.strictEqual( - resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { platform }), - "composer.cycleHost", - ); - assert.strictEqual( - resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { - platform, - context: { terminalFocus: true }, - }), - null, - ); - } - }); - const altEffortBindings = compileResolvedKeybindingsConfig([ { key: "mod+alt+e", command: "composer.effort", when: "!terminalFocus" }, ]); diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts index d4e1098a364e..d5a07058a80b 100644 --- a/apps/web/src/state/projects.ts +++ b/apps/web/src/state/projects.ts @@ -7,7 +7,13 @@ import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; -export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); +export const environmentProjects = createEnvironmentProjectAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); +export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime, { + projectAtom: environmentProjects.projectAtom, +}); /** * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile * surface, so the atom family lives here instead of the shared client-runtime @@ -19,7 +25,3 @@ export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connecti staleTimeMs: 5_000, idleTtlMs: 60_000, }); -export const environmentProjects = createEnvironmentProjectAtoms({ - catalogValueAtom: environmentCatalog.catalogValueAtom, - snapshotAtom: environmentSnapshotAtom, -}); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index a0996160e8ec..43bd23333f66 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -21,7 +21,6 @@ When an active turn has queued messages, `mod+Shift+Enter` sends the first as a steer. Change it under **Queue: Send First Queued Message as Steer** in Keybindings. Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. -In a new thread, `mod+alt+h` cycles through available machines. Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the current checkout, a new worktree, and the previous worktree when available. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 3dfce3df14e1..ee1d2b7b2d40 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -19,9 +19,9 @@ A thread does not need a project. To start one without a project, click **or start without a project** under a new thread's heading, pick **No project** from the project menu in that heading or from **New thread in...** in the command palette, or press `mod+alt+n`. On mobile, pick **No project** from the project -list. It starts on your current machine. Before sending, use the normal machine -dropdown or `mod+alt+h` to switch machines on web and desktop. To move a draft into -a project, pick the project in the heading. +list. It starts on your current machine; before sending, pick another machine +from the machine menu to move it there. To move a draft into a project, pick the +project in the heading. Each thread without a project works in its own folder under `~/.t3/scratch` (the `scratch` folder of your T3 data directory), named after its date, the first words diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 505f99ad8e25..8dd937d3a68d 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -4,6 +4,7 @@ import type { EnvironmentId, ProjectMutation, ProjectId, + ServerConfig, SourceControlDiscoveryResult, SourceControlProviderKind, SourceControlRepositoryInfo, @@ -36,6 +37,19 @@ export function canCreateProjectInEnvironment( return connectionPhase === "connected"; } +/** + * The Scratch folder an environment offers threads without a project right + * now, or null while it is not connected or has none. + */ +export function availableScratchWorkspaceRoot( + connectionPhase: EnvironmentConnectionPhase | null | undefined, + serverConfig: Pick | null | undefined, +): string | null { + return canCreateProjectInEnvironment(connectionPhase) + ? (serverConfig?.scratchWorkspaceRoot ?? null) + : null; +} + export type AddProjectRemoteSourceReadiness = Record< AddProjectRemoteSource, { readonly ready: boolean; readonly hint: string | null } diff --git a/packages/client-runtime/src/state/projectCommands.test.ts b/packages/client-runtime/src/state/projectCommands.test.ts new file mode 100644 index 000000000000..4e1bc85ca6df --- /dev/null +++ b/packages/client-runtime/src/state/projectCommands.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ProjectId, type ScopedProjectRef, WS_METHODS } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import type { EnvironmentProject } from "./models.ts"; +import { createProjectEnvironmentAtoms } from "./projectCommands.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const PROJECT_ID = ProjectId.make("scratch"); +const PROJECT = { + id: PROJECT_ID, + environmentId: ENVIRONMENT_ID, + workspaceRoot: "/scratch", +} as EnvironmentProject; + +const makeHarness = Effect.fn("TestProjectCommands.makeHarness")(function* () { + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: { environmentId: ENVIRONMENT_ID }, + session: yield* SubscriptionRef.make( + Option.some({ + client: { + [WS_METHODS.projectsEnsureScratch]: () => Effect.succeed({ projectId: PROJECT_ID }), + }, + } as unknown as RpcSession), + ), + } as EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, { + run: (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]), + Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + ), + ); + const storedProject = Atom.make(null); + const commands = createProjectEnvironmentAtoms(runtime, { + projectAtom: (ref: ScopedProjectRef) => + ref.environmentId === ENVIRONMENT_ID && ref.projectId === PROJECT_ID + ? storedProject + : Atom.make(null), + }); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + const openScratch = Effect.promise(() => + commands.openScratch.run(registry, { environmentId: ENVIRONMENT_ID, input: {} }), + ); + return { registry, storedProject, openScratch }; +}); + +describe("openScratch", () => { + it.effect("resolves once the created project reaches the client store", () => + Effect.gen(function* () { + const { registry, storedProject, openScratch } = yield* makeHarness(); + const opening = yield* Effect.forkChild(openScratch); + yield* Effect.yieldNow; + registry.set(storedProject, PROJECT); + const result = yield* Fiber.join(opening); + expect(result).toMatchObject({ _tag: "Success", value: PROJECT }); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 5c39113501fb..261fee9db09e 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -1,7 +1,20 @@ -import { type EnvironmentId, type ProjectReadFileResult, WS_METHODS } from "@t3tools/contracts"; +import { + type EnvironmentId, + ProjectId, + type ProjectReadFileResult, + type ScopedProjectRef, + WS_METHODS, +} from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; -import { Atom } from "effect/unstable/reactivity"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { type EnvironmentRpcInput, request } from "../rpc/client.ts"; +import type { EnvironmentProject } from "./models.ts"; import { createAtomCommandScheduler, createEnvironmentCommand, @@ -39,8 +52,22 @@ function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string { return JSON.stringify([target.environmentId, target.cwd, target.relativePath]); } +/** The Scratch project was created, but its event never reached this client. */ +export class ScratchProjectNotLoadedError extends Schema.TaggedError()( + "ScratchProjectNotLoadedError", + { projectId: ProjectId }, +) { + override get message(): string { + return "The folder for threads without a project has not reached this device yet. Try again."; + } +} + export function createProjectEnvironmentAtoms( runtime: Atom.AtomRuntime, + options: { + /** The client store's project; openScratch waits here for the created project. */ + readonly projectAtom: (ref: ScopedProjectRef) => Atom.Atom; + }, ) { const projectScheduler = createAtomCommandScheduler(); const fileScheduler = createAtomCommandScheduler(); @@ -92,10 +119,31 @@ export function createProjectEnvironmentAtoms( scheduler: projectScheduler, concurrency: projectConcurrency, }), - // Finds or creates the environment's Scratch project and returns its id. - ensureScratch: createEnvironmentRpcCommand(runtime, { - label: "environment-data:projects:ensure-scratch", - tag: WS_METHODS.projectsEnsureScratch, + // Finds or creates the environment's Scratch project and resolves once the + // project is in the client store, since drafts key off its stored path. + openScratch: createEnvironmentCommand(runtime, { + label: "environment-data:projects:open-scratch", + execute: ( + input: EnvironmentRpcInput, + registry, + environmentId, + ) => + request(WS_METHODS.projectsEnsureScratch, input).pipe( + Effect.flatMap(({ projectId }) => + AtomRegistry.toStream(registry, options.projectAtom({ environmentId, projectId })).pipe( + Stream.filter(Predicate.isNotNull), + Stream.runHead, + Effect.timeoutOption("10 seconds"), + Effect.map(Option.flatten), + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => Effect.fail(new ScratchProjectNotLoadedError({ projectId })), + }), + ), + ), + ), + ), scheduler: projectScheduler, concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, }), diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 7e90327c30c3..06ac4fb83820 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -88,7 +88,6 @@ export const STATIC_KEYBINDING_COMMANDS = [ "composer.sendAlternate", "composer.sendBackground", "composer.host", - "composer.cycleHost", "composer.effort", "composer.mode", "composer.workspace", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 3eca69039575..447d42560365 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -59,7 +59,6 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+alt+n", command: "chat.newWithoutProject", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, - { key: "mod+alt+h", command: "composer.cycleHost", when: "!terminalFocus" }, { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" }, { key: "mod+shift+a", command: "composer.mode", when: "!terminalFocus" }, { key: "mod+shift+x", command: "composer.workspace", when: "!terminalFocus" },