Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/core/src/sidebar/runEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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");
});
});
18 changes: 18 additions & 0 deletions packages/core/src/sidebar/runEnvironment.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 7 additions & 1 deletion packages/ui/src/features/sidebar/components/TaskListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@ export function TaskInput({
}),
currentMode: workspaceMode,
lastUsedLocalMode: lastUsedLocalWorkspaceMode,
mostRecentEnvironment: view.folderRunEnvironment,
setSelectedDirectory,
setSelectedRepository,
switchWorkspaceMode: switchWorkspaceModeForFolder,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -224,6 +288,7 @@ type HookArgs = {
repositories: string[];
reposLoaded: boolean;
currentMode: WorkspaceMode;
mostRecentEnvironment?: "local" | "cloud";
};

function renderRepoSelectionHook(initial: HookArgs) {
Expand All @@ -239,6 +304,7 @@ function renderRepoSelectionHook(initial: HookArgs) {
reposLoaded: props.reposLoaded,
currentMode: props.currentMode,
lastUsedLocalMode: "local",
mostRecentEnvironment: props.mostRecentEnvironment,
setSelectedDirectory,
setSelectedRepository,
switchWorkspaceMode: setWorkspaceMode,
Expand Down Expand Up @@ -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");
});
Comment thread
mp-hog marked this conversation as resolved.

it("switches to local mode for a local-only folder once repos load", () => {
const { setWorkspaceMode, setSelectedRepository } = renderRepoSelectionHook(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,33 +38,47 @@ 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 {
/** Local directory to select (always the folder's path). */
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;
}

/**
* Pure resolver: given the folder a user picked (e.g. via the sidebar "+"), decide
* 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,
repositories,
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
Expand All @@ -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;
Expand All @@ -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). */
Expand All @@ -120,6 +150,7 @@ export function useInitialRepoSelectionFromFolderId({
reposLoaded,
currentMode,
lastUsedLocalMode,
mostRecentEnvironment,
setSelectedDirectory,
setSelectedRepository,
switchWorkspaceMode,
Expand Down Expand Up @@ -149,6 +180,7 @@ export function useInitialRepoSelectionFromFolderId({
reposLoaded,
currentMode: currentModeRef.current,
lastUsedLocalMode,
mostRecentEnvironment,
});

if (dirInitRef.current !== folderId) {
Expand All @@ -172,6 +204,7 @@ export function useInitialRepoSelectionFromFolderId({
repositories,
reposLoaded,
lastUsedLocalMode,
mostRecentEnvironment,
setSelectedDirectory,
setSelectedRepository,
switchWorkspaceMode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface TaskInputPrefill {
initialCloudRepository?: string;
initialModel?: string;
initialMode?: string;
folderRunEnvironment?: "local" | "cloud";
reportAssociation?: TaskInputReportAssociation;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/router/useAppView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface AppView {
initialCloudRepository?: string;
initialModel?: string;
initialMode?: string;
folderRunEnvironment?: "local" | "cloud";
reportAssociation?: TaskInputReportAssociation;
}

Expand Down Expand Up @@ -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,
};
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/router/useOpenTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()}`)
Expand Down
Loading