Skip to content
Merged
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
50 changes: 40 additions & 10 deletions packages/ui/src/features/canvas/components/WebsiteContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import {
EmptyMedia,
EmptyTitle,
Button as QuillButton,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@posthog/quill";
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import { isTerminalStatus } from "@posthog/shared/domain-types";
Expand All @@ -30,6 +33,10 @@ import {
import { useGenerateContext } from "@posthog/ui/features/canvas/hooks/useGenerateContext";
import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer";
import { useSessionForTask } from "@posthog/ui/features/sessions/useSession";
import {
type WorkspaceMode,
WorkspaceModeSelect,
} from "@posthog/ui/features/task-detail/components/WorkspaceModeSelect";
import { taskDetailQuery } from "@posthog/ui/features/tasks/queries";
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
import { track } from "@posthog/ui/shell/analytics";
Expand Down Expand Up @@ -469,24 +476,47 @@ function GenerateWithAgent({
}) {
const { generate, isStarting } = useGenerateContext(channelId, channelName);

// Generation always runs in the cloud, except the dev-only picker below lets a
// local build of these features be tested before it's merged to the cloud env.
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("cloud");

const onGenerate = () => {
track(ANALYTICS_EVENTS.CONTEXT_ACTION, {
action_type: "generate_started",
channel_id: channelId,
});
void generate();
void generate(workspaceMode);
};

return (
<QuillButton
variant="outline"
size="default"
disabled={isStarting}
onClick={onGenerate}
>
{isStarting ? <Spinner size="1" /> : <SparkleIcon size={14} />}
{regenerate ? "Generate again" : "Generate with agent"}
</QuillButton>
<Flex align="center" gap="2">
<QuillButton
variant="outline"
size="default"
disabled={isStarting}
onClick={onGenerate}
>
{isStarting ? <Spinner size="1" /> : <SparkleIcon size={14} />}
{regenerate ? "Generate again" : "Generate with agent"}
</QuillButton>
{/* Dev-only: pick local vs cloud so a local build can be tested pre-merge. */}
{import.meta.env.DEV && (
<Tooltip>
<TooltipTrigger render={<div />}>
<WorkspaceModeSelect
value={workspaceMode}
onChange={setWorkspaceMode}
overrideModes={["local", "cloud"]}
disabled={isStarting}
size="1"
/>
</TooltipTrigger>
<TooltipContent>
Dev mode only — generation always runs in the cloud in production.
</TooltipContent>
</Tooltip>
)}
</Flex>
);
}

Expand Down
27 changes: 27 additions & 0 deletions packages/ui/src/features/canvas/freeform/FreeformGenerateBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@posthog/quill";
import { useGenerateFreeformCanvas } from "@posthog/ui/features/canvas/hooks/useGenerateFreeformCanvas";
import { PromptInput } from "@posthog/ui/features/message-editor/components/PromptInput";
import type { EditorHandle } from "@posthog/ui/features/message-editor/types";
import {
type WorkspaceMode,
WorkspaceModeSelect,
} from "@posthog/ui/features/task-detail/components/WorkspaceModeSelect";
import { forwardRef, useState } from "react";

// Composer that kicks off freeform canvas generation as a dedicated task: the
Expand Down Expand Up @@ -55,13 +60,18 @@ export const FreeformGenerateBar = forwardRef<
const isEdit = !!currentCode?.trim();
const [useStarter, setUseStarter] = useState(true);

// Generation always runs in the cloud, except the dev-only picker below lets a
// local build of these features be tested before it's merged to the cloud env.
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("cloud");

const run = async (text: string) => {
const instruction = text.trim();
if (!instruction) return;
const taskId = await generate({
instruction,
currentCode,
useStarter: !isEdit && useStarter,
workspaceMode,
});
if (taskId) onStarted?.(taskId);
};
Expand Down Expand Up @@ -92,6 +102,23 @@ export const FreeformGenerateBar = forwardRef<
scratch)
</label>
)}
{/* Dev-only: pick local vs cloud so a local build can be tested pre-merge. */}
{import.meta.env.DEV && (
<Tooltip>
<TooltipTrigger render={<div className="self-start px-1" />}>
<WorkspaceModeSelect
value={workspaceMode}
onChange={setWorkspaceMode}
overrideModes={["local", "cloud"]}
disabled={isStarting}
size="1"
/>
</TooltipTrigger>
<TooltipContent>
Dev mode only — generation always runs in the cloud in production.
</TooltipContent>
</Tooltip>
)}
</div>
);
});
99 changes: 53 additions & 46 deletions packages/ui/src/features/canvas/hooks/useGenerateContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
} from "@posthog/core/task-detail/taskService";
import { useService } from "@posthog/di/react";
import { useHostTRPC } from "@posthog/host-router/react";
import type { WorkspaceMode } from "@posthog/shared";
import { buildContextGenerationPrompt } from "@posthog/ui/features/canvas/contextPrompt";
import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks";
import { useFolderGenerationTaskMutation } from "@posthog/ui/features/canvas/hooks/useFolderGenerationTask";
Expand All @@ -14,10 +15,10 @@ import { useCallback, useState } from "react";

