Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a1f6a6d
feat(code): add plan view with threaded inline comments
mcoll-posthog May 20, 2026
371d171
fix(code): plan path source-of-truth, anchor uniqueness, deletion sub…
mcoll-posthog May 20, 2026
ddbd204
fix(code): exact block matching + use typed ACP locations for plan path
mcoll-posthog May 20, 2026
985b0f8
fix(code): markdown-aware block matching + drop unsupported MultiEdit
mcoll-posthog May 20, 2026
386e94a
fix(code): align anchor surface — drop code/table from anchorable types
mcoll-posthog May 20, 2026
f9fedf7
fix(code): guarantee a blank line after every inserted thread blockquote
mcoll-posthog May 20, 2026
ddb734e
feat(code): gate plan threads behind an experimental setting (default…
mcoll-posthog May 20, 2026
74b6bc1
fix(code): keep users in Plan view, inline composer, add approve/reje…
mcoll-posthog May 20, 2026
c94deb7
fix(code): parse threads from blockquote source, not mdast children
mcoll-posthog May 20, 2026
f7f0bb9
feat(code): show per-thread agent activity (responding / queued)
mcoll-posthog May 20, 2026
cb3cb37
fix(code): agent activity indicator was being cleared by StrictMode
mcoll-posthog May 20, 2026
68729fb
test(code): add StrictMode-aware test for plan activity indicator
mcoll-posthog May 21, 2026
9e8fc91
test(code): integration test for plan thread activity indicator
mcoll-posthog May 21, 2026
ffb2eab
fix(code): explicit mode picker on plan approval + gate + dequeue on …
mcoll-posthog May 21, 2026
929f284
test(code): verify Plan approval bar renders Approve/Reject/Mode
mcoll-posthog May 21, 2026
db46472
fix(code): show plan approval bar whenever session is in plan mode
mcoll-posthog May 21, 2026
ad76c25
feat(code): on Approve in plan view, switch to Chat tab and prompt ag…
mcoll-posthog May 21, 2026
96e3833
fix(code): unblock plan comments while ExitPlanMode permission is pen…
mcoll-posthog May 21, 2026
85bfbaa
feat(code): anchor plan comments on individual list items
mcoll-posthog May 21, 2026
f3571e7
fix(code): plan-view DOM validity, composer responsiveness, jsdom imp…
mcoll-posthog May 21, 2026
ac8f81e
fix(code): render plan comment composer inside the <li>, not as a sib…
mcoll-posthog May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/code/src/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/code/src/main/di/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
195 changes: 195 additions & 0 deletions apps/code/src/main/services/agent/plan-file-detector.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
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);
});
});
95 changes: 95 additions & 0 deletions apps/code/src/main/services/agent/plan-file-detector.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions apps/code/src/main/services/agent/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading