From d80c2d91d39373162d701f88b54b89f84baa43bc Mon Sep 17 00:00:00 2001 From: Jordan Harrison Date: Tue, 30 Jun 2026 08:24:05 +0100 Subject: [PATCH 1/3] feat(desktop): enrich agent context and improve diff UX --- .../desktop/src/main/__tests__/agent.test.ts | 116 +++++++++++ .../src/main/__tests__/integration.test.ts | 7 +- .../desktop/src/main/__tests__/spaces.test.ts | 15 ++ .../src/main/__tests__/storage.test.ts | 51 ++++- .../desktop/src/main/agent/systemPrompt.ts | 104 +++++++++- packages/desktop/src/main/agent/types.ts | 30 +++ packages/desktop/src/main/bootstrap.ts | 9 +- .../desktop/src/main/services/AgentService.ts | 194 +++++++++++++++++- .../src/main/services/BrowserTabService.ts | 38 +++- .../desktop/src/main/storage/migrations.ts | 15 +- packages/desktop/src/main/tools/spaceTools.ts | 5 +- packages/desktop/src/renderer/src/App.tsx | 18 +- packages/desktop/src/renderer/src/bridge.ts | 18 +- .../src/renderer/src/components/DiffView.tsx | 15 +- .../src/renderer/src/components/TabStrip.tsx | 12 +- .../renderer/src/components/TopBarGitDiff.tsx | 5 +- .../src/renderer/src/hooks/useGitDiff.ts | 22 +- packages/shared/src/schemas.ts | 4 +- packages/shared/src/types.ts | 2 +- 19 files changed, 638 insertions(+), 42 deletions(-) diff --git a/packages/desktop/src/main/__tests__/agent.test.ts b/packages/desktop/src/main/__tests__/agent.test.ts index c3e7062..834a2fc 100644 --- a/packages/desktop/src/main/__tests__/agent.test.ts +++ b/packages/desktop/src/main/__tests__/agent.test.ts @@ -19,6 +19,7 @@ import { buildSystemPrompt, renderToolCatalog } from "../agent/systemPrompt.js"; import type { AgentAdapter, AgentSession, AgentStreamChunk } from "../agent/types.js"; import { AgentConfigStore } from "../services/AgentConfigStore.js"; import { AgentService } from "../services/AgentService.js"; +import { AppStateService } from "../services/AppStateService.js"; import { Logger } from "../services/Logger.js"; import { ToolRegistry } from "../tools/registry.js"; @@ -83,6 +84,63 @@ describe("system prompt builder", () => { it("handles an empty registry gracefully", () => { expect(renderToolCatalog([])).toContain("No tools are currently registered"); }); + + it("renders live IDE context, instructions, and precedence rules", () => { + const prompt = buildSystemPrompt([], { + cwd: "/repo", + spaceName: "Project", + activeEditorFile: { tabTitle: "Editor", cwd: "/repo", path: "src/app.ts" }, + selectedDiffFile: { tabTitle: "Diff", cwd: "/repo", path: "src/app.ts" }, + openTabs: [{ title: "Local app", url: "http://localhost:3000" }], + terminals: [ + { + id: "term_1", + tabTitle: "Terminal", + cwd: "/repo", + status: "running", + pid: 123, + exitCode: null, + active: true, + }, + ], + devServers: [ + { + id: "dev_1", + cwd: "/repo", + status: "running", + command: "pnpm dev", + url: "http://localhost:3000", + pid: 456, + }, + ], + consoleErrors: [ + { + tabTitle: "Local app", + url: "http://localhost:3000", + text: "Uncaught Error: boom", + }, + ], + git: { + branch: "main", + status: "changes", + summary: "1 changed file(s)", + files: ["M src/app.ts"], + }, + instructionFiles: [ + { path: "/repo/AGENTS.md", content: "Use pnpm.", truncated: false }, + ], + }); + + expect(prompt).toContain("## Instruction precedence"); + expect(prompt).toContain("latest user request defines the task"); + expect(prompt).toContain("## Project instructions"); + expect(prompt).toContain("Use pnpm."); + expect(prompt).toContain("Active editor file: `src/app.ts`"); + expect(prompt).toContain("Selected diff file: `src/app.ts`"); + expect(prompt).toContain("http://localhost:3000"); + expect(prompt).toContain("Uncaught Error: boom"); + expect(prompt).toContain("Git: changes on main"); + }); }); describe("ACP adapter prompt", () => { @@ -473,6 +531,64 @@ describe("AgentService host context", () => { // The host exposes a registry-derived prompt to the adapter. expect(captured.systemPrompt).toContain("__probe"); }); + + it("builds prompt context from app state and project instruction files", async () => { + const { mkdtempSync, writeFileSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const cwd = mkdtempSync(join(tmpdir(), "meith-agent-context-")); + writeFileSync(join(cwd, "AGENTS.md"), "Use pnpm for this project.\n", "utf8"); + + const logger = new Logger(); + const appState = new AppStateService(join(cwd, "state.json"), logger, 0); + const spaceId = appState.getState().activeSpaceId ?? appState.getState().spaces[0].id; + appState.update((draft) => { + draft.workspaceTabs.push( + { + id: "w_editor", + spaceId, + title: "Editor", + cwd, + kind: "editor", + active: false, + activeFilePath: "src/app.ts", + createdAt: 1, + }, + { + id: "w_diff", + spaceId, + title: "Diff", + cwd, + kind: "diff", + active: true, + selectedDiffFilePath: "src/app.ts", + createdAt: 2, + }, + ); + }); + const registry = new ToolRegistry(); + registry.register( + defineTool({ + name: "__probe", + description: "captures call context", + inputSchema: z.object({}), + execute: () => ({}), + }), + ); + const service = new AgentService(registry, logger, { appState }); + const { adapter, captured } = makeCapturingAdapter(); + service.registerAdapter(adapter); + const session = service.createSession({ cwd, spaceId }); + + for await (const _chunk of service.run(session.id)) { + void _chunk; + } + + expect(captured.systemPrompt).toContain("Use pnpm for this project."); + expect(captured.systemPrompt).toContain("Active editor file: `src/app.ts`"); + expect(captured.systemPrompt).toContain("Selected diff file: `src/app.ts`"); + expect(captured.systemPrompt).toContain("Git: not a git repository"); + }); }); describe("AgentService permission model", () => { diff --git a/packages/desktop/src/main/__tests__/integration.test.ts b/packages/desktop/src/main/__tests__/integration.test.ts index 7f3c5ac..f4e285e 100644 --- a/packages/desktop/src/main/__tests__/integration.test.ts +++ b/packages/desktop/src/main/__tests__/integration.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { ToolClient } from "@meith/cli/client"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { type ServiceContainer, bootstrap } from "../bootstrap.js"; +import { CURRENT_STATE_VERSION } from "../storage/migrations.js"; function sendRawFrame( socketPath: string, @@ -322,8 +323,8 @@ describe("socket integration", () => { dataDirectory: string; state: { version: number }; }; - expect(snapshot.stateVersion).toBe(4); - expect(snapshot.state.version).toBe(4); + expect(snapshot.stateVersion).toBe(CURRENT_STATE_VERSION); + expect(snapshot.state.version).toBe(CURRENT_STATE_VERSION); }); it("returns app health and live instances over the socket", async () => { @@ -389,7 +390,7 @@ describe("socket integration", () => { }; expect(content.path).toBeTruthy(); expect(content.report.schema).toBe("meith-bug-report/v1"); - expect(content.report.stateSummary.version).toBe(4); + expect(content.report.stateSummary.version).toBe(CURRENT_STATE_VERSION); expect(content.report.toolRegistry.some((t) => t.name === "app_health")).toBe(true); }); }); diff --git a/packages/desktop/src/main/__tests__/spaces.test.ts b/packages/desktop/src/main/__tests__/spaces.test.ts index 88090df..3776c3a 100644 --- a/packages/desktop/src/main/__tests__/spaces.test.ts +++ b/packages/desktop/src/main/__tests__/spaces.test.ts @@ -173,6 +173,21 @@ describe("BrowserTabService workspace tabs", () => { expect(cleared.terminalId).toBeUndefined(); }); + it("persists the selected file on diff workspace tabs", () => { + const tab = ctx.tabs.openWorkspaceTab({ + title: "Diff", + cwd: "/tmp/project", + kind: "diff", + }); + + const updated = ctx.tabs.setWorkspaceTabFile(tab.id, { + selectedDiffFilePath: "src/app.ts", + }); + + expect(updated.selectedDiffFilePath).toBe("src/app.ts"); + expect(ctx.tabs.listWorkspaceTabs()[0].selectedDiffFilePath).toBe("src/app.ts"); + }); + it("rejects terminal session ids on non-terminal workspace tabs", () => { const tab = ctx.tabs.openWorkspaceTab({ title: "Editor", cwd: "/tmp/project" }); expect(() => ctx.tabs.setWorkspaceTabTerminal(tab.id, "term_123")).toThrow( diff --git a/packages/desktop/src/main/__tests__/storage.test.ts b/packages/desktop/src/main/__tests__/storage.test.ts index 8894a5d..c603add 100644 --- a/packages/desktop/src/main/__tests__/storage.test.ts +++ b/packages/desktop/src/main/__tests__/storage.test.ts @@ -38,12 +38,22 @@ describe("migrations", () => { it("passes through a valid current-version state", () => { const valid = { - version: 3, - spaces: [{ id: "s1", name: "S", createdAt: 1 }], + version: CURRENT_STATE_VERSION, + spaces: [{ id: "s1", name: "S", projectId: null, createdAt: 1 }], activeSpaceId: "s1", browserTabs: [], workspaceTabs: [], projects: [], + workspaceFileEvents: [], + plugins: [], + settings: { + autoRunOnOpen: false, + confirmOnClose: true, + stopServersOnClose: true, + showOutputOnRun: true, + defaultPackageManager: "unknown", + debugMode: false, + }, }; expect(migrateAppState(valid).activeSpaceId).toBe("s1"); }); @@ -116,6 +126,43 @@ describe("migrations", () => { }); }); + it("migrates v4 -> v5 by preserving selected diff file state", () => { + const v4 = { + version: 4, + spaces: [{ id: "s1", name: "S", createdAt: 1, projectId: null }], + activeSpaceId: "s1", + browserTabs: [], + workspaceTabs: [ + { + id: "w1", + spaceId: "s1", + title: "Diff", + cwd: "/tmp/proj", + kind: "diff", + selectedDiffFilePath: "src/app.ts", + active: true, + createdAt: 1, + }, + ], + projects: [], + workspaceFileEvents: [], + plugins: [], + settings: { + autoRunOnOpen: false, + confirmOnClose: true, + stopServersOnClose: true, + showOutputOnRun: true, + defaultPackageManager: "unknown", + debugMode: false, + }, + }; + + const migrated = migrateAppState(v4); + + expect(migrated.version).toBe(CURRENT_STATE_VERSION); + expect(migrated.workspaceTabs[0].selectedDiffFilePath).toBe("src/app.ts"); + }); + it("throws on a newer-than-supported version", () => { expect(() => migrateAppState({ version: 99 })).toThrow(); }); diff --git a/packages/desktop/src/main/agent/systemPrompt.ts b/packages/desktop/src/main/agent/systemPrompt.ts index 53aa06e..cd4d9e0 100644 --- a/packages/desktop/src/main/agent/systemPrompt.ts +++ b/packages/desktop/src/main/agent/systemPrompt.ts @@ -59,6 +59,20 @@ The host (the Electron main process) is the authority for all state and actions. and keep final answers focused on what changed and how it was verified. - Never assume a browser tab, workspace tab, or process exists — list it first. +## Instruction precedence + +- App safety rules, permission boundaries, and the tool call contract are + mandatory. Do not bypass them to satisfy user or repo instructions. +- Tool descriptors, schemas, and tool results are authoritative for how to call + Meith tools and interpret their output. +- The latest user request defines the task. It overrides project instruction + files when they conflict, unless doing so would violate app safety rules or + tool contracts. +- Project-specific instruction files apply to work inside their project scope. + More specific nested files override broader repo files. +- If instructions conflict and the safe precedence is unclear, explain the + conflict and ask before making a risky change. + ## Tool call contract Each tool has a name (snake_case), a description, and a JSON Schema for input. @@ -97,6 +111,20 @@ export function renderContext(context: AgentPromptContext): string { const lines: string[] = ["## Current context", ""]; lines.push(`- Working directory: \`${context.cwd}\``); if (context.spaceName) lines.push(`- Space: ${context.spaceName}`); + if (context.activeEditorFile) { + lines.push( + `- Active editor file: \`${context.activeEditorFile.path}\` (${context.activeEditorFile.tabTitle}, cwd \`${context.activeEditorFile.cwd}\`)`, + ); + } else { + lines.push("- Active editor file: none"); + } + if (context.selectedDiffFile) { + lines.push( + `- Selected diff file: \`${context.selectedDiffFile.path}\` (${context.selectedDiffFile.tabTitle}, cwd \`${context.selectedDiffFile.cwd}\`)`, + ); + } else { + lines.push("- Selected diff file: none"); + } if (context.openTabs && context.openTabs.length > 0) { lines.push("- Open browser tabs:"); for (const tab of context.openTabs) { @@ -105,6 +133,75 @@ export function renderContext(context: AgentPromptContext): string { } else { lines.push("- Open browser tabs: none"); } + if (context.terminals && context.terminals.length > 0) { + lines.push("- Terminal status:"); + for (const terminal of context.terminals) { + const title = terminal.tabTitle ? `${terminal.tabTitle}: ` : ""; + const active = terminal.active ? ", active" : ""; + const exit = + terminal.status === "exited" ? `, exit=${terminal.exitCode ?? "null"}` : ""; + lines.push( + ` - ${title}\`${terminal.id}\` ${terminal.status}${active}${exit}, cwd \`${terminal.cwd}\`, pid ${terminal.pid ?? "n/a"}`, + ); + } + } else { + lines.push("- Terminal status: no relevant terminals"); + } + if (context.devServers && context.devServers.length > 0) { + lines.push("- Running dev servers:"); + for (const server of context.devServers) { + const name = server.name ? `${server.name}: ` : ""; + const url = server.url ? `, url ${server.url}` : ""; + lines.push( + ` - ${name}\`${server.id}\` ${server.status}${url}, command \`${server.command}\`, cwd \`${server.cwd}\`, pid ${server.pid ?? "n/a"}`, + ); + } + } else { + lines.push("- Running dev servers: none"); + } + if (context.consoleErrors && context.consoleErrors.length > 0) { + lines.push("- Recent console errors:"); + for (const entry of context.consoleErrors) { + const source = entry.source ? ` (${entry.source})` : ""; + lines.push( + ` - ${entry.tabTitle || "(untitled)"} — ${entry.url}: ${oneLine(entry.text)}${source}`, + ); + } + } else { + lines.push("- Recent console errors: none"); + } + if (context.git) { + const branch = context.git.branch ? ` on ${context.git.branch}` : ""; + const summary = context.git.summary ? ` — ${context.git.summary}` : ""; + lines.push(`- Git: ${context.git.status}${branch}${summary}`); + if (context.git.files && context.git.files.length > 0) { + for (const file of context.git.files) lines.push(` - ${file}`); + } + } + return lines.join("\n"); +} + +/** Render project-specific instruction files discovered for the session cwd. */ +export function renderInstructionFiles(context: AgentPromptContext): string { + if (!context.instructionFiles || context.instructionFiles.length === 0) { + return [ + "## Project instructions", + "", + "_No project-specific instruction files were found for this session cwd._", + ].join("\n"); + } + const lines = [ + "## Project instructions", + "", + "Instruction files are listed from broadest to most specific. Apply the", + "precedence rules above when they conflict.", + ]; + for (const file of context.instructionFiles) { + lines.push("", `### ${file.path}${file.truncated ? " (truncated)" : ""}`, ""); + lines.push("```"); + lines.push(file.content); + lines.push("```"); + } return lines.join("\n"); } @@ -142,5 +239,10 @@ export function buildSystemPrompt( ? `${SYSTEM_PROMPT_BASE}\n\n${catalog}` : `${SYSTEM_PROMPT_BASE.slice(0, idx)}${catalog}\n\n${SYSTEM_PROMPT_BASE.slice(idx)}`; if (!context) return base; - return `${base}\n\n${renderContext(context)}\n\n${renderSafety(context)}`; + return `${base}\n\n${renderInstructionFiles(context)}\n\n${renderContext(context)}\n\n${renderSafety(context)}`; +} + +function oneLine(value: string): string { + const text = value.replace(/\s+/g, " ").trim(); + return text.length > 240 ? `${text.slice(0, 237)}...` : text; } diff --git a/packages/desktop/src/main/agent/types.ts b/packages/desktop/src/main/agent/types.ts index cd56ee5..0194180 100644 --- a/packages/desktop/src/main/agent/types.ts +++ b/packages/desktop/src/main/agent/types.ts @@ -47,8 +47,38 @@ export interface AgentPromptContext { cwd: string; /** Active space name, when the session is associated with one. */ spaceName?: string; + /** Active editor file in this space, when the editor has one focused. */ + activeEditorFile?: { tabTitle: string; cwd: string; path: string }; + /** Selected file in the active/relevant diff surface. */ + selectedDiffFile?: { tabTitle: string; cwd: string; path: string }; /** URLs/titles of browser tabs currently open in the session's space. */ openTabs?: Array<{ title: string; url: string }>; + /** Recent console errors captured from browser tabs in the session's space. */ + consoleErrors?: Array<{ tabTitle: string; url: string; text: string; source?: string }>; + /** Live terminal sessions relevant to the session's space/cwd. */ + terminals?: Array<{ + id: string; + tabTitle?: string; + cwd: string; + status: string; + pid: number | null; + exitCode: number | null; + active: boolean; + }>; + /** Managed dev servers relevant to the session's space/cwd. */ + devServers?: Array<{ + id: string; + name?: string; + cwd: string; + status: string; + command: string; + url?: string; + pid: number | null; + }>; + /** Current git state for the session cwd. */ + git?: { branch?: string; status: string; summary?: string; files?: string[] }; + /** Project-specific instruction files discovered for the session cwd. */ + instructionFiles?: Array<{ path: string; content: string; truncated: boolean }>; /** Whether gated tool calls are auto-accepted (affects the safety section). */ autoAccept?: boolean; } diff --git a/packages/desktop/src/main/bootstrap.ts b/packages/desktop/src/main/bootstrap.ts index 55b777c..8cd394e 100644 --- a/packages/desktop/src/main/bootstrap.ts +++ b/packages/desktop/src/main/bootstrap.ts @@ -18,6 +18,7 @@ import { import { AcpAdapter } from "./agent/adapters/AcpAdapter.js"; import { MockAdapter } from "./agent/adapters/MockAdapter.js"; import type { BrowserViewHost } from "./browser/BrowserViewHost.js"; +import type { PtyHost } from "./process/PtyHost.js"; import { buildDesktopExecutablePath, findBundledNodeExecutable, @@ -25,7 +26,6 @@ import { findBundledNpmExecutable, findBundledNpxExecutable, } from "./process/executablePath.js"; -import type { PtyHost } from "./process/PtyHost.js"; import { type RuntimeInstallInfo, installRuntimeCli } from "./runtime/install.js"; import { AgentConfigStore } from "./services/AgentConfigStore.js"; import { AgentService } from "./services/AgentService.js"; @@ -276,9 +276,7 @@ export async function bootstrap( // Runtime environment injected into every spawned terminal / dev server so // tools and plugins launched inside them can reach this running app: the // socket path for dev-log attachment plus app-scoped identifiers. - const runtimePathBins = [ - runtimeInstall?.binDir ?? join(home, "bin"), - ]; + const runtimePathBins = [runtimeInstall?.binDir ?? join(home, "bin")]; const runtimeEnv: Record = { MEITH_SOCKET: socketPath, MEITH_HOME: home, @@ -393,6 +391,9 @@ export async function bootstrap( store: agentStore, configStore: agentConfig, appState, + browserTabs, + terminals, + devServers, mcpBridge, permissions, // Probe ACP agents on demand (install detection + advertised model/reasoning diff --git a/packages/desktop/src/main/services/AgentService.ts b/packages/desktop/src/main/services/AgentService.ts index 64641c8..d83ae0d 100644 --- a/packages/desktop/src/main/services/AgentService.ts +++ b/packages/desktop/src/main/services/AgentService.ts @@ -1,7 +1,15 @@ +import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; -import { copyFileSync, mkdirSync, statSync, writeFileSync } from "node:fs"; -import { basename, extname, join } from "node:path"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, extname, join, relative, resolve, sep } from "node:path"; import type { ToolDescriptor } from "@meith/protocol"; import { type AgentAttachment, @@ -35,14 +43,20 @@ import type { ToolRegistry } from "../tools/registry.js"; import type { AgentConfigStore } from "./AgentConfigStore.js"; import type { AgentMessagePatch, AgentStore } from "./AgentStore.js"; import type { AppStateService } from "./AppStateService.js"; +import type { BrowserTabService } from "./BrowserTabService.js"; +import type { DevServerService } from "./DevServerService.js"; import type { Logger } from "./Logger.js"; import type { McpBridgeService, McpSessionEndpoint } from "./McpBridgeService.js"; import type { PermissionService } from "./PermissionService.js"; +import type { TerminalService } from "./TerminalService.js"; export interface AgentServiceOptions { store?: AgentStore; configStore?: AgentConfigStore; appState?: AppStateService; + browserTabs?: BrowserTabService; + terminals?: TerminalService; + devServers?: DevServerService; mcpBridge?: McpBridgeService; permissions?: PermissionService; /** @@ -430,10 +444,98 @@ export class AgentService extends EventEmitter { if (state) { const space = state.spaces.find((s) => s.id === session.spaceId); if (space) ctx.spaceName = space.name; - ctx.openTabs = state.browserTabs + const browserTabs = state.browserTabs.filter((t) => + session.spaceId ? t.spaceId === session.spaceId : true, + ); + const workspaceTabs = state.workspaceTabs.filter((t) => + session.spaceId ? t.spaceId === session.spaceId : true, + ); + const activeEditor = + workspaceTabs.find((t) => t.kind === "editor" && t.active && t.activeFilePath) ?? + workspaceTabs.find((t) => t.kind === "editor" && t.activeFilePath); + if (activeEditor?.activeFilePath) { + ctx.activeEditorFile = { + tabTitle: activeEditor.title, + cwd: activeEditor.cwd, + path: activeEditor.activeFilePath, + }; + } + const selectedDiff = + workspaceTabs.find( + (t) => t.kind === "diff" && t.active && t.selectedDiffFilePath, + ) ?? workspaceTabs.find((t) => t.kind === "diff" && t.selectedDiffFilePath); + if (selectedDiff?.selectedDiffFilePath) { + ctx.selectedDiffFile = { + tabTitle: selectedDiff.title, + cwd: selectedDiff.cwd, + path: selectedDiff.selectedDiffFilePath, + }; + } + ctx.openTabs = browserTabs .filter((t) => (session.spaceId ? t.spaceId === session.spaceId : true)) .map((t) => ({ title: t.title, url: t.url })); + ctx.consoleErrors = browserTabs.flatMap((tab) => + (this.options.browserTabs?.getConsoleLogSnapshot(tab.id, 10) ?? []) + .filter((entry) => entry.level === "error") + .slice(-3) + .map((entry) => ({ + tabTitle: tab.title, + url: tab.url, + text: entry.text, + source: entry.source, + })), + ); + const relevantCwds = new Set([ + resolve(session.cwd), + ...workspaceTabs.map((t) => resolve(t.cwd)), + ...state.projects + .filter((project) => + session.spaceId + ? project.spaceId === session.spaceId + : project.cwd === session.cwd, + ) + .map((project) => resolve(project.cwd)), + ]); + const terminalTabs = new Map( + workspaceTabs + .filter((tab) => tab.kind === "terminal" && tab.terminalId) + .map((tab) => [tab.terminalId as string, tab]), + ); + ctx.terminals = (this.options.terminals?.list() ?? []) + .filter( + (terminal) => + terminalTabs.has(terminal.id) || relevantCwds.has(resolve(terminal.cwd)), + ) + .map((terminal) => { + const tab = terminalTabs.get(terminal.id); + return { + id: terminal.id, + tabTitle: tab?.title, + cwd: terminal.cwd, + status: terminal.status, + pid: terminal.pid, + exitCode: terminal.exitCode, + active: tab?.active ?? false, + }; + }); + ctx.devServers = (this.options.devServers?.list() ?? []) + .filter( + (server) => + (server.status === "running" || server.status === "starting") && + relevantCwds.has(resolve(server.cwd)), + ) + .map((server) => ({ + id: server.id, + name: server.name, + cwd: server.cwd, + status: server.status, + command: [server.command, ...server.args].join(" "), + url: server.port ? `http://localhost:${server.port}` : undefined, + pid: server.pid, + })); } + ctx.git = readGitSummary(session.cwd); + ctx.instructionFiles = readInstructionFiles(session.cwd); return ctx; } @@ -902,6 +1004,92 @@ function inferAttachmentKind( : "file"; } +const INSTRUCTION_FILE_NAMES = [ + "AGENTS.md", + "CLAUDE.md", + ".cursorrules", + ".github/copilot-instructions.md", +] as const; +const MAX_INSTRUCTION_BYTES = 24_000; + +function readGitSummary(cwd: string): AgentPromptContext["git"] { + if (runGit(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true") { + return { status: "not a git repository" }; + } + const branch = + runGit(cwd, ["branch", "--show-current"]) || + runGit(cwd, ["rev-parse", "--short", "HEAD"]); + const statusText = runGit(cwd, ["status", "--short"]) ?? ""; + const files = statusText + .split("\n") + .map((line) => line.trimEnd()) + .filter(Boolean); + return { + branch, + status: files.length === 0 ? "clean" : "changes", + summary: + files.length === 0 ? "working tree clean" : `${files.length} changed file(s)`, + files: files.slice(0, 12), + }; +} + +function readInstructionFiles(cwd: string): AgentPromptContext["instructionFiles"] { + const root = runGit(cwd, ["rev-parse", "--show-toplevel"]) || cwd; + const dirs = instructionSearchDirs(root, cwd); + const seen = new Set(); + const files: NonNullable = []; + for (const dir of dirs) { + for (const name of INSTRUCTION_FILE_NAMES) { + const path = resolve(dir, name); + if (seen.has(path) || !isRegularFile(path)) continue; + seen.add(path); + const raw = readFileSync(path); + const truncated = raw.byteLength > MAX_INSTRUCTION_BYTES; + const content = raw.subarray(0, MAX_INSTRUCTION_BYTES).toString("utf8").trimEnd(); + files.push({ path, content, truncated }); + } + } + return files; +} + +function instructionSearchDirs(root: string, cwd: string): string[] { + const base = resolve(root); + const current = resolve(cwd); + const rel = relative(base, current); + if (rel.startsWith("..") || rel === "" || rel.split(sep).includes("..")) { + return rel === "" ? [base] : [current]; + } + const dirs = [base]; + let cursor = base; + for (const part of rel.split(sep).filter(Boolean)) { + cursor = resolve(cursor, part); + dirs.push(cursor); + } + return dirs; +} + +function isRegularFile(path: string): boolean { + try { + return existsSync(path) && statSync(path).isFile(); + } catch { + return false; + } +} + +function runGit(cwd: string, args: string[]): string | undefined { + try { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 750, + maxBuffer: 128 * 1024, + }).trim(); + } catch { + return undefined; + } +} + function appendAssistantText( assistant: AgentMessage, text: string, diff --git a/packages/desktop/src/main/services/BrowserTabService.ts b/packages/desktop/src/main/services/BrowserTabService.ts index 3dd1b60..ed88460 100644 --- a/packages/desktop/src/main/services/BrowserTabService.ts +++ b/packages/desktop/src/main/services/BrowserTabService.ts @@ -513,6 +513,18 @@ export class BrowserTabService { return this.host.getConsoleLogs(id, limit); } + /** + * Read whatever console messages are already available without creating or + * hydrating a browser view. Used while composing synchronous agent context. + */ + getConsoleLogSnapshot(id: string, limit?: number): ConsoleLogEntry[] { + try { + return this.host.getConsoleLogs(id, limit); + } catch { + return []; + } + } + /** Read observed network requests for a tab (read-only). */ async getNetworkLogs(id: string, limit?: number): Promise { const tab = this.requireTab(id); @@ -526,6 +538,7 @@ export class BrowserTabService { kind?: WorkspaceTab["kind"]; spaceId?: string; terminalId?: string; + selectedDiffFilePath?: string; }): WorkspaceTab { const spaceId = input.spaceId ?? this.activeSpaceId(); const tab: WorkspaceTab = { @@ -535,6 +548,8 @@ export class BrowserTabService { cwd: input.cwd, kind: input.kind ?? "editor", terminalId: input.terminalId, + selectedDiffFilePath: + input.kind === "diff" ? input.selectedDiffFilePath : undefined, active: true, createdAt: Date.now(), }; @@ -567,19 +582,29 @@ export class BrowserTabService { } /** - * Persist the editor session state of an editor workspace tab: the focused - * file and the list of open files (paths are relative to the tab's cwd). This - * lets the renderer editor, CLI, and agents agree on what is open via state. + * Persist file-focused state for workspace tabs. Editor tabs keep their + * focused/open files; diff tabs keep the selected changed file. Paths are + * relative to the tab's cwd. */ setWorkspaceTabFile( id: string, - input: { activeFilePath?: string | null; openFilePaths?: string[] }, + input: { + activeFilePath?: string | null; + openFilePaths?: string[]; + selectedDiffFilePath?: string | null; + }, ): WorkspaceTab { const tab = this.appState.getState().workspaceTabs.find((t) => t.id === id); if (!tab) throw new Error(`Unknown workspace tab: ${id}`); - if (tab.kind !== "editor") { + const editsEditor = + input.activeFilePath !== undefined || input.openFilePaths !== undefined; + const editsDiff = input.selectedDiffFilePath !== undefined; + if (editsEditor && tab.kind !== "editor") { throw new Error(`Workspace tab is not an editor: ${id}`); } + if (editsDiff && tab.kind !== "diff") { + throw new Error(`Workspace tab is not a diff view: ${id}`); + } this.appState.update((draft) => { const t = draft.workspaceTabs.find((w) => w.id === id); if (!t) return; @@ -593,6 +618,9 @@ export class BrowserTabService { t.activeFilePath = input.openFilePaths[input.openFilePaths.length - 1]; } } + if (input.selectedDiffFilePath !== undefined) { + t.selectedDiffFilePath = input.selectedDiffFilePath ?? undefined; + } }, "set_workspace_tab_file"); const next = this.appState.getState().workspaceTabs.find((t) => t.id === id); if (!next) throw new Error(`Unknown workspace tab: ${id}`); diff --git a/packages/desktop/src/main/storage/migrations.ts b/packages/desktop/src/main/storage/migrations.ts index aebbb98..3105aa3 100644 --- a/packages/desktop/src/main/storage/migrations.ts +++ b/packages/desktop/src/main/storage/migrations.ts @@ -1,7 +1,7 @@ import { type AppState, AppStateSchema } from "@meith/shared"; /** The state version this build writes and migrates up to. */ -export const CURRENT_STATE_VERSION = 4; +export const CURRENT_STATE_VERSION = 5; type RawState = Record; @@ -68,6 +68,19 @@ const migrations: Record RawState> = { }; }), }), + + // 4 -> 5: diff workspace tabs persist their selected changed file so agents + // and restored UI sessions can agree on the currently inspected diff. + 4: (raw) => ({ + ...raw, + version: 5, + workspaceTabs: (Array.isArray(raw.workspaceTabs) ? raw.workspaceTabs : []).map( + (tab) => { + const t = (tab ?? {}) as RawState; + return { ...t, selectedDiffFilePath: t.selectedDiffFilePath ?? undefined }; + }, + ), + }), }; function rawVersion(raw: unknown): number { diff --git a/packages/desktop/src/main/tools/spaceTools.ts b/packages/desktop/src/main/tools/spaceTools.ts index 4306487..2390aff 100644 --- a/packages/desktop/src/main/tools/spaceTools.ts +++ b/packages/desktop/src/main/tools/spaceTools.ts @@ -70,6 +70,7 @@ export function createSpaceTools(deps: ToolDeps): ToolDefinition[] { kind: z.enum(["editor", "terminal", "agent", "preview", "diff"]).optional(), spaceId: z.string().optional(), terminalId: z.string().optional(), + selectedDiffFilePath: z.string().optional(), }), execute: (_ctx, input) => deps.browserTabs.openWorkspaceTab(input), }); @@ -89,17 +90,19 @@ export function createSpaceTools(deps: ToolDeps): ToolDefinition[] { const setWorkspaceTabFile = defineTool({ name: "set_workspace_tab_file", description: - "Set the focused file and/or open files of an editor workspace tab (paths relative to its cwd).", + "Set file-focused workspace tab state: editor focused/open files or diff selected file (paths relative to cwd).", capabilities: ["writes-files"], inputSchema: z.object({ tabId: z.string(), activeFilePath: z.string().nullable().optional(), openFilePaths: z.array(z.string()).optional(), + selectedDiffFilePath: z.string().nullable().optional(), }), execute: (_ctx, input) => deps.browserTabs.setWorkspaceTabFile(input.tabId, { activeFilePath: input.activeFilePath, openFilePaths: input.openFilePaths, + selectedDiffFilePath: input.selectedDiffFilePath, }), }); diff --git a/packages/desktop/src/renderer/src/App.tsx b/packages/desktop/src/renderer/src/App.tsx index c31d941..5ccc53d 100644 --- a/packages/desktop/src/renderer/src/App.tsx +++ b/packages/desktop/src/renderer/src/App.tsx @@ -538,13 +538,17 @@ export function App() { // Open (or focus, if one already exists for this space) the diff surface for // the active project. const openDiffTab = useCallback( - (pane: PaneId = "primary") => { + (pane: PaneId = "secondary") => { setSettingsOpen(false); const existing = workspaceTabs.find( (t) => t.kind === "diff" && t.spaceId === activeSpaceId, ); if (existing) { - layout.setActive(pane, existing.id); + if (layout.paneOf(existing.id) !== pane) { + layout.moveTabToPane(existing.id, pane); + } else { + layout.setActive(pane, existing.id); + } void run("focus_workspace_tab", { tabId: existing.id }); return; } @@ -907,7 +911,7 @@ export function App() { openDiffTab("primary")} + onOpenDiff={openDiffTab} refreshKey={workspaceFileEvents.length} /> layout.setFocused("primary")} + onPointerDownCapture={() => layout.setFocused("primary")} style={effectiveSplit ? { width: splitPane.size } : { flex: "1 1 0%" }} > {/* Secondary pane: its own tab strip + active surface. */} -
+
layout.setFocused("secondary")} + onPointerDownCapture={() => layout.setFocused("secondary")} + > t.id === args.tabId); if (!tab) return errorResult("TOOL_FAILED", "Unknown workspace tab"); - if (tab.kind !== "editor") { + const editsEditor = + args.activeFilePath !== undefined || Array.isArray(args.openFilePaths); + const editsDiff = args.selectedDiffFilePath !== undefined; + if (editsEditor && tab.kind !== "editor") { return errorResult("TOOL_FAILED", "Workspace tab is not an editor"); } + if (editsDiff && tab.kind !== "diff") { + return errorResult("TOOL_FAILED", "Workspace tab is not a diff view"); + } if (args.activeFilePath !== undefined) { tab.activeFilePath = (args.activeFilePath as string | null) ?? undefined; } if (Array.isArray(args.openFilePaths)) { tab.openFilePaths = args.openFilePaths as string[]; } + if (args.selectedDiffFilePath !== undefined) { + tab.selectedDiffFilePath = + (args.selectedDiffFilePath as string | null) ?? undefined; + } emitState(); return okResult(structuredClone(tab)); } diff --git a/packages/desktop/src/renderer/src/components/DiffView.tsx b/packages/desktop/src/renderer/src/components/DiffView.tsx index c7178cc..ebb840b 100644 --- a/packages/desktop/src/renderer/src/components/DiffView.tsx +++ b/packages/desktop/src/renderer/src/components/DiffView.tsx @@ -50,9 +50,13 @@ type DiffTreeNode = DiffTreeDir | DiffTreeFile; export function DiffView({ tab, call, refreshKey }: DiffViewProps) { const { data, loading, error, refresh } = useGitDiff(call, tab.cwd, { refreshKey, + pollMs: 2500, + forcePoll: true, includePatches: false, }); - const [selectedPath, setSelectedPath] = useState(null); + const [selectedPath, setSelectedPath] = useState( + tab.selectedDiffFilePath ?? null, + ); const [patchByPath, setPatchByPath] = useState>({}); const [patchLoading, setPatchLoading] = useState(false); const [patchError, setPatchError] = useState(null); @@ -95,6 +99,15 @@ export function DiffView({ tab, call, refreshKey }: DiffViewProps) { } }, [data.files, selectedPath]); + useEffect(() => { + const persistedPath = tab.selectedDiffFilePath ?? null; + if (selectedPath === persistedPath) return; + void call("set_workspace_tab_file", { + tabId: tab.id, + selectedDiffFilePath: selectedPath, + }); + }, [call, selectedPath, tab.id, tab.selectedDiffFilePath]); + // Keep selected files visible in the tree, and drop expansion entries for // directories that no longer exist after a diff refresh. useEffect(() => { diff --git a/packages/desktop/src/renderer/src/components/TabStrip.tsx b/packages/desktop/src/renderer/src/components/TabStrip.tsx index b8263a1..1d73183 100644 --- a/packages/desktop/src/renderer/src/components/TabStrip.tsx +++ b/packages/desktop/src/renderer/src/components/TabStrip.tsx @@ -240,9 +240,11 @@ export function TabStrip({ }} className={cn( "group relative flex h-full min-w-32 max-w-52 items-center gap-2 border-r border-border px-3 text-sm transition-colors", - isActive - ? "bg-background text-foreground" - : "text-muted-foreground hover:bg-accent/40 hover:text-foreground", + isFocused + ? "bg-orange-50 text-orange-950 dark:bg-orange-950/30 dark:text-orange-100" + : isActive + ? "bg-background text-foreground" + : "text-muted-foreground hover:bg-accent/40 hover:text-foreground", isDropTarget && "bg-primary/10", dragId === item.tab.id && "opacity-50", )} @@ -252,7 +254,7 @@ export function TabStrip({ aria-hidden className={cn( "pointer-events-none absolute inset-x-0 top-0 h-0.5", - isFocused ? "bg-primary" : "bg-border", + isFocused ? "bg-orange-500" : "bg-border", )} /> )} @@ -265,7 +267,7 @@ export function TabStrip({ {label} diff --git a/packages/desktop/src/renderer/src/components/TopBarGitDiff.tsx b/packages/desktop/src/renderer/src/components/TopBarGitDiff.tsx index cbd6b98..9a838e5 100644 --- a/packages/desktop/src/renderer/src/components/TopBarGitDiff.tsx +++ b/packages/desktop/src/renderer/src/components/TopBarGitDiff.tsx @@ -22,7 +22,8 @@ interface TopBarGitDiffProps { */ export function TopBarGitDiff({ cwd, call, onOpenDiff, refreshKey }: TopBarGitDiffProps) { const { data, loading } = useGitDiff(call, cwd, { - pollMs: 8000, + pollMs: 2500, + forcePoll: true, refreshKey, includePatches: false, }); @@ -37,7 +38,7 @@ export function TopBarGitDiff({ cwd, call, onOpenDiff, refreshKey }: TopBarGitDi render={