From a1f6a6d9a20578391fbc5b9311ffa53791ebd1fc Mon Sep 17 00:00:00 2001 From: Marcelino Coll Date: Wed, 20 May 2026 21:38:46 +0000 Subject: [PATCH 01/21] feat(code): add plan view with threaded inline comments Add a spacious Plan tab that renders the agent's plan markdown and lets the user attach threaded `[H]:` / `[A]:` comments anchored to specific blocks. Threads live as plain markdown blockquotes in the plan file, so the agent can read and edit them as part of the same document. - PlansWatcherService watches `~/.claude/plans/` and exposes read / append-thread / resolve-thread mutations via tRPC - remarkPlanThreads rewrites `> [H]:` / `> [A]:` / `> [resolved]` blockquotes into custom `` nodes and annotates every other top-level block with a `data-plan-block` source snippet - PlanView renders the plan through the existing MarkdownRenderer with a hover `+` gutter button per block; the compose popover and PlanThread reuse the InputGroup / Avatar patterns from the PR review thread UI - Submitting a comment writes to the file and auto-prompts the agent to reply on the same thread; resolving asks the agent to integrate the feedback and remove the thread block - The Plan tab is registered the first time the agent writes a plan file (detected from session tool calls), and the cramped approval preview gets an "Open in Plan view" button Generated-By: PostHog Code Task-Id: c2ee090b-484a-4c47-aae0-0876862b1ebf --- apps/code/src/main/di/container.ts | 2 + apps/code/src/main/di/tokens.ts | 1 + .../main/services/plans-watcher/schemas.ts | 53 ++++ .../services/plans-watcher/service.test.ts | 108 ++++++++ .../main/services/plans-watcher/service.ts | 256 ++++++++++++++++++ apps/code/src/main/trpc/router.ts | 2 + apps/code/src/main/trpc/routers/plans.ts | 51 ++++ .../components/permissions/PlanContent.tsx | 65 ++++- .../panels/constants/panelConstants.ts | 1 + .../features/panels/store/panelLayoutStore.ts | 70 +++++ .../features/panels/store/panelTypes.ts | 4 + .../plans/components/PlanBlockGutter.tsx | 61 +++++ .../plans/components/PlanComposePopover.tsx | 118 ++++++++ .../features/plans/components/PlanThread.tsx | 223 +++++++++++++++ .../features/plans/components/PlanView.tsx | 163 +++++++++++ .../features/plans/hooks/usePlanFilePath.ts | 24 ++ .../features/plans/hooks/usePlanTab.ts | 21 ++ .../plans/remark/remarkPlanThreads.ts | 161 +++++++++++ .../features/plans/stores/planComposeStore.ts | 41 +++ .../features/plans/utils/planFilePath.ts | 55 ++++ .../features/plans/utils/planPrompts.ts | 46 ++++ .../components/TabContentRenderer.tsx | 4 + .../task-detail/components/TaskDetail.tsx | 2 + 23 files changed, 1520 insertions(+), 12 deletions(-) create mode 100644 apps/code/src/main/services/plans-watcher/schemas.ts create mode 100644 apps/code/src/main/services/plans-watcher/service.test.ts create mode 100644 apps/code/src/main/services/plans-watcher/service.ts create mode 100644 apps/code/src/main/trpc/routers/plans.ts create mode 100644 apps/code/src/renderer/features/plans/components/PlanBlockGutter.tsx create mode 100644 apps/code/src/renderer/features/plans/components/PlanComposePopover.tsx create mode 100644 apps/code/src/renderer/features/plans/components/PlanThread.tsx create mode 100644 apps/code/src/renderer/features/plans/components/PlanView.tsx create mode 100644 apps/code/src/renderer/features/plans/hooks/usePlanFilePath.ts create mode 100644 apps/code/src/renderer/features/plans/hooks/usePlanTab.ts create mode 100644 apps/code/src/renderer/features/plans/remark/remarkPlanThreads.ts create mode 100644 apps/code/src/renderer/features/plans/stores/planComposeStore.ts create mode 100644 apps/code/src/renderer/features/plans/utils/planFilePath.ts create mode 100644 apps/code/src/renderer/features/plans/utils/planPrompts.ts diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 959ea1431..4b7f83e72 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -53,6 +53,7 @@ import { McpCallbackService } from "../services/mcp-callback/service"; import { McpProxyService } from "../services/mcp-proxy/service"; import { NotificationService } from "../services/notification/service"; import { OAuthService } from "../services/oauth/service"; +import { PlansWatcherService } from "../services/plans-watcher/service"; import { PosthogPluginService } from "../services/posthog-plugin/service"; import { ProcessTrackingService } from "../services/process-tracking/service"; import { ProvisioningService } from "../services/provisioning/service"; @@ -117,6 +118,7 @@ container.bind(MAIN_TOKENS.ExternalAppsService).to(ExternalAppsService); container.bind(MAIN_TOKENS.LlmGatewayService).to(LlmGatewayService); container.bind(MAIN_TOKENS.McpAppsService).to(McpAppsService); container.bind(MAIN_TOKENS.FileWatcherService).to(FileWatcherService); +container.bind(MAIN_TOKENS.PlansWatcherService).to(PlansWatcherService); container.bind(MAIN_TOKENS.FocusService).to(FocusService); container.bind(MAIN_TOKENS.FocusSyncService).to(FocusSyncService); container.bind(MAIN_TOKENS.FoldersService).to(FoldersService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index c8225b2b1..00d3cf8cb 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -52,6 +52,7 @@ export const MAIN_TOKENS = Object.freeze({ LlmGatewayService: Symbol.for("Main.LlmGatewayService"), McpAppsService: Symbol.for("Main.McpAppsService"), FileWatcherService: Symbol.for("Main.FileWatcherService"), + PlansWatcherService: Symbol.for("Main.PlansWatcherService"), FocusService: Symbol.for("Main.FocusService"), FocusSyncService: Symbol.for("Main.FocusSyncService"), FoldersService: Symbol.for("Main.FoldersService"), diff --git a/apps/code/src/main/services/plans-watcher/schemas.ts b/apps/code/src/main/services/plans-watcher/schemas.ts new file mode 100644 index 000000000..6a6840684 --- /dev/null +++ b/apps/code/src/main/services/plans-watcher/schemas.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +export const PlansWatcherEvent = { + PlanFileChanged: "plan-file-changed", + PlanFileDeleted: "plan-file-deleted", +} as const; + +export type PlanFileChangedPayload = { + filePath: string; +}; + +export type PlanFileDeletedPayload = { + filePath: string; +}; + +export interface PlansWatcherEvents { + [PlansWatcherEvent.PlanFileChanged]: PlanFileChangedPayload; + [PlansWatcherEvent.PlanFileDeleted]: PlanFileDeletedPayload; +} + +export const planReadInput = z.object({ + filePath: z.string(), +}); + +export const planReadOutput = z.object({ + content: z.string().nullable(), +}); + +export const speakerSchema = z.enum(["H", "A"]); + +/** + * `blockText` is the verbatim source markdown text of the block the thread is + * anchored to (e.g. the paragraph or heading the user clicked `+` on). The + * main process finds the matching block in the file by string-searching for + * this snippet, then inserts or extends the trailing thread blockquote. + */ +export const planAppendInput = z.object({ + filePath: z.string(), + blockText: z.string().min(1), + message: z.string().min(1), + speaker: speakerSchema, +}); + +export const planResolveInput = z.object({ + filePath: z.string(), + blockText: z.string().min(1), +}); + +export type PlanReadInput = z.infer; +export type PlanReadOutput = z.infer; +export type PlanAppendInput = z.infer; +export type PlanResolveInput = z.infer; +export type Speaker = z.infer; diff --git a/apps/code/src/main/services/plans-watcher/service.test.ts b/apps/code/src/main/services/plans-watcher/service.test.ts new file mode 100644 index 000000000..334ccb25f --- /dev/null +++ b/apps/code/src/main/services/plans-watcher/service.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + findBlockInsertionLine, + findExistingThreadRange, + formatThreadLine, + isThreadLine, +} from "./service"; + +const SAMPLE_PLAN = `# Plan + +## Step 1: Refactor auth middleware + +Move session validation from middleware to a dedicated service. + +## Step 2: Update tests + +Add coverage for the new service. +`; + +describe("plans-watcher helpers", () => { + describe("isThreadLine", () => { + it("recognises `[H]:` lines", () => { + expect(isThreadLine("> [H]: hello")).toBe(true); + }); + it("recognises `[A]:` lines", () => { + expect(isThreadLine("> [A]: hi back")).toBe(true); + }); + it("recognises `[resolved]` markers", () => { + expect(isThreadLine("> [resolved]")).toBe(true); + }); + it("ignores regular blockquotes", () => { + expect(isThreadLine("> just a quote")).toBe(false); + }); + it("ignores blank lines and paragraphs", () => { + expect(isThreadLine("")).toBe(false); + expect(isThreadLine("Move session validation…")).toBe(false); + }); + }); + + describe("findBlockInsertionLine", () => { + it("returns the line after the matched block", () => { + const lines = SAMPLE_PLAN.split("\n"); + const line = findBlockInsertionLine( + lines, + "Move session validation from middleware", + ); + // Source line 4 (0-indexed) is the paragraph; insertion is line 5. + expect(line).toBe(5); + }); + + it("matches across multiple source lines", () => { + const lines = ["a paragraph", "spanning", "three lines", "", "next"]; + const line = findBlockInsertionLine(lines, "paragraph\nspanning\nthree"); + expect(line).toBe(3); + }); + + it("returns null when no block matches", () => { + expect(findBlockInsertionLine(SAMPLE_PLAN.split("\n"), "nope")).toBe( + null, + ); + }); + }); + + describe("findExistingThreadRange", () => { + it("returns null when no thread exists", () => { + const lines = ["paragraph", "", "another paragraph"]; + expect(findExistingThreadRange(lines, 1)).toBe(null); + }); + + it("identifies a contiguous thread block", () => { + const lines = [ + "paragraph", + "", + "> [H]: question", + "> [A]: answer", + "", + "next", + ]; + const range = findExistingThreadRange(lines, 1); + expect(range).toEqual({ start: 2, end: 4 }); + }); + + it("includes a trailing `[resolved]` marker", () => { + const lines = [ + "paragraph", + "", + "> [H]: question", + "> [A]: answer", + "> [resolved]", + "", + "next", + ]; + const range = findExistingThreadRange(lines, 1); + expect(range).toEqual({ start: 2, end: 5 }); + }); + }); + + describe("formatThreadLine", () => { + it("emits a single-line blockquote with the speaker tag", () => { + expect(formatThreadLine("H", "hello world")).toBe("> [H]: hello world"); + }); + it("collapses newlines in the message", () => { + expect(formatThreadLine("A", "line one\n line two")).toBe( + "> [A]: line one line two", + ); + }); + }); +}); diff --git a/apps/code/src/main/services/plans-watcher/service.ts b/apps/code/src/main/services/plans-watcher/service.ts new file mode 100644 index 000000000..55c4ba822 --- /dev/null +++ b/apps/code/src/main/services/plans-watcher/service.ts @@ -0,0 +1,256 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import * as watcher from "@parcel/watcher"; +import { inject, injectable, preDestroy } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import type { WatcherRegistryService } from "../watcher-registry/service"; +import { + type PlanAppendInput, + type PlanResolveInput, + PlansWatcherEvent, + type PlansWatcherEvents, + type Speaker, +} from "./schemas"; + +const log = logger.scope("plans-watcher"); +const DEBOUNCE_MS = 100; +const WATCHER_ID = "plans-watcher:plans-dir"; + +/** Mirrors `getClaudePlansDir` in @posthog/agent — kept local to avoid a new subpath export. */ +function getClaudePlansDir(): string { + const configDir = + process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); + return path.join(configDir, "plans"); +} + +/** + * A thread is a contiguous markdown blockquote of lines like + * `> [H]: …`, `> [A]: …`, or `> [resolved]` placed immediately after the + * block it is anchored to (the user clicked `+` on the preceding paragraph + * / heading / list item). We never need to identify a thread by an opaque + * id — its anchor is the preceding block, located via verbatim text match. + */ +const THREAD_LINE_RE = /^\s*>\s*\[(H|A|resolved)\](?::\s*(.*))?$/; + +export function isThreadLine(line: string): boolean { + return THREAD_LINE_RE.test(line); +} + +export function findBlockInsertionLine( + lines: string[], + blockText: string, +): number | null { + const trimmed = blockText.trim(); + if (!trimmed) return null; + + // Try to match as a contiguous run of source lines that fully contains the + // user-supplied text. Walk the file line by line and check whether a + // window starting at each line — joined by newlines — contains `trimmed` + // as a substring. + for (let i = 0; i < lines.length; i += 1) { + if (!lines[i].trim()) continue; + let acc = lines[i]; + let j = i; + while (j < lines.length - 1 && !acc.includes(trimmed)) { + j += 1; + acc = `${acc}\n${lines[j]}`; + if (acc.length > trimmed.length + 400) break; + } + if (acc.includes(trimmed)) { + // The block ends on line `j`. Insertion point is `j + 1` (the line + // after the matched block). + return j + 1; + } + } + return null; +} + +export function findExistingThreadRange( + lines: string[], + startLine: number, +): { start: number; end: number } | null { + // Skip blank lines immediately after the anchor block. + let cursor = startLine; + while (cursor < lines.length && lines[cursor].trim() === "") cursor += 1; + if (cursor >= lines.length || !isThreadLine(lines[cursor])) return null; + + const threadStart = cursor; + while (cursor < lines.length && isThreadLine(lines[cursor])) cursor += 1; + return { start: threadStart, end: cursor }; +} + +export function formatThreadLine(speaker: Speaker, message: string): string { + // Collapse newlines so the message lives in a single blockquote line — the + // agent and parser both expect one line per message. + const oneLine = message.replace(/\s+/g, " ").trim(); + return `> [${speaker}]: ${oneLine}`; +} + +@injectable() +export class PlansWatcherService extends TypedEventEmitter { + private started = false; + private debounceTimers = new Map>(); + + constructor( + @inject(MAIN_TOKENS.WatcherRegistryService) + private watcherRegistry: WatcherRegistryService, + ) { + super(); + } + + @preDestroy() + async destroy(): Promise { + for (const timer of this.debounceTimers.values()) clearTimeout(timer); + this.debounceTimers.clear(); + await this.stop(); + } + + /** Idempotent — starts watching the plans directory if not already. */ + async ensureStarted(): Promise { + if (this.started) return; + this.started = true; + + const plansDir = getClaudePlansDir(); + try { + await fs.mkdir(plansDir, { recursive: true }); + } catch (err) { + log.warn(`Failed to ensure plans dir exists at ${plansDir}:`, err); + } + + try { + const subscription = await watcher.subscribe(plansDir, (err, events) => { + if (this.watcherRegistry.isShutdown) return; + if (err) { + log.warn("Plans watcher error:", err); + return; + } + for (const event of events) { + this.queueEvent(event); + } + }); + this.watcherRegistry.register(WATCHER_ID, subscription); + log.info(`Watching plans dir: ${plansDir}`); + } catch (err) { + log.error(`Failed to start plans watcher at ${plansDir}:`, err); + this.started = false; + } + } + + async stop(): Promise { + if (!this.started) return; + this.started = false; + await this.watcherRegistry.unregister(WATCHER_ID); + } + + async readPlan(filePath: string): Promise { + if (!this.isPlanFilePath(filePath)) { + throw new Error(`Refusing to read non-plan file: ${filePath}`); + } + try { + return await fs.readFile(filePath, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return null; + throw err; + } + } + + async appendThreadMessage(input: PlanAppendInput): Promise { + if (!this.isPlanFilePath(input.filePath)) { + throw new Error(`Refusing to write non-plan file: ${input.filePath}`); + } + const original = (await this.readPlan(input.filePath)) ?? ""; + const lines = original.split("\n"); + const insertionLine = findBlockInsertionLine(lines, input.blockText); + if (insertionLine === null) { + throw new Error("Plan thread anchor block not found in file"); + } + + const newLine = formatThreadLine(input.speaker, input.message); + const existing = findExistingThreadRange(lines, insertionLine); + + let next: string[]; + if (existing) { + // Extend the existing thread. If the last line is `> [resolved]`, insert + // before it so the resolved marker stays terminal. + const insertAt = + lines[existing.end - 1]?.trim() === "> [resolved]" + ? existing.end - 1 + : existing.end; + next = [...lines.slice(0, insertAt), newLine, ...lines.slice(insertAt)]; + } else { + // Create a new thread immediately after the anchor block. Ensure there + // is exactly one blank line between the block and the thread. + const prefix = lines.slice(0, insertionLine); + const suffix = lines.slice(insertionLine); + const needsBlank = prefix.length > 0 && prefix[prefix.length - 1] !== ""; + next = [...prefix, ...(needsBlank ? [""] : []), newLine, ...suffix]; + } + + await this.atomicWrite(input.filePath, next.join("\n")); + } + + async resolveThread(input: PlanResolveInput): Promise { + if (!this.isPlanFilePath(input.filePath)) { + throw new Error(`Refusing to write non-plan file: ${input.filePath}`); + } + const original = (await this.readPlan(input.filePath)) ?? ""; + const lines = original.split("\n"); + const insertionLine = findBlockInsertionLine(lines, input.blockText); + if (insertionLine === null) { + throw new Error("Plan thread anchor block not found in file"); + } + + const existing = findExistingThreadRange(lines, insertionLine); + if (!existing) { + throw new Error("No thread to resolve under that block"); + } + if (lines[existing.end - 1]?.trim() === "> [resolved]") { + return; // already resolved + } + + const next = [ + ...lines.slice(0, existing.end), + "> [resolved]", + ...lines.slice(existing.end), + ]; + await this.atomicWrite(input.filePath, next.join("\n")); + } + + private isPlanFilePath(filePath: string): boolean { + const resolved = path.resolve(filePath); + const plansDir = path.resolve(getClaudePlansDir()); + return resolved.startsWith(plansDir + path.sep) && resolved.endsWith(".md"); + } + + private async atomicWrite(filePath: string, content: string): Promise { + const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`; + await fs.writeFile(tmp, content, "utf8"); + await fs.rename(tmp, filePath); + } + + private queueEvent(event: watcher.Event): void { + if (!event.path.endsWith(".md")) return; + + const key = event.path; + const existing = this.debounceTimers.get(key); + if (existing) clearTimeout(existing); + this.debounceTimers.set( + key, + setTimeout(() => { + this.debounceTimers.delete(key); + if (event.type === "delete") { + this.emit(PlansWatcherEvent.PlanFileDeleted, { + filePath: event.path, + }); + } else { + this.emit(PlansWatcherEvent.PlanFileChanged, { + filePath: event.path, + }); + } + }, DEBOUNCE_MS), + ); + } +} diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 75a5c85c2..b9eb2bb41 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -25,6 +25,7 @@ import { mcpCallbackRouter } from "./routers/mcp-callback"; import { notificationRouter } from "./routers/notification"; import { oauthRouter } from "./routers/oauth"; import { osRouter } from "./routers/os"; +import { plansRouter } from "./routers/plans"; import { processTrackingRouter } from "./routers/process-tracking"; import { provisioningRouter } from "./routers/provisioning"; import { secureStoreRouter } from "./routers/secure-store"; @@ -65,6 +66,7 @@ export const trpcRouter = router({ oauth: oauthRouter, logs: logsRouter, os: osRouter, + plans: plansRouter, processTracking: processTrackingRouter, provisioning: provisioningRouter, sleep: sleepRouter, diff --git a/apps/code/src/main/trpc/routers/plans.ts b/apps/code/src/main/trpc/routers/plans.ts new file mode 100644 index 000000000..f662494f7 --- /dev/null +++ b/apps/code/src/main/trpc/routers/plans.ts @@ -0,0 +1,51 @@ +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { + PlansWatcherEvent, + type PlansWatcherEvents, + planAppendInput, + planReadInput, + planReadOutput, + planResolveInput, +} from "../../services/plans-watcher/schemas"; +import type { PlansWatcherService } from "../../services/plans-watcher/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => + container.get(MAIN_TOKENS.PlansWatcherService); + +function subscribe(event: K) { + return publicProcedure.subscription(async function* (opts) { + const service = getService(); + await service.ensureStarted(); + const iterable = service.toIterable(event, { signal: opts.signal }); + for await (const data of iterable) { + yield data; + } + }); +} + +export const plansRouter = router({ + read: publicProcedure + .input(planReadInput) + .output(planReadOutput) + .query(async ({ input }) => { + const service = getService(); + await service.ensureStarted(); + const content = await service.readPlan(input.filePath); + return { content }; + }), + + appendThreadMessage: publicProcedure + .input(planAppendInput) + .mutation(({ input }) => getService().appendThreadMessage(input)), + + resolveThread: publicProcedure + .input(planResolveInput) + .mutation(({ input }) => getService().resolveThread(input)), + + ensureWatching: publicProcedure.mutation(() => getService().ensureStarted()), + + onChanged: subscribe(PlansWatcherEvent.PlanFileChanged), + onDeleted: subscribe(PlansWatcherEvent.PlanFileDeleted), +}); diff --git a/apps/code/src/renderer/components/permissions/PlanContent.tsx b/apps/code/src/renderer/components/permissions/PlanContent.tsx index 0b12ebbb5..a8675ff59 100644 --- a/apps/code/src/renderer/components/permissions/PlanContent.tsx +++ b/apps/code/src/renderer/components/permissions/PlanContent.tsx @@ -1,5 +1,15 @@ -import { ArrowsIn, ArrowsOut, ListChecks, X } from "@phosphor-icons/react"; -import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; +import { DEFAULT_TAB_IDS } from "@features/panels/constants/panelConstants"; +import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; +import { findTabInTree } from "@features/panels/store/panelTree"; +import { useTaskStore } from "@features/tasks/stores/taskStore"; +import { + ArrowsIn, + ArrowsOut, + ListChecks, + SidebarSimple, + X, +} from "@phosphor-icons/react"; +import { Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import ReactMarkdown from "react-markdown"; @@ -12,9 +22,26 @@ interface PlanContentProps { plan: string; } +function openPlanTab(taskId: string): void { + const { taskLayouts, setActiveTab } = usePanelLayoutStore.getState(); + const layout = taskLayouts[taskId]; + if (!layout) return; + const result = findTabInTree(layout.panelTree, DEFAULT_TAB_IDS.PLAN); + if (result) { + setActiveTab(taskId, result.panelId, DEFAULT_TAB_IDS.PLAN); + } +} + export function PlanContent({ id, plan }: PlanContentProps) { const scrollRef = useRef(null); const [isFullscreen, setIsFullscreen] = useState(false); + const taskId = useTaskStore((s) => s.selectedTaskId); + const hasPlanTab = usePanelLayoutStore((state) => { + if (!taskId) return false; + const layout = state.taskLayouts[taskId]; + if (!layout) return false; + return !!findTabInTree(layout.panelTree, DEFAULT_TAB_IDS.PLAN); + }); useEffect(() => { const el = scrollRef.current; @@ -109,16 +136,30 @@ export function PlanContent({ id, plan }: PlanContentProps) { ref={scrollRef} className="relative max-h-[50vh] max-w-[750px] overflow-y-auto rounded-lg border-2 border-blue-6 bg-blue-2 p-4" > - setIsFullscreen(true)} - title="Expand to fullscreen" - > - - + + {taskId && hasPlanTab && ( + + openPlanTab(taskId)} + title="Open in Plan view" + > + + + + )} + setIsFullscreen(true)} + title="Expand to fullscreen" + > + + + {markdown} diff --git a/apps/code/src/renderer/features/panels/constants/panelConstants.ts b/apps/code/src/renderer/features/panels/constants/panelConstants.ts index aa990772c..108514a49 100644 --- a/apps/code/src/renderer/features/panels/constants/panelConstants.ts +++ b/apps/code/src/renderer/features/panels/constants/panelConstants.ts @@ -24,4 +24,5 @@ export const DEFAULT_TAB_IDS = { SHELL: "shell", FILES: "files", CHANGES: "changes", + PLAN: "plan", } as const; diff --git a/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts b/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts index 47c08f67c..dc62bcd2f 100644 --- a/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts +++ b/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts @@ -104,6 +104,7 @@ export interface PanelLayoutStore { label: string; }, ) => void; + ensurePlanTab: (taskId: string, filePath: string) => void; clearAllLayouts: () => void; } @@ -899,6 +900,75 @@ export const usePanelLayoutStore = createWithEqualityFn()( ); }, + ensurePlanTab: (taskId, filePath) => { + set((state) => + updateTaskLayout(state, taskId, (layout) => { + const existingTab = findTabInTree( + layout.panelTree, + DEFAULT_TAB_IDS.PLAN, + ); + + if (existingTab) { + // Tab exists — refresh the filePath (the agent may have started + // a fresh plan file in this session) and activate it. + const updatedTree = updateTreeNode( + layout.panelTree, + existingTab.panelId, + (panel) => { + if (panel.type !== "leaf") return panel; + return { + ...panel, + content: { + ...panel.content, + tabs: panel.content.tabs.map((tab) => + tab.id === DEFAULT_TAB_IDS.PLAN + ? { ...tab, data: { type: "plan", filePath } } + : tab, + ), + activeTabId: DEFAULT_TAB_IDS.PLAN, + }, + }; + }, + ); + return { panelTree: updatedTree }; + } + + const targetPanelId = + layout.focusedPanelId ?? DEFAULT_PANEL_IDS.MAIN_PANEL; + const targetPanel = + getLeafPanel(layout.panelTree, targetPanelId) ?? + getLeafPanel(layout.panelTree, DEFAULT_PANEL_IDS.MAIN_PANEL); + if (!targetPanel) return {}; + + const updatedTree = updateTreeNode( + layout.panelTree, + targetPanel.id, + (panel) => { + if (panel.type !== "leaf") return panel; + const newTab: Tab = { + id: DEFAULT_TAB_IDS.PLAN, + label: "Plan", + data: { type: "plan", filePath }, + component: null, + draggable: true, + closeable: true, + }; + return { + ...panel, + content: { + ...panel.content, + tabs: [...panel.content.tabs, newTab], + activeTabId: DEFAULT_TAB_IDS.PLAN, + }, + }; + }, + ); + + return { panelTree: updatedTree }; + }), + ); + }, + clearAllLayouts: () => { set({ taskLayouts: {} }); }, diff --git a/apps/code/src/renderer/features/panels/store/panelTypes.ts b/apps/code/src/renderer/features/panels/store/panelTypes.ts index d50c9e9f4..6deda706a 100644 --- a/apps/code/src/renderer/features/panels/store/panelTypes.ts +++ b/apps/code/src/renderer/features/panels/store/panelTypes.ts @@ -31,6 +31,10 @@ export type TabData = | { type: "review"; } + | { + type: "plan"; + filePath: string; + } | { type: "other"; }; diff --git a/apps/code/src/renderer/features/plans/components/PlanBlockGutter.tsx b/apps/code/src/renderer/features/plans/components/PlanBlockGutter.tsx new file mode 100644 index 000000000..3f49f88bf --- /dev/null +++ b/apps/code/src/renderer/features/plans/components/PlanBlockGutter.tsx @@ -0,0 +1,61 @@ +import { Plus } from "@phosphor-icons/react"; +import { Tooltip } from "@radix-ui/themes"; +import type { ReactNode } from "react"; +import { useCallback, useRef } from "react"; +import { usePlanComposeStore } from "../stores/planComposeStore"; + +interface PlanBlockGutterProps { + blockText: string | undefined; + filePath: string; + taskId: string; + children: ReactNode; +} + +/** + * Wraps a markdown block (heading, paragraph, list, code) with a hover- + * revealed `+` button in the left gutter. Clicking opens the compose + * popover anchored to this block, scoped to `filePath` / `taskId`. + */ +export function PlanBlockGutter({ + blockText, + filePath, + taskId, + children, +}: PlanBlockGutterProps) { + const ref = useRef(null); + const openAt = usePlanComposeStore((s) => s.openAt); + + const handleClick = useCallback(() => { + if (!ref.current || !blockText) return; + const rect = ref.current.getBoundingClientRect(); + openAt({ + anchorRect: { + top: rect.top, + left: rect.left, + right: rect.right, + bottom: rect.bottom, + }, + blockText, + filePath, + taskId, + }); + }, [blockText, filePath, taskId, openAt]); + + return ( +
+ {blockText && ( + + + + )} + {children} +
+ ); +} diff --git a/apps/code/src/renderer/features/plans/components/PlanComposePopover.tsx b/apps/code/src/renderer/features/plans/components/PlanComposePopover.tsx new file mode 100644 index 000000000..9859974bd --- /dev/null +++ b/apps/code/src/renderer/features/plans/components/PlanComposePopover.tsx @@ -0,0 +1,118 @@ +import { sendPromptToAgent } from "@features/sessions/utils/sendPromptToAgent"; +import { X } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Card, Flex } from "@radix-ui/themes"; +import { trpcClient } from "@renderer/trpc/client"; +import { logger } from "@utils/logger"; +import { isSendMessageSubmitKey } from "@utils/sendMessageKey"; +import { useCallback, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { usePlanComposeStore } from "../stores/planComposeStore"; +import { buildAskAgentToReplyToPlanThreadPrompt } from "../utils/planPrompts"; + +const log = logger.scope("plan-compose-popover"); + +const POPOVER_WIDTH = 360; +const GAP = 8; + +export function PlanComposePopover() { + const open = usePlanComposeStore((s) => s.open); + const anchorRect = usePlanComposeStore((s) => s.anchorRect); + const blockText = usePlanComposeStore((s) => s.blockText); + const filePath = usePlanComposeStore((s) => s.filePath); + const taskId = usePlanComposeStore((s) => s.taskId); + const close = usePlanComposeStore((s) => s.close); + + const cardRef = useRef(null); + const textareaRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (!cardRef.current) return; + if (!cardRef.current.contains(e.target as Node)) close(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey); + requestAnimationFrame(() => textareaRef.current?.focus()); + return () => { + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey); + }; + }, [open, close]); + + const handleSubmit = useCallback(async () => { + const text = textareaRef.current?.value?.trim(); + if (!text || !filePath || !blockText || !taskId) return; + try { + await trpcClient.plans.appendThreadMessage.mutate({ + filePath, + blockText, + message: text, + speaker: "H", + }); + sendPromptToAgent( + taskId, + buildAskAgentToReplyToPlanThreadPrompt(filePath, blockText), + ); + } catch (err) { + log.warn("Failed to append plan thread", { err }); + } finally { + close(); + } + }, [filePath, blockText, taskId, close]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (isSendMessageSubmitKey(e)) { + e.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit], + ); + + if (!open || !anchorRect) return null; + + const viewportWidth = window.innerWidth; + const preferredLeft = anchorRect.right + GAP; + const fitsRight = preferredLeft + POPOVER_WIDTH + 8 <= viewportWidth; + const left = fitsRight + ? preferredLeft + : Math.max(8, anchorRect.left - POPOVER_WIDTH - GAP); + const top = Math.max(8, anchorRect.top); + + return createPortal( +
+ +