diff --git a/bun.lock b/bun.lock index 0059a87..1de3f23 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@electric-sql/client": "^1.5.4", "@octokit/webhooks": "^14.2.0", "@opencode-ai/sdk": "^1.2.1", + "@pierre/diffs": "^1.0.11", "@tailwindcss/vite": "^4.1.18", "@tanstack/electric-db-collection": "^0.2.33", "@tanstack/hotkeys": "^0.4.1", @@ -597,6 +598,8 @@ "@oxlint/win32-x64": ["@oxlint/win32-x64@1.42.0", "", { "os": "win32", "cpu": "x64" }, "sha512-3/KmyUOHNriL6rLpaFfm9RJxdhpXY2/Ehx9UuorJr2pUA+lrZL15FAEx/DOszYm5r10hfzj40+efAHcCilNvSQ=="], + "@pierre/diffs": ["@pierre/diffs@1.0.11", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "^3.0.0", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-j6zIEoyImQy1HfcJqbrDwP0O5I7V2VNXAaw53FqQ+SykRfaNwABeZHs9uibXO4supaXPmTx6LEH9Lffr03e1Tw=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], @@ -785,6 +788,8 @@ "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + "@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], + "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -1707,6 +1712,8 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], + "lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], diff --git a/package.json b/package.json index b24c100..7180f52 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@electric-sql/client": "^1.5.4", "@octokit/webhooks": "^14.2.0", "@opencode-ai/sdk": "^1.2.1", + "@pierre/diffs": "^1.0.11", "@tailwindcss/vite": "^4.1.18", "@tanstack/electric-db-collection": "^0.2.33", "@tanstack/hotkeys": "^0.4.1", diff --git a/packages/desktop-shell/src/desktop-runner.mts b/packages/desktop-shell/src/desktop-runner.mts index 15ccf27..b0fcaae 100644 --- a/packages/desktop-shell/src/desktop-runner.mts +++ b/packages/desktop-shell/src/desktop-runner.mts @@ -37,6 +37,14 @@ type RunnerModelProvider = { name: string; }; +type RunnerDiff = { + additions: number; + after: string; + before: string; + deletions: number; + file: string; +}; + type ListRunnerModelsResponse = { connected: string[]; default: Record; @@ -75,6 +83,7 @@ type AppRunnerController = { workspaceDirectory: string; }>; deleteRunnerWorkspace: (args: DeleteRunnerWorkspaceArgs) => Promise; + getRunnerDiff: (args: { directory: string; sessionId: string }) => Promise; listRunnerModels: (args: { directory: string }) => Promise; openWorkspaceInEditor: (args: OpenWorkspaceInEditorArgs) => Promise; promptRunnerTask: (args: PromptRunnerTaskArgs) => Promise; @@ -94,6 +103,10 @@ type DeleteWorkspaceResponse = { ok: boolean; }; +type GetRunnerDiffResponse = { + diffs: RunnerDiff[]; +}; + export function createDesktopRunnerController({ workspaceRoot, }: { @@ -138,6 +151,13 @@ export function createDesktopRunnerController({ ); } + async function getRunnerDiff(args: { + directory: string; + sessionId: string; + }): Promise { + return await requestRunnerDiff(args); + } + async function promptRunnerTask(args: PromptRunnerTaskArgs): Promise { const runner = await ensureRunner(); const payload = await postRunnerJson( @@ -264,9 +284,25 @@ export function createDesktopRunnerController({ return nextRunnerProcess; } + async function requestRunnerDiff(args: { + directory: string; + sessionId: string; + }): Promise { + const runner = await ensureRunner(); + const payload = await getRunnerJson( + `${runner.baseUrl}/assistant/session/diff?${new URLSearchParams({ + directory: args.directory, + sessionId: args.sessionId, + }).toString()}`, + ); + + return payload.diffs; + } + return { createRunnerSession, deleteRunnerWorkspace, + getRunnerDiff, listRunnerModels, openWorkspaceInEditor, promptRunnerTask, diff --git a/packages/desktop-shell/src/index.mts b/packages/desktop-shell/src/index.mts index 812d422..563f4d9 100644 --- a/packages/desktop-shell/src/index.mts +++ b/packages/desktop-shell/src/index.mts @@ -49,6 +49,10 @@ function registerIpcHandlers(): void { return await getDesktopRunnerController().deleteRunnerWorkspace(args); }); + ipcMain.handle("desktop-runner:get-diff", async (_event, args) => { + return await getDesktopRunnerController().getRunnerDiff(args); + }); + ipcMain.handle("desktop-runner:list-models", async (_event, args) => { return await getDesktopRunnerController().listRunnerModels(args); }); diff --git a/packages/desktop-shell/src/preload.mts b/packages/desktop-shell/src/preload.mts index 2343d6d..74ed38f 100644 --- a/packages/desktop-shell/src/preload.mts +++ b/packages/desktop-shell/src/preload.mts @@ -7,6 +7,9 @@ contextBridge.exposeInMainWorld("clankiDesktop", { deleteRunnerWorkspace(workspaceDirectory: string) { return ipcRenderer.invoke("desktop-runner:delete-workspace", { workspaceDirectory }); }, + getRunnerDiff(args: { directory: string; sessionId: string }) { + return ipcRenderer.invoke("desktop-runner:get-diff", args); + }, listRunnerModels(args: { directory: string }) { return ipcRenderer.invoke("desktop-runner:list-models", args); }, diff --git a/packages/runner/src/assistant-session-diff.ts b/packages/runner/src/assistant-session-diff.ts new file mode 100644 index 0000000..3310005 --- /dev/null +++ b/packages/runner/src/assistant-session-diff.ts @@ -0,0 +1,143 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import type { FileDiff } from "@opencode-ai/sdk"; + +export async function getAssistantSessionDiff(args: { + directory: string; + messageId?: string; + sessionId: string; +}): Promise { + void args.messageId; + void args.sessionId; + + const directory = path.resolve(args.directory); + const defaultBranch = resolveDefaultBranch(directory); + fetchDefaultBranch(directory, defaultBranch); + + const mergeBase = runGitCommand( + directory, + ["merge-base", `origin/${defaultBranch}`, "HEAD"], + "Failed to resolve merge base for workspace diff", + ).trim(); + + if (mergeBase.length === 0) { + throw new Error("Failed to resolve merge base for workspace diff"); + } + + const changedFiles = listChangedFiles(directory, mergeBase); + + return changedFiles.map((file) => { + const stats = readDiffStats(directory, mergeBase, file); + + return { + additions: stats.additions, + after: readWorkingTreeFile(directory, file), + before: readGitFile(directory, mergeBase, file), + deletions: stats.deletions, + file, + }; + }); +} + +function fetchDefaultBranch(directory: string, defaultBranch: string): void { + runGitCommand( + directory, + ["fetch", "origin", defaultBranch, "--prune"], + `Failed to fetch origin/${defaultBranch} for workspace diff`, + ); +} + +function listChangedFiles(directory: string, mergeBase: string): string[] { + const output = runGitCommand( + directory, + ["diff", "--name-only", "--no-renames", mergeBase, "--"], + "Failed to list workspace diff files", + ); + + return output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function readGitFile(directory: string, revision: string, file: string): string { + const output = spawnSync("git", ["-C", directory, "show", `${revision}:${file}`], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (output.status === 0) { + return output.stdout; + } + + const stderr = output.stderr.trim(); + if (stderr.includes("exists on disk, but not in") || stderr.includes("does not exist in")) { + return ""; + } + + throw new Error(stderr || `Failed to read ${file} from ${revision}`); +} + +function readDiffStats( + directory: string, + mergeBase: string, + file: string, +): { additions: number; deletions: number } { + const output = runGitCommand( + directory, + ["diff", "--numstat", "--no-renames", mergeBase, "--", file], + `Failed to read diff stats for ${file}`, + ).trim(); + + const [additionsRaw = "0", deletionsRaw = "0"] = output.split("\t"); + + return { + additions: Number.parseInt(additionsRaw, 10) || 0, + deletions: Number.parseInt(deletionsRaw, 10) || 0, + }; +} + +function readWorkingTreeFile(directory: string, file: string): string { + const absolutePath = path.join(directory, file); + if (!fs.existsSync(absolutePath)) { + return ""; + } + + return fs.readFileSync(absolutePath, "utf8"); +} + +function resolveDefaultBranch(directory: string): string { + const branch = runGitCommand( + directory, + ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + "Failed to resolve default branch for workspace diff", + ) + .trim() + .replace(/^origin\//u, ""); + + if (branch.length === 0) { + throw new Error("Failed to resolve default branch for workspace diff"); + } + + return branch; +} + +function runGitCommand(directory: string, args: string[], errorContext: string): string { + const output = spawnSync("git", ["-C", directory, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (output.error) { + throw new Error(`${errorContext}: ${output.error.message}`); + } + + if (output.status === 0) { + return output.stdout; + } + + const stderr = output.stderr.trim(); + const stdout = output.stdout.trim(); + throw new Error(`${errorContext}: ${stderr || stdout || `exit status ${output.status}`}`); +} diff --git a/packages/runner/src/index.ts b/packages/runner/src/index.ts index 8bbde4c..1ff8960 100644 --- a/packages/runner/src/index.ts +++ b/packages/runner/src/index.ts @@ -1,4 +1,5 @@ export * from "./assistant-session"; +export * from "./assistant-session-diff"; export * from "./local-runner-client"; export * from "./local-runner-protocol"; export * from "./local-runner-server"; diff --git a/packages/runner/src/local-runner-client.ts b/packages/runner/src/local-runner-client.ts index a194710..0244c16 100644 --- a/packages/runner/src/local-runner-client.ts +++ b/packages/runner/src/local-runner-client.ts @@ -5,6 +5,8 @@ import type { DeleteWorkspaceResponse, EnsureAssistantSessionRequest, EnsureAssistantSessionResponse, + GetAssistantSessionDiffRequest, + GetAssistantSessionDiffResponse, ListAssistantSessionsRequest, ListAssistantSessionsResponse, ListOpencodeModelsRequest, @@ -32,6 +34,22 @@ export function createLocalRunnerClient(baseUrl: string) { ): Promise { return await postJson(`${normalizedBaseUrl}/assistant/session/ensure`, body); }, + async getAssistantSessionDiff( + params: GetAssistantSessionDiffRequest, + ): Promise { + const searchParams = new URLSearchParams({ + directory: params.directory, + sessionId: params.sessionId, + }); + + if (params.messageId) { + searchParams.set("messageId", params.messageId); + } + + return await getJson( + `${normalizedBaseUrl}/assistant/session/diff?${searchParams.toString()}`, + ); + }, async health(): Promise { return await getJson(`${normalizedBaseUrl}/health`); }, diff --git a/packages/runner/src/local-runner-protocol.ts b/packages/runner/src/local-runner-protocol.ts index 35c0a10..f3da1b8 100644 --- a/packages/runner/src/local-runner-protocol.ts +++ b/packages/runner/src/local-runner-protocol.ts @@ -1,4 +1,4 @@ -import type { ProviderListResponse } from "@opencode-ai/sdk"; +import type { FileDiff, ProviderListResponse } from "@opencode-ai/sdk"; export const LOCAL_RUNNER_PROTOCOL_VERSION = "v1alpha1"; @@ -67,6 +67,16 @@ export type ListAssistantSessionsResponse = { sessions: AssistantSessionSummary[]; }; +export type GetAssistantSessionDiffRequest = { + directory: string; + messageId?: string; + sessionId: string; +}; + +export type GetAssistantSessionDiffResponse = { + diffs: FileDiff[]; +}; + export type PromptAssistantSessionRequest = { directory: string; model?: string; diff --git a/packages/runner/src/local-runner-server.ts b/packages/runner/src/local-runner-server.ts index 4a81200..63ddf5c 100644 --- a/packages/runner/src/local-runner-server.ts +++ b/packages/runner/src/local-runner-server.ts @@ -1,6 +1,7 @@ import type { Server } from "node:http"; import { createAdaptorServer } from "@hono/node-server"; import { Hono, type Context } from "hono"; +import { getAssistantSessionDiff } from "./assistant-session-diff"; import { ensureAssistantSession, promptAssistantSession } from "./assistant-session"; import { listAssistantSessions } from "./list-assistant-sessions"; import { @@ -8,6 +9,7 @@ import { type CreateAssistantSessionRequest, type DeleteWorkspaceRequest, type EnsureAssistantSessionRequest, + type GetAssistantSessionDiffRequest, type ListAssistantSessionsRequest, type ListOpencodeModelsRequest, type PromptAssistantSessionRequest, @@ -76,6 +78,20 @@ export function createLocalRunnerApp(): Hono { }); }); + app.get("/assistant/session/diff", async (c) => { + const directory = readDirectoryQuery(c); + const sessionId = readRequiredQuery(c, "sessionId"); + const messageId = readOptionalQuery(c, "messageId"); + + return c.json({ + diffs: await getAssistantSessionDiff({ + directory, + messageId, + sessionId, + } satisfies GetAssistantSessionDiffRequest), + }); + }); + app.post("/assistant/session/create", async (c) => { const body = await readJson(c); const workspaceDirectory = createWorkspace({ @@ -193,10 +209,19 @@ function setCorsHeaders(c: Context): void { } function readDirectoryQuery(c: Context): string { - const directory = c.req.query("directory")?.trim() ?? ""; - if (directory.length === 0) { - throw new RequestError("directory query parameter is required"); + return readRequiredQuery(c, "directory"); +} + +function readOptionalQuery(c: Context, key: string): string | undefined { + const value = c.req.query(key)?.trim(); + return value && value.length > 0 ? value : undefined; +} + +function readRequiredQuery(c: Context, key: string): string { + const value = readOptionalQuery(c, key) ?? ""; + if (value.length === 0) { + throw new RequestError(`${key} query parameter is required`); } - return directory; + return value; } diff --git a/src/components/task-page-code-view.tsx b/src/components/task-page-code-view.tsx new file mode 100644 index 0000000..7846473 --- /dev/null +++ b/src/components/task-page-code-view.tsx @@ -0,0 +1,108 @@ +import { MultiFileDiff } from "@pierre/diffs/react"; +import { Loader2 } from "lucide-react"; +import { useTheme } from "@/components/theme-provider"; + +interface TaskPageCodeViewProps { + diffErrorMessage: string | null; + diffs: + | Array<{ + additions: number; + after: string; + before: string; + deletions: number; + file: string; + }> + | undefined; + isDiffLoading: boolean; + isRunnerBackedTask: boolean; + preparingWorkspace: boolean; +} + +export function TaskPageCodeView({ + diffErrorMessage, + diffs, + isDiffLoading, + isRunnerBackedTask, + preparingWorkspace, +}: TaskPageCodeViewProps) { + const { theme } = useTheme(); + + if (preparingWorkspace) { + return ( +
+
+
+
+

Setting up worktree

+

Code mode will load as soon as the runner workspace is ready.

+
+
+ ); + } + + if (!isRunnerBackedTask) { + return ( +
+ Code mode is only available for runner-backed workspaces. +
+ ); + } + + if (isDiffLoading && !diffs) { + return ( +
+
+ ); + } + + if (diffErrorMessage) { + return ( +
+ {diffErrorMessage} +
+ ); + } + + if (!diffs || diffs.length === 0) { + return ( +
+ No code changes yet in this workspace. +
+ ); + } + + return ( +
+
+ {diffs.map((diff) => ( +
+
+ {diff.file} + + +{diff.additions} + -{diff.deletions} + +
+ +
+ ))} +
+
+ ); +} diff --git a/src/components/task-page-header.tsx b/src/components/task-page-header.tsx index c93356d..a33b9e8 100644 --- a/src/components/task-page-header.tsx +++ b/src/components/task-page-header.tsx @@ -1,6 +1,7 @@ import { useHotkey } from "@tanstack/react-hotkeys"; import { ExternalLink } from "lucide-react"; import { OpenEditorDropdown } from "@/components/open-editor-dropdown"; +import { TaskPageViewToggle } from "@/components/task-page-view-toggle"; import { Button } from "@/components/ui/button"; import { Kbd } from "@/components/ui/kbd"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -30,6 +31,9 @@ interface TaskPageHeaderProps { } | null; desktopApp: boolean; isRunnerBackedTask: boolean; + showCodeModeToggle: boolean; + onViewModeChange: (mode: "chat" | "code") => void; + viewMode: "chat" | "code"; workspacePath: string | null; sending: boolean; isRunning: boolean; @@ -44,6 +48,9 @@ export function TaskPageHeader({ pullRequest, desktopApp, isRunnerBackedTask, + showCodeModeToggle, + onViewModeChange, + viewMode, workspacePath, sending, isRunning, @@ -93,6 +100,11 @@ export function TaskPageHeader({
+ {desktopApp && isRunnerBackedTask && workspacePath ? ( ) : null} diff --git a/src/components/task-page-view-toggle.tsx b/src/components/task-page-view-toggle.tsx new file mode 100644 index 0000000..015476f --- /dev/null +++ b/src/components/task-page-view-toggle.tsx @@ -0,0 +1,52 @@ +import { Code2, MessagesSquare } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface TaskPageViewToggleProps { + onViewModeChange: (mode: "chat" | "code") => void; + showCodeMode: boolean; + viewMode: "chat" | "code"; +} + +export function TaskPageViewToggle({ + onViewModeChange, + showCodeMode, + viewMode, +}: TaskPageViewToggleProps) { + if (!showCodeMode) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/src/lib/desktop-runner.ts b/src/lib/desktop-runner.ts index 9f770b6..d6cdf08 100644 --- a/src/lib/desktop-runner.ts +++ b/src/lib/desktop-runner.ts @@ -17,6 +17,14 @@ export type DesktopRunnerModelProvider = { name: string; }; +export type DesktopRunnerDiff = { + additions: number; + after: string; + before: string; + deletions: number; + file: string; +}; + export type ListDesktopRunnerModelsResponse = { connected: string[]; default: Record; @@ -29,6 +37,7 @@ type DesktopRunnerBridge = { repoUrl: string, ) => Promise; deleteRunnerWorkspace: (workspaceDirectory: string) => Promise; + getRunnerDiff: (args: { directory: string; sessionId: string }) => Promise; listRunnerModels: (args: { directory: string }) => Promise; openWorkspaceInEditor: (args: { editor: DesktopWorkspaceEditor; @@ -71,6 +80,13 @@ export async function deleteDesktopRunnerWorkspace(workspaceDirectory: string): await getDesktopRunnerBridge().deleteRunnerWorkspace(workspaceDirectory); } +export async function getDesktopRunnerDiff(args: { + directory: string; + sessionId: string; +}): Promise { + return await getDesktopRunnerBridge().getRunnerDiff(args); +} + export async function listDesktopRunnerModels(args: { directory: string; }): Promise { diff --git a/src/lib/runner-diffs.ts b/src/lib/runner-diffs.ts new file mode 100644 index 0000000..01f605d --- /dev/null +++ b/src/lib/runner-diffs.ts @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; +import type { DesktopRunnerDiff } from "@/lib/desktop-runner"; +import { getDesktopRunnerDiff } from "@/lib/desktop-runner"; +import { isDesktopApp } from "@/lib/is-desktop-app"; + +type UseRunnerDiffArgs = { + directory: string | null; + enabled?: boolean; + refetchIntervalMs?: number; + sessionId: string | null; +}; + +export function useRunnerDiff({ + directory, + enabled = true, + refetchIntervalMs, + sessionId, +}: UseRunnerDiffArgs) { + const desktopApp = isDesktopApp(); + const normalizedDirectory = directory?.trim() ?? ""; + const normalizedSessionId = sessionId?.trim() ?? ""; + + return useQuery({ + queryKey: ["runner-diff", normalizedDirectory, normalizedSessionId], + queryFn: async () => + await getDesktopRunnerDiff({ + directory: normalizedDirectory, + sessionId: normalizedSessionId, + }), + enabled: + enabled && desktopApp && normalizedDirectory.length > 0 && normalizedSessionId.length > 0, + gcTime: Number.POSITIVE_INFINITY, + refetchInterval: refetchIntervalMs, + refetchOnWindowFocus: false, + staleTime: 2_000, + }); +} diff --git a/src/lib/session-state.ts b/src/lib/session-state.ts index a60b9e7..d4c5687 100644 --- a/src/lib/session-state.ts +++ b/src/lib/session-state.ts @@ -132,6 +132,11 @@ export const sessionStateKeys = { "session", `task-model:${taskId}`, ), + taskView: (taskId: string) => + createStorageStateKey<"chat" | "code">("session", `task-view:${taskId}`, { + parse: (value) => (value === "code" ? "code" : "chat"), + serialize: (value) => value, + }), }; export const localStorageKeys = { diff --git a/src/pages/task-page.tsx b/src/pages/task-page.tsx index 5bcfe9b..05abe79 100644 --- a/src/pages/task-page.tsx +++ b/src/pages/task-page.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useLiveQuery, eq } from "@tanstack/react-db"; import { AlertCircle, Loader2 } from "lucide-react"; +import { TaskPageCodeView } from "@/components/task-page-code-view"; import { TaskPageHeader } from "@/components/task-page-header"; import { TaskPageMessageList } from "@/components/task-page-message-list"; import { TaskPageInput } from "@/components/task-page-input"; @@ -13,6 +14,7 @@ import { useLocalStorageState, useSessionState, } from "@/lib/session-state"; +import { useRunnerDiff } from "@/lib/runner-diffs"; import { getDefaultRunnerModelSelection, getRunnerModelOptions, @@ -74,6 +76,7 @@ export function TaskPage({ sessionStateKeys.taskModel(taskId), null, ); + const [viewMode, setViewMode] = useSessionState(sessionStateKeys.taskView(taskId), "chat"); const [lastUsedModel, setLastUsedModel] = useLocalStorageState( localStorageKeys.lastUsedTaskModel(), null, @@ -131,6 +134,18 @@ export function TaskPage({ : (selectedModel ?? lastUsedModel ?? defaultModelSelection); const runnerModelErrorMessage = runnerModelsError instanceof Error ? runnerModelsError.message : null; + const showCodeModeToggle = willBeRunnerBacked; + const { + data: diffs, + error: runnerDiffError, + isLoading: isDiffLoading, + } = useRunnerDiff({ + directory: isRunnerBackedTask ? workspacePath : null, + enabled: viewMode === "code", + refetchIntervalMs: isRunning ? 3_000 : undefined, + sessionId: isRunnerBackedTask ? runnerSessionId : null, + }); + const runnerDiffErrorMessage = runnerDiffError instanceof Error ? runnerDiffError.message : null; const displayError = localError ?? error; useEffect(() => { @@ -145,6 +160,12 @@ export function TaskPage({ shouldStickToBottomRef.current = true; }, [taskId]); + useEffect(() => { + if (!showCodeModeToggle && viewMode !== "chat") { + setViewMode("chat"); + } + }, [setViewMode, showCodeModeToggle, viewMode]); + useEffect(() => { if (messages.length > 0) { return; @@ -268,6 +289,9 @@ export function TaskPage({ pullRequest={pullRequest} desktopApp={desktopApp} isRunnerBackedTask={isRunnerBackedTask} + showCodeModeToggle={showCodeModeToggle} + onViewModeChange={setViewMode} + viewMode={viewMode} workspacePath={workspacePath} sending={sending} isRunning={isRunning} @@ -284,16 +308,26 @@ export function TaskPage({
) : null} - + {viewMode === "code" ? ( + + ) : ( + + )}