diff --git a/packages/core/src/sessions/sessionEvents.test.ts b/packages/core/src/sessions/sessionEvents.test.ts index d57bb3312..aa5771b44 100644 --- a/packages/core/src/sessions/sessionEvents.test.ts +++ b/packages/core/src/sessions/sessionEvents.test.ts @@ -1,9 +1,10 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; -import type { AcpMessage } from "@posthog/shared"; +import type { AcpMessage, StoredLogEntry } from "@posthog/shared"; import { describe, expect, it } from "vitest"; import { makeAttachmentUri } from "./promptContent"; import { + convertStoredEntriesToEvents, extractUserPromptsFromEvents, hasSessionPromptEvent, isAbsoluteFolderPath, @@ -196,6 +197,48 @@ describe("hasSessionPromptEvent", () => { }); }); +describe("convertStoredEntriesToEvents — imported user prompts", () => { + const userChunkEntry = ( + text: string, + meta?: Record, + ): StoredLogEntry => + ({ + timestamp: "2026-06-22T00:00:00.000Z", + notification: { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text }, + ...(meta ? { _meta: meta } : {}), + }, + }, + }, + }) as unknown as StoredLogEntry; + + it("promotes a marked imported user prompt into a session/prompt event", () => { + const events = convertStoredEntriesToEvents([ + userChunkEntry("my earlier prompt", { importedUserPrompt: true }), + ]); + const msg = events[0].message; + expect("method" in msg && msg.method).toBe("session/prompt"); + const params = (msg as { params?: { prompt?: ContentBlock[] } }).params; + expect(params?.prompt?.[0]).toEqual({ + type: "text", + text: "my earlier prompt", + }); + }); + + it("leaves an unmarked user_message_chunk as a raw notification", () => { + const events = convertStoredEntriesToEvents([ + userChunkEntry("internal user content"), + ]); + const msg = events[0].message; + expect("method" in msg && msg.method).toBe("session/update"); + }); +}); + describe("isAbsoluteFolderPath", () => { it.each(["/Users/x/repo", "~/repo", "C:\\repo", "D:/repo"])( "treats %s as absolute", diff --git a/packages/core/src/sessions/sessionEvents.ts b/packages/core/src/sessions/sessionEvents.ts index 9cba7d4c1..48371e707 100644 --- a/packages/core/src/sessions/sessionEvents.ts +++ b/packages/core/src/sessions/sessionEvents.ts @@ -14,7 +14,11 @@ import type { StoredLogEntry, UserShellExecuteParams, } from "@posthog/shared"; -import { isJsonRpcNotification, isJsonRpcRequest } from "@posthog/shared"; +import { + IMPORTED_USER_PROMPT_META_KEY, + isJsonRpcNotification, + isJsonRpcRequest, +} from "@posthog/shared"; import { isNotification, POSTHOG_NOTIFICATIONS } from "./acpNotifications"; import { extractPromptDisplayContent } from "./promptContent"; @@ -22,13 +26,47 @@ import { extractPromptDisplayContent } from "./promptContent"; * Convert a stored log entry to an ACP message. */ function storedEntryToAcpMessage(entry: StoredLogEntry): AcpMessage { + const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(); + const promoted = promoteImportedUserPrompt(entry, ts); + if (promoted) return promoted; return { type: "acp_message", - ts: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(), + ts, message: (entry.notification ?? {}) as JsonRpcMessage, }; } +/** + * A typed user prompt replayed from an imported Claude Code session arrives as + * a `user_message_chunk` tagged with `_meta.importedUserPrompt`. The renderer + * ignores raw user_message_chunks (live, user turns render from session/prompt + * requests), so promote the tagged ones into a session/prompt user event. Only + * affects imported sessions; normal logs carry no such marker. + */ +function promoteImportedUserPrompt( + entry: StoredLogEntry, + ts: number, +): AcpMessage | null { + const notification = entry.notification as + | { method?: string; params?: { update?: Record } } + | undefined; + if (notification?.method !== "session/update") return null; + const update = notification.params?.update; + const meta = update?._meta as Record | undefined; + if ( + !update || + update.sessionUpdate !== "user_message_chunk" || + meta?.[IMPORTED_USER_PROMPT_META_KEY] !== true + ) { + return null; + } + const content = update.content as + | { type?: string; text?: string } + | undefined; + if (content?.type !== "text" || !content.text) return null; + return createUserMessageEvent(content.text, ts); +} + /** * Create a user message event for display. */ diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 1cfcd0274..3efb09985 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -289,6 +289,11 @@ export interface ConnectParams { adapter?: "claude" | "codex"; model?: string; reasoningLevel?: string; + /** + * Session ID of an imported Claude Code CLI transcript already copied into + * the app's Claude config dir. The agent loads it and replays its history. + */ + importedSessionId?: string; } export interface CloudConnectionAuth { @@ -607,6 +612,7 @@ export class SessionService { adapter, model, reasoningLevel, + importedSessionId, } = params; const { id: taskId, latest_run: latestRun } = task; const taskTitle = task.title || task.description || "Task"; @@ -716,6 +722,7 @@ export class SessionService { adapter, model, reasoningLevel, + importedSessionId, ); } } catch (error) { @@ -1195,6 +1202,7 @@ export class SessionService { adapter?: "claude" | "codex", model?: string, reasoningLevel?: string, + importedSessionId?: string, ): Promise { const { client } = auth; if (!client) { @@ -1221,12 +1229,32 @@ export class SessionService { ? (reasoningLevel as EffortLevel) : undefined, model: preferredModel, + importedSessionId, }); const session = createBaseSession(taskRun.id, taskId, taskTitle); session.channel = result.channel; session.status = "connected"; session.adapter = adapter; + + // An imported CLI session had its history replayed during agent.start; + // the replay is already in the local run log, so load it for the UI. + if (importedSessionId) { + try { + const { rawEntries } = await this.fetchSessionLogs( + undefined, + taskRun.id, + ); + session.events = convertStoredEntriesToEvents(rawEntries); + } catch { + this.d.log.warn( + "Failed to load replayed history for imported session", + { + taskRunId: taskRun.id, + }, + ); + } + } const configOptions = result.configOptions as | SessionConfigOption[] | undefined; diff --git a/packages/core/src/task-detail/taskCreationHost.ts b/packages/core/src/task-detail/taskCreationHost.ts index e321443d1..fda986014 100644 --- a/packages/core/src/task-detail/taskCreationHost.ts +++ b/packages/core/src/task-detail/taskCreationHost.ts @@ -51,6 +51,25 @@ export interface SetupActionDispatch { label: string; } +export interface ClaudeCliImportFingerprint { + sourceMtimeMs: number; + sourceSizeBytes: number; + sourceLastEntryUuid: string | null; +} + +export interface ImportedClaudeCliSession { + importedSessionId: string; + fingerprint: ClaudeCliImportFingerprint; +} + +export interface RecordClaudeCliImportArgs { + sourceSessionId: string; + importedSessionId: string; + repoPath: string; + taskId: string; + fingerprint: ClaudeCliImportFingerprint; +} + export interface ITaskCreationHost { getAuthenticatedClient(): Promise; assertCloudUsageAvailable(): Promise; @@ -92,4 +111,24 @@ export interface ITaskCreationHost { clearProvisioning(taskId: string): void; dispatchSetupAction(args: SetupActionDispatch): void; track(event: string, props?: Record): void; + importClaudeCliSession(args: { + repoPath: string; + sourceSessionId: string; + }): Promise; + /** Compensate the import step: remove the copied transcript on rollback. */ + deleteClaudeCliImport(args: { + repoPath: string; + importedSessionId: string; + }): Promise; + recordClaudeCliImport(args: RecordClaudeCliImportArgs): Promise; + /** Compensate the record step: drop the tracking row on rollback. */ + deleteClaudeCliImportRecord(args: { + importedSessionId: string; + }): Promise; + /** + * Link the task to the branch the imported session worked on, without + * checking it out. Lets the standard branch-mismatch prompt surface if the + * local checkout is on a different branch. + */ + linkTaskBranch(args: { taskId: string; branchName: string }): Promise; } diff --git a/packages/core/src/task-detail/taskCreationSaga.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts index 3729cfa27..5ba2e9c57 100644 --- a/packages/core/src/task-detail/taskCreationSaga.test.ts +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -24,6 +24,11 @@ const mockHost = vi.hoisted(() => ({ setProvisioningActive: vi.fn(), clearProvisioning: vi.fn(), dispatchSetupAction: vi.fn(), + importClaudeCliSession: vi.fn(), + deleteClaudeCliImport: vi.fn(), + recordClaudeCliImport: vi.fn(), + deleteClaudeCliImportRecord: vi.fn(), + linkTaskBranch: vi.fn(), })); import { TaskCreationSaga } from "./taskCreationSaga"; @@ -66,6 +71,28 @@ const createRun = (overrides: Partial = {}): TaskRun => ({ ...overrides, }); +function makeSaga( + posthog: Record = {}, + extra: { onTaskReady?: (output: unknown) => void } = {}, +) { + return new TaskCreationSaga({ + posthogClient: { + createTask: vi.fn(), + deleteTask: vi.fn(), + getTask: vi.fn(), + createTaskRun: vi.fn(), + startTaskRun: vi.fn(), + sendRunCommand: vi.fn(), + updateTask: vi.fn(), + ...posthog, + } as never, + host, + sessionService, + track: vi.fn(), + ...extra, + }); +} + describe("TaskCreationSaga", () => { beforeEach(() => { vi.clearAllMocks(); @@ -76,6 +103,10 @@ describe("TaskCreationSaga", () => { mockHost.getWorkspace.mockResolvedValue(null); mockHost.getFolders.mockResolvedValue([]); mockHost.uploadRunAttachments.mockResolvedValue([]); + mockHost.linkTaskBranch.mockResolvedValue(undefined); + mockHost.recordClaudeCliImport.mockResolvedValue(undefined); + mockHost.deleteClaudeCliImport.mockResolvedValue(undefined); + mockHost.deleteClaudeCliImportRecord.mockResolvedValue(undefined); mockHost.getCloudPromptTransport.mockImplementation( ( prompt: string | unknown[], @@ -97,21 +128,15 @@ describe("TaskCreationSaga", () => { const sendRunCommandMock = vi.fn(); const onTaskReady = vi.fn(); - const saga = new TaskCreationSaga({ - posthogClient: { + const saga = makeSaga( + { createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), createTaskRun: createTaskRunMock, startTaskRun: startTaskRunMock, sendRunCommand: sendRunCommandMock, - updateTask: vi.fn(), - } as never, - host, - sessionService, - track: vi.fn(), - onTaskReady, - }); + }, + { onTaskReady }, + ); const result = await saga.run({ content: "Ship the fix", @@ -165,19 +190,10 @@ describe("TaskCreationSaga", () => { const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); vi.mocked(sessionService.rememberInitialCloudPrompt).mockClear(); - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: vi.fn().mockResolvedValue(createdTask), - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - host, - sessionService, - track: vi.fn(), + const saga = makeSaga({ + createTask: vi.fn().mockResolvedValue(createdTask), + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, }); const result = await saga.run({ @@ -208,20 +224,7 @@ describe("TaskCreationSaga", () => { const createdTask = createTask({ repository: undefined }); const createTaskMock = vi.fn().mockResolvedValue(createdTask); - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: vi.fn(), - startTaskRun: vi.fn(), - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - host, - sessionService, - track: vi.fn(), - }); + const saga = makeSaga({ createTask: createTaskMock }); const result = await saga.run({ content: "Draft a launch email", @@ -255,21 +258,15 @@ describe("TaskCreationSaga", () => { }); mockHost.uploadRunAttachments.mockResolvedValue(["artifact-1"]); - const saga = new TaskCreationSaga({ - posthogClient: { + const saga = makeSaga( + { createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), createTaskRun: createTaskRunMock, startTaskRun: startTaskRunMock, sendRunCommand: sendRunCommandMock, - updateTask: vi.fn(), - } as never, - host, - sessionService, - track: vi.fn(), - onTaskReady, - }); + }, + { onTaskReady }, + ); const result = await saga.run({ content: 'read this file ', @@ -337,19 +334,10 @@ describe("TaskCreationSaga", () => { const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - host, - sessionService, - track: vi.fn(), + const saga = makeSaga({ + createTask: createTaskMock, + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, }); const result = await saga.run({ @@ -387,19 +375,10 @@ describe("TaskCreationSaga", () => { const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - host, - sessionService, - track: vi.fn(), + const saga = makeSaga({ + createTask: createTaskMock, + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, }); const result = await saga.run({ @@ -436,19 +415,10 @@ describe("TaskCreationSaga", () => { const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - host, - sessionService, - track: vi.fn(), + const saga = makeSaga({ + createTask: createTaskMock, + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, }); await saga.run({ @@ -473,19 +443,10 @@ describe("TaskCreationSaga", () => { const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - host, - sessionService, - track: vi.fn(), + const saga = makeSaga({ + createTask: createTaskMock, + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, }); await saga.run({ @@ -516,19 +477,10 @@ describe("TaskCreationSaga", () => { const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - host, - sessionService, - track: vi.fn(), + const saga = makeSaga({ + createTask: createTaskMock, + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, }); const result = await saga.run({ @@ -554,4 +506,121 @@ describe("TaskCreationSaga", () => { }), ); }); + + it("imports a Claude CLI session, records it, and connects with the imported id", async () => { + const createdTask = createTask(); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const fingerprint = { + sourceMtimeMs: 1_700_000_000_000, + sourceSizeBytes: 2048, + sourceLastEntryUuid: "entry-1", + }; + mockHost.importClaudeCliSession.mockResolvedValue({ + importedSessionId: "imported-session-id", + fingerprint, + }); + mockHost.recordClaudeCliImport.mockResolvedValue(undefined); + mockHost.addFolder.mockResolvedValue({ id: "folder-1", path: "/repo" }); + mockHost.detectRepo.mockResolvedValue(null); + + const saga = makeSaga({ createTask: createTaskMock }); + + const result = await saga.run({ + taskDescription: "Fix the login flow", + repoPath: "/repo", + workspaceMode: "local", + adapter: "codex", + importedClaudeSession: { + sourceSessionId: "source-session-id", + branch: "feature/login", + }, + }); + + expect(result.success).toBe(true); + expect(mockHost.importClaudeCliSession).toHaveBeenCalledWith({ + repoPath: "/repo", + sourceSessionId: "source-session-id", + }); + expect(mockHost.linkTaskBranch).toHaveBeenCalledWith({ + taskId: "task-123", + branchName: "feature/login", + }); + expect(mockHost.recordClaudeCliImport).toHaveBeenCalledWith({ + sourceSessionId: "source-session-id", + importedSessionId: "imported-session-id", + repoPath: "/repo", + taskId: "task-123", + fingerprint, + }); + expect(sessionService.connectToTask).toHaveBeenCalledWith( + expect.objectContaining({ + importedSessionId: "imported-session-id", + adapter: "claude", + }), + ); + }); + + it("rolls back the import snapshot and tracking row when a later step fails", async () => { + const createdTask = createTask(); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const deleteTaskMock = vi.fn().mockResolvedValue(undefined); + const fingerprint = { + sourceMtimeMs: 1_700_000_000_000, + sourceSizeBytes: 2048, + sourceLastEntryUuid: "entry-1", + }; + mockHost.importClaudeCliSession.mockResolvedValue({ + importedSessionId: "imported-session-id", + fingerprint, + }); + mockHost.addFolder.mockResolvedValue({ id: "folder-1", path: "/repo" }); + mockHost.detectRepo.mockResolvedValue(null); + // Fail the workspace step, which runs after the import and record steps. + mockHost.createWorkspace.mockRejectedValue(new Error("workspace boom")); + + const saga = makeSaga({ + createTask: createTaskMock, + deleteTask: deleteTaskMock, + }); + + const result = await saga.run({ + taskDescription: "Fix the login flow", + repoPath: "/repo", + workspaceMode: "local", + importedClaudeSession: { sourceSessionId: "source-session-id" }, + }); + + expect(result.success).toBe(false); + // Record step rollback drops the tracking row... + expect(mockHost.deleteClaudeCliImportRecord).toHaveBeenCalledWith({ + importedSessionId: "imported-session-id", + }); + // ...and the import step rollback removes the copied snapshot. + expect(mockHost.deleteClaudeCliImport).toHaveBeenCalledWith({ + repoPath: "/repo", + importedSessionId: "imported-session-id", + }); + }); + + it("does not import a Claude CLI session for non-local workspace modes", async () => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + + const saga = makeSaga({ + createTask: createTaskMock, + createTaskRun: vi.fn().mockResolvedValue(createRun()), + startTaskRun: vi.fn().mockResolvedValue(startedTask), + }); + + await saga.run({ + content: "Ship the fix", + workspaceMode: "cloud", + branch: "main", + importedClaudeSession: { sourceSessionId: "source-session-id" }, + }); + + expect(mockHost.importClaudeCliSession).not.toHaveBeenCalled(); + expect(mockHost.recordClaudeCliImport).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index fef5e75dc..ca67c425f 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -18,7 +18,10 @@ import { import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; import type { TaskCreationApiClient } from "./taskCreationApiClient"; -import type { ITaskCreationHost } from "./taskCreationHost"; +import type { + ImportedClaudeCliSession, + ITaskCreationHost, +} from "./taskCreationHost"; export interface TaskCreationDeps { posthogClient: TaskCreationApiClient; @@ -50,12 +53,18 @@ export class TaskCreationSaga extends Saga< ? this.resolveFolder(input.repoPath) : undefined; + const importedClaude = await this.importClaudeSession(input); + let task = taskId ? await this.readOnlyStep("fetch_task", () => this.deps.posthogClient.getTask(taskId), ) : await this.createTask(input); + if (importedClaude && input.repoPath) { + await this.recordClaudeImport(input, importedClaude, task.id); + } + const repoKey = getTaskRepository(task); const repoPath = input.repoPath ?? @@ -123,6 +132,14 @@ export class TaskCreationSaga extends Saga< createdAt: workspaceInfo.worktree?.createdAt ?? new Date().toISOString(), }; + + // Link after the workspace row exists, so the branch-mismatch prompt can + // compare the session's branch against the live checkout. + if (importedClaude) { + this.linkImportedSessionBranch(input, task.id); + workspace.linkedBranch = + input.importedClaudeSession?.branch ?? workspace.linkedBranch; + } } else if (workspaceMode === "cloud") { await this.step({ name: "cloud_workspace_creation", @@ -389,6 +406,10 @@ export class TaskCreationSaga extends Saga< if (input.model) connectParams.model = input.model; if (input.reasoningLevel) connectParams.reasoningLevel = input.reasoningLevel; + if (importedClaude) { + connectParams.importedSessionId = importedClaude.importedSessionId; + connectParams.adapter = "claude"; + } this.deps.sessionService.connectToTask(connectParams); return { taskId: task.id }; @@ -405,6 +426,84 @@ export class TaskCreationSaga extends Saga< return { task, workspace }; } + /** + * Snapshot an existing Claude Code CLI transcript into the app's Claude + * config dir so the agent session can resume it. On rollback the copied + * transcript is removed so abandoned snapshots don't accumulate. + */ + private async importClaudeSession( + input: TaskCreationInput, + ): Promise { + const repoPath = input.repoPath; + if ( + input.taskId || + !input.importedClaudeSession || + !repoPath || + (input.workspaceMode ?? "local") !== "local" + ) { + return undefined; + } + const { sourceSessionId } = input.importedClaudeSession; + return this.step({ + name: "import_claude_session", + execute: () => + this.deps.host.importClaudeCliSession({ repoPath, sourceSessionId }), + rollback: (imported) => + this.deps.host.deleteClaudeCliImport({ + repoPath, + importedSessionId: imported.importedSessionId, + }), + }); + } + + /** + * Link the task to the branch the CLI session worked on (best-effort, no + * checkout). The standard branch-mismatch prompt then offers to switch if + * the local checkout is elsewhere — consistent with how the app handles + * sending a message on a differing branch. + */ + private linkImportedSessionBranch( + input: TaskCreationInput, + taskId: string, + ): void { + const branchName = input.importedClaudeSession?.branch; + if (!branchName) return; + this.deps.host.linkTaskBranch({ taskId, branchName }).catch((error) => { + this.log.warn("Failed to link imported session branch", { error }); + }); + } + + /** + * Persist the import tracking row so the source session lists as `imported` + * and reopens to this task. A first-class step paired with the import: on + * rollback the row is dropped (by imported session id), so a later-step + * failure can never leave a row pointing at a discarded task. Awaited so it + * is ordered before any step that could trigger that rollback. + */ + private async recordClaudeImport( + input: TaskCreationInput, + imported: ImportedClaudeCliSession, + taskId: string, + ): Promise { + const sourceSessionId = input.importedClaudeSession?.sourceSessionId; + const repoPath = input.repoPath; + if (!sourceSessionId || !repoPath) return; + const { importedSessionId, fingerprint } = imported; + await this.step({ + name: "record_claude_import", + execute: () => + this.deps.host.recordClaudeCliImport({ + sourceSessionId, + importedSessionId, + repoPath, + taskId, + fingerprint, + }), + rollback: () => + this.deps.host.deleteClaudeCliImportRecord({ importedSessionId }), + }); + } + private async resolveFolder(repoPath: string) { const folders = await this.deps.host.getFolders(); let existingFolder = folders.find((f) => f.path === repoPath); diff --git a/packages/core/src/task-detail/taskService.ts b/packages/core/src/task-detail/taskService.ts index 0f9558bf8..0989046c3 100644 --- a/packages/core/src/task-detail/taskService.ts +++ b/packages/core/src/task-detail/taskService.ts @@ -61,7 +61,12 @@ export class TaskService { hasRepo: !!input.repository, }); - if (!input.content?.trim()) { + // Imported Claude Code sessions carry a transcript, not a typed prompt, so + // they supply a taskDescription instead of content. + const hasDescription = + !!input.content?.trim() || + (!!input.importedClaudeSession && !!input.taskDescription?.trim()); + if (!hasDescription) { return { success: false, error: "Task description cannot be empty", diff --git a/packages/ui/src/features/task-detail/components/ContinueCliSessionPicker.tsx b/packages/ui/src/features/task-detail/components/ContinueCliSessionPicker.tsx new file mode 100644 index 000000000..8240815c3 --- /dev/null +++ b/packages/ui/src/features/task-detail/components/ContinueCliSessionPicker.tsx @@ -0,0 +1,211 @@ +import { ClockCounterClockwise, GitBranch } from "@phosphor-icons/react"; +import { + TASK_SERVICE, + type TaskService, +} from "@posthog/core/task-detail/taskService"; +import { useService } from "@posthog/di/react"; +import { formatRelativeTimeShort, type WorkspaceMode } from "@posthog/shared"; +import { navigateToTaskDetail } from "@posthog/ui/router/navigationBridge"; +import { openTask } from "@posthog/ui/router/useOpenTask"; +import { Flex, Popover, Spinner, Text } from "@radix-ui/themes"; +import { useState } from "react"; +import { toast } from "../../../primitives/toast"; +import { useCreateTask } from "../../tasks/useTaskCrudMutations"; +import { useClaudeCliSessions } from "../hooks/useClaudeCliSessions"; + +interface CliSessionRow { + sourceSessionId: string; + title: string | null; + lastPrompt: string | null; + updatedAt: string; + gitBranch: string | null; + status: "new" | "imported" | "updated"; + importedTaskId: string | null; +} + +interface ContinueCliSessionPickerProps { + repoPath: string | null; + workspaceMode: WorkspaceMode; + disabled?: boolean; +} + +function sessionLabel(session: CliSessionRow): string { + return ( + session.title?.trim() || + session.lastPrompt?.trim() || + "Untitled Claude Code session" + ); +} + +const actionButtonClass = + "shrink-0 cursor-pointer rounded border border-(--gray-6) px-1.5 py-0.5 text-[11px] text-(--gray-11) hover:border-(--gray-8) hover:text-(--gray-12) disabled:cursor-not-allowed disabled:opacity-50"; + +export function ContinueCliSessionPicker({ + repoPath, + workspaceMode, + disabled, +}: ContinueCliSessionPickerProps) { + // Imports always resume against the main repo checkout, so the affordance + // shows for any non-cloud mode; the created task itself runs in local mode. + const enabled = workspaceMode !== "cloud"; + const query = useClaudeCliSessions(repoPath, enabled); + const taskService = useService(TASK_SERVICE); + const { invalidateTasks } = useCreateTask(); + const [open, setOpen] = useState(false); + const [importingId, setImportingId] = useState(null); + + const sessions = (query.data?.sessions ?? []) as CliSessionRow[]; + if (!enabled || !repoPath || sessions.length === 0) return null; + + const handleImport = async (session: CliSessionRow) => { + if (importingId) return; + setImportingId(session.sourceSessionId); + try { + const result = await taskService.createTask( + { + repoPath, + workspaceMode: "local", + taskDescription: sessionLabel(session), + importedClaudeSession: { + sourceSessionId: session.sourceSessionId, + branch: session.gitBranch, + }, + }, + (output) => { + invalidateTasks(output.task); + void openTask(output.task); + }, + ); + if (result.success) { + setOpen(false); + } else { + toast.error("Failed to import Claude Code session", { + description: result.error, + }); + } + } finally { + setImportingId(null); + void query.refetch(); + } + }; + + const handleOpenImported = (session: CliSessionRow) => { + if (!session.importedTaskId) return; + setOpen(false); + navigateToTaskDetail(session.importedTaskId); + }; + + return ( + { + setOpen(next); + if (next) void query.refetch(); + }} + > + + + + + {workspaceMode !== "local" && ( + + Imported sessions open as local tasks + + )} +
+ {sessions.map((session) => { + const isImporting = importingId === session.sourceSessionId; + const canOpen = + session.status !== "new" && !!session.importedTaskId; + return ( +
+ + {isImporting ? ( + + ) : session.status === "new" ? ( + + ) : ( + + {canOpen && ( + + )} + {session.status === "updated" && ( + + )} + + )} +
+ ); + })} +
+
+
+ ); +} diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index c249d3c69..c2ca6d7b8 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -56,6 +56,7 @@ import { useInitialDirectoryFromFolderId } from "../hooks/useInitialDirectoryFro import { usePreviewConfig } from "../hooks/usePreviewConfig"; import { useTaskCreation } from "../hooks/useTaskCreation"; import { CloudGithubMissingNotice } from "./CloudGithubMissingNotice"; +import { ContinueCliSessionPicker } from "./ContinueCliSessionPicker"; import { type SuggestedPrompt, SuggestedPromptCard, @@ -970,6 +971,11 @@ export function TaskInput({ )} +
{suggestions ? ( diff --git a/packages/ui/src/features/task-detail/hooks/useClaudeCliSessions.ts b/packages/ui/src/features/task-detail/hooks/useClaudeCliSessions.ts new file mode 100644 index 000000000..b2a41b7e8 --- /dev/null +++ b/packages/ui/src/features/task-detail/hooks/useClaudeCliSessions.ts @@ -0,0 +1,21 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQuery } from "@tanstack/react-query"; + +/** Recent Claude Code CLI sessions in ~/.claude for the selected repo. */ +export function useClaudeCliSessions( + repoPath: string | null | undefined, + enabled: boolean, +) { + const trpc = useHostTRPC(); + return useQuery( + trpc.claudeCliSessions.list.queryOptions( + { repoPath: repoPath ?? "" }, + { + enabled: enabled && !!repoPath, + staleTime: 30_000, + // Local IPC call — never gate it on navigator.onLine. + networkMode: "always", + }, + ), + ); +} diff --git a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts index d88885507..1b2c74ac1 100644 --- a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts +++ b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts @@ -12,7 +12,9 @@ import type { CreatedWorkspaceInfo, CreateWorkspaceArgs, DetectedRepo, + ImportedClaudeCliSession, ITaskCreationHost, + RecordClaudeCliImportArgs, SetupActionDispatch, TaskEnvironment, TaskFolderInfo, @@ -186,4 +188,35 @@ export class TrpcTaskCreationHost implements ITaskCreationHost { props, ); } + + importClaudeCliSession(args: { + repoPath: string; + sourceSessionId: string; + }): Promise { + return hostClient().claudeCliSessions.import.mutate(args); + } + + async deleteClaudeCliImport(args: { + repoPath: string; + importedSessionId: string; + }): Promise { + await hostClient().claudeCliSessions.deleteImport.mutate(args); + } + + async recordClaudeCliImport(args: RecordClaudeCliImportArgs): Promise { + await hostClient().claudeCliSessions.recordImport.mutate(args); + } + + async deleteClaudeCliImportRecord(args: { + importedSessionId: string; + }): Promise { + await hostClient().claudeCliSessions.deleteImportRecord.mutate(args); + } + + async linkTaskBranch(args: { + taskId: string; + branchName: string; + }): Promise { + await hostClient().workspace.linkBranch.mutate(args); + } }