From 5941dbdb16e7c76ee0b107a23794011baae703c4 Mon Sep 17 00:00:00 2001 From: Marcel Poelker Date: Thu, 25 Jun 2026 19:19:09 +0200 Subject: [PATCH 1/3] derive a repo's most recent run environment for mode prefill --- .../core/src/sidebar/runEnvironment.test.ts | 58 +++++++++++++++++++ packages/core/src/sidebar/runEnvironment.ts | 18 ++++++ 2 files changed, 76 insertions(+) create mode 100644 packages/core/src/sidebar/runEnvironment.test.ts create mode 100644 packages/core/src/sidebar/runEnvironment.ts diff --git a/packages/core/src/sidebar/runEnvironment.test.ts b/packages/core/src/sidebar/runEnvironment.test.ts new file mode 100644 index 000000000..f2a120745 --- /dev/null +++ b/packages/core/src/sidebar/runEnvironment.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { mostRecentRunEnvironment } from "./runEnvironment"; +import type { TaskData } from "./sidebarData.types"; + +const task = (overrides: Partial): TaskData => ({ + id: "t", + title: "t", + createdAt: 0, + lastActivityAt: 0, + isGenerating: false, + isUnread: false, + isPinned: false, + needsPermission: false, + repository: null, + isSuspended: false, + folderPath: null, + cloudPrUrl: null, + branchName: null, + linkedBranch: null, + ...overrides, +}); + +describe("mostRecentRunEnvironment", () => { + it("returns undefined when no task has run", () => { + expect(mostRecentRunEnvironment([])).toBeUndefined(); + expect( + mostRecentRunEnvironment([task({ taskRunEnvironment: undefined })]), + ).toBeUndefined(); + }); + + it("returns the environment of the most recently active run", () => { + expect( + mostRecentRunEnvironment([ + task({ id: "old", lastActivityAt: 1, taskRunEnvironment: "cloud" }), + task({ id: "new", lastActivityAt: 2, taskRunEnvironment: "local" }), + ]), + ).toBe("local"); + }); + + it("picks the most recent regardless of array order", () => { + // Same tasks as above, most-recent listed first: position must not matter. + expect( + mostRecentRunEnvironment([ + task({ id: "new", lastActivityAt: 2, taskRunEnvironment: "local" }), + task({ id: "old", lastActivityAt: 1, taskRunEnvironment: "cloud" }), + ]), + ).toBe("local"); + }); + + it("ignores tasks without a recorded environment (drafts) when picking the most recent", () => { + expect( + mostRecentRunEnvironment([ + task({ id: "ran", lastActivityAt: 1, taskRunEnvironment: "cloud" }), + task({ id: "draft", lastActivityAt: 5, taskRunEnvironment: undefined }), + ]), + ).toBe("cloud"); + }); +}); diff --git a/packages/core/src/sidebar/runEnvironment.ts b/packages/core/src/sidebar/runEnvironment.ts new file mode 100644 index 000000000..ae2a65c89 --- /dev/null +++ b/packages/core/src/sidebar/runEnvironment.ts @@ -0,0 +1,18 @@ +import type { TaskData } from "./sidebarData.types"; + +/** + * The workspace environment ("local" | "cloud") of the most recently active task + * in a repo group that has actually run, or `undefined` when none has. + */ +export function mostRecentRunEnvironment( + tasks: readonly TaskData[], +): "local" | "cloud" | undefined { + let best: TaskData | undefined; + for (const task of tasks) { + if (!task.taskRunEnvironment) continue; + if (!best || task.lastActivityAt > best.lastActivityAt) { + best = task; + } + } + return best?.taskRunEnvironment; +} From 6bd77e5eb05fcf21731d69f3b8cb3e59883e35b1 Mon Sep 17 00:00:00 2001 From: Marcel Poelker Date: Thu, 25 Jun 2026 19:27:22 +0200 Subject: [PATCH 2/3] Open new-task in the repo's most recent run mode --- ...seInitialRepoSelectionFromFolderId.test.ts | 95 +++++++++++++++++++ .../useInitialRepoSelectionFromFolderId.ts | 53 +++++++++-- 2 files changed, 138 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/features/task-detail/hooks/useInitialRepoSelectionFromFolderId.test.ts b/packages/ui/src/features/task-detail/hooks/useInitialRepoSelectionFromFolderId.test.ts index 908d3a22c..41b79168c 100644 --- a/packages/ui/src/features/task-detail/hooks/useInitialRepoSelectionFromFolderId.test.ts +++ b/packages/ui/src/features/task-detail/hooks/useInitialRepoSelectionFromFolderId.test.ts @@ -164,6 +164,70 @@ describe("resolveRepoSelectionForFolder", () => { nextMode: undefined, }, }, + { + name: "most recent run was local while in cloud: switch to local (keep cloud repo seeded)", + input: { + remoteUrl: "posthog/posthog", + repositories: ["posthog/posthog"], + reposLoaded: true, + currentMode: "cloud", + lastUsedLocalMode: "local", + mostRecentEnvironment: "local", + }, + expected: { + directory: "/repos/a", + cloudRepository: "posthog/posthog", + nextMode: "local", + }, + }, + { + name: "most recent run was cloud while in local: switch to cloud", + input: { + remoteUrl: "posthog/posthog", + repositories: ["posthog/posthog"], + reposLoaded: true, + currentMode: "local", + lastUsedLocalMode: "local", + mostRecentEnvironment: "cloud", + }, + expected: { + directory: "/repos/a", + cloudRepository: "posthog/posthog", + nextMode: "cloud", + }, + }, + { + name: "most recent run was cloud but repo not cloud-capable, in local: stay local", + input: { + remoteUrl: null, + repositories: ["posthog/posthog"], + reposLoaded: true, + currentMode: "local", + lastUsedLocalMode: "local", + mostRecentEnvironment: "cloud", + }, + expected: { + directory: "/repos/a", + cloudRepository: undefined, + nextMode: undefined, + }, + }, + { + name: "most recent run was cloud but repo not cloud-capable, in cloud: drop to local", + input: { + remoteUrl: null, + repositories: ["posthog/posthog"], + reposLoaded: true, + currentMode: "cloud", + lastUsedLocalMode: "worktree", + mostRecentEnvironment: "cloud", + }, + expected: { + directory: "/repos/a", + cloudRepository: undefined, + nextMode: "worktree", + }, + }, ])("$name", ({ input: { remoteUrl, ...rest }, expected }) => { expect( resolveRepoSelectionForFolder({ @@ -224,6 +288,7 @@ type HookArgs = { repositories: string[]; reposLoaded: boolean; currentMode: WorkspaceMode; + mostRecentEnvironment?: "local" | "cloud"; }; function renderRepoSelectionHook(initial: HookArgs) { @@ -239,6 +304,7 @@ function renderRepoSelectionHook(initial: HookArgs) { reposLoaded: props.reposLoaded, currentMode: props.currentMode, lastUsedLocalMode: "local", + mostRecentEnvironment: props.mostRecentEnvironment, setSelectedDirectory, setSelectedRepository, switchWorkspaceMode: setWorkspaceMode, @@ -281,6 +347,35 @@ describe("useInitialRepoSelectionFromFolderId", () => { expect(setSelectedDirectory).toHaveBeenCalledTimes(1); }); + it("switches into cloud when the repo's most recent run was cloud", () => { + const { setWorkspaceMode, setSelectedRepository } = renderRepoSelectionHook( + { + folderId: "a", + folders: [folder("a", "/repos/a", "posthog/posthog")], + repositories: ["posthog/posthog"], + reposLoaded: true, + currentMode: "local", + mostRecentEnvironment: "cloud", + }, + ); + expect(setWorkspaceMode).toHaveBeenCalledExactlyOnceWith("cloud"); + expect(setSelectedRepository).toHaveBeenCalledExactlyOnceWith( + "posthog/posthog", + ); + }); + + it("switches to local when the repo's most recent run was local while in cloud", () => { + const { setWorkspaceMode } = renderRepoSelectionHook({ + folderId: "a", + folders: [folder("a", "/repos/a", "posthog/posthog")], + repositories: ["posthog/posthog"], + reposLoaded: true, + currentMode: "cloud", + mostRecentEnvironment: "local", + }); + expect(setWorkspaceMode).toHaveBeenCalledExactlyOnceWith("local"); + }); + it("switches to local mode for a local-only folder once repos load", () => { const { setWorkspaceMode, setSelectedRepository } = renderRepoSelectionHook( { diff --git a/packages/ui/src/features/task-detail/hooks/useInitialRepoSelectionFromFolderId.ts b/packages/ui/src/features/task-detail/hooks/useInitialRepoSelectionFromFolderId.ts index 0795f7988..a179fafa7 100644 --- a/packages/ui/src/features/task-detail/hooks/useInitialRepoSelectionFromFolderId.ts +++ b/packages/ui/src/features/task-detail/hooks/useInitialRepoSelectionFromFolderId.ts @@ -38,6 +38,12 @@ export interface RepoSelectionInput { currentMode: WorkspaceMode; /** Mode to fall back to when leaving cloud (local or worktree). */ lastUsedLocalMode: LocalWorkspaceMode; + /** + * Environment ("local" | "cloud") of this repo's most recent visible run, used + * to prefill the mode. `undefined` when nothing visible has run yet — then we + * fall back to the user's current (global last-used) mode. + */ + mostRecentEnvironment?: "local" | "cloud"; } export interface RepoSelection { @@ -45,8 +51,12 @@ export interface RepoSelection { directory: string; /** Cloud `owner/repo` slug to select, or undefined to leave the cloud pick as-is. */ cloudRepository?: string; - /** Workspace mode to switch to, or undefined to keep the current mode. */ - nextMode?: LocalWorkspaceMode; + /** + * Workspace mode to switch to, or undefined to keep the current mode. Can be + * `"cloud"` when the repo's most recent run was in the cloud, so this is the full + * `WorkspaceMode` rather than the local-only fallback type. + */ + nextMode?: WorkspaceMode; } /** @@ -54,10 +64,13 @@ export interface RepoSelection { * what to select in both the local-directory and cloud-repo pickers, and whether the * workspace mode must change. * - * Rules (see plan): prefill both selectors; keep the current mode when it can represent - * the repo; only switch when it can't, i.e. you're in cloud but the repo has no cloud - * counterpart (no remote slug, or the slug isn't a connected integration), in which case - * fall back to the last-used local mode. + * Rules: always prefill the local directory and (when cloud-capable) the cloud repo. + * The mode follows the repo's own most recent visible run — open Local for a repo last + * run locally, Cloud for one last run in the cloud — falling back to the user's current + * (global last-used) mode only when nothing visible has run yet. A desired Cloud mode is + * honoured only when the repo has a connected cloud counterpart; otherwise it drops to + * the last-used local mode. A desired Local mode keeps the current mode when it's already + * local (preserving worktree), and otherwise switches to the last-used local mode. */ export function resolveRepoSelectionForFolder({ folder, @@ -65,6 +78,7 @@ export function resolveRepoSelectionForFolder({ reposLoaded, currentMode, lastUsedLocalMode, + mostRecentEnvironment, }: RepoSelectionInput): RepoSelection { const slug = folder.remoteUrl?.toLowerCase(); // A folder is cloud-capable only when its remote is a real `owner/repo` (guards against @@ -79,10 +93,21 @@ export function resolveRepoSelectionForFolder({ cloudRepository, }; - // Only decide the mode once the integrations list has loaded, so we never switch out - // of cloud while the repo list is still in flight (it would look "not cloud-capable"). - if (reposLoaded && currentMode === "cloud" && !cloudRepository) { - selection.nextMode = lastUsedLocalMode; + // Only decide the mode once the integrations list has loaded, so cloud-capability is + // known and we never switch out of cloud while the repo list is still in flight. + if (reposLoaded) { + // Prefer the repo's own most recent run; fall back to the current global mode. + const desiredEnvironment = + mostRecentEnvironment ?? (currentMode === "cloud" ? "cloud" : "local"); + const targetMode: WorkspaceMode = + desiredEnvironment === "cloud" && cloudRepository + ? "cloud" + : currentMode === "cloud" + ? lastUsedLocalMode + : currentMode; + if (targetMode !== currentMode) { + selection.nextMode = targetMode; + } } return selection; @@ -98,6 +123,11 @@ export interface UseInitialRepoSelectionParams { currentMode: WorkspaceMode; /** Mode to fall back to when leaving cloud (local or worktree). */ lastUsedLocalMode: LocalWorkspaceMode; + /** + * Environment of this repo's most recent visible run, used to prefill the mode. + * `undefined` falls back to the current global mode. + */ + mostRecentEnvironment?: "local" | "cloud"; setSelectedDirectory: (path: string) => void; setSelectedRepository: (repo: string) => void; /** Switches the workspace mode (without persisting it as the user's preference). */ @@ -120,6 +150,7 @@ export function useInitialRepoSelectionFromFolderId({ reposLoaded, currentMode, lastUsedLocalMode, + mostRecentEnvironment, setSelectedDirectory, setSelectedRepository, switchWorkspaceMode, @@ -149,6 +180,7 @@ export function useInitialRepoSelectionFromFolderId({ reposLoaded, currentMode: currentModeRef.current, lastUsedLocalMode, + mostRecentEnvironment, }); if (dirInitRef.current !== folderId) { @@ -172,6 +204,7 @@ export function useInitialRepoSelectionFromFolderId({ repositories, reposLoaded, lastUsedLocalMode, + mostRecentEnvironment, setSelectedDirectory, setSelectedRepository, switchWorkspaceMode, From c01996870bd4cb8432293ffb760ce70e823d72c5 Mon Sep 17 00:00:00 2001 From: Marcel Poelker Date: Thu, 25 Jun 2026 19:29:26 +0200 Subject: [PATCH 3/3] Pass repo's run history from "+" to the mode resolver --- .../ui/src/features/sidebar/components/TaskListView.tsx | 8 +++++++- .../ui/src/features/task-detail/components/TaskInput.tsx | 1 + .../features/task-detail/stores/taskInputPrefillStore.ts | 1 + packages/ui/src/router/useAppView.ts | 2 ++ packages/ui/src/router/useOpenTask.ts | 6 ++++++ 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/sidebar/components/TaskListView.tsx b/packages/ui/src/features/sidebar/components/TaskListView.tsx index 749eb8de2..df15ed2db 100644 --- a/packages/ui/src/features/sidebar/components/TaskListView.tsx +++ b/packages/ui/src/features/sidebar/components/TaskListView.tsx @@ -3,6 +3,7 @@ import type { DragDropEvents } from "@dnd-kit/react"; import { DragDropProvider } from "@dnd-kit/react"; import { GitBranch } from "@phosphor-icons/react"; import { groupTasksByRelativeDate } from "@posthog/core/sidebar/groupTasks"; +import { mostRecentRunEnvironment } from "@posthog/core/sidebar/runEnvironment"; import type { TaskData, TaskGroup, @@ -292,7 +293,12 @@ export function TaskListView({ tooltipContent={folder?.path ?? group.id} onNewTask={() => { if (groupFolderId) { - openTaskInput(groupFolderId); + openTaskInput({ + folderId: groupFolderId, + folderRunEnvironment: mostRecentRunEnvironment( + group.tasks, + ), + }); } else { openTaskInput(); } diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 93631a781..e0d483170 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -536,6 +536,7 @@ export function TaskInput({ }), currentMode: workspaceMode, lastUsedLocalMode: lastUsedLocalWorkspaceMode, + mostRecentEnvironment: view.folderRunEnvironment, setSelectedDirectory, setSelectedRepository, switchWorkspaceMode: switchWorkspaceModeForFolder, diff --git a/packages/ui/src/features/task-detail/stores/taskInputPrefillStore.ts b/packages/ui/src/features/task-detail/stores/taskInputPrefillStore.ts index e9e98c189..d7ba861f4 100644 --- a/packages/ui/src/features/task-detail/stores/taskInputPrefillStore.ts +++ b/packages/ui/src/features/task-detail/stores/taskInputPrefillStore.ts @@ -12,6 +12,7 @@ export interface TaskInputPrefill { initialCloudRepository?: string; initialModel?: string; initialMode?: string; + folderRunEnvironment?: "local" | "cloud"; reportAssociation?: TaskInputReportAssociation; } diff --git a/packages/ui/src/router/useAppView.ts b/packages/ui/src/router/useAppView.ts index c94a0a12e..ec2fad08d 100644 --- a/packages/ui/src/router/useAppView.ts +++ b/packages/ui/src/router/useAppView.ts @@ -30,6 +30,7 @@ export interface AppView { initialCloudRepository?: string; initialModel?: string; initialMode?: string; + folderRunEnvironment?: "local" | "cloud"; reportAssociation?: TaskInputReportAssociation; } @@ -143,6 +144,7 @@ export function useAppView(): AppView { initialCloudRepository: prefill.initialCloudRepository, initialModel: prefill.initialModel, initialMode: prefill.initialMode, + folderRunEnvironment: prefill.folderRunEnvironment, reportAssociation: prefill.reportAssociation, taskInputRequestId: prefill.requestId, }; diff --git a/packages/ui/src/router/useOpenTask.ts b/packages/ui/src/router/useOpenTask.ts index 392e6e9ca..07bdcac57 100644 --- a/packages/ui/src/router/useOpenTask.ts +++ b/packages/ui/src/router/useOpenTask.ts @@ -67,6 +67,11 @@ export interface TaskInputNavigationOptions { initialCloudRepository?: string; initialModel?: string; initialMode?: string; + /** + * Environment ("local" | "cloud") of the folder's most recent visible run, + * used to prefill the workspace mode when starting a task scoped to a folder. + */ + folderRunEnvironment?: "local" | "cloud"; reportAssociation?: { reportId: string; title: string }; // Which space's new-task screen to open. Both render the same TaskInput; the // channels variant keeps the channels chrome instead of switching to Code. @@ -100,6 +105,7 @@ export function openTaskInput( initialCloudRepository: options.initialCloudRepository, initialModel: options.initialModel, initialMode: options.initialMode, + folderRunEnvironment: options.folderRunEnvironment, reportAssociation: options.reportAssociation, requestId: hasTransientState ? (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`)