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/agent/plan-file-detector.test.ts b/apps/code/src/main/services/agent/plan-file-detector.test.ts new file mode 100644 index 000000000..d4e036d2f --- /dev/null +++ b/apps/code/src/main/services/agent/plan-file-detector.test.ts @@ -0,0 +1,195 @@ +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getPlanFilePathFromSessionUpdate } from "./plan-file-detector"; + +function buildToolCall( + toolName: string, + filePath: string, + opts: { useLocations?: boolean; useRawInput?: boolean } = {}, +) { + // Default: produce a "complete" notification with both fields (matching + // what the Claude adapter actually emits). + const useLocations = opts.useLocations ?? true; + const useRawInput = opts.useRawInput ?? true; + const update: Record = { + sessionUpdate: "tool_call", + toolCallId: "tc-1", + _meta: { claudeCode: { toolName } }, + }; + if (useLocations) update.locations = [{ path: filePath }]; + if (useRawInput) update.rawInput = { file_path: filePath }; + return { method: "session/update", params: { update } }; +} + +describe("getPlanFilePathFromSessionUpdate", () => { + let savedConfigDir: string | undefined; + + beforeEach(() => { + savedConfigDir = process.env.CLAUDE_CONFIG_DIR; + }); + + afterEach(() => { + if (savedConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = savedConfigDir; + } + }); + + it("returns the file path for Write/Edit calls inside the plans dir", () => { + process.env.CLAUDE_CONFIG_DIR = "/var/data/claude"; + const planPath = "/var/data/claude/plans/my-plan.md"; + expect( + getPlanFilePathFromSessionUpdate(buildToolCall("Write", planPath)), + ).toBe(planPath); + expect( + getPlanFilePathFromSessionUpdate(buildToolCall("Edit", planPath)), + ).toBe(planPath); + }); + + it("respects CLAUDE_CONFIG_DIR — the canonical source for the plans dir", () => { + // This is the core P1 fix: the regex-based renderer code required a leading + // dot (`.claude`) which the desktop app does not use. The main-process + // detector reads the same env var that env.ts sets at boot. + process.env.CLAUDE_CONFIG_DIR = "/var/data/claude"; + expect( + getPlanFilePathFromSessionUpdate( + buildToolCall("Write", "/var/data/claude/plans/x.md"), + ), + ).toBe("/var/data/claude/plans/x.md"); + // The dot-prefixed home-dir convention also works when the env var is unset + delete process.env.CLAUDE_CONFIG_DIR; + const home = os.homedir(); + expect( + getPlanFilePathFromSessionUpdate( + buildToolCall("Write", path.join(home, ".claude", "plans", "x.md")), + ), + ).toBe(path.join(home, ".claude", "plans", "x.md")); + }); + + it("ignores tool calls outside the plans directory", () => { + process.env.CLAUDE_CONFIG_DIR = "/var/data/claude"; + expect( + getPlanFilePathFromSessionUpdate( + buildToolCall("Write", "/var/data/claude/cache/foo.md"), + ), + ).toBe(null); + expect( + getPlanFilePathFromSessionUpdate( + buildToolCall("Write", "/tmp/random.md"), + ), + ).toBe(null); + }); + + it("ignores non-markdown files even inside the plans dir", () => { + process.env.CLAUDE_CONFIG_DIR = "/var/data/claude"; + expect( + getPlanFilePathFromSessionUpdate( + buildToolCall("Write", "/var/data/claude/plans/notes.txt"), + ), + ).toBe(null); + }); + + it("ignores read-only tools (Read, Bash, etc.)", () => { + process.env.CLAUDE_CONFIG_DIR = "/var/data/claude"; + expect( + getPlanFilePathFromSessionUpdate( + buildToolCall("Read", "/var/data/claude/plans/x.md"), + ), + ).toBe(null); + expect( + getPlanFilePathFromSessionUpdate( + buildToolCall("Bash", "/var/data/claude/plans/x.md"), + ), + ).toBe(null); + }); + + it("ignores MultiEdit — the Claude adapter doesn't emit typed locations for it", () => { + // tool-use-to-acp.ts has no MultiEdit case; it falls through to the + // default branch which returns no `locations`. We deliberately drop + // MultiEdit from the supported set so we never accidentally rely on + // locations the adapter doesn't promise. Even if a caller hand-rolls + // a `locations` array for MultiEdit, we ignore it. + process.env.CLAUDE_CONFIG_DIR = "/var/data/claude"; + expect( + getPlanFilePathFromSessionUpdate( + buildToolCall("MultiEdit", "/var/data/claude/plans/x.md"), + ), + ).toBe(null); + expect( + getPlanFilePathFromSessionUpdate({ + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + _meta: { claudeCode: { toolName: "MultiEdit" } }, + }, + }, + }), + ).toBe(null); + }); + + it("uses the typed ACP `locations` field as the source of the file path", () => { + // Per repo guidance we don't trust `rawInput` for agent-facing + // contracts. The Claude adapter populates `tool_call.locations` for + // Write / Edit / MultiEdit / NotebookEdit — that's the canonical + // typed channel. + process.env.CLAUDE_CONFIG_DIR = "/var/data/claude"; + const planPath = "/var/data/claude/plans/my-plan.md"; + expect( + getPlanFilePathFromSessionUpdate( + buildToolCall("Write", planPath, { useRawInput: false }), + ), + ).toBe(planPath); + }); + + it("returns null when there is no typed `locations` entry", () => { + process.env.CLAUDE_CONFIG_DIR = "/var/data/claude"; + expect( + getPlanFilePathFromSessionUpdate({ + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + _meta: { claudeCode: { toolName: "Write" } }, + rawInput: { file_path: "/var/data/claude/plans/x.md" }, + // no `locations` + }, + }, + }), + ).toBe(null); + }); + + it("returns null when there is no rawInput with a file_path (still requires locations)", () => { + expect( + getPlanFilePathFromSessionUpdate({ + method: "session/update", + params: { update: { sessionUpdate: "tool_call" } }, + }), + ).toBe(null); + }); + + it("returns null for unrelated session-update notifications", () => { + expect( + getPlanFilePathFromSessionUpdate({ + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hi" }, + }, + }, + }), + ).toBe(null); + }); + + it("returns null for non-session/update notifications", () => { + expect( + getPlanFilePathFromSessionUpdate({ + method: "session/prompt", + params: {}, + }), + ).toBe(null); + }); +}); diff --git a/apps/code/src/main/services/agent/plan-file-detector.ts b/apps/code/src/main/services/agent/plan-file-detector.ts new file mode 100644 index 000000000..5e3ca347a --- /dev/null +++ b/apps/code/src/main/services/agent/plan-file-detector.ts @@ -0,0 +1,95 @@ +import os from "node:os"; +import path from "node:path"; +import { z } from "zod"; + +/** + * Mirrors `getClaudePlansDir()` in @posthog/agent. Kept local so the main + * process never has to depend on the agent package for this single helper + * (and so we don't add a new subpath export). + */ +function getClaudePlansDir(): string { + const configDir = + process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); + return path.join(configDir, "plans"); +} + +// MultiEdit is intentionally excluded: the Claude adapter +// (packages/agent/.../conversion/tool-use-to-acp.ts) has no `MultiEdit` +// case and falls through to the default branch, which does not populate +// `locations`. If that's fixed upstream, add it back here. +const WRITE_TOOL_NAMES = new Set(["Write", "Edit", "NotebookEdit"]); + +const ToolCallLocation = z.object({ path: z.string().min(1) }).passthrough(); + +const SessionUpdateNotification = z.object({ + method: z.literal("session/update"), + params: z + .object({ + update: z + .object({ + sessionUpdate: z.string(), + // `locations` is the typed ACP channel for "what files does this + // tool call touch". The Claude adapter populates it for every + // Write/Edit/MultiEdit/NotebookEdit call (see + // packages/agent/.../conversion/tool-use-to-acp.ts). We rely on + // this instead of `rawInput.file_path` to honour the repo + // guidance against building contracts on agent rawInput. + locations: z.array(ToolCallLocation).optional(), + _meta: z + .object({ + claudeCode: z + .object({ toolName: z.string().optional() }) + .passthrough() + .optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(), + }) + .passthrough(), +}); + +function isPlanFilePath(filePath: string): boolean { + if (!filePath.endsWith(".md")) return false; + const resolved = path.resolve(filePath); + const plansDir = path.resolve(getClaudePlansDir()); + return resolved.startsWith(plansDir + path.sep); +} + +/** + * Inspects a raw JSON-RPC message coming from the agent SDK and returns the + * plan file path if it represents a Write/Edit tool call targeting the + * configured `~/.claude/plans/` directory. + * + * This is the *single source of truth* for plan-file detection across the + * app: it uses the same env var (`CLAUDE_CONFIG_DIR`) that `env.ts` sets at + * boot, so the detection matches the directory the watcher actually + * watches. + * + * Source of the file path: the typed `tool_call.locations` ACP field. We + * deliberately do not consult `rawInput`, per the repo guidance — that + * field is the raw, unstable agent SDK contract. + */ +export function getPlanFilePathFromSessionUpdate( + message: unknown, +): string | null { + const parsed = SessionUpdateNotification.safeParse(message); + if (!parsed.success) return null; + + const update = parsed.data.params.update; + if ( + update.sessionUpdate !== "tool_call" && + update.sessionUpdate !== "tool_call_update" + ) { + return null; + } + + const toolName = update._meta?.claudeCode?.toolName; + if (!toolName || !WRITE_TOOL_NAMES.has(toolName)) return null; + + const firstLocation = update.locations?.[0]; + if (!firstLocation) return null; + + return isPlanFilePath(firstLocation.path) ? firstLocation.path : null; +} diff --git a/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts index 3ead6cf15..e5a7dabed 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -203,6 +203,7 @@ export const AgentServiceEvent = { SessionsIdle: "sessions-idle", SessionIdleKilled: "session-idle-killed", AgentFileActivity: "agent-file-activity", + PlanFileChanged: "plan-file-changed", } as const; export interface AgentSessionEventPayload { @@ -228,12 +229,18 @@ export interface AgentFileActivityPayload { branchName: string | null; } +export interface PlanFileChangedPayload { + taskRunId: string; + filePath: string; +} + export interface AgentServiceEvents { [AgentServiceEvent.SessionEvent]: AgentSessionEventPayload; [AgentServiceEvent.PermissionRequest]: PermissionRequestPayload; [AgentServiceEvent.SessionsIdle]: undefined; [AgentServiceEvent.SessionIdleKilled]: SessionIdleKilledPayload; [AgentServiceEvent.AgentFileActivity]: AgentFileActivityPayload; + [AgentServiceEvent.PlanFileChanged]: PlanFileChangedPayload; } // Permission response input for tRPC diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 4c3eecb07..375d4603c 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -57,6 +57,7 @@ import { loadSessionEnvOverrides } from "../session-env/loader"; import type { SleepService } from "../sleep/service"; import type { AgentAuthAdapter, McpToolInstallations } from "./auth-adapter"; import { discoverExternalPlugins } from "./discover-plugins"; +import { getPlanFilePathFromSessionUpdate } from "./plan-file-detector"; import { AgentServiceEvent, type AgentServiceEvents, @@ -249,6 +250,13 @@ interface ManagedSession { mcpToolApprovals: McpToolApprovals; /** Maps tool keys to their installation for backend approval updates */ toolInstallations: McpToolInstallations; + /** + * Most recently observed `~/.claude/plans/.md` file the agent has + * written or edited in this session. Populated by the plan-file detector + * as tool_call notifications flow through `onAcpMessage`. Surfaced to + * the renderer via `agent.getPlanFilePath` + `agent.onPlanFileChanged`. + */ + planFilePath?: string | null; } /** Get the agent session ID from a managed session, throwing if not set. */ @@ -397,6 +405,15 @@ export class AgentService extends TypedEventEmitter { this.recordActivity(taskRunId); } + /** + * Returns the most recently observed plan file path for a session, or + * `null` if the agent hasn't written one yet. Used by the renderer to + * seed the Plan tab when navigating to an in-flight task. + */ + public getPlanFilePath(taskRunId: string): string | null { + return this.sessions.get(taskRunId)?.planFilePath ?? null; + } + /** * Check if any sessions are currently active (i.e. have a prompt pending). */ @@ -1221,6 +1238,19 @@ For git operations while detached: // Inspect tool call updates for PR URLs and file activity this.handleToolCallUpdate(taskRunId, message as AcpMessage["message"]); + + // Surface plan-file activity as a typed event so the renderer's Plan + // tab can latch onto the right `~/.claude/plans/.md` without + // re-parsing tool call rawInput in the renderer. + const planFilePath = getPlanFilePathFromSessionUpdate(message); + if (planFilePath) { + const session = this.sessions.get(taskRunId); + if (session) session.planFilePath = planFilePath; + this.emit(AgentServiceEvent.PlanFileChanged, { + taskRunId, + filePath: planFilePath, + }); + } }; const tappedReadable = createTappedReadableStream( 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..89d9bd43e --- /dev/null +++ b/apps/code/src/main/services/plans-watcher/schemas.ts @@ -0,0 +1,60 @@ +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. + * + * `occurrence` disambiguates repeated blocks (two headings with the same + * text, two identical bullets) — it's the 0-based index of the matching + * block in document order. The renderer computes this from the position + * of the block in the rendered tree. + */ +export const planAppendInput = z.object({ + filePath: z.string(), + blockText: z.string().min(1), + occurrence: z.number().int().nonnegative().default(0), + message: z.string().min(1), + speaker: speakerSchema, +}); + +export const planResolveInput = z.object({ + filePath: z.string(), + blockText: z.string().min(1), + occurrence: z.number().int().nonnegative().default(0), +}); + +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..29b2f9a01 --- /dev/null +++ b/apps/code/src/main/services/plans-watcher/service.test.ts @@ -0,0 +1,409 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { WatcherRegistryService } from "../watcher-registry/service"; +import { + findBlockInsertionLine, + findExistingThreadRange, + formatThreadLine, + isThreadLine, + PlansWatcherService, +} 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 (exact paragraph text)", () => { + const lines = SAMPLE_PLAN.split("\n"); + const line = findBlockInsertionLine( + lines, + "Move session validation from middleware to a dedicated service.", + ); + // Source line 4 (0-indexed) is the paragraph; insertion is line 5. + expect(line).toBe(5); + }); + + it("matches a multi-line block when blockText spans all of its lines", () => { + const lines = ["a paragraph", "spanning", "three lines", "", "next"]; + const line = findBlockInsertionLine( + lines, + "a paragraph\nspanning\nthree lines", + ); + expect(line).toBe(3); + }); + + it("returns null when no block matches", () => { + expect(findBlockInsertionLine(SAMPLE_PLAN.split("\n"), "nope")).toBe( + null, + ); + }); + + it("finds the Nth occurrence when the block text repeats", () => { + const lines = [ + "## Step 1", + "", + "First step content", + "", + "## Step 1", + "", + "Duplicate heading", + "", + "## Step 1", + "", + "Third one", + ]; + expect(findBlockInsertionLine(lines, "## Step 1", 0)).toBe(1); + expect(findBlockInsertionLine(lines, "## Step 1", 1)).toBe(5); + expect(findBlockInsertionLine(lines, "## Step 1", 2)).toBe(9); + }); + + it("defaults to occurrence 0 when none specified", () => { + const lines = ["## Step 1", "", "## Step 1", "", "..."]; + expect(findBlockInsertionLine(lines, "## Step 1")).toBe(1); + }); + + it("returns null when the occurrence index exceeds the match count", () => { + const lines = ["## Step 1", "", "only one"]; + expect(findBlockInsertionLine(lines, "## Step 1", 1)).toBe(null); + }); + + it("requires an exact block match — `## Step 1` must not match `## Step 10`", () => { + const lines = ["## Step 1", "", "first", "", "## Step 10", "", "tenth"]; + // Only one block exactly equals "## Step 1" (line 0). + expect(findBlockInsertionLine(lines, "## Step 1", 0)).toBe(1); + expect(findBlockInsertionLine(lines, "## Step 1", 1)).toBe(null); + }); + + it("doesn't count thread blockquote content as an anchor match", () => { + // A previous reply that mentions the snippet must not be counted as + // an occurrence of the heading itself. + const lines = [ + "## Step 1", + "", + "> [H]: I think `## Step 1` should be renamed", + "> [A]: Got it.", + "", + "## Step 1", + "", + "second", + ]; + // There are two real "## Step 1" headings (line 0 and line 5). The + // blockquote does not count. + expect(findBlockInsertionLine(lines, "## Step 1", 0)).toBe(1); + expect(findBlockInsertionLine(lines, "## Step 1", 1)).toBe(6); + expect(findBlockInsertionLine(lines, "## Step 1", 2)).toBe(null); + }); + + it("matches a fenced code block that contains a blank line", () => { + // A fenced code block is ONE markdown block, even when its body has + // a blank line. The renderer exposes a single gutter for it, so the + // watcher must treat it as one block too — not three sub-blocks + // split on blank lines. + const source = + "```ts\n" + + "function foo() {\n" + + "\n" + + " return 1;\n" + + "}\n" + + "```\n" + + "\n" + + "Some prose after.\n"; + const lines = source.split("\n"); + const fencedBlock = "```ts\nfunction foo() {\n\n return 1;\n}\n```"; + // The fenced block spans lines 0–5; insertion after it is line 6. + expect(findBlockInsertionLine(lines, fencedBlock)).toBe(6); + }); + + it("matches an individual list item, not the whole list", () => { + // The renderer anchors each list item separately so users can + // comment per item. The watcher must therefore find a single item + // by its source text and return the line right after it. + const source = + "- item one\n- item two\n- item three\n\nNext paragraph.\n"; + const lines = source.split("\n"); + // Insertion after "- item one" is line 1 (right between items). + expect(findBlockInsertionLine(lines, "- item one")).toBe(1); + expect(findBlockInsertionLine(lines, "- item two")).toBe(2); + expect(findBlockInsertionLine(lines, "- item three")).toBe(3); + }); + + it("matches list items in ordered lists by their full marker", () => { + const source = "1. first step\n2. second step\n\nNext paragraph.\n"; + const lines = source.split("\n"); + expect(findBlockInsertionLine(lines, "1. first step")).toBe(1); + expect(findBlockInsertionLine(lines, "2. second step")).toBe(2); + }); + + it("counts occurrences across identical list items in the doc", () => { + const source = "- repeat\n- repeat\n\nNext paragraph.\n"; + const lines = source.split("\n"); + expect(findBlockInsertionLine(lines, "- repeat", 0)).toBe(1); + expect(findBlockInsertionLine(lines, "- repeat", 1)).toBe(2); + expect(findBlockInsertionLine(lines, "- repeat", 2)).toBeNull(); + }); + + it("matches a GFM table as one block (renderer uses remark-gfm)", () => { + // The renderer parses with remark-gfm, which treats this as one + // `table` block. Without gfm in the watcher parser, the table rows + // would parse as paragraphs and we'd split incorrectly. + const source = + "| a | b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |\n\nAfter table.\n"; + const lines = source.split("\n"); + const tableBlock = "| a | b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |"; + expect(findBlockInsertionLine(lines, tableBlock)).toBe(4); + }); + }); + + 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", + ); + }); + }); +}); + +describe("PlansWatcherService.appendThreadMessage / resolveThread", () => { + let tmpDir: string; + let plansDir: string; + let service: PlansWatcherService; + let savedConfigDir: string | undefined; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "plans-watcher-test-")); + plansDir = path.join(tmpDir, "claude", "plans"); + await fs.mkdir(plansDir, { recursive: true }); + savedConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = path.join(tmpDir, "claude"); + + const registryStub = { + isShutdown: false, + register: vi.fn(), + unregister: vi.fn(), + } as unknown as WatcherRegistryService; + service = new PlansWatcherService(registryStub); + }); + + afterEach(async () => { + if (savedConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = savedConfigDir; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("appends a thread under the requested occurrence of a repeated block", async () => { + const planPath = path.join(plansDir, "plan.md"); + const original = [ + "## Step 1", + "", + "First step content", + "", + "## Step 1", + "", + "Second step content", + "", + ].join("\n"); + await fs.writeFile(planPath, original, "utf8"); + + await service.appendThreadMessage({ + filePath: planPath, + blockText: "## Step 1", + occurrence: 1, + message: "Why is the same heading used twice?", + speaker: "H", + }); + + const updated = await fs.readFile(planPath, "utf8"); + const lines = updated.split("\n"); + // The thread must be attached to the SECOND occurrence (line index 4), + // not the first. + const threadIdx = lines.findIndex((l) => + l.startsWith("> [H]: Why is the same heading"), + ); + expect(threadIdx).toBeGreaterThan(4); + // And the first "## Step 1" must NOT have a thread directly after it. + expect(lines[1]).toBe(""); + expect(lines[2]).toBe("First step content"); + }); + + it("inserts a blank line AFTER the new thread when the next line is non-blank", async () => { + // In CommonMark, `## Heading\nNext paragraph` is two blocks with no + // blank between them. If we insert `> [H]: msg` right after the + // heading without a trailing blank, "Next paragraph" becomes lazy + // continuation of the new blockquote and `remarkPlanThreads` can't + // parse it as a thread anymore. + const planPath = path.join(plansDir, "plan.md"); + await fs.writeFile(planPath, "## Heading\nNext paragraph\n", "utf8"); + + await service.appendThreadMessage({ + filePath: planPath, + blockText: "## Heading", + occurrence: 0, + message: "Why this heading?", + speaker: "H", + }); + + const updated = await fs.readFile(planPath, "utf8"); + // The thread line must be sandwiched by blank lines on both sides. + const lines = updated.split("\n"); + const threadIdx = lines.findIndex((l) => l.startsWith("> [H]: Why")); + expect(threadIdx).toBeGreaterThan(0); + expect(lines[threadIdx - 1]).toBe(""); + expect(lines[threadIdx + 1]).toBe(""); + expect(lines[threadIdx + 2]).toBe("Next paragraph"); + }); + + it("inserts a blank line AFTER `> [resolved]` when the next line is non-blank", async () => { + // Defensively also keep the resolve marker separated from any + // following non-thread content. + const planPath = path.join(plansDir, "plan.md"); + // Manually construct a state where the next line after the thread + // is non-blank. This shouldn't normally happen (the agent and the + // append helper produce a trailing blank), but we want to be robust. + await fs.writeFile( + planPath, + "Anchor para.\n\n> [H]: q\nNot a thread line\n", + "utf8", + ); + + // The renderer wouldn't actually surface this as a thread (the + // mdast blockquote contains lazy-continuation text), so we exercise + // the resolve helper directly. + await service.resolveThread({ + filePath: planPath, + blockText: "Anchor para.", + occurrence: 0, + }); + + const updated = await fs.readFile(planPath, "utf8"); + const lines = updated.split("\n"); + const resolvedIdx = lines.findIndex((l) => l.trim() === "> [resolved]"); + expect(resolvedIdx).toBeGreaterThan(0); + // The line after `> [resolved]` must be blank to terminate the + // blockquote. + expect(lines[resolvedIdx + 1]).toBe(""); + }); + + it("appends a thread anchored to a single list item", async () => { + const planPath = path.join(plansDir, "plan.md"); + const original = ["- First item", "- Second item", "- Third item", ""].join( + "\n", + ); + await fs.writeFile(planPath, original, "utf8"); + + await service.appendThreadMessage({ + filePath: planPath, + blockText: "- Second item", + occurrence: 0, + message: "Why is this here?", + speaker: "H", + }); + + const updated = await fs.readFile(planPath, "utf8"); + const lines = updated.split("\n"); + const threadIdx = lines.findIndex((l) => l.startsWith("> [H]: Why")); + // Thread must come AFTER "- Second item" and BEFORE "- Third item". + const secondIdx = lines.indexOf("- Second item"); + const thirdIdx = lines.indexOf("- Third item"); + expect(threadIdx).toBeGreaterThan(secondIdx); + expect(threadIdx).toBeLessThan(thirdIdx); + }); + + it("resolves the thread under the requested occurrence", async () => { + const planPath = path.join(plansDir, "plan.md"); + const original = [ + "## Step", + "", + "> [H]: question about first", + "", + "## Step", + "", + "> [H]: question about second", + "", + ].join("\n"); + await fs.writeFile(planPath, original, "utf8"); + + await service.resolveThread({ + filePath: planPath, + blockText: "## Step", + occurrence: 1, + }); + + const updated = await fs.readFile(planPath, "utf8"); + // First thread must NOT be resolved + expect(updated.match(/> \[resolved\]/g) ?? []).toHaveLength(1); + // The resolved marker must appear AFTER the second thread question + const resolvedIdx = updated.indexOf("> [resolved]"); + const secondQuestionIdx = updated.indexOf("question about second"); + expect(resolvedIdx).toBeGreaterThan(secondQuestionIdx); + }); +}); 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..4197f043b --- /dev/null +++ b/apps/code/src/main/services/plans-watcher/service.ts @@ -0,0 +1,363 @@ +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 remarkParse from "remark-parse"; +import { unified } from "unified"; +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); +} + +interface AstBlock { + /** Verbatim source slice of this block. */ + text: string; + /** 0-based line index immediately AFTER the block (insertion point). */ + endLine: number; + /** True if this block is a `[H]:` / `[A]:` / `[resolved]` thread blockquote. */ + isThread: boolean; +} + +interface MdNodeLike { + type: string; + children?: MdNodeLike[]; + position?: { + start: { line: number; column: number; offset?: number }; + end: { line: number; column: number; offset?: number }; + }; + // For blockquote thread detection (definition nodes carry the label) + label?: string; + value?: string; +} + +function isThreadBlockquote(node: MdNodeLike): boolean { + if (node.type !== "blockquote" || !node.children?.length) return false; + for (const child of node.children) { + if (child.type === "definition") { + if (child.label !== "H" && child.label !== "A") return false; + continue; + } + if (child.type === "paragraph") { + const text = (function getText(n: MdNodeLike): string { + if (typeof n.value === "string") return n.value; + return (n.children ?? []).map(getText).join(""); + })(child); + const lines = text.split("\n"); + const allThread = lines.every( + (l) => l.trim() === "" || /^\s*\[(H|A|resolved)\]/.test(l), + ); + if (!allThread) return false; + continue; + } + return false; + } + return true; +} + +function parseAstBlocks(source: string): AstBlock[] { + // Parse the file's top-level markdown structure to identify anchor + // blocks the same way the renderer does. Doing this with `remark-parse` + // means a fenced code block with a blank line counts as ONE block, + // matching the renderer's single gutter button for that block. + // + // Lists are NOT anchorable as a whole — instead we descend into each + // `listItem` so users can comment on individual items (mirroring the + // renderer's `ANCHORABLE_TYPES`). + const tree = unified() + .use(remarkParse) + .parse(source) as unknown as MdNodeLike; + + const blocks: AstBlock[] = []; + const pushBlock = (node: MdNodeLike, isThread: boolean): void => { + const startOffset = node.position?.start.offset; + const endOffset = node.position?.end.offset; + if (typeof startOffset !== "number" || typeof endOffset !== "number") { + return; + } + const text = source.slice(startOffset, endOffset); + const endLine = source.slice(0, endOffset).split("\n").length; + blocks.push({ text, endLine, isThread }); + }; + + for (const child of tree.children ?? []) { + if (child.type === "list") { + // Walk the list's items as separate anchor blocks. Nested lists are + // covered by the parent item's source slice — we don't descend into + // them as their own blocks, since the renderer's `data-plan-block` + // is the outer item's verbatim source. + for (const item of child.children ?? []) { + if (item.type === "listItem") pushBlock(item, false); + } + continue; + } + pushBlock(child, isThreadBlockquote(child)); + } + return blocks; +} + +export function findBlockInsertionLine( + lines: string[], + blockText: string, + occurrence = 0, +): number | null { + const target = blockText.trim(); + if (!target) return null; + + const source = lines.join("\n"); + let remainingToSkip = occurrence; + + for (const block of parseAstBlocks(source)) { + if (block.isThread) continue; + if (block.text.trim() !== target) continue; + if (remainingToSkip === 0) return block.endLine; + remainingToSkip -= 1; + } + return null; +} + +/** + * After inserting or extending a thread blockquote, ensure the line right + * after the thread is blank (or the file ends there). Without this guard, + * a non-blank line immediately following `> [H]: …` becomes lazy + * continuation of the blockquote under CommonMark and `remarkPlanThreads` + * stops recognising it as a pure `[H]/[A]/[resolved]` thread. + * + * @param threadEnd 0-based index immediately after the last thread line + */ +function ensureBlankAfterThread(lines: string[], threadEnd: number): string[] { + if (threadEnd >= lines.length) return lines; + if (lines[threadEnd].trim() === "") return lines; + return [...lines.slice(0, threadEnd), "", ...lines.slice(threadEnd)]; +} + +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, + input.occurrence, + ); + 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[]; + let threadEnd: number; + 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)]; + threadEnd = existing.end + 1; + } 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]; + threadEnd = prefix.length + (needsBlank ? 1 : 0) + 1; + } + + next = ensureBlankAfterThread(next, threadEnd); + 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, + input.occurrence, + ); + 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 + } + + let next = [ + ...lines.slice(0, existing.end), + "> [resolved]", + ...lines.slice(existing.end), + ]; + next = ensureBlankAfterThread(next, existing.end + 1); + 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/agent.ts b/apps/code/src/main/trpc/routers/agent.ts index 98c20a8ce..e7d651354 100644 --- a/apps/code/src/main/trpc/routers/agent.ts +++ b/apps/code/src/main/trpc/routers/agent.ts @@ -144,6 +144,10 @@ export const agentRouter = router({ getService().hasActiveSessions(), ), + getPlanFilePath: publicProcedure + .input(subscribeSessionInput) + .query(({ input }) => getService().getPlanFilePath(input.taskRunId)), + onSessionsIdle: publicProcedure.subscription(async function* (opts) { const service = getService(); for await (const _ of service.toIterable(AgentServiceEvent.SessionsIdle, { @@ -201,6 +205,21 @@ export const agentRouter = router({ } }), + onPlanFileChanged: publicProcedure + .input(subscribeSessionInput) + .subscription(async function* (opts) { + const service = getService(); + const targetTaskRunId = opts.input.taskRunId; + const iterable = service.toIterable(AgentServiceEvent.PlanFileChanged, { + signal: opts.signal, + }); + for await (const event of iterable) { + if (event.taskRunId === targetTaskRunId) { + yield event; + } + } + }), + getGatewayModels: publicProcedure .input(getGatewayModelsInput) .output(getGatewayModelsOutput) 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..f1ad2bc9b 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,31 @@ 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); + // The button is gated on `hasPlanTab` alone — `usePlanTab` is the + // single load-bearing check for `planThreadsEnabled`, so reading the + // setting here would just duplicate that gate AND drag the + // `electronStorage` → `trpcClient` import chain into every test that + // mounts PlanContent (breaking jsdom unless explicitly mocked). + 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 +141,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..6e83184e7 --- /dev/null +++ b/apps/code/src/renderer/features/plans/components/PlanBlockGutter.tsx @@ -0,0 +1,184 @@ +import { getPendingPermissionsForTask } from "@features/sessions/hooks/useSession"; +import { getSessionService } from "@features/sessions/service/service"; +import { Plus, X } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { Flex, Tooltip } from "@radix-ui/themes"; +import { trpcClient } from "@renderer/trpc/client"; +import { logger } from "@utils/logger"; +import { isSendMessageSubmitKey } from "@utils/sendMessageKey"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + buildThreadKey, + usePlanAgentActivityStore, +} from "../stores/planAgentActivityStore"; +import { dispatchPlanComment } from "../utils/dispatchPlanComment"; +import { buildAskAgentToReplyToPlanThreadPrompt } from "../utils/planPrompts"; + +const log = logger.scope("plan-block-gutter"); + +interface PlanBlockGutterProps { + blockText: string | undefined; + occurrence: number; + filePath: string; + taskId: string; + children: ReactNode; +} + +interface InlineComposerProps { + blockText: string; + occurrence: number; + filePath: string; + taskId: string; + onClose: () => void; +} + +function InlineComposer({ + blockText, + occurrence, + filePath, + taskId, + onClose, +}: InlineComposerProps) { + const textareaRef = useRef(null); + const [pending, setPending] = useState(false); + + useEffect(() => { + requestAnimationFrame(() => textareaRef.current?.focus()); + }, []); + + const enqueueAgentActivity = usePlanAgentActivityStore((s) => s.enqueue); + const dequeueAgentActivity = usePlanAgentActivityStore((s) => s.dequeue); + + const handleSubmit = useCallback(async () => { + const text = textareaRef.current?.value?.trim(); + if (!text) return; + setPending(true); + const threadKey = buildThreadKey({ filePath, blockText, occurrence }); + try { + await trpcClient.plans.appendThreadMessage.mutate({ + filePath, + blockText, + occurrence, + message: text, + speaker: "H", + }); + enqueueAgentActivity(threadKey); + // Fire-and-forget — `sendPrompt` resolves only when the agent's + // turn ends (potentially many seconds). Awaiting it would freeze + // the composer in "Sending…" until the agent stops talking; the + // user already sees ongoing work via the per-thread activity + // indicator. Dequeue on rejection so the indicator doesn't stick. + const service = getSessionService(); + dispatchPlanComment({ + taskId, + pendingPermissions: getPendingPermissionsForTask(taskId), + prompt: buildAskAgentToReplyToPlanThreadPrompt(filePath, blockText), + sessionService: { + respondToPermission: service.respondToPermission.bind(service), + sendPrompt: service.sendPrompt.bind(service), + }, + }).catch((sendErr) => { + log.warn("Failed to send plan-thread prompt", { err: sendErr }); + dequeueAgentActivity(threadKey); + }); + } catch (err) { + log.warn("Failed to append plan thread", { err }); + } finally { + setPending(false); + onClose(); + } + }, [ + blockText, + occurrence, + filePath, + taskId, + onClose, + enqueueAgentActivity, + dequeueAgentActivity, + ]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (isSendMessageSubmitKey(e)) { + e.preventDefault(); + handleSubmit(); + } else if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }, + [handleSubmit, onClose], + ); + + return ( +
+
+