// Kicks off CONTEXT.md generation as a repo-less task. The user no longer picks
// a folder/repo up front — the agent decides at runtime whether it needs one and
// asks the user to clarify if it can't find the right one. The task runs in a
// per-task scratch dir (local), is filed to the channel, and recorded
// server-side as the channel's generation task so every user's CONTEXT.md view
// can track it.
// asks the user to clarify if it can't find the right one. The task runs as a
// cloud run (not a local agent) so generation proceeds server-side regardless of
// which client kicked it off, is filed to the channel, and recorded server-side
// as the channel's generation task so every user's CONTEXT.md view can track it.
export function useGenerateContext(channelId: string, channelName: string) {
const taskService = useService<TaskService>(TASK_SERVICE);
const trpc = useHostTRPC();
Expand All @@ -27,51 +28,57 @@ export function useGenerateContext(channelId: string, channelName: string) {
const { set: setGenerationTask } = useFolderGenerationTaskMutation(channelId);
const [isStarting, setIsStarting] = useState(false);

const generate = useCallback(async (): Promise<string | null> => {
setIsStarting(true);
try {
const result = await taskService.createTask(
{
content: buildContextGenerationPrompt({ channelName, channelId }),
taskDescription: `Generate CONTEXT.md for #${channelName}`,
workspaceMode: "local",
allowNoRepo: true,
},
(output) => invalidateTasks(output.task),
);
const generate = useCallback(
async (workspaceMode: WorkspaceMode = "cloud"): Promise<string | null> => {
setIsStarting(true);
try {
const result = await taskService.createTask(
{
content: buildContextGenerationPrompt({ channelName, channelId }),
taskDescription: `Generate CONTEXT.md for #${channelName}`,
// Defaults to a cloud run — generation should never tie up (or
// depend on) the local machine, and it's never the sticky last-used
// workspace mode. The dev-only picker can override to "local" to
// test a local build of these features before merging.
workspaceMode,
allowNoRepo: true,
},
(output) => invalidateTasks(output.task),
);

if (!result.success) {
toast.error("Couldn't start CONTEXT.md generation", {
description: result.error,
if (!result.success) {
toast.error("Couldn't start CONTEXT.md generation", {
description: result.error,
});
return null;
}

const task = result.data.task;
// File into the channel + record as the (shared) generation task. Both
// are best-effort: a failure here shouldn't undo a started task.
void fileTask(channelId, task.id, task.title).catch(() => {});
void setGenerationTask(task.id).catch(() => {});
// Refresh the workspace cache so the new cloud workspace row appears and
// the task view resolves the cloud run instead of the repo-picker prompt.
void queryClient.invalidateQueries({
queryKey: trpc.workspace.getAll.queryKey(),
});
return null;
return task.id;
} finally {
setIsStarting(false);
}

const task = result.data.task;
// File into the channel + record as the (shared) generation task. Both
// are best-effort: a failure here shouldn't undo a started task.
void fileTask(channelId, task.id, task.title).catch(() => {});
void setGenerationTask(task.id).catch(() => {});
// Repo-less tasks create no workspace row, so the usual workspace.create
// invalidation never fires — refresh the cache so the task view resolves
// its scratch cwd instead of showing the repo-picker prompt.
void queryClient.invalidateQueries({
queryKey: trpc.workspace.getAll.queryKey(),
});
return task.id;
} finally {
setIsStarting(false);
}
}, [
taskService,
trpc,
queryClient,
invalidateTasks,
fileTask,
setGenerationTask,
channelId,
channelName,
]);
},
[
taskService,
trpc,
queryClient,
invalidateTasks,
fileTask,
setGenerationTask,
channelId,
channelName,
],
);

return { generate, isStarting };
}
22 changes: 15 additions & 7 deletions packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@posthog/core/task-detail/taskService";
import { useService } from "@posthog/di/react";
import { useHostTRPC } from "@posthog/host-router/react";
import type { WorkspaceMode } from "@posthog/shared";
import { buildFreeformGenerationPrompt } from "@posthog/ui/features/canvas/freeformPrompt";
import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks";
import {
Expand All @@ -24,9 +25,9 @@ import { useCallback, useState } from "react";
// so every client's canvas view tracks the in-flight run. Canvas generation
// reads PostHog data via the MCP rather than repo code, so no repo is selected
// up front — the agent attaches one lazily only if it decides it needs one. The
// task runs in a per-task scratch dir (local) and the agent publishes the result
// via the `desktop-file-system-canvas-partial-update` MCP tool — mirrors
// useGenerateContext.
// task runs as a cloud run (not a local agent) so generation proceeds
// server-side regardless of which client kicked it off, and the agent publishes
// the result via the `desktop-file-system-canvas-partial-update` MCP tool.
export function useGenerateFreeformCanvas(args: {
dashboardId: string;
channelId: string;
Expand Down Expand Up @@ -56,6 +57,10 @@ export function useGenerateFreeformCanvas(args: {
currentCode?: string;
// Default on (opt out in the bar): seed the starter scaffold on first build.
useStarter?: boolean;
// Dev-only override (the bar exposes a local/cloud picker in dev so a
// local build of these features can be tested before merging). Production
// always runs in the cloud — see the default below.
workspaceMode?: WorkspaceMode;
}): Promise<string | null> => {
setIsStarting(true);
try {
Expand All @@ -73,7 +78,11 @@ export function useGenerateFreeformCanvas(args: {
taskDescription: `Generate canvas "${name}"`,
// Unattended generation: run in auto mode so it doesn't stall on edit-approval prompts.
executionMode: "auto" as const,
workspaceMode: "local",
// Defaults to a cloud run — canvas generation should never tie up
// (or depend on) the local machine, and it's never the sticky
// last-used workspace mode. The dev-only picker can override to
// "local" to test a local build of these features before merging.
workspaceMode: opts.workspaceMode ?? "cloud",
allowNoRepo: true,
channelContext,
channelName,
Expand All @@ -98,9 +107,8 @@ export function useGenerateFreeformCanvas(args: {
useCanvasGenerationTrackerStore
.getState()
.track({ taskId: task.id, dashboardId, channelId, name });
// Repo-less tasks create no workspace row, so the usual workspace.create
// invalidation never fires — refresh the cache so the task view resolves
// its scratch cwd instead of showing the repo-picker prompt.
// Refresh the workspace cache so the new cloud workspace row appears and
// the task view resolves the cloud run instead of the repo-picker prompt.
void queryClient.invalidateQueries({
queryKey: trpc.workspace.getAll.queryKey(),
});
Expand Down
Loading