diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index a104824de..a596fc854 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -239,7 +239,7 @@ ade git push --lane lane-id ade git branches --lane lane-id --text ade git user-identity --lane lane-id --text ade diff patch --lane lane-id --path src/file.ts --text -ade prs create --lane lane-id --base main --title "Fix checkout flow" +ade prs create --lane lane-id --base main --title "Fix checkout flow" --text # prints GitHub + ADE PR URLs ade prs create --lane lane-id --base main --close-linear-issue-on-merge ade prs list-open --text ade prs github-snapshot --include-external-closed diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 158d13ab5..ba680a0fb 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -435,7 +435,16 @@ function createRuntime() { createQueuePrs: vi.fn(async () => ({ groupId: "group-1", prs: [] })), createIntegrationPr: vi.fn(async () => ({ prId: "pr-int-1", url: "https://github.com/pr/1" })), draftDescription: vi.fn(async () => ({ title: "Drafted PR", body: "Drafted body" })), - createFromLane: vi.fn(async () => ({ id: "pr-new", laneId: "lane-1", title: "New PR", status: "open" })), + createFromLane: vi.fn(async () => ({ + id: "pr-new", + laneId: "lane-1", + repoOwner: "acme", + repoName: "ade", + githubPrNumber: 42, + githubUrl: "https://github.com/acme/ade/pull/42", + title: "New PR", + status: "open", + })), getPrHealth: vi.fn(async (prId: string) => ({ prId, healthy: true, checks: "pass", reviews: "approved" })), landQueueNext: vi.fn(async () => ({ landed: true, prId: "pr-1", sha: "def456" })), getChecks: vi.fn(async () => [ @@ -4538,6 +4547,10 @@ describe("adeRpcServer", () => { closeLinearIssueOnMerge: true, }); expect(created?.isError).toBeUndefined(); + expect(created?.structuredContent).toMatchObject({ + githubUrl: "https://github.com/acme/ade/pull/42", + adeUrl: "https://ade.app/open?type=pr&repo=acme%2Fade&number=42", + }); expect(fixture.runtime.prService.createFromLane).toHaveBeenCalledWith({ laneId: "lane-1", baseBranch: "main", @@ -4570,6 +4583,36 @@ describe("adeRpcServer", () => { expect(fixture.runtime.prService.addComment).toHaveBeenCalledWith({ prId: "pr-1", body: "Looks good" }); }); + it("synthesizes PR browser links from repo metadata when RPC PR creation omits githubUrl", async () => { + const fixture = createRuntime(); + fixture.runtime.prService.createFromLane = vi.fn(async () => ({ + id: "pr-new", + laneId: "lane-1", + repoOwner: "acme", + repoName: "ade", + githubPrNumber: 42, + title: "New PR", + status: "open", + })) as any; + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "agent-1", role: "agent" }); + + const created = await callTool(handler, "create_pr_from_lane", { + laneId: "lane-1", + baseBranch: "main", + title: "My PR", + body: "Body text", + draft: true, + closeLinearIssueOnMerge: true, + }); + + expect(created?.isError).toBeUndefined(); + expect(created?.structuredContent).toMatchObject({ + githubUrl: "https://github.com/acme/ade/pull/42", + adeUrl: "https://ade.app/open?type=pr&repo=acme%2Fade&number=42", + }); + }); + it("lists ADE actions across runtime domains", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index c3042798c..adc980397 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -32,6 +32,7 @@ import { runGit } from "../../desktop/src/main/services/git/git"; import { resolvePathWithinRoot } from "../../desktop/src/main/services/shared/utils"; import { getDefaultModelDescriptor } from "../../desktop/src/shared/modelRegistry"; import { buildAdeCliInlineGuidance } from "../../desktop/src/shared/adeCliGuidance"; +import { buildDeeplink } from "../../desktop/src/shared/deeplinks"; import { ADE_AGENT_SKILLS_DIRS_ENV, getAdeAgentSkillRootsForPrompt, @@ -1187,7 +1188,7 @@ const TOOL_SPECS: ToolSpec[] = [ }, { name: "create_pr_from_lane", - description: "Create a PR from a lane branch. Drafts a title/body from ADE context when omitted.", + description: "Create a PR from a lane branch. Drafts a title/body from ADE context when omitted. Returns GitHub and ADE PR URLs when available.", inputSchema: { type: "object", required: ["laneId"], @@ -2387,6 +2388,34 @@ function asBoolean(value: unknown, fallback = false): boolean { return typeof value === "boolean" ? value : fallback; } +function asPositiveInteger(value: unknown): number | null { + let parsed = NaN; + if (typeof value === "number") parsed = value; + else if (typeof value === "string") parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function prLinkUrls(pr: unknown): { githubUrl?: string; adeUrl?: string } { + if (!isRecord(pr)) return {}; + const githubUrl = asOptionalTrimmedString(pr.githubUrl); + const repoOwner = asOptionalTrimmedString(pr.repoOwner); + const repoName = asOptionalTrimmedString(pr.repoName); + const prNumber = asPositiveInteger( + pr.githubPrNumber ?? pr.prNumber ?? pr.number, + ); + const derivedGithubUrl = repoOwner && repoName && prNumber + ? `https://github.com/${repoOwner}/${repoName}/pull/${prNumber}` + : null; + const adeUrl = repoOwner && repoName && prNumber + ? buildDeeplink({ kind: "pr", repoOwner, repoName, prNumber }) + : null; + const resolvedGithubUrl = githubUrl ?? derivedGithubUrl; + return { + ...(resolvedGithubUrl ? { githubUrl: resolvedGithubUrl } : {}), + ...(adeUrl ? { adeUrl } : {}), + }; +} + function asNumber(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } @@ -6791,7 +6820,7 @@ async function runTool(args: { ...(baseBranch ? { baseBranch } : {}), ...(closeLinearIssueOnMerge ? { closeLinearIssueOnMerge } : {}), }); - return { pr }; + return { pr, ...prLinkUrls(pr) }; } if (name === "pr_update_title") { diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 56032cc7a..a3029e711 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -69,7 +69,12 @@ import { getSharedModelPickerStore } from "./services/modelPickerStore"; import type { createAutomationIngressService } from "../../desktop/src/main/services/automations/automationIngressService"; import type { createGithubService } from "../../desktop/src/main/services/github/githubService"; import { createFeedbackReporterService } from "../../desktop/src/main/services/feedback/feedbackReporterService"; -import { ADE_AGENT_SKILLS_DIRS_ENV, joinAdeAgentSkillRoots, splitAdeAgentSkillRoots } from "../../desktop/src/shared/agentSkillRoots"; +import { + ADE_AGENT_SKILLS_DIRS_ENV, + getAdeAgentSkillRootsForPrompt, + joinAdeAgentSkillRoots, + splitAdeAgentSkillRoots, +} from "../../desktop/src/shared/agentSkillRoots"; import { createUsageTrackingService } from "../../desktop/src/main/services/usage/usageTrackingService"; import { createBudgetCapService } from "../../desktop/src/main/services/usage/budgetCapService"; import { createSessionDeltaService } from "../../desktop/src/main/services/sessions/sessionDeltaService"; @@ -373,6 +378,10 @@ function createHeadlessAdeCliAgentEnv(baseEnv: NodeJS.ProcessEnv = process.env): next[ADE_AGENT_SKILLS_DIRS_ENV], inferAgentSkillsRootForCliEntry(cliEntry), ); + next[ADE_AGENT_SKILLS_DIRS_ENV] = joinAdeAgentSkillRoots(getAdeAgentSkillRootsForPrompt({ + env: next, + cwd: process.cwd(), + })); return next; } diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 3fb9654e0..f0448ad5c 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2175,6 +2175,51 @@ describe("ADE CLI", () => { }); }); + it("summarizes PR create output with GitHub and ADE links", () => { + const connection = { + mode: "headless" as const, + projectRoot: "/tmp/project", + workspaceRoot: "/tmp/project", + socketPath: "/tmp/project/.ade/ade.sock", + request: async () => null, + close: () => {}, + }; + const summarized = summarizeExecution({ + plan: { kind: "execute", label: "PR create", steps: [] }, + connection, + values: { + result: { + pr: { + id: "pr-42", + laneId: "lane-1", + repoOwner: "acme", + repoName: "ade", + githubPrNumber: 42, + githubUrl: "https://github.com/acme/ade/pull/42", + title: "Add PR deeplinks", + state: "open", + }, + }, + }, + } as any); + + expect(summarized).toMatchObject({ + githubUrl: "https://github.com/acme/ade/pull/42", + adeUrl: "https://ade.app/open?type=pr&repo=acme%2Fade&number=42", + }); + + const text = formatOutput( + summarized, + { ...baseResolveOpts(), projectRoot: null, workspaceRoot: null, text: true }, + "pr-create", + ); + expect(text).toContain("ADE pull request created"); + expect(text).toContain("GitHub URL"); + expect(text).toContain("https://github.com/acme/ade/pull/42"); + expect(text).toContain("ADE URL"); + expect(text).toContain("https://ade.app/open?type=pr&repo=acme%2Fade&number=42"); + }); + it("maps lane create Linear issue JSON to the typed RPC tool", () => { const plan = buildCliPlan([ "lanes", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index c931a7b0d..19ee9dced 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -16,6 +16,7 @@ import { CliDeeplinkUsageError, runDeeplinkCommand, } from "./commands/deeplinks"; +import { buildDeeplink } from "../../desktop/src/shared/deeplinks"; import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { findAdeManagedWorktreeRoot, @@ -83,6 +84,7 @@ type FormatterId = | "files-tree" | "files-search" | "prs-list" + | "pr-create" | "pr-detail" | "pr-checks" | "pr-comments" @@ -998,7 +1000,7 @@ const HELP_BY_COMMAND: Record = { $ ade prs list --text List PRs known to ADE $ ade prs list-open --text List every open GitHub PR in the repo, keyed by head branch - $ ade prs create --lane --base main Open and map a GitHub PR from a lane + $ ade prs create --lane --base main Open and map a GitHub PR; prints GitHub + ADE URLs $ ade prs create --lane --close-linear-issue-on-merge $ ade prs link --lane --url Map an existing GitHub PR to a lane $ ade prs checks --text Show check status @@ -11564,6 +11566,47 @@ function cell(value: unknown, width = 42): string { return truncateCell(String(value), width); } +function positiveInteger(value: unknown): number | null { + let parsed = NaN; + if (typeof value === "number") parsed = value; + else if (typeof value === "string") parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function getPrCreateLinks(value: unknown): { + pr: JsonObject; + githubUrl: string | null; + adeUrl: string | null; +} { + const result = unwrapActionEnvelope(value); + const root = isRecord(result) ? result : {}; + const pr = firstRecord(root, ["pr"]) ?? root; + const githubUrl = + asString(root.githubUrl) ?? + asString(root.githubPrUrl) ?? + asString(pr.githubUrl) ?? + asString(pr.url); + const explicitAdeUrl = asString(root.adeUrl) ?? asString(root.adePrUrl); + const repoOwner = asString(pr.repoOwner); + const repoName = asString(pr.repoName); + const prNumber = positiveInteger(pr.githubPrNumber ?? pr.prNumber ?? pr.number); + const derivedAdeUrl = repoOwner && repoName && prNumber + ? buildDeeplink({ kind: "pr", repoOwner, repoName, prNumber }) + : null; + return { pr, githubUrl, adeUrl: explicitAdeUrl ?? derivedAdeUrl }; +} + +function summarizePrCreateResult(value: unknown): unknown { + const result = unwrapActionEnvelope(value); + if (!isRecord(result)) return result; + const { githubUrl, adeUrl } = getPrCreateLinks(result); + return { + ...result, + ...(githubUrl ? { githubUrl } : {}), + ...(adeUrl ? { adeUrl } : {}), + }; +} + function formatAutomationRunDetail(value: unknown): string { if (!isRecord(value)) return JSON.stringify(value, null, 2); const run = isRecord(value.run) ? value.run : value; @@ -11768,6 +11811,20 @@ function formatPrList(value: unknown): string { ); } +function formatPrCreate(value: unknown): string { + const { pr, githubUrl, adeUrl } = getPrCreateLinks(value); + const prNumber = positiveInteger(pr.githubPrNumber ?? pr.prNumber ?? pr.number); + return renderKeyValues("ADE pull request created", [ + ["id", pr.id], + ["number", prNumber ? `#${prNumber}` : null], + ["title", pr.title], + ["state", pr.state ?? pr.status], + ["lane", pr.laneId], + ["GitHub URL", githubUrl], + ["ADE URL", adeUrl], + ]); +} + function formatPrChecks(value: unknown): string { const checks = firstArray(value, ["checks", "items"]); const summary = isRecord(value) ? value.summary : null; @@ -12895,6 +12952,8 @@ function formatTextOutput( return formatFilesSearch(value); case "prs-list": return formatPrList(value); + case "pr-create": + return formatPrCreate(value); case "pr-detail": return renderKeyValues( "ADE pull request", @@ -12999,6 +13058,7 @@ function inferFormatter( if (label === "file search" || label === "file quick-open") return "files-search"; if (label === "pr list" || label === "pr list open") return "prs-list"; + if (label === "pr create") return "pr-create"; if (label === "pr detail" || label === "pr health") return "pr-detail"; if (label === "pr checks") return "pr-checks"; if (label === "pr comments") return "pr-comments"; @@ -13176,6 +13236,10 @@ function summarizeExecution(args: { }; } + if (plan.label === "PR create") { + return summarizePrCreateResult(values.result ?? values); + } + const result = values.result ?? values; if ( isRecord(result) && diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 23c1fd8da..0e2d1771f 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -141,7 +141,7 @@ describe("commands", () => { })); }); - it("includes Phase 4 Claude parity builtins", () => { + it("includes Claude parity builtins plus provider-agnostic skills", () => { const rows = paletteCommands("/", [], { provider: "claude" }); for (const name of ["/agents", "/skills", "/init"]) { expect(rows).toContainEqual(expect.objectContaining({ name, source: "ade" })); @@ -155,11 +155,15 @@ describe("commands", () => { } }); - it("filters Phase 4 Claude-only builtins outside Claude chats", () => { + it("keeps provider-agnostic skills visible outside Claude chats", () => { const rows = paletteCommands("/", [], { provider: "codex" }); - for (const name of ["/agents", "/skills", "/init", "/usage", "/insights", "/fast"]) { + for (const name of ["/agents", "/init", "/usage", "/insights", "/fast"]) { expect(rows).not.toContainEqual(expect.objectContaining({ name })); } + expect(rows).toContainEqual(expect.objectContaining({ + name: "/skills", + description: "List agent skills from project, user, and ADE bundled roots", + })); expect(rows).toContainEqual(expect.objectContaining({ name: "/compact", description: "Compact the active chat context", diff --git a/apps/ade-cli/src/tuiClient/__tests__/rightPaneFormatters.test.ts b/apps/ade-cli/src/tuiClient/__tests__/rightPaneFormatters.test.ts index bf00c308e..cb29068fd 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/rightPaneFormatters.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/rightPaneFormatters.test.ts @@ -42,6 +42,39 @@ describe("rightPaneFormatters", () => { expect(body).not.toContain("\"title\""); }); + it("formats PR create links from the new action envelope", () => { + const body = formatPrSummary({ + pr: { + id: "pr-42", + laneId: "lane-1", + repoOwner: "acme", + repoName: "ade", + githubPrNumber: 42, + githubUrl: "https://github.com/acme/ade/pull/42", + title: "Add PR deeplinks", + state: "open", + }, + adeUrl: "https://ade.app/open?type=pr&repo=acme%2Fade&number=42", + }); + + expect(body).toContain("#42 · open"); + expect(body).toContain("github https://github.com/acme/ade/pull/42"); + expect(body).toContain("ade https://ade.app/open?type=pr&repo=acme%2Fade&number=42"); + }); + + it("derives an ADE PR URL when repo metadata is present", () => { + const body = formatPrSummary({ + repoOwner: "acme", + repoName: "ade", + githubPrNumber: 7, + title: "Review parity", + githubUrl: "https://github.com/acme/ade/pull/7", + }); + + expect(body).toContain("github https://github.com/acme/ade/pull/7"); + expect(body).toContain("ade https://ade.app/open?type=pr&repo=acme%2Fade&number=7"); + }); + it("summarizes PR checks", () => { const body = formatPrChecks([ { name: "ci / unit", status: "completed", conclusion: "success", completedAt: "2026-05-20T12:34:00.000Z" }, diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 8dbf64af0..028112a29 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -15,7 +15,7 @@ import { } from "../../../desktop/src/shared/modelRegistry"; import { resolveClaudeCliModelForLaunch } from "../../../desktop/src/shared/cliLaunch"; import { CURSOR_AVAILABLE_MODE_IDS, CURSOR_MODE_LABELS } from "../../../desktop/src/shared/cursorModes"; -import { getAdeAgentSkillRootCandidates } from "../../../desktop/src/shared/agentSkillRoots"; +import { getAgentSkillRootCandidates } from "../../../desktop/src/shared/agentSkillRoots"; import type { AgentChatCodexApprovalPolicy, AgentChatCodexConfigSource, @@ -857,23 +857,24 @@ function titleFromMarkdown(filePath: string, fallback: string): string { } } -function listClaudeCompatMarkdownEntries(workspaceRoot: string, kind: "agents" | "skills"): string { +function listAgentMarkdownEntries(workspaceRoot: string, kind: "agents" | "skills"): string { const roots = kind === "agents" ? [ { label: "project", dir: path.join(workspaceRoot, ".claude", "agents") }, { label: "user", dir: claudeHomePath("agents") }, ] - : [ - { label: "project", dir: path.join(workspaceRoot, ".claude", "skills") }, - { label: "ADE", dir: path.join(workspaceRoot, ".ade", "skills") }, - { label: "user", dir: claudeHomePath("skills") }, - ...getAdeAgentSkillRootCandidates({ includeDeepSourceFallbacks: true }) - .map((dir) => ({ label: "bundled", dir })), - ]; + : getAgentSkillRootCandidates({ cwd: workspaceRoot, includeDeepSourceFallbacks: true }) + .map((dir) => ({ label: "skill root", dir })); const rows: string[] = []; for (const root of roots) { if (!fs.existsSync(root.dir)) continue; - for (const entry of fs.readdirSync(root.dir, { withFileTypes: true })) { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(root.dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { const filePath = entry.isDirectory() ? path.join(root.dir, entry.name, "SKILL.md") : path.join(root.dir, entry.name); @@ -883,8 +884,12 @@ function listClaudeCompatMarkdownEntries(workspaceRoot: string, kind: "agents" | rows.push(`- ${title} (${root.label})\n ${filePath}`); } } - if (!rows.length) return `No Claude ${kind} were found in project or user config.`; - return [`Claude ${kind}:`, "", ...rows].join("\n"); + if (!rows.length) { + return kind === "skills" + ? "No agent skills were found in project, user, or bundled ADE skill roots." + : "No Claude agents were found in project or user config."; + } + return [kind === "skills" ? "Agent skills:" : "Claude agents:", "", ...rows].join("\n"); } function ensureClaudeInitFiles(workspaceRoot: string): string { @@ -5467,15 +5472,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "Agents", body: "/agents is only available for Claude chats." }); return; } - setRightPane({ kind: "details", title: "Agents", body: listClaudeCompatMarkdownEntries(project.workspaceRoot, "agents") }); + setRightPane({ kind: "details", title: "Agents", body: listAgentMarkdownEntries(project.workspaceRoot, "agents") }); return; } if (name === "/skills") { - if (activeCommandProvider !== "claude") { - setRightPane({ kind: "details", title: "Skills", body: "/skills is only available for Claude chats." }); - return; - } - setRightPane({ kind: "details", title: "Skills", body: listClaudeCompatMarkdownEntries(project.workspaceRoot, "skills") }); + setRightPane({ kind: "details", title: "Skills", body: listAgentMarkdownEntries(project.workspaceRoot, "skills") }); return; } if (name === "/init") { diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index ce2e0ee30..5d15c5233 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -35,7 +35,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/context", description: "Show Claude context usage", placement: "right", providers: ["claude"] }, { name: "/agents", description: "List Claude agents from user and project config", placement: "right", providers: ["claude"] }, { name: "/info", description: "Open active chat info, plan, goal, and agents", placement: "right" }, - { name: "/skills", description: "List Claude skills from user and project config", placement: "right", providers: ["claude"] }, + { name: "/skills", description: "List agent skills from project, user, and ADE bundled roots", placement: "right" }, { name: "/compact", description: "Compact the active chat context", placement: "chat", argumentHint: "[instructions]", providers: ["claude", "codex"] }, { name: "/init", description: "Generate AGENTS.md and Claude pointer files", placement: "right", providers: ["claude"] }, { name: "/usage", description: "Show Claude usage through the active SDK session", placement: "chat", providers: ["claude"] }, diff --git a/apps/ade-cli/src/tuiClient/rightPaneFormatters.ts b/apps/ade-cli/src/tuiClient/rightPaneFormatters.ts index 727be7940..a592104da 100644 --- a/apps/ade-cli/src/tuiClient/rightPaneFormatters.ts +++ b/apps/ade-cli/src/tuiClient/rightPaneFormatters.ts @@ -1,3 +1,5 @@ +import { buildDeeplink } from "../../../desktop/src/shared/deeplinks"; + type JsonRecord = Record; function isRecord(value: unknown): value is JsonRecord { @@ -35,6 +37,27 @@ function pickBoolean(record: JsonRecord, keys: string[]): boolean | null { return null; } +function pickPositiveInteger(record: JsonRecord, keys: string[]): number | null { + for (const key of keys) { + const value = record[key]; + let parsed = NaN; + if (typeof value === "number") parsed = value; + else if (typeof value === "string") parsed = Number(value); + if (Number.isInteger(parsed) && parsed > 0) return parsed; + } + return null; +} + +function unwrapPrValue(value: unknown): { root: JsonRecord; pr: JsonRecord } | null { + const root = unwrapStructured(value); + if (!isRecord(root)) return null; + const nested = root.pr; + return { + root, + pr: isRecord(nested) ? nested : root, + }; +} + function firstRecordArray(value: unknown, keys: string[]): JsonRecord[] { const root = unwrapStructured(value); if (Array.isArray(root)) return root.filter(isRecord); @@ -110,9 +133,11 @@ export function formatSystemDetails(args: { } export function formatPrSummary(value: unknown): string { - const pr = unwrapStructured(value); - if (!isRecord(pr)) return "No PR data."; + const unwrapped = unwrapPrValue(value); + if (!unwrapped) return "No PR data."; + const { root, pr } = unwrapped; const number = pickString(pr, ["number", "githubPrNumber", "prNumber"]); + const prNumber = pickPositiveInteger(pr, ["number", "githubPrNumber", "prNumber"]); const state = pickString(pr, ["state", "status"]) ?? "unknown"; const draft = pickBoolean(pr, ["isDraft", "draft"]) === true ? " · draft" : ""; const title = pickString(pr, ["title", "name"]) ?? "Untitled PR"; @@ -120,7 +145,17 @@ export function formatPrSummary(value: unknown): string { const lane = pickString(pr, ["laneName", "laneId"]); const head = pickString(pr, ["headBranch", "headRefName", "branchRef", "branch"]); const base = pickString(pr, ["baseBranch", "baseRefName", "baseRef", "targetBranch"]); - const url = pickString(pr, ["htmlUrl", "url", "webUrl"]); + const githubUrl = pickString(root, ["githubUrl", "githubPrUrl"]) + ?? pickString(pr, ["githubUrl", "htmlUrl", "url", "webUrl"]); + const fallbackUrl = pickString(pr, ["htmlUrl", "url", "webUrl"]); + const repoOwner = pickString(pr, ["repoOwner", "owner"]); + const repoName = pickString(pr, ["repoName", "repo"]); + const derivedAdeUrl = repoOwner && repoName && prNumber + ? buildDeeplink({ kind: "pr", repoOwner, repoName, prNumber }) + : null; + const adeUrl = pickString(root, ["adeUrl", "adePrUrl"]) + ?? pickString(pr, ["adeUrl", "adePrUrl"]) + ?? derivedAdeUrl; const mergeable = pickString(pr, ["mergeable", "mergeStateStatus"]); const rows = [ `#${number ?? id ?? "?"} · ${state}${draft}`, @@ -130,7 +165,9 @@ export function formatPrSummary(value: unknown): string { lane ? `lane ${lane}` : null, head || base ? `branch ${head ?? "unknown"}${base ? ` -> ${base}` : ""}` : null, mergeable ? `merge ${mergeable}` : null, - url ? `url ${url}` : null, + githubUrl ? `github ${githubUrl}` : null, + fallbackUrl && fallbackUrl !== githubUrl ? `url ${fallbackUrl}` : null, + adeUrl ? `ade ${adeUrl}` : null, ]; return rows.filter((row): row is string => row != null).join("\n"); } diff --git a/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md b/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md index f47361836..bd1ef038a 100644 --- a/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md @@ -116,6 +116,18 @@ cross-machine link to any Linear issue linked to the lane (Linear attachment + one-time comment). Agents do not need to call `ade link` for those flows — they fire on PR creation / Linear-link events. +Agents should still include a user-facing ADE PR link when handing off a newly +created or adopted PR. Use the GitHub PR URL for the browser link and the +`adeUrl` printed by `ade prs create`. If the PR came from another path, mint +the ADE link with: + +```bash +ade link pr --no-clipboard +``` + +Use that output for the ADE link. Prefer the default HTTPS form in chat and terminal output +because it is clickable, shareable, and upgrades into the ADE PRs tab. + When you copy a deeplink from a lane context menu in the desktop UI, the right-click menu offers: Copy lane link, Copy branch link (cross-machine), Copy PR link, Copy Linear-issue link. diff --git a/apps/desktop/resources/agent-skills/ade-pr-workflows/SKILL.md b/apps/desktop/resources/agent-skills/ade-pr-workflows/SKILL.md index b362f4f78..7f09d1bd8 100644 --- a/apps/desktop/resources/agent-skills/ade-pr-workflows/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-pr-workflows/SKILL.md @@ -17,6 +17,21 @@ ade prs path-to-merge --model --max-rounds 3 -- Use `ade help prs` and `ade help git rebase` before guessing PR or rebase flags. +## PR creation closeout links + +When you create or adopt a GitHub PR, include two links in your final handoff: + +- GitHub PR: use the PR's `githubUrl` / `html_url`. +- ADE PR: use the `adeUrl` printed by `ade prs create`; if you created or + adopted the PR through another path, run `ade link pr + --no-clipboard` and include the printed `https://ade.app/open?...` URL. + +Prefer the HTTPS ADE link in chat, PR comments, and terminal output because it +unfurls and upgrades into `ade://pr///` on machines with +ADE installed. The PR body already gets an automatic "Open in ADE" footer, but +the final agent message should still include both links so the user can jump +straight to either GitHub or the ADE PRs tab. + ## Use actions for niche surfaces ```bash diff --git a/apps/desktop/scripts/validate-mac-artifacts.mjs b/apps/desktop/scripts/validate-mac-artifacts.mjs index 92bdf15cb..4612ea93b 100644 --- a/apps/desktop/scripts/validate-mac-artifacts.mjs +++ b/apps/desktop/scripts/validate-mac-artifacts.mjs @@ -15,6 +15,18 @@ const releaseDir = path.join(appDir, "release"); const DEFAULT_MAX_APP_ASAR_BYTES = 900 * 1024 * 1024; const DEFAULT_MAX_UNPACKED_BYTES = 600 * 1024 * 1024; const REMOTE_RUNTIME_TARGETS = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; +const bundledAgentSkills = [ + "ade-cli-control-plane", + "ade-ios-simulator", + "ade-app-control", + "ade-browser", + "ade-pr-workflows", + "ade-lanes-git", + "ade-cto-missions", + "ade-proof-artifacts", + "ade-macos-vm", + "ade-deeplinks", +]; function readFlag(name) { const prefix = `${name}=`; @@ -113,6 +125,16 @@ async function assertPathExists(targetPath, description) { } } +async function assertBundledAgentSkills(agentSkillsRoot) { + await assertPathExists(agentSkillsRoot, "bundled ADE agent skills root"); + for (const skillName of bundledAgentSkills) { + await assertPathExists( + path.join(agentSkillsRoot, skillName, "SKILL.md"), + `bundled ADE agent skill ${skillName}`, + ); + } +} + async function pathExists(targetPath) { try { await fs.access(targetPath); @@ -322,7 +344,7 @@ async function validatePackagedRuntime(appPath, description) { const adeCliTuiPath = path.join(resourcesPath, "ade-cli", "tuiClient", "cli.mjs"); const adeCliBinPath = path.join(resourcesPath, "ade-cli", "bin", "ade"); const adeCliInstallerPath = path.join(resourcesPath, "ade-cli", "install-path.sh"); - const bundledAgentSkillsPath = path.join(resourcesPath, "agent-skills", "ade-cli-control-plane", "SKILL.md"); + const bundledAgentSkillsRoot = path.join(resourcesPath, "agent-skills"); const iosSimHelperRoot = path.join(resourcesPath, "native", "ios-sim-helpers"); const iosSimHelperBuildScript = path.join(iosSimHelperRoot, "build.sh"); const nodeModulesPath = path.join(unpackedPath, "node_modules"); @@ -339,7 +361,7 @@ async function validatePackagedRuntime(appPath, description) { await assertPathExists(adeCliTuiPath, "bundled ADE CLI TUI entry"); await assertPathExists(adeCliBinPath, "bundled ADE CLI wrapper"); await assertPathExists(adeCliInstallerPath, "bundled ADE CLI PATH installer"); - await assertPathExists(bundledAgentSkillsPath, "bundled ADE agent skills"); + await assertBundledAgentSkills(bundledAgentSkillsRoot); await assertPathExists(iosSimHelperBuildScript, "bundled iOS simulator helper build script"); await assertPathExists(path.join(iosSimHelperRoot, "sim-capture.swift"), "bundled iOS simulator capture helper source"); await assertPathExists(path.join(iosSimHelperRoot, "sim-input.m"), "bundled iOS simulator input helper source"); diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs index 2a6d92749..687099d97 100644 --- a/apps/desktop/scripts/validate-win-artifacts.mjs +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -17,6 +17,18 @@ const DEFAULT_MAX_APP_ASAR_BYTES = 900 * 1024 * 1024; // ONNX payloads. Keep a ceiling, but size it to the current required toolset. const DEFAULT_MAX_UNPACKED_BYTES = 720 * 1024 * 1024; const REMOTE_RUNTIME_TARGETS = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; +const bundledAgentSkills = [ + "ade-cli-control-plane", + "ade-ios-simulator", + "ade-app-control", + "ade-browser", + "ade-pr-workflows", + "ade-lanes-git", + "ade-cto-missions", + "ade-proof-artifacts", + "ade-macos-vm", + "ade-deeplinks", +]; function readFlag(name) { const prefix = `${name}=`; @@ -114,6 +126,16 @@ async function assertPathExists(targetPath, description) { } } +async function assertBundledAgentSkills(agentSkillsRoot) { + await assertPathExists(agentSkillsRoot, "bundled ADE agent skills root"); + for (const skillName of bundledAgentSkills) { + await assertPathExists( + path.join(agentSkillsRoot, skillName, "SKILL.md"), + `bundled ADE agent skill ${skillName}`, + ); + } +} + async function assertPathMissing(targetPath, description) { try { await fsp.access(targetPath); @@ -486,7 +508,7 @@ async function validatePackagedRuntime(appDir) { const adeCliTuiPath = path.join(resourcesPath, "ade-cli", "tuiClient", "cli.mjs"); const adeCliBinPath = path.join(resourcesPath, "ade-cli", "bin", "ade.cmd"); const adeCliInstallerPath = path.join(resourcesPath, "ade-cli", "install-path.cmd"); - const bundledAgentSkillsPath = path.join(resourcesPath, "agent-skills", "ade-cli-control-plane", "SKILL.md"); + const bundledAgentSkillsRoot = path.join(resourcesPath, "agent-skills"); const nodeModulesPath = path.join(unpackedPath, "node_modules"); const nodePtyModulePath = path.join(nodeModulesPath, "node-pty"); const sqlJsModulePath = path.join(nodeModulesPath, "sql.js"); @@ -513,7 +535,7 @@ async function validatePackagedRuntime(appDir) { await assertPathExists(adeCliTuiPath, "bundled ADE CLI TUI entry"); await assertPathExists(adeCliBinPath, "bundled ADE CLI wrapper"); await assertPathExists(adeCliInstallerPath, "bundled ADE CLI PATH installer"); - await assertPathExists(bundledAgentSkillsPath, "bundled ADE agent skills"); + await assertBundledAgentSkills(bundledAgentSkillsRoot); await assertPathExists(nodePtyModulePath, "unpacked node-pty module"); await assertPathExists(sqlJsModulePath, "unpacked sql.js module"); await assertPathExists(path.join(onnxRuntimeWinPath, "onnxruntime_binding.node"), "Windows ONNX Runtime native addon"); diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 76aa86c92..31b92c103 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -49,6 +49,7 @@ import type { createProcessService } from "../../processes/processService"; import type { createSessionService } from "../../sessions/sessionService"; import type { createCtoStateService } from "../../cto/ctoStateService"; import { getErrorMessage, nowIso, parseIsoToEpoch } from "../../shared/utils"; +import { buildAdePrUrl } from "../../../../shared/deeplinks"; export interface CtoOperatorToolDeps { currentSessionId: string; @@ -2024,7 +2025,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { expect(result).toContain("## Operating Loop"); expect(result).toContain("## ADE CLI"); expect(result).toContain("only normal reason to skip ADE CLI"); - expect(result).toContain("ADE ships Agent Skills for deeper operating details"); + expect(result).toContain("ADE exposes Agent Skills from project, user, runtime, and bundled ADE skill roots"); expect(result).toContain("ade-ios-simulator"); expect(result).toContain("ade-cli-control-plane"); expect(result).toContain("## Editing Rules"); diff --git a/apps/desktop/src/main/services/ai/tools/workflowTools.ts b/apps/desktop/src/main/services/ai/tools/workflowTools.ts index 31447a854..83bba488a 100644 --- a/apps/desktop/src/main/services/ai/tools/workflowTools.ts +++ b/apps/desktop/src/main/services/ai/tools/workflowTools.ts @@ -15,6 +15,7 @@ import type { ComputerUseArtifactBrokerService } from "../../computerUse/compute import { getLocalComputerUseCapabilities } from "../../computerUse/localComputerUse"; import { nowIso } from "../../shared/utils"; import { getPrIssueResolutionAvailability } from "../../../../shared/prIssueResolution"; +import { buildAdePrUrl } from "../../../../shared/deeplinks"; import type { AgentChatCompletionReport } from "../../../../shared/types"; const execFileAsync = promisify(execFile); @@ -128,6 +129,8 @@ export function createWorkflowTools( prId: pr.id, prNumber: pr.githubPrNumber, url: pr.githubUrl, + githubUrl: pr.githubUrl, + adeUrl: buildAdePrUrl(pr), title: pr.title, state: pr.state, }; diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 3a566ca6d..827d95687 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4633,7 +4633,18 @@ describe("createAgentChatService", () => { expect(names).not.toContain("/apps"); }); - it("returns local commands for a opencode session", async () => { + it("returns local and filesystem-backed skill commands for an opencode session", async () => { + const skillDir = path.join(tmpRoot, ".agents", "skills", "deploy-helper"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), [ + "---", + "name: deploy-helper", + "description: Use this skill for deployment help", + "---", + "", + "Deploy safely.", + "", + ].join("\n")); const { service } = createService(); const session = await service.createSession({ laneId: "lane-1", @@ -4648,6 +4659,13 @@ describe("createAgentChatService", () => { const clearCmd = commands.find((c: any) => c.name === "/clear"); expect(clearCmd).toBeDefined(); expect(clearCmd!.source).toBe("local"); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/deploy-helper", + description: "Use this skill for deployment help", + source: "sdk", + }), + ])); }); it("returns Claude and Codex prompt commands plus /clear for a droid lane", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index a674851ef..aec2d033e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -1671,7 +1671,6 @@ type ResolvedChatConfig = { const MAX_PENDING_STEERS = 10; const MAX_INJECTED_PROJECT_COMMANDS = 20; -const MAX_INJECTED_PROJECT_SKILLS = 20; const CURSOR_SDK_AGENT_PROTOCOL_VERSION = 2; const CLAUDE_WARMUP_WAIT_TIMEOUT_MS = 20_000; const CLAUDE_STOP_TASK_TIMEOUT_MS = 2_000; @@ -14448,9 +14447,7 @@ export function createAgentChatService(args: { const projectCommandFiles = projectSlashCommands.filter((cmd) => cmd.source === "command"); const projectSkillFiles = projectSlashCommands.filter((cmd) => cmd.source === "skill"); const visibleProjectCommandFiles = projectCommandFiles.slice(0, MAX_INJECTED_PROJECT_COMMANDS); - const visibleProjectSkillFiles = projectSkillFiles.slice(0, MAX_INJECTED_PROJECT_SKILLS); const hiddenProjectCommandCount = projectCommandFiles.length - visibleProjectCommandFiles.length; - const hiddenProjectSkillCount = projectSkillFiles.length - visibleProjectSkillFiles.length; const formatDiscoveredCommand = (cmd: (typeof projectSlashCommands)[number]): string => { const desc = cmd.description.trim(); const head = desc.length ? `- ${cmd.name} — ${desc}` : `- ${cmd.name}`; @@ -14473,8 +14470,7 @@ export function createAgentChatService(args: { ...(projectSkillFiles.length ? [ "", "Skills (autonomously usable when relevant):", - ...visibleProjectSkillFiles.map(formatDiscoveredCommand), - ...(hiddenProjectSkillCount > 0 ? [`- ${hiddenProjectSkillCount} more skill(s) hidden to keep startup context lean. Use slash command search or inspect skill roots if needed.`] : []), + ...projectSkillFiles.map(formatDiscoveredCommand), ] : []), ] : []; @@ -21595,47 +21591,36 @@ export function createAgentChatService(args: { return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); }; - // Claude SDK commands plus filesystem-backed Claude Code commands/skills. - if (provider === "claude") { - const runtimeCommands: AgentChatSlashCommand[] = (managed?.runtime?.kind === "claude" ? managed.runtime.slashCommands : []) - .filter(isDispatchableClaudeSdkSlashCommand) - .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + const filesystemBackedCommands = (): AgentChatSlashCommand[] => { + const promptCommands: AgentChatSlashCommand[] = discoverCodexSlashCommands(laneWorktreePath) + .map((cmd) => ({ name: cmd.name, description: cmd.description, argumentHint: cmd.argumentHint, source: "sdk" as const, })); - const projectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(laneWorktreePath) + const skillAndCommandFiles: AgentChatSlashCommand[] = discoverClaudeSlashCommands(laneWorktreePath) .filter(isDispatchableClaudeSdkSlashCommand) - .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + .map((cmd) => ({ name: cmd.name, description: cmd.description, argumentHint: cmd.argumentHint, source: "sdk" as const, })); - return mergeSlashCommands([projectCommands, CLAUDE_BUILT_IN_SLASH_COMMANDS, runtimeCommands]); - } + return mergeSlashCommands([promptCommands, skillAndCommandFiles]); + }; - // Codex SDK commands - if (provider === "codex") { - const rt = managed?.runtime?.kind === "codex" ? managed.runtime : null; - const dynamicCommands: AgentChatSlashCommand[] = (rt?.slashCommands ?? []) - .filter(isVisibleCodexSlashCommand) - .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ - name: cmd.name, - description: cmd.description, - argumentHint: cmd.argumentHint, - source: "sdk" as const, - })); - const promptCommands: AgentChatSlashCommand[] = discoverCodexSlashCommands(laneWorktreePath) - .filter(isVisibleCodexSlashCommand) + // Claude SDK commands plus filesystem-backed Claude Code commands/skills. + if (provider === "claude") { + const runtimeCommands: AgentChatSlashCommand[] = (managed?.runtime?.kind === "claude" ? managed.runtime.slashCommands : []) + .filter(isDispatchableClaudeSdkSlashCommand) .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ name: cmd.name, description: cmd.description, argumentHint: cmd.argumentHint, source: "sdk" as const, })); - const claudeProjectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(laneWorktreePath) + const projectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(laneWorktreePath) .filter(isDispatchableClaudeSdkSlashCommand) .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ name: cmd.name, @@ -21643,33 +21628,27 @@ export function createAgentChatService(args: { argumentHint: cmd.argumentHint, source: "sdk" as const, })); - return mergeSlashCommands([promptCommands, claudeProjectCommands, CODEX_BUILT_IN_SLASH_COMMANDS, dynamicCommands]); + return mergeSlashCommands([projectCommands, CLAUDE_BUILT_IN_SLASH_COMMANDS, runtimeCommands]); } - // Droid uses Claude/Codex models under the hood, so surface the same - // filesystem-backed prompt commands codex exposes (Claude `.claude/commands` - // and Codex `.codex/prompts`) plus a local `/clear` for chat housekeeping. - if (provider === "droid") { - const promptCommands: AgentChatSlashCommand[] = discoverCodexSlashCommands(laneWorktreePath) - .map((cmd) => ({ - name: cmd.name, - description: cmd.description, - argumentHint: cmd.argumentHint, - source: "sdk" as const, - })); - const claudeProjectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(laneWorktreePath) - .filter(isDispatchableClaudeSdkSlashCommand) - .map((cmd) => ({ + // Codex SDK commands + if (provider === "codex") { + const rt = managed?.runtime?.kind === "codex" ? managed.runtime : null; + const dynamicCommands: AgentChatSlashCommand[] = (rt?.slashCommands ?? []) + .filter(isVisibleCodexSlashCommand) + .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ name: cmd.name, description: cmd.description, argumentHint: cmd.argumentHint, source: "sdk" as const, })); - return mergeSlashCommands([promptCommands, claudeProjectCommands, localCommands]); + const promptCommands = filesystemBackedCommands().filter(isVisibleCodexSlashCommand); + return mergeSlashCommands([promptCommands, CODEX_BUILT_IN_SLASH_COMMANDS, dynamicCommands]); } - // OpenCode / Cursor — only local commands - return localCommands; + // Droid, Cursor, and OpenCode can all use the same filesystem-backed prompt + // and skill list even when their native runtimes do not auto-list it. + return mergeSlashCommands([filesystemBackedCommands(), localCommands]); }; const normalizeClaudeSessionTimestamp = (value: unknown): string | null => { diff --git a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts index 901253ac2..ce2236135 100644 --- a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts @@ -159,11 +159,13 @@ describe("discoverClaudeSlashCommands", () => { ])); }); - it("discovers cross-client .agents skills and ADE project skills", () => { + it("discovers cross-client project skills from .agents, .ade, and .codex roots", () => { const agentsSkill = path.join(tmpRoot, ".agents", "skills", "ios-lab"); const adeSkill = path.join(tmpRoot, ".ade", "skills", "pr-resolver"); + const codexSkill = path.join(tmpRoot, ".codex", "skills", "source-audit"); fs.mkdirSync(agentsSkill, { recursive: true }); fs.mkdirSync(adeSkill, { recursive: true }); + fs.mkdirSync(codexSkill, { recursive: true }); fs.writeFileSync(path.join(agentsSkill, "SKILL.md"), [ "---", "name: ios-lab", @@ -182,6 +184,15 @@ describe("discoverClaudeSlashCommands", () => { "Resolve the PR.", "", ].join("\n")); + fs.writeFileSync(path.join(codexSkill, "SKILL.md"), [ + "---", + "name: source-audit", + "description: Use this skill for source audits", + "---", + "", + "Audit the source.", + "", + ].join("\n")); const commands = discoverClaudeSlashCommands(tmpRoot); expect(commands).toEqual(expect.arrayContaining([ @@ -195,6 +206,11 @@ describe("discoverClaudeSlashCommands", () => { description: "Use this skill for PR resolver work", source: "skill", }), + expect.objectContaining({ + name: "/source-audit", + description: "Use this skill for source audits", + source: "skill", + }), ])); }); diff --git a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts index 17862bcfb..bebcffcb0 100644 --- a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts +++ b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { parse as parseYaml } from "yaml"; -import { getAdeAgentSkillRootCandidates } from "../../../shared/agentSkillRoots"; +import { getAgentSkillRootCandidates } from "../../../shared/agentSkillRoots"; export type DiscoveredClaudeSlashCommand = { name: string; @@ -252,27 +252,6 @@ function claudeRootsByPrecedence(cwd: string): string[] { return roots; } -function ancestorSkillRoots(cwd: string, dirName: ".agents" | ".ade"): string[] { - const roots: string[] = []; - const seen = new Set(); - const home = path.resolve(os.homedir()); - let current = path.resolve(cwd); - let depth = 0; - while (depth < 25) { - const candidate = path.join(current, dirName, "skills"); - if (!seen.has(candidate)) { - seen.add(candidate); - roots.push(candidate); - } - const parent = path.dirname(current); - if (parent === current) break; - if (current === home) break; - current = parent; - depth += 1; - } - return roots; -} - function skillRootsByPrecedence(cwd: string): string[] { const roots: string[] = []; const seen = new Set(); @@ -283,12 +262,12 @@ function skillRootsByPrecedence(cwd: string): string[] { roots.push(resolved); }; - for (const root of claudeRootsByPrecedence(cwd)) addRoot(path.join(root, "skills")); - for (const root of ancestorSkillRoots(cwd, ".agents")) addRoot(root); - for (const root of ancestorSkillRoots(cwd, ".ade")) addRoot(root); - addRoot(path.join(os.homedir(), ".agents", "skills")); - addRoot(path.join(os.homedir(), ".ade", "skills")); - for (const root of getAdeAgentSkillRootCandidates({ dirname: __dirname, includeDeepSourceFallbacks: true })) addRoot(root); + for (const root of getAgentSkillRootCandidates({ + cwd, + dirname: __dirname, + home: os.homedir(), + includeDeepSourceFallbacks: true, + })) addRoot(root); return roots; } diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 9e85cac85..15c8e5cb4 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -194,6 +194,156 @@ describe("registerRuntimeBridge", () => { ); }); + it("uses an explicit local runtime root when it is already open in the window", async () => { + const localRuntimeConnectionPool = { + callActionForRoot: vi.fn(async () => ({ + ok: true, + domain: "lane", + action: "list", + result: [], + statusHints: {}, + })), + }; + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: localRuntimeConnectionPool as any, + getWindowSession: () => ({ + windowId: 7, + project: { rootPath: "/old-repo", displayName: "Old", baseRef: "main" }, + binding: localBinding("/old-repo"), + openProjectTabs: [ + { rootPath: "/old-repo", displayName: "Old", baseRef: "main" }, + { rootPath: "/new-repo", displayName: "New", baseRef: "main" }, + ], + }), + }); + + await expect( + ipcHandlers.get(IPC.localRuntimeCallAction)?.( + eventForSender(sender(101)), + { + rootPath: "/new-repo", + request: { + domain: "lane", + action: "list", + args: {}, + }, + }, + ), + ).resolves.toMatchObject({ result: [] }); + + expect(localRuntimeConnectionPool.callActionForRoot).toHaveBeenCalledWith( + "/new-repo", + expect.objectContaining({ + domain: "lane", + action: "list", + }), + ); + }); + + it("rejects explicit local runtime roots that are not bound to the window session", async () => { + const localRuntimeConnectionPool = { + callActionForRoot: vi.fn(), + callSyncForRoot: vi.fn(), + subscribeEventsForRoot: vi.fn(async () => vi.fn()), + }; + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: localRuntimeConnectionPool as any, + getWindowSession: () => ({ + windowId: 7, + project: { rootPath: "/repo", displayName: "Repo", baseRef: "main" }, + binding: localBinding("/repo"), + openProjectTabs: [ + { rootPath: "/repo", displayName: "Repo", baseRef: "main" }, + ], + }), + }); + + await expect( + ipcHandlers.get(IPC.localRuntimeCallAction)?.( + eventForSender(sender(101)), + { + rootPath: "/other-repo", + request: { + domain: "lane", + action: "list", + args: {}, + }, + }, + ), + ).rejects.toThrow(/not available/i); + await expect( + ipcHandlers.get(IPC.localRuntimeCallSync)?.(eventForSender(), { + rootPath: "/other-repo", + method: "sync.getStatus", + params: {}, + }), + ).rejects.toThrow(/not available/i); + await expect( + ipcHandlers.get(IPC.localRuntimeStreamEvents)?.(eventForSender(), { + rootPath: "/other-repo", + request: { cursor: 0, limit: 10 }, + }), + ).rejects.toThrow(/not available/i); + + expect(localRuntimeConnectionPool.callActionForRoot).not.toHaveBeenCalled(); + expect(localRuntimeConnectionPool.callSyncForRoot).not.toHaveBeenCalled(); + expect(localRuntimeConnectionPool.subscribeEventsForRoot).not.toHaveBeenCalled(); + }); + + it("authorizes explicit local roots for sync and event streams from open tabs", async () => { + const localRuntimeConnectionPool = { + callSyncForRoot: vi.fn(async () => ({ connectedPeers: [] })), + subscribeEventsForRoot: vi.fn(async () => vi.fn()), + }; + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: localRuntimeConnectionPool as any, + getWindowSession: () => ({ + windowId: 7, + project: { rootPath: "/repo", displayName: "Repo", baseRef: "main" }, + binding: localBinding("/repo"), + openProjectTabs: [ + { rootPath: "/repo", displayName: "Repo", baseRef: "main" }, + { rootPath: "/other-repo", displayName: "Other", baseRef: "main" }, + ], + }), + }); + + await expect( + ipcHandlers.get(IPC.localRuntimeCallSync)?.(eventForSender(), { + rootPath: "/other-repo", + method: "sync.getStatus", + params: { includeTransferReadiness: true }, + }), + ).resolves.toEqual({ connectedPeers: [] }); + await expect( + ipcHandlers.get(IPC.localRuntimeStreamEvents)?.( + eventForSender(sender(102)), + { + rootPath: "/other-repo", + request: { cursor: 2, limit: 10, category: "sync" }, + }, + ), + ).resolves.toEqual({ events: [], nextCursor: 2, hasMore: false }); + + expect(localRuntimeConnectionPool.callSyncForRoot).toHaveBeenCalledWith( + "/other-repo", + "sync.getStatus", + { includeTransferReadiness: true }, + ); + expect(localRuntimeConnectionPool.subscribeEventsForRoot).toHaveBeenCalledWith( + "/other-repo", + { cursor: 2, limit: 10, category: "sync" }, + expect.any(Function), + expect.any(Function), + ); + }); + it("forwards remote project runtime actions through the selected target and project", async () => { remoteRegistryGetMock.mockReturnValue(target); remoteConnectMock.mockResolvedValue({ diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 9b05fdb1e..ab86fc29e 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -120,6 +120,63 @@ function withRuntimeActionClientMetadata( }; } +type WindowRuntimeSession = NonNullable< + ReturnType> +>; + +function normalizeLocalRuntimeRootPath( + rootPath: string | null | undefined, +): string | null { + const trimmed = typeof rootPath === "string" ? rootPath.trim() : ""; + if (!trimmed) return null; + return path.resolve(trimmed); +} + +function localRuntimeRootKey(rootPath: string): string { + const resolved = path.resolve(rootPath); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function collectAuthorizedLocalRuntimeRoots( + session: WindowRuntimeSession | null | undefined, +): Map { + const roots = new Map(); + const addRoot = (rootPath: string | null | undefined): void => { + const normalized = normalizeLocalRuntimeRootPath(rootPath); + if (!normalized) return; + const key = localRuntimeRootKey(normalized); + if (!roots.has(key)) roots.set(key, normalized); + }; + + if (session?.binding?.kind === "local") addRoot(session.binding.rootPath); + addRoot(session?.project?.rootPath); + for (const project of session?.openProjectTabs ?? []) { + addRoot(project.rootPath); + } + + return roots; +} + +function resolveAuthorizedLocalRuntimeRootPath( + session: WindowRuntimeSession | null | undefined, + requestedRootPath: string | null | undefined, +): string | null { + const roots = collectAuthorizedLocalRuntimeRoots(session); + const requested = normalizeLocalRuntimeRootPath(requestedRootPath); + if (requested) { + return roots.get(localRuntimeRootKey(requested)) ?? null; + } + + const fallbackRoot = + session?.binding?.kind === "local" + ? session.binding.rootPath + : (session?.project?.rootPath ?? null); + const normalizedFallback = normalizeLocalRuntimeRootPath(fallbackRoot); + return normalizedFallback + ? roots.get(localRuntimeRootKey(normalizedFallback)) ?? null + : null; +} + function normalizeGitRemoteForComparison( value: string | null | undefined, ): string | null { @@ -596,7 +653,7 @@ export function registerRuntimeBridge({ IPC.localRuntimeCallAction, async ( event, - arg: { request: RemoteRuntimeActionRequest }, + arg: { rootPath?: string | null; request: RemoteRuntimeActionRequest }, ): Promise => { if (!localRuntimeConnectionPool) { throw new Error("Local runtime daemon is not available."); @@ -616,11 +673,10 @@ export function registerRuntimeBridge({ const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; const session = getWindowSession ? getWindowSession(windowId) : null; - const binding = session?.binding; - const rootPath = - binding?.kind === "local" - ? binding.rootPath - : (session?.project?.rootPath ?? null); + const rootPath = resolveAuthorizedLocalRuntimeRootPath( + session, + arg?.rootPath, + ); if (!rootPath) { throw new Error( "Local runtime project is not available for this window.", @@ -641,7 +697,7 @@ export function registerRuntimeBridge({ IPC.localRuntimeCallSync, async ( event, - arg: { method: string; params?: Record }, + arg: { rootPath?: string | null; method: string; params?: Record }, ): Promise => { if (!localRuntimeConnectionPool) { throw new Error("Local runtime daemon is not available."); @@ -653,11 +709,10 @@ export function registerRuntimeBridge({ const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; const session = getWindowSession ? getWindowSession(windowId) : null; - const binding = session?.binding; - const rootPath = - binding?.kind === "local" - ? binding.rootPath - : (session?.project?.rootPath ?? null); + const rootPath = resolveAuthorizedLocalRuntimeRootPath( + session, + arg?.rootPath, + ); if (!rootPath) { throw new Error( "Local runtime project is not available for this window.", @@ -675,7 +730,7 @@ export function registerRuntimeBridge({ IPC.localRuntimeStreamEvents, async ( event, - arg: { request?: RemoteRuntimeStreamEventsRequest }, + arg: { rootPath?: string | null; request?: RemoteRuntimeStreamEventsRequest }, ): Promise => { if (!localRuntimeConnectionPool) { throw new Error("Local runtime daemon is not available."); @@ -684,18 +739,26 @@ export function registerRuntimeBridge({ const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; const session = getWindowSession ? getWindowSession(windowId) : null; const binding = session?.binding; - const rootPath = - binding?.kind === "local" - ? binding.rootPath - : (session?.project?.rootPath ?? null); + const rootPath = resolveAuthorizedLocalRuntimeRootPath( + session, + arg?.rootPath, + ); if (!rootPath) { - return { events: [], nextCursor: 0, hasMore: false }; + throw new Error( + "Local runtime project is not available for this window.", + ); } - if (binding?.kind === "local") { + const requestedRootPath = normalizeLocalRuntimeRootPath(arg?.rootPath); + if (binding?.kind === "local" || requestedRootPath) { + const bindingKey = + binding?.kind === "local" && + localRuntimeRootKey(binding.rootPath) === localRuntimeRootKey(rootPath) + ? binding.key + : `local:${rootPath}`; ensureRuntimeEventSubscription( event.sender, - binding.key, - `${binding.key}:${arg?.request?.category ?? "*"}`, + bindingKey, + `${bindingKey}:${arg?.request?.category ?? "*"}`, (onEvent, onEnded) => localRuntimeConnectionPool.subscribeEventsForRoot( rootPath, diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index c90620e4a..f342b54d0 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -250,6 +250,7 @@ describe("preload OAuth bridge", () => { await expect(bridge.review.startRun(startArgs)).resolves.toEqual(run); expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", request: { domain: "review", action: "startRun", args: startArgs }, }); expect(invoke).not.toHaveBeenCalledWith(IPC.reviewStartRun, expect.anything()); @@ -300,6 +301,7 @@ describe("preload OAuth bridge", () => { await expect(bridge.review.startRun(args)).rejects.toThrow("not callable"); expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", request: { domain: "review", action: "startRun", args }, }); expect(invoke).not.toHaveBeenCalledWith(IPC.reviewStartRun, expect.anything()); @@ -458,6 +460,7 @@ describe("preload OAuth bridge", () => { expect(invoke).toHaveBeenCalledWith(IPC.appGetWindowSession); expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", request: { domain: "lane", action: "create", @@ -754,6 +757,7 @@ describe("preload OAuth bridge", () => { await expect(bridge.lanes.list()).resolves.toEqual(lanes); expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", request: { domain: "lane", action: "list", args: {} }, }); expect(invoke).toHaveBeenCalledWith(IPC.lanesList, {}); @@ -807,6 +811,7 @@ describe("preload OAuth bridge", () => { await expect(bridge.agentChat.modelCatalog({ mode: "cached" })).resolves.toEqual(catalog); expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", request: { domain: "chat", action: "modelCatalog", args: { mode: "cached" } }, }); expect(invoke).toHaveBeenCalledWith(IPC.agentChatModelCatalog, { mode: "cached" }); @@ -902,12 +907,15 @@ describe("preload OAuth bridge", () => { await expect(bridge.prs.getGitHubSnapshot()).resolves.toEqual(snapshot); expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", request: { domain: "pr", action: "getDetail", arg: "pr-1" }, }); expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", request: { domain: "pr", action: "listWithConflicts", args: {} }, }); expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", request: { domain: "pr", action: "getGithubSnapshot", args: {} }, }); expect(invoke).not.toHaveBeenCalledWith(IPC.prsGetDetail, expect.anything()); @@ -961,9 +969,11 @@ describe("preload OAuth bridge", () => { await expect(bridge.prs.createLaneFromPrBranch(args)).rejects.toThrow("not callable"); expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", request: { domain: "pr", action: "preflightCreateLaneFromPrBranch", args }, }); expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", request: { domain: "pr", action: "createLaneFromPrBranch", args }, }); expect(invoke).not.toHaveBeenCalledWith(IPC.prsPreflightCreateLaneFromPrBranch, expect.anything()); @@ -1196,6 +1206,7 @@ describe("preload OAuth bridge", () => { await expect(bridge.files.listWorkspaces()).resolves.toEqual(workspaces); expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", request: { domain: "file", action: "listWorkspaces", args: {} }, }); expect(invoke).not.toHaveBeenCalledWith(IPC.filesListWorkspaces, expect.anything()); @@ -1246,6 +1257,7 @@ describe("preload OAuth bridge", () => { await expect(bridge.files.listWorkspaces()).rejects.toThrow("ade.localRuntime.callAction"); expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", request: { domain: "file", action: "listWorkspaces", args: {} }, }); expect(invoke).not.toHaveBeenCalledWith(IPC.filesListWorkspaces, expect.anything()); @@ -2863,6 +2875,108 @@ describe("preload OAuth bridge", () => { await pendingSwitch; }); + it("keeps the previous local runtime binding until a project switch succeeds", async () => { + let resolveSwitch!: (project: unknown) => void; + const switchPromise = new Promise((resolve) => { + resolveSwitch = resolve; + }); + const localRuntimeRoots: string[] = []; + const invoke = vi.fn(async (channel: string, arg?: unknown) => { + if (channel === IPC.projectSwitchToPath) { + return switchPromise; + } + if (channel === IPC.localRuntimeCallAction) { + localRuntimeRoots.push((arg as { rootPath?: string }).rootPath ?? ""); + return { result: [] }; + } + if (channel === IPC.appGetWindowSession) { + return { + windowId: 1, + project: { rootPath: "/old", displayName: "Old", baseRef: "main" }, + binding: { + kind: "local", + key: "local:/old", + rootPath: "/old", + displayName: "Old", + }, + }; + } + throw new Error(`unexpected IPC: ${channel}`); + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + const pendingSwitch = bridge.project.switchToPath("/next"); + + await expect(bridge.lanes.list()).resolves.toEqual([]); + expect(invoke).not.toHaveBeenCalledWith(IPC.lanesList, expect.anything()); + expect(localRuntimeRoots).toEqual(["/old"]); + + resolveSwitch({ rootPath: "/next", displayName: "Next", baseRef: "main" }); + await pendingSwitch; + + await expect(bridge.lanes.list()).resolves.toEqual([]); + expect(localRuntimeRoots).toEqual(["/old", "/next"]); + }); + + it("rejects empty project switch paths before updating local runtime binding", async () => { + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { + windowId: 1, + project: { rootPath: "/old", displayName: "Old", baseRef: "main" }, + binding: { + kind: "local", + key: "local:/old", + rootPath: "/old", + displayName: "Old", + }, + }; + } + throw new Error(`unexpected IPC: ${channel}`); + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.project.switchToPath(" ")).rejects.toThrow(/required/i); + + expect(invoke).not.toHaveBeenCalledWith(IPC.projectSwitchToPath, expect.anything()); + }); + it("falls through read-only chat actions to IPC while a project switch is in flight", async () => { let resolveSwitch!: (project: unknown) => void; const switchPromise = new Promise((resolve) => { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0cc02aabc..81b91be30 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1140,6 +1140,20 @@ function rememberProjectBinding(binding: OpenProjectBinding | null): void { } } +function localProjectBindingForRoot(rootPath: string): OpenProjectBinding { + const trimmed = rootPath.trim(); + if (!trimmed) { + throw new Error("Project root path is required."); + } + const displayName = trimmed.split(/[\\/]/).filter(Boolean).pop() ?? trimmed; + return { + kind: "local", + key: `local:${trimmed}`, + rootPath: trimmed, + displayName, + }; +} + async function refreshProjectBinding(): Promise { if (projectBindingRefreshPromise) return projectBindingRefreshPromise; const refreshVersion = projectBindingVersion; @@ -1205,8 +1219,9 @@ async function callLocalProjectActionIfBound( if (!binding) return { handled: false }; try { const response = (await ipcRenderer.invoke(IPC.localRuntimeCallAction, { + rootPath: binding.rootPath, request: { domain, action, ...request }, - })) as RemoteRuntimeActionResult; + })) as RemoteRuntimeActionResult; return { handled: true, result: response.result as T }; } catch (error) { const canUseFallback = @@ -1231,6 +1246,7 @@ async function callLocalProjectActionStrictIfBound( const binding = await getLocalProjectBinding(); if (!binding) return { handled: false }; const response = (await ipcRenderer.invoke(IPC.localRuntimeCallAction, { + rootPath: binding.rootPath, request: { domain, action, ...request }, })) as RemoteRuntimeActionResult; return { handled: true, result: response.result as T }; @@ -1384,6 +1400,7 @@ async function callLocalProjectSyncIfBound( if (!binding) return { handled: false }; try { const result = (await ipcRenderer.invoke(IPC.localRuntimeCallSync, { + rootPath: binding.rootPath, method, params, })) as T; @@ -1680,6 +1697,7 @@ async function pollRemoteRuntimeEvents(): Promise { request, })) as RemoteRuntimeStreamEventsResult) : ((await ipcRenderer.invoke(IPC.localRuntimeStreamEvents, { + rootPath: binding.rootPath, request, })) as RemoteRuntimeStreamEventsResult); @@ -2814,15 +2832,28 @@ contextBridge.exposeInMainWorld("ade", { // See openRepo above: `clearAround` runs cleanup twice, so nulling the // binding inside it would clobber the new one set by the // appProjectBindingChanged listener. - rememberProjectBinding(null); - return clearAround( - () => { - clearProjectScopedReadCaches(); - }, - () => runProjectRuntimeTransition(() => - ipcRenderer.invoke(IPC.projectSwitchToPath, { rootPath }), - ), - ); + const normalizedRootPath = typeof rootPath === "string" ? rootPath.trim() : ""; + if (!normalizedRootPath) { + throw new Error("Project root path is required."); + } + const previousBinding = currentProjectBinding; + try { + const project = await clearAround( + () => { + clearProjectScopedReadCaches(); + }, + () => runProjectRuntimeTransition(() => + ipcRenderer.invoke(IPC.projectSwitchToPath, { rootPath: normalizedRootPath }), + ), + ); + rememberProjectBinding( + localProjectBindingForRoot(project?.rootPath ?? normalizedRootPath), + ); + return project; + } catch (error) { + rememberProjectBinding(previousBinding); + throw error; + } }, forgetRecent: async (rootPath: string): Promise => ipcRenderer.invoke(IPC.projectForgetRecent, { rootPath }), diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index f9591d44c..2fb580093 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -174,6 +174,10 @@ function isWorkRoutePath(pathname: string): boolean { return pathname === "/work" || pathname.startsWith("/work/"); } +function isLanesRoutePath(pathname: string): boolean { + return pathname === "/lanes" || pathname.startsWith("/lanes/"); +} + const PROJECT_ROUTE_STORAGE_PREFIX = "ade:project-route:"; const WARM_PROJECT_SURFACE_LIMIT = 8; const EMPTY_PROJECT_TAB_ROOTS: string[] = []; @@ -224,14 +228,45 @@ function writeStoredProjectRoute(projectRoot: string, route: string): void { } } +function projectNameFromRoot(rootPath: string | null | undefined): string | null { + if (!rootPath) return null; + const segments = rootPath.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] ?? rootPath; +} + +function ProjectTransitionVeil({ label }: { label: string }) { + return ( +
+
+
+
+ ); +} + function ProjectRouteContent({ active, route }: { active: boolean; route: string }) { const workSurfaceRef = React.useRef(null); + const lanesSurfaceRef = React.useRef(null); const isWorkRoute = isWorkRoutePath(route.split(/[?#]/, 1)[0] || "/work"); + const isLanesRoute = isLanesRoutePath(route.split(/[?#]/, 1)[0] || "/work"); const [workRoute, setWorkRoute] = React.useState(() => isWorkRoute ? route : "/work"); const [workMounted, setWorkMounted] = React.useState(isWorkRoute); + const [lanesRoute, setLanesRoute] = React.useState(() => isLanesRoute ? route : "/lanes"); + const [lanesMounted, setLanesMounted] = React.useState(isLanesRoute); const routeProps = { active } as { active?: boolean }; const shouldRenderWork = workMounted || isWorkRoute; + const shouldRenderLanes = lanesMounted || isLanesRoute; const visibleWorkRoute = isWorkRoute ? route : workRoute; + const visibleLanesRoute = isLanesRoute ? route : lanesRoute; React.useEffect(() => { if (!isWorkRoute) return; @@ -239,6 +274,12 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string setWorkMounted(true); }, [isWorkRoute, route]); + React.useEffect(() => { + if (!isLanesRoute) return; + setLanesRoute(route); + setLanesMounted(true); + }, [isLanesRoute, route]); + React.useEffect(() => { const node = workSurfaceRef.current; if (!node) return; @@ -246,6 +287,13 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string else node.setAttribute("inert", ""); }, [isWorkRoute, shouldRenderWork]); + React.useEffect(() => { + const node = lanesSurfaceRef.current; + if (!node) return; + if (isLanesRoute) node.removeAttribute("inert"); + else node.setAttribute("inert", ""); + }, [isLanesRoute, shouldRenderLanes]); + const workSurface = shouldRenderWork ? ( ) : null; + const lanesSurface = shouldRenderLanes ? ( + + + + + + + + + } /> + + ) : null; + return (
{workSurface} - {!isWorkRoute ? ( + {lanesSurface} + {!isWorkRoute && !isLanesRoute ? ( } /> } /> } /> } /> - - {React.createElement(LanesPage as React.ComponentType<{ active?: boolean }>, routeProps)} - - } /> {React.createElement(FilesPage as React.ComponentType<{ active?: boolean }>, routeProps)} @@ -439,6 +510,7 @@ function ProjectTabHost() { const activeProject = useAppStore((s) => s.project); const projectHydrated = useAppStore((s) => s.projectHydrated); const showWelcome = useAppStore((s) => s.showWelcome); + const projectTransition = useAppStore((s) => s.projectTransition); const openProjectTabRoots = useAppStore((s) => s.openProjectTabRoots ?? EMPTY_PROJECT_TAB_ROOTS); const projectInfoByRoot = useAppStore((s) => s.projectInfoByRoot ?? EMPTY_PROJECT_INFO_BY_ROOT); const rootPrefs = useAppStore(useShallow((s) => ({ @@ -567,6 +639,23 @@ function ProjectTabHost() { ); } + const transitionTargetName = projectTransition?.rootPath + ? projectInfoByRoot[projectTransition.rootPath]?.displayName + ?? projectNameFromRoot(projectTransition.rootPath) + : null; + let transitionLabel: string | null = null; + switch (projectTransition?.kind) { + case "switching": + transitionLabel = `Switching${transitionTargetName ? ` to ${transitionTargetName}` : " projects"}...`; + break; + case "opening": + transitionLabel = "Opening project..."; + break; + case "closing": + transitionLabel = "Closing project..."; + break; + } + return (
{mountedProjects.map((project) => { @@ -583,6 +672,7 @@ function ProjectTabHost() { /> ); })} + {transitionLabel ? : null}
); } diff --git a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx index 75312a158..34cd93cb1 100644 --- a/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx +++ b/apps/desktop/src/renderer/components/app/App.workKeepAlive.test.tsx @@ -11,13 +11,19 @@ const workLifecycle = vi.hoisted(() => ({ unmounts: 0, })); +const lanesLifecycle = vi.hoisted(() => ({ + mounts: 0, + unmounts: 0, +})); + const appStoreState = vi.hoisted(() => ({ projectHydrated: true, showWelcome: false, project: { rootPath: "/fake/project" }, + projectTransition: null as { kind: "opening" | "switching" | "closing"; rootPath: string | null; startedAtMs: number } | null, theme: "dark", openProjectTabRoots: [] as string[], - projectInfoByRoot: {} as Record, + projectInfoByRoot: {} as Record, })); vi.mock("../../state/appStore", async () => { @@ -126,18 +132,42 @@ vi.mock("../files/FilesPage", async () => { }; }); -vi.mock("../lanes/LanesPage", () => ({ - LanesPage: () =>
, -})); +vi.mock("../lanes/LanesPage", async () => { + const ReactModule = await vi.importActual("react") as typeof ReactNamespace; + const Router = await vi.importActual("react-router-dom") as typeof RouterNamespace; + + return { + LanesPage: ({ active = true }: { active?: boolean }) => { + const navigate = Router.useNavigate(); + ReactModule.useEffect(() => { + lanesLifecycle.mounts += 1; + return () => { + lanesLifecycle.unmounts += 1; + }; + }, []); + + return ( +
+ +
+ ); + }, + }; +}); describe("App Work route keep-alive", () => { beforeEach(() => { vi.clearAllMocks(); workLifecycle.mounts = 0; workLifecycle.unmounts = 0; + lanesLifecycle.mounts = 0; + lanesLifecycle.unmounts = 0; appStoreState.projectHydrated = true; appStoreState.showWelcome = false; appStoreState.project = { rootPath: "/fake/project" }; + appStoreState.projectTransition = null; appStoreState.theme = "dark"; appStoreState.openProjectTabRoots = []; appStoreState.projectInfoByRoot = {}; @@ -258,6 +288,59 @@ describe("App Work route keep-alive", () => { expect(workLifecycle.mounts).toBe(0); }); + it("keeps the Lanes page mounted after visiting it and leaving for another tab", async () => { + window.history.replaceState({}, "", "/lanes"); + const { App } = await import("./App"); + + render(); + + const lanesPage = await screen.findByTestId("lanes-page"); + await waitFor(() => { + expect(lanesLifecycle.mounts).toBe(1); + }); + expect(lanesLifecycle.unmounts).toBe(0); + expect(lanesPage.closest("[aria-hidden='true']")).toBeNull(); + expect(lanesPage.getAttribute("data-active")).toBe("true"); + + fireEvent.click(screen.getByRole("button", { name: "Lanes open files" })); + await screen.findByTestId("files-page"); + + const parkedLanesSurface = screen.getByTestId("lanes-page").closest("[aria-hidden='true']"); + expect(parkedLanesSurface).not.toBeNull(); + expect(screen.getByTestId("lanes-page").getAttribute("data-active")).toBe("false"); + expect(lanesLifecycle.mounts).toBe(1); + expect(lanesLifecycle.unmounts).toBe(0); + }); + + it("covers the old project surface while a cold project switch is pending", async () => { + appStoreState.projectTransition = { + kind: "switching", + rootPath: "/other/project", + startedAtMs: 123, + }; + appStoreState.projectInfoByRoot = { + "/other/project": { rootPath: "/other/project", displayName: "Other project" }, + }; + const { App } = await import("./App"); + + render(); + + await screen.findByTestId("work-page"); + expect(screen.getByTestId("project-transition-veil").textContent).toContain( + "Switching to Other project...", + ); + }); + + it("does not mount Lanes until the user first navigates to /lanes", async () => { + const { App } = await import("./App"); + + render(); + + await screen.findByTestId("work-page"); + expect(screen.queryByTestId("lanes-page")).toBeNull(); + expect(lanesLifecycle.mounts).toBe(0); + }); + it("converts legacy hash app routes into BrowserRouter paths", async () => { window.history.replaceState({}, "", "/work#/lanes"); const { App } = await import("./App"); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index dd4feb4a9..f94cde30a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -884,6 +884,121 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("does not let a late lane-session hydration overwrite a model picked for the draft", async () => { + const lanes = [ + { + id: "lane-1", + name: "current-lane", + laneType: "worktree", + branchRef: "refs/heads/current-lane", + worktreePath: "/tmp/project-under-test/current-lane", + }, + { + id: "lane-2", + name: "target-lane", + laneType: "worktree", + branchRef: "refs/heads/target-lane", + worktreePath: "/tmp/project-under-test/target-lane", + }, + ] as any[]; + useAppStore.setState({ + project: { rootPath: "/tmp/project-under-test" } as any, + lanes, + selectedLaneId: "lane-1", + }); + const launchConfigKey = [ + "ade.chat.lastLaunchConfig.v1", + "/tmp/project-under-test", + "lane-1", + "standard", + "chat", + ].map(encodeURIComponent).join(":"); + window.localStorage.setItem(launchConfigKey, JSON.stringify({ + version: 1, + modelId: "openai/gpt-5.4", + reasoningEffort: "xhigh", + codexFastMode: false, + executionMode: "focused", + updatedAt: "2026-05-20T12:00:00.000Z", + controls: { + interactionMode: "default", + claudePermissionMode: "default", + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + opencodePermissionMode: "edit", + droidPermissionMode: "auto-low", + cursorModeId: "agent", + cursorConfigValues: {}, + }, + })); + const { create } = installAdeMocks({ sessions: [], includeClaudeModel: true }); + let resolveLaneTwoSessions!: (rows: AgentChatSessionSummary[]) => void; + const laneTwoSessions = new Promise((resolve) => { + resolveLaneTwoSessions = resolve; + }); + window.ade.agentChat.list = vi.fn().mockImplementation(async ({ laneId }: { laneId: string }) => ( + laneId === "lane-2" ? laneTwoSessions : [] + )) as any; + + function DraftLaneHarness() { + const [laneId, setLaneId] = React.useState("lane-1"); + return ( + + + + ); + } + + render(); + + const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; + expect(await screen.findByRole("button", { name: new RegExp(`current: ${escapeRegExp(codexLabel)}`, "i") })).toBeTruthy(); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Launch on the selected lane and model." } }); + + fireEvent.click(await screen.findByRole("button", { name: "Select lane" })); + fireEvent.click(await screen.findByRole("button", { name: /target-lane/i })); + + const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); + const claudeLabel = getModelById("anthropic/claude-sonnet-4-6")?.displayName ?? "Claude Sonnet 4.6"; + fireEvent.pointerDown(modelTrigger, { button: 0 }); + fireEvent.click(modelTrigger); + fireEvent.click(await screen.findByRole("tab", { name: /^Anthropic$/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(claudeLabel), "i")); + + await act(async () => { + resolveLaneTwoSessions([ + buildSession("lane-2-previous", { + laneId: "lane-2", + model: "gpt-5.4", + modelId: "openai/gpt-5.4", + reasoningEffort: "high", + }), + ]); + await laneTwoSessions; + }); + + expect(await screen.findByRole("button", { name: new RegExp(`current: ${escapeRegExp(claudeLabel)}`, "i") })).toBeTruthy(); + + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-2", + provider: "claude", + modelId: "anthropic/claude-sonnet-4-6", + })); + }); + }); + it("loads Claude slash commands for a draft chat before session creation", async () => { installAdeMocks({ sessions: [], includeClaudeModel: true }); vi.mocked(window.ade.agentChat.slashCommands).mockImplementation(async (args) => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index f80d35e6b..6e2244e9a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1799,6 +1799,10 @@ export function AgentChatPane({ surfaceProfile, workDraftKind, }), [laneId, projectRoot, surfaceProfile, workDraftKind]); + const draftLaunchConfigScopeKey = useMemo( + () => `${projectRoot ?? "project"}:${laneId ?? "no-lane"}:${surfaceProfile}:${workDraftKind}`, + [laneId, projectRoot, surfaceProfile, workDraftKind], + ); const initialCompanionStateKey = lockSessionId ?? initialSessionId ?? (laneId ? `draft:${laneId}` : "draft"); const [sessions, setSessions] = useState([]); const [archivedSessions, setArchivedSessions] = useState([]); @@ -2055,6 +2059,7 @@ export function AgentChatPane({ const localTouchBySessionRef = useRef>(new Map()); const cursorWarmupKeyRef = useRef(null); const draftLaunchConfigHydratedRef = useRef(null); + const draftLaunchConfigTouchedKeyRef = useRef(null); const recoveredParallelLaunchKeyRef = useRef(null); const selectedSession = useMemo( () => (selectedSessionId ? sessions.find((session) => session.sessionId === selectedSessionId) ?? null : null), @@ -2884,6 +2889,9 @@ export function AgentChatPane({ const syncComposerToSession = useCallback((session: AgentChatSessionSummary | null) => { if (!session) { + if (draftLaunchConfigTouchedKeyRef.current === draftLaunchConfigScopeKey) { + return; + } const lastLaunchConfig = readLastLaunchConfig(lastLaunchConfigStorageKey, initialNativeControls); if (lastLaunchConfig) { applyLaunchConfigToComposer(lastLaunchConfig); @@ -2927,7 +2935,7 @@ export function AgentChatPane({ .flatMap((option) => option.currentValue == null ? [] : [[option.id, option.currentValue]]), ), ); - }, [applyLaunchConfigToComposer, initialNativeControls, lastLaunchConfigStorageKey]); + }, [applyLaunchConfigToComposer, draftLaunchConfigScopeKey, initialNativeControls, lastLaunchConfigStorageKey]); const executionModeOptions = useMemo( () => getExecutionModeOptions(selectedModelDesc), [selectedModelDesc], @@ -3559,6 +3567,8 @@ export function AgentChatPane({ pendingSelectedSessionIdRef.current = null; appliedInitialSessionIdRef.current = initialSessionId ?? null; eagerCreateFiredRef.current = false; + draftLaunchConfigHydratedRef.current = null; + draftLaunchConfigTouchedKeyRef.current = null; if (forceDraft && !lockSessionId) { draftSelectionLockedRef.current = true; setSelectedSessionId(null); @@ -3593,7 +3603,8 @@ export function AgentChatPane({ useEffect(() => { if (selectedSessionId || lockSessionId) return; - const draftKey = `${projectRoot ?? "project"}:${laneId ?? "no-lane"}:${surfaceProfile}:${workDraftKind}`; + if (draftLaunchConfigTouchedKeyRef.current === draftLaunchConfigScopeKey) return; + const draftKey = draftLaunchConfigScopeKey; const latestSessionConfig = sessions[0] ? buildLastLaunchConfig(sessions[0], initialNativeControls) : null; @@ -3614,6 +3625,7 @@ export function AgentChatPane({ }, [ applyLaunchConfigToComposer, initialNativeControls, + draftLaunchConfigScopeKey, laneId, lastLaunchConfigStorageKey, lockSessionId, @@ -5832,6 +5844,9 @@ export function AgentChatPane({ const updateNativeControls = useCallback(async (patch: Partial) => { if (isPersistentIdentitySurface && sessionMutationKind) return; + if (!selectedSessionId) { + draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; + } const nextControls: NativeControlState = { ...nativeControlsRef.current, @@ -5914,6 +5929,7 @@ export function AgentChatPane({ await updatePromise; }, [ + draftLaunchConfigScopeKey, isPersistentIdentitySurface, patchSessionSummary, refreshSessions, @@ -5931,6 +5947,9 @@ export function AgentChatPane({ const handleReasoningEffortChange = useCallback((nextReasoningEffort: string | null) => { const previousReasoningEffort = reasoningEffort; + if (!selectedSessionId) { + draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; + } setReasoningEffort(nextReasoningEffort); if (!selectedSessionId) return; if (isPersistentIdentitySurface && sessionMutationKind) return; @@ -5959,6 +5978,7 @@ export function AgentChatPane({ setError(err instanceof Error ? err.message : String(err)); }); }, [ + draftLaunchConfigScopeKey, isPersistentIdentitySurface, patchSessionSummary, reasoningEffort, @@ -5969,6 +5989,9 @@ export function AgentChatPane({ const handleCodexFastModeChange = useCallback((enabled: boolean) => { const previousFastMode = codexFastMode; + if (!selectedSessionId) { + draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; + } setCodexFastMode(enabled); if (!selectedSessionId) return; if (isPersistentIdentitySurface && sessionMutationKind) return; @@ -6010,6 +6033,7 @@ export function AgentChatPane({ void updatePromise.catch(() => {}); }, [ codexFastMode, + draftLaunchConfigScopeKey, isPersistentIdentitySurface, patchSessionSummary, refreshSessions, @@ -6017,6 +6041,13 @@ export function AgentChatPane({ sessionMutationKind, ]); + const handleExecutionModeChange = useCallback((nextExecutionMode: AgentChatExecutionMode) => { + if (!selectedSessionId) { + draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; + } + setExecutionMode(nextExecutionMode); + }, [draftLaunchConfigScopeKey, selectedSessionId]); + const handleComputerUsePolicyChange = useCallback(async (_nextPolicy: unknown) => { // Computer-use policy gating has been removed; this handler is a no-op retained for UI compat. }, []); @@ -6767,6 +6798,8 @@ export function AgentChatPane({ onClick={() => { pendingSelectedSessionIdRef.current = null; draftSelectionLockedRef.current = true; + draftLaunchConfigHydratedRef.current = null; + draftLaunchConfigTouchedKeyRef.current = null; setError(null); setSelectedSessionId(null); setDraft(""); @@ -6844,7 +6877,7 @@ export function AgentChatPane({ permissionModeLocked={permissionModeLocked || identitySessionSettingsBusy || projectTransitionBlocksChat} hideNativeControls={hideNativeControls} messagePlaceholder={effectiveMessagePlaceholder} - onExecutionModeChange={setExecutionMode} + onExecutionModeChange={handleExecutionModeChange} onInteractionModeChange={(value) => { void updateNativeControls({ interactionMode: value }); }} onClaudeModeChange={handleClaudeModeChange} onClaudePermissionModeChange={(value) => { void updateNativeControls({ claudePermissionMode: value }); }} @@ -6877,6 +6910,9 @@ export function AgentChatPane({ if (isPersistentIdentitySurface && sessionMutationKind) { return; } + if (!selectedSessionId) { + draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; + } const snapshot = buildModelSelectionSnapshot(nextModelId); if (!selectedSessionId || turnActive) { applyModelSelectionSnapshot(snapshot); diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts index 380bd5719..2efaa6aa5 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts @@ -10,6 +10,7 @@ const focusSessionSpy = vi.fn(); const selectLaneSpy = vi.fn(); const setWorkViewStateSpy = vi.fn(); const setLaneWorkViewStateSpy = vi.fn(); +let fakeProjectRoot = "/fake/project"; // --------------------------------------------------------------------------- // Module-level mocks (hoisted by vitest) @@ -59,7 +60,7 @@ vi.mock("../../lib/sessions", () => ({ vi.mock("../../state/appStore", () => ({ useAppStore: vi.fn((selector: (state: Record) => unknown) => { const fakeState: Record = { - project: { rootPath: "/fake/project" }, + project: { rootPath: fakeProjectRoot }, lanes: [{ id: "lane-1", name: "Lane 1" }], focusSession: focusSessionSpy, focusedSessionId: null, @@ -98,6 +99,30 @@ function installWindowAde() { }; } +function makeSession(id: string, laneId: string, title = id) { + return { + id, + laneId, + laneName: laneId, + ptyId: null, + tracked: true, + pinned: false, + toolType: "claude-chat", + title, + status: "running", + startedAt: "2026-05-01T12:00:00.000Z", + endedAt: null, + exitCode: null, + transcriptPath: "", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "idle", + resumeCommand: null, + }; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -109,6 +134,7 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { installWindowAde(); // Default: instant resolve for mount-time refresh calls listSessionsCachedMock.mockResolvedValue([]); + fakeProjectRoot = "/fake/project"; }); afterEach(() => { @@ -334,6 +360,114 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { expect(queuedDoneIdx).toBeLessThan(openTabIdx); }); + it("replays a queued refresh against the latest lane after switching lanes mid-refresh", async () => { + const fetchedLaneIds: string[] = []; + let firstRefreshResolve: ((rows: unknown[]) => void) | null = null; + let secondRefreshResolve: ((rows: unknown[]) => void) | null = null; + + listSessionsCachedMock.mockImplementation((args: { laneId: string }) => { + fetchedLaneIds.push(args.laneId); + if (fetchedLaneIds.length === 1) { + return new Promise((resolve) => { + firstRefreshResolve = resolve; + }); + } + if (fetchedLaneIds.length === 2) { + return new Promise((resolve) => { + secondRefreshResolve = resolve; + }); + } + return Promise.resolve([]); + }); + + const { result, rerender } = renderHook( + ({ laneId }: { laneId: string }) => useLaneWorkSessions(laneId), + { initialProps: { laneId: "lane-1" } }, + ); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + expect(fetchedLaneIds).toEqual(["lane-1"]); + + act(() => { + rerender({ laneId: "lane-2" }); + }); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + expect(fetchedLaneIds).toEqual(["lane-1"]); + + await act(async () => { + expect(firstRefreshResolve).not.toBeNull(); + firstRefreshResolve!([makeSession("session-old", "lane-1")]); + await new Promise((r) => setTimeout(r, 0)); + }); + expect(fetchedLaneIds).toEqual(["lane-1", "lane-2"]); + + await act(async () => { + expect(secondRefreshResolve).not.toBeNull(); + secondRefreshResolve!([makeSession("session-new", "lane-2")]); + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(result.current.sessions.map((session) => session.id)).toEqual(["session-new"]); + }); + + it("replays a queued refresh against the latest project after switching projects mid-refresh", async () => { + const fetchedProjectRoots: Array = []; + let firstRefreshResolve: ((rows: unknown[]) => void) | null = null; + let secondRefreshResolve: ((rows: unknown[]) => void) | null = null; + + listSessionsCachedMock.mockImplementation((_args: { laneId: string }, options?: { projectRoot?: string | null }) => { + fetchedProjectRoots.push(options?.projectRoot); + if (fetchedProjectRoots.length === 1) { + return new Promise((resolve) => { + firstRefreshResolve = resolve; + }); + } + if (fetchedProjectRoots.length === 2) { + return new Promise((resolve) => { + secondRefreshResolve = resolve; + }); + } + return Promise.resolve([]); + }); + + const { result, rerender } = renderHook(() => useLaneWorkSessions("lane-1")); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + expect(fetchedProjectRoots).toEqual(["/fake/project"]); + + fakeProjectRoot = "/other/project"; + act(() => { + rerender(); + }); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + expect(fetchedProjectRoots).toEqual(["/fake/project"]); + + await act(async () => { + expect(firstRefreshResolve).not.toBeNull(); + firstRefreshResolve!([makeSession("session-old", "lane-1")]); + await new Promise((r) => setTimeout(r, 0)); + }); + expect(fetchedProjectRoots).toEqual(["/fake/project", "/other/project"]); + + await act(async () => { + expect(secondRefreshResolve).not.toBeNull(); + secondRefreshResolve!([makeSession("session-new", "lane-1")]); + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(result.current.sessions.map((session) => session.id)).toEqual(["session-new"]); + }); + // ----------------------------------------------------------------------- // handleOpenChatSession: opens immediately, then reconciles in background // ----------------------------------------------------------------------- diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index eef129515..06516b81b 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -87,6 +87,8 @@ export function useLaneWorkSessions(laneId: string | null) { const hasActiveSessionsRef = useRef(false); const hasLoadedOnceRef = useRef(false); const hasFetchedOnceRef = useRef(false); + const laneIdRef = useRef(laneId); + const projectRootRef = useRef(projectRoot); const scopeKeyRef = useRef(""); const currentLane = useMemo( @@ -101,8 +103,10 @@ export function useLaneWorkSessions(laneId: string | null) { }, [projectRoot, laneId]); useEffect(() => { + laneIdRef.current = laneId; + projectRootRef.current = projectRoot; scopeKeyRef.current = scopeKey; - }, [scopeKey]); + }, [laneId, projectRoot, scopeKey]); const hasStoredState = scopeKey.length > 0 && scopeKey in laneWorkViewByScope; const laneViewState = scopeKey @@ -123,7 +127,9 @@ export function useLaneWorkSessions(laneId: string | null) { const refresh = useCallback( async (options: { showLoading?: boolean; force?: boolean } = {}) => { - if (!laneId) { + const targetLaneId = laneIdRef.current; + const targetProjectRoot = projectRootRef.current; + if (!targetLaneId) { setSessions([]); hasLoadedOnceRef.current = true; return; @@ -153,8 +159,8 @@ export function useLaneWorkSessions(laneId: string | null) { try { const requestedScopeKey = scopeKeyRef.current; const rows = await listSessionsCached( - { laneId, limit: 200 }, - { force: Boolean(options.force), projectRoot }, + { laneId: targetLaneId, limit: 200 }, + { force: Boolean(options.force), projectRoot: targetProjectRoot }, ); if (scopeKeyRef.current !== requestedScopeKey) return; const nextSessions = rows.filter((session) => !isRunOwnedSession(session)); @@ -178,7 +184,7 @@ export function useLaneWorkSessions(laneId: string | null) { } } }, - [laneId, projectRoot], + [], ); const scheduleBackgroundRefresh = useCallback((delayMs = 300) => { diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts index 2586aeabf..8709aa77a 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts @@ -224,9 +224,11 @@ describe("buildTrackedCliStartupCommand", () => { laneWorktreePath: "/repo/.ade/worktrees/chat-lane", }); + expect(launch.args.at(-1)).toContain("/repo/.ade/worktrees/chat-lane/.claude/skills"); + expect(launch.args.at(-1)).toContain("/repo/.ade/worktrees/chat-lane/.agents/skills"); expect(launch.args.at(-1)).toContain("/repo/.ade/worktrees/chat-lane/apps/desktop/resources/agent-skills"); expect(launch.env?.[ADE_AGENT_SKILLS_DIRS_ENV]?.startsWith( - "/repo/.ade/worktrees/chat-lane/apps/desktop/resources/agent-skills", + "/repo/.ade/worktrees/chat-lane/.claude/skills", )).toBe(true); }); @@ -260,6 +262,8 @@ describe("buildTrackedCliStartupCommand", () => { expect(launch.startupCommand).toContain("cursor-agent --mode plan --model cursor-fast --resume \"$ADE_CURSOR_CHAT_ID\""); expect(launch.startupCommand).toContain("Continue with cursor-agent --resume"); expect(launch.startupCommand).toContain("Review this lane."); + expect(launch.startupCommand).toContain("ADE session guidance"); + expect(launch.env?.[ADE_AGENT_SKILLS_DIRS_ENV]).toContain("agent-skills"); }); it("launches Droid through exec with model, reasoning, autonomy, guidance, and prompt", () => { @@ -273,8 +277,10 @@ describe("buildTrackedCliStartupCommand", () => { expect(launch.command).toBe("droid"); expect(launch.args).toEqual(expect.arrayContaining(["exec", "--model", "sonnet", "--reasoning-effort", "high", "--auto", "low"])); expect(launch.args.at(-1)).toContain("ADE session guidance"); + expect(launch.args.at(-1)).toContain("ADE_AGENT_SKILLS_DIRS"); expect(launch.args.at(-1)).toContain("Run the Droid path."); expect(launch.startupCommand).toContain("droid exec --model sonnet --reasoning-effort high --auto low"); + expect(launch.env?.[ADE_AGENT_SKILLS_DIRS_ENV]).toContain("agent-skills"); }); it("launches OpenCode with inline permission config", () => { diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 6493891ab..7ad52c395 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -861,6 +861,8 @@ describe("appStore", () => { it("activates a warm project tab before the backend switch round trip finishes", async () => { const projectA = { rootPath: "/p/a", displayName: "A", baseRef: "main" } as any; const projectB = { rootPath: "/p/b", displayName: "B", baseRef: "main" } as any; + const cachedLanesB = [{ id: "lane-B2", name: "Lane B2" }] as any[]; + const cachedSnapshotsB = [{ lane: cachedLanesB[0] }] as any[]; let resolveSwitch!: (project: typeof projectB) => void; (window.ade.project.switchToPath as any).mockReturnValueOnce( new Promise((resolve) => { @@ -879,6 +881,10 @@ describe("appStore", () => { laneSelectionByProject: { "/p/b": { laneId: "lane-B2", sessionId: "session-B2" }, }, + lanes: [{ id: "lane-A1", name: "Lane A1" }] as any[], + laneCacheByProject: { + "/p/b": { lanes: cachedLanesB, laneSnapshots: cachedSnapshotsB }, + }, }); const pending = useAppStore.getState().switchProjectToPath("/p/b"); @@ -890,6 +896,9 @@ describe("appStore", () => { ); expect(useAppStore.getState().selectedLaneId).toBe("lane-B2"); expect(useAppStore.getState().focusedSessionId).toBe("session-B2"); + expect(useAppStore.getState().lanes).toBe(cachedLanesB); + expect(useAppStore.getState().laneSnapshots).toBe(cachedSnapshotsB); + expect(useAppStore.getState().lanesLoading).toBe(false); expect(useAppStore.getState().laneSelectionByProject["/p/a"]).toEqual({ laneId: "lane-A1", sessionId: "session-A1", @@ -973,6 +982,67 @@ describe("appStore", () => { } }); + it("retains project-scoped Work and lane caches for open project tabs even when they are absent from recents", async () => { + const originalWindowSetTimeout = window.setTimeout; + vi.useFakeTimers(); + window.setTimeout = globalThis.setTimeout as typeof window.setTimeout; + try { + const workState = useAppStore.getState().getWorkViewState("/p/c"); + useAppStore.setState({ + project: { rootPath: "/p/b", displayName: "B", baseRef: "main" } as any, + openProjectTabRoots: ["/p/a", "/p/b", "/p/c"], + workViewByProject: { + "/p/c": { ...workState, openItemIds: ["session-c"], activeItemId: "session-c", selectedItemId: "session-c" }, + "/p/d": { ...workState, openItemIds: ["session-d"], activeItemId: "session-d", selectedItemId: "session-d" }, + }, + laneWorkViewByScope: { + "/p/c::lane-c": { ...workState, openItemIds: ["session-c"] }, + "/p/d::lane-d": { ...workState, openItemIds: ["session-d"] }, + }, + laneSelectionByProject: { + "/p/c": { laneId: "lane-c", sessionId: "session-c" }, + "/p/d": { laneId: "lane-d", sessionId: "session-d" }, + }, + laneCacheByProject: { + "/p/c": { lanes: [{ id: "lane-c", name: "Lane C" }] as any[], laneSnapshots: [] }, + "/p/d": { lanes: [{ id: "lane-d", name: "Lane D" }] as any[], laneSnapshots: [] }, + }, + sessionsCacheByProject: { + "/p/c": [{ id: "session-c" }], + "/p/d": [{ id: "session-d" }], + }, + } as any); + + const nextProject = { rootPath: "/p/a", displayName: "A", baseRef: "main" } as any; + (window.ade.project.switchToPath as any).mockResolvedValueOnce(nextProject); + (window.ade.project.listRecent as any).mockResolvedValueOnce([ + { rootPath: "/p/a" }, + { rootPath: "/p/b" }, + ]); + + await useAppStore.getState().switchProjectToPath("/p/a"); + await vi.advanceTimersByTimeAsync(750); + + expect(useAppStore.getState().workViewByProject["/p/c"]?.openItemIds).toEqual(["session-c"]); + expect(useAppStore.getState().laneWorkViewByScope["/p/c::lane-c"]?.openItemIds).toEqual(["session-c"]); + expect(useAppStore.getState().laneSelectionByProject["/p/c"]).toEqual({ + laneId: "lane-c", + sessionId: "session-c", + }); + expect(useAppStore.getState().laneCacheByProject["/p/c"]?.lanes[0]?.id).toBe("lane-c"); + expect(useAppStore.getState().sessionsCacheByProject["/p/c"]).toEqual([{ id: "session-c" }]); + + expect(useAppStore.getState().workViewByProject["/p/d"]).toBeUndefined(); + expect(useAppStore.getState().laneWorkViewByScope["/p/d::lane-d"]).toBeUndefined(); + expect(useAppStore.getState().laneSelectionByProject["/p/d"]).toBeUndefined(); + expect(useAppStore.getState().laneCacheByProject["/p/d"]).toBeUndefined(); + expect(useAppStore.getState().sessionsCacheByProject["/p/d"]).toBeUndefined(); + } finally { + window.setTimeout = originalWindowSetTimeout; + vi.useRealTimers(); + } + }); + it("clears all banner-dismiss maps when the project is closed", async () => { useAppStore.setState({ project: { rootPath: "/p/x" } as any, diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 9568b1f52..f9ce09b36 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -1350,6 +1350,8 @@ const createAppState: StateCreator = (set, get) => { if (isWarmTabSwitch && cachedProject) { get().setProject(cachedProject); } + const cachedWarmLanes = + cachedProject != null ? get().laneCacheByProject[cachedProject.rootPath] : undefined; const restoredWarmSelection = cachedProject != null ? get().laneSelectionByProject[cachedProject.rootPath] ?? { laneId: null, sessionId: null } @@ -1376,8 +1378,16 @@ const createAppState: StateCreator = (set, get) => { projectHydrated: true, showWelcome: false, isNewTabOpen: false, + laneSnapshots: cachedWarmLanes?.laneSnapshots ?? [], + lanes: cachedWarmLanes?.lanes ?? [], + lanesLoading: !cachedWarmLanes, + laneDeleteProgressByLaneId: {}, selectedLaneId: restoredWarmSelection.laneId, focusedSessionId: restoredWarmSelection.sessionId, + laneInspectorTabs: {}, + keybindings: null, + terminalAttention: EMPTY_TERMINAL_ATTENTION, + macosVmTabIndicator: null, } : {}), ...(outgoingProjectRoot @@ -1449,7 +1459,13 @@ const createAppState: StateCreator = (set, get) => { void window.ade.project.listRecent().then((recentRows) => { const recentRoots = new Set(recentRows.map((r: { rootPath: string }) => r.rootPath)); const activeRoot = get().project?.rootPath ?? null; - const retainedRoots = [activeRoot, ...recentRoots]; + const openProjectRoots = new Set(get().openProjectTabRoots); + const retainedRootSet = new Set(); + for (const root of [activeRoot, ...recentRoots, ...openProjectRoots]) { + const key = normalizeProjectKey(root); + if (key) retainedRootSet.add(key); + } + const retainedRoots = Array.from(retainedRootSet); set((prev) => { const nextWorkViews: Record = {}; const nextLaneWorkViews: Record = {}; @@ -1457,20 +1473,20 @@ const createAppState: StateCreator = (set, get) => { const nextLaneCache: Record = {}; const nextSessionsCache: Record = {}; for (const [key, value] of Object.entries(prev.workViewByProject)) { - if (key === activeRoot || recentRoots.has(key)) nextWorkViews[key] = value; + if (retainedRootSet.has(key)) nextWorkViews[key] = value; } for (const [scopeKey, value] of Object.entries(prev.laneWorkViewByScope)) { const projectKey = scopeKey.split("::")[0]; - if (projectKey === activeRoot || recentRoots.has(projectKey)) nextLaneWorkViews[scopeKey] = value; + if (retainedRootSet.has(projectKey)) nextLaneWorkViews[scopeKey] = value; } for (const [key, value] of Object.entries(prev.laneSelectionByProject)) { - if (key === activeRoot || recentRoots.has(key)) nextLaneSelections[key] = value; + if (retainedRootSet.has(key)) nextLaneSelections[key] = value; } for (const [key, value] of Object.entries(prev.laneCacheByProject)) { - if (key === activeRoot || recentRoots.has(key)) nextLaneCache[key] = value; + if (retainedRootSet.has(key)) nextLaneCache[key] = value; } for (const [key, value] of Object.entries(prev.sessionsCacheByProject)) { - if (key === activeRoot || recentRoots.has(key)) nextSessionsCache[key] = value; + if (retainedRootSet.has(key)) nextSessionsCache[key] = value; } persistWorkViewState({ workViewByProject: nextWorkViews, diff --git a/apps/desktop/src/shared/adeCliGuidance.test.ts b/apps/desktop/src/shared/adeCliGuidance.test.ts new file mode 100644 index 000000000..bbe4e329b --- /dev/null +++ b/apps/desktop/src/shared/adeCliGuidance.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { adeBundledAgentSkills, buildAdeCliAgentGuidance } from "./adeCliGuidance"; + +describe("ADE CLI guidance", () => { + it("preinjects bundled skill discovery guidance for every ADE runtime surface", () => { + const guidance = buildAdeCliAgentGuidance(["/Applications/ADE.app/Contents/Resources/agent-skills"]); + + expect(guidance).toContain("### Skills"); + expect(guidance).toContain("project, user, runtime, and bundled ADE skill roots"); + expect(guidance).toContain("ADE-hosted Work chats"); + expect(guidance).toContain("ADE Code/TUI sessions"); + expect(guidance).toContain("CTO and mission worker prompts"); + expect(guidance).toContain("mobile-started work"); + expect(guidance).toContain("ADE_AGENT_SKILLS_DIRS"); + expect(guidance).toContain("/SKILL.md"); + expect(guidance).toContain("references/"); + for (const skillName of adeBundledAgentSkills) { + expect(guidance).toContain(`\`${skillName}\``); + } + }); +}); diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index f5cd3532a..243953fe3 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -1,5 +1,18 @@ import { formatAdeAgentSkillRootsForPrompt, getAdeAgentSkillRootsForPrompt } from "./agentSkillRoots"; +export const adeBundledAgentSkills = [ + "ade-cli-control-plane", + "ade-ios-simulator", + "ade-app-control", + "ade-browser", + "ade-pr-workflows", + "ade-lanes-git", + "ade-cto-missions", + "ade-proof-artifacts", + "ade-macos-vm", + "ade-deeplinks", +] as const; + export function buildAdeCliAgentGuidance(skillRoots: readonly string[] = getAdeAgentSkillRootsForPrompt()): string { return [ "## ADE CLI", @@ -7,10 +20,13 @@ export function buildAdeCliAgentGuidance(skillRoots: readonly string[] = getAdeA "`ade` is the default control plane for ADE-managed sessions. Use normal shell commands for immediate repo inspection/edit/test work; use ADE CLI when you need ADE state, drawer/session state, proof registration, missions, PR metadata, memory, or managed app/simulator/browser/VM control.", "", "### Skills", - "- ADE ships Agent Skills for deeper operating details. Use the relevant skill instead of relying on long prompt guidance: `ade-cli-control-plane`, `ade-ios-simulator`, `ade-app-control`, `ade-browser`, `ade-pr-workflows`, `ade-lanes-git`, `ade-cto-missions`, `ade-proof-artifacts`, `ade-macos-vm`, and `ade-deeplinks`.", + "- ADE exposes Agent Skills from project, user, runtime, and bundled ADE skill roots. Use the relevant skill instead of relying on long prompt guidance.", + `- Bundled ADE skills include: ${adeBundledAgentSkills.map((name) => `\`${name}\``).join(", ")}.`, + "- ADE injects this guidance into ADE-hosted Work chats, Work tab CLI launches, ADE Code/TUI sessions, CTO and mission worker prompts, and mobile-started work that executes through ADE's desktop or project runtime.", + "- Skills use the Agent Skills package shape: `/SKILL.md` plus optional `references/`, `scripts/`, and `assets/` files. When a skill applies, read its `SKILL.md` before acting, then load referenced files only when needed.", "- If skills are not auto-listed by your runtime, look for them in project/user `.agents/skills`, `.ade/skills`, `.claude/skills`, or ADE's bundled `agent-skills` resources, then read that skill's `SKILL.md` on demand.", `- ${formatAdeAgentSkillRootsForPrompt(skillRoots)}`, - "- ADE also sets `ADE_AGENT_SKILLS_DIRS` for ADE-launched CLI sessions when the bundled skills root is known.", + "- ADE also sets `ADE_AGENT_SKILLS_DIRS` for ADE-launched CLI sessions when skill roots are known so CLI runtimes can discover the same skills.", "", "### Minimum operating rules", "- Start with `ade doctor --text` when the ADE environment is unclear. Use `ade help ` for exact flags and `ade actions list --text` as the escape hatch for service actions without a typed command.", diff --git a/apps/desktop/src/shared/agentSkillRoots.test.ts b/apps/desktop/src/shared/agentSkillRoots.test.ts index 751f9aebf..69fde4848 100644 --- a/apps/desktop/src/shared/agentSkillRoots.test.ts +++ b/apps/desktop/src/shared/agentSkillRoots.test.ts @@ -1,15 +1,18 @@ import { describe, expect, it } from "vitest"; import { ADE_AGENT_SKILLS_DIRS_ENV, + formatAdeAgentSkillRootsForPrompt, + getAgentSkillRootCandidates, getAdeAgentSkillRootsForPrompt, joinAdeAgentSkillRoots, } from "./agentSkillRoots"; describe("agent skill roots", () => { - it("prefers the active lane worktree skill root before inherited app roots", () => { - const roots = getAdeAgentSkillRootsForPrompt({ + it("lists project, user, inherited, and bundled skill roots for agent runtimes", () => { + const roots = getAgentSkillRootCandidates({ cwd: "/repo/.ade/worktrees/chat-lane", env: { + HOME: "/home/agent", [ADE_AGENT_SKILLS_DIRS_ENV]: joinAdeAgentSkillRoots([ "/repo/apps/desktop/resources/agent-skills", ]), @@ -17,8 +20,67 @@ describe("agent skill roots", () => { resourcesPath: "/Applications/ADE.app/Contents/Resources", }); - expect(roots[0]).toBe("/repo/.ade/worktrees/chat-lane/apps/desktop/resources/agent-skills"); + expect(roots[0]).toBe("/repo/.ade/worktrees/chat-lane/.claude/skills"); + expect(roots).toContain("/repo/.ade/worktrees/chat-lane/.agents/skills"); + expect(roots).toContain("/repo/.ade/worktrees/chat-lane/.ade/skills"); + expect(roots).toContain("/repo/.ade/worktrees/chat-lane/.codex/skills"); + expect(roots).toContain("/home/agent/.claude/skills"); + expect(roots).toContain("/home/agent/.agents/skills"); + expect(roots).toContain("/home/agent/.ade/skills"); + expect(roots).toContain("/home/agent/.codex/skills"); + expect(roots).toContain("/repo/.ade/worktrees/chat-lane/apps/desktop/resources/agent-skills"); expect(roots).toContain("/repo/apps/desktop/resources/agent-skills"); expect(roots).toContain("/Applications/ADE.app/Contents/Resources/agent-skills"); }); + + it("caps prompt-facing ADE skill roots while preserving runtime candidates", () => { + const cwd = process.cwd().replace(/[\\/]+$/, ""); + const sep = cwd.includes("\\") ? "\\" : "/"; + const fromCwd = (...parts: string[]) => [cwd, ...parts].join(sep); + const options = { + cwd, + env: { + HOME: "/home/agent", + [ADE_AGENT_SKILLS_DIRS_ENV]: joinAdeAgentSkillRoots([ + "/external/agent-skills-a", + "/external/agent-skills-b", + "/external/agent-skills-c", + ]), + }, + resourcesPath: "/Applications/ADE.app/Contents/Resources", + }; + + const promptRoots = getAdeAgentSkillRootsForPrompt(options); + const runtimeRoots = getAgentSkillRootCandidates(options); + + expect(promptRoots).toEqual([ + fromCwd("apps", "desktop", "resources", "agent-skills"), + fromCwd("resources", "agent-skills"), + "/external/agent-skills-a", + "/external/agent-skills-b", + ]); + expect(promptRoots).toHaveLength(4); + expect(runtimeRoots.length).toBeGreaterThan(promptRoots.length); + expect(runtimeRoots).toContain("/home/agent/.codex/skills"); + expect(runtimeRoots).toContain("/external/agent-skills-c"); + expect(runtimeRoots).toContain("/Applications/ADE.app/Contents/Resources/agent-skills"); + }); + + it("dedupes all skill root candidates and describes them generically", () => { + const roots = getAgentSkillRootCandidates({ + cwd: "/repo", + env: { + HOME: "/home/agent", + [ADE_AGENT_SKILLS_DIRS_ENV]: joinAdeAgentSkillRoots([ + "/repo/.agents/skills", + "/repo/apps/desktop/resources/agent-skills", + ]), + }, + }); + + expect(roots.filter((root) => root === "/repo/.agents/skills")).toHaveLength(1); + const description = formatAdeAgentSkillRootsForPrompt(roots.slice(0, 2)); + expect(description).toContain("Agent skill roots for this session"); + expect(description).toContain("named skill"); + }); }); diff --git a/apps/desktop/src/shared/agentSkillRoots.ts b/apps/desktop/src/shared/agentSkillRoots.ts index a1bcbb78a..3a0add34f 100644 --- a/apps/desktop/src/shared/agentSkillRoots.ts +++ b/apps/desktop/src/shared/agentSkillRoots.ts @@ -19,6 +19,12 @@ function joinPath(root: string, ...parts: string[]): string { return [root.replace(/[\\/]+$/, ""), ...parts.map((part) => part.replace(/^[\\/]+|[\\/]+$/g, ""))].join(sep); } +function parentPath(value: string): string | null { + const normalized = value.replace(/[\\/]+$/, ""); + const parent = normalized.replace(/[\\/][^\\/]+$/, ""); + return parent && parent !== normalized ? parent : null; +} + function addPath(target: string[], seen: Set, value: string | null | undefined): void { const normalized = normalizePathEntry(value); if (!normalized) return; @@ -28,6 +34,36 @@ function addPath(target: string[], seen: Set, value: string | null | und target.push(normalized); } +function homePath(env: NodeJS.ProcessEnv): string | null { + const home = normalizePathEntry(env.HOME ?? env.USERPROFILE); + if (home) return home; + const drive = normalizePathEntry(env.HOMEDRIVE); + const pathPart = String(env.HOMEPATH ?? "").trim(); + return drive && pathPart ? normalizePathEntry(`${drive}${pathPart}`) : null; +} + +const ancestorSkillDirs = [".claude", ".agents", ".ade", ".codex"] as const; +const promptAgentSkillRootLimit = 4; +type AncestorSkillDir = (typeof ancestorSkillDirs)[number]; + +function addAncestorSkillRoots( + roots: string[], + seen: Set, + cwd: string | null | undefined, + dirName: AncestorSkillDir, + home: string | null, +): void { + let current = normalizePathEntry(cwd); + if (!current) return; + for (let depth = 0; depth < 25; depth += 1) { + addPath(roots, seen, joinPath(current, dirName, "skills")); + if (home && current.toLowerCase() === home.toLowerCase()) break; + const parent = parentPath(current); + if (!parent) break; + current = parent; + } +} + export function splitAdeAgentSkillRoots(value: string | null | undefined): string[] { const roots: string[] = []; const seen = new Set(); @@ -57,10 +93,12 @@ export function getAdeAgentSkillRootCandidates(options: { const seen = new Set(); const cwd = options.cwd ?? (typeof proc?.cwd === "function" ? proc.cwd() : null); - if (cwd) { + const processCwd = typeof proc?.cwd === "function" ? proc.cwd() : null; + for (const rootCwd of [cwd, processCwd]) { + if (!rootCwd) continue; // Prefer the active lane worktree before inherited app roots. - addPath(roots, seen, joinPath(cwd, "apps", "desktop", "resources", "agent-skills")); - addPath(roots, seen, joinPath(cwd, "resources", "agent-skills")); + addPath(roots, seen, joinPath(rootCwd, "apps", "desktop", "resources", "agent-skills")); + addPath(roots, seen, joinPath(rootCwd, "resources", "agent-skills")); } for (const root of splitAdeAgentSkillRoots(env[ADE_AGENT_SKILLS_DIRS_ENV])) addPath(roots, seen, root); @@ -83,12 +121,42 @@ export function getAdeAgentSkillRootCandidates(options: { return roots; } +export function getAgentSkillRootCandidates(options: { + env?: NodeJS.ProcessEnv; + resourcesPath?: string | null; + cwd?: string | null; + dirname?: string | null; + home?: string | null; + includeDeepSourceFallbacks?: boolean; +} = {}): string[] { + const proc = processRef(); + const env = options.env ?? proc?.env ?? {}; + const roots: string[] = []; + const seen = new Set(); + const cwd = options.cwd ?? (typeof proc?.cwd === "function" ? proc.cwd() : null); + + const home = normalizePathEntry(options.home) ?? homePath(env); + for (const dirName of ancestorSkillDirs) { + addAncestorSkillRoots(roots, seen, cwd, dirName, home); + } + + if (home) { + for (const dirName of ancestorSkillDirs) { + addPath(roots, seen, joinPath(home, dirName, "skills")); + } + } + + for (const root of getAdeAgentSkillRootCandidates(options)) addPath(roots, seen, root); + + return roots; +} + export function getAdeAgentSkillRootsForPrompt(options: { env?: NodeJS.ProcessEnv; resourcesPath?: string | null; cwd?: string | null; } = {}): string[] { - return getAdeAgentSkillRootCandidates(options).slice(0, 4); + return getAdeAgentSkillRootCandidates(options).slice(0, promptAgentSkillRootLimit); } export function formatAdeAgentSkillRootsForPrompt(roots: readonly string[]): string { @@ -96,7 +164,7 @@ export function formatAdeAgentSkillRootsForPrompt(roots: readonly string[]): str .map((root) => normalizePathEntry(root)) .filter((root): root is string => Boolean(root)); if (!normalized.length) { - return "The exact bundled ADE skills root is exposed as `ADE_AGENT_SKILLS_DIRS` when ADE launches this CLI; inspect that env var if your runtime does not auto-list ADE skills."; + return "The exact agent skill roots are exposed as `ADE_AGENT_SKILLS_DIRS` when ADE launches this CLI; inspect that env var if your runtime does not auto-list skills."; } - return `Bundled ADE skills root${normalized.length === 1 ? "" : "s"} for this session: ${normalized.join(", ")}. Read \`//SKILL.md\` on demand when a named ADE skill is relevant.`; + return `Agent skill root${normalized.length === 1 ? "" : "s"} for this session: ${normalized.join(", ")}. Read \`//SKILL.md\` on demand when a named skill is relevant.`; } diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index 023856d31..74394ef75 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -4,7 +4,12 @@ import type { TerminalSessionSummary, TerminalToolType, } from "./types"; -import { ADE_AGENT_SKILLS_DIRS_ENV, getAdeAgentSkillRootsForPrompt, joinAdeAgentSkillRoots } from "./agentSkillRoots"; +import { + ADE_AGENT_SKILLS_DIRS_ENV, + getAdeAgentSkillRootsForPrompt, + getAgentSkillRootCandidates, + joinAdeAgentSkillRoots, +} from "./agentSkillRoots"; import { buildAdeCliAgentGuidance, buildAdeCliInlineGuidance } from "./adeCliGuidance"; import { isProviderSlashCommandInput } from "./chatSlashCommands"; import { commandArrayToLine, quoteShellArg } from "./shell"; @@ -300,7 +305,9 @@ export function buildTrackedCliLaunchCommand(args: { }): TrackedCliLaunchCommand { validateLaunchProfilePermissionMode(args.provider, args.permissionMode); const initialPrompt = normalizeInitialPrompt(args.initialPrompt); - const skillRoots = getAdeAgentSkillRootsForPrompt({ cwd: args.laneWorktreePath ?? undefined }); + const skillRoots = args.laneWorktreePath + ? getAgentSkillRootCandidates({ cwd: args.laneWorktreePath }) + : getAdeAgentSkillRootsForPrompt(); const agentSkillEnv = adeAgentSkillEnv(skillRoots); if (args.provider === "claude") { diff --git a/apps/desktop/src/shared/deeplinks.ts b/apps/desktop/src/shared/deeplinks.ts index 747f8d378..7f06cf610 100644 Binary files a/apps/desktop/src/shared/deeplinks.ts and b/apps/desktop/src/shared/deeplinks.ts differ diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 1ec5b2e7e..cb835f4d6 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ AA5300000000000000000002 /* DeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5300000000000000000012 /* DeepLinkRouter.swift */; }; AA5300000000000000000003 /* NotificationCategories.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5300000000000000000013 /* NotificationCategories.swift */; }; K10000000000000000000001 /* SendToMacCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = K20000000000000000000001 /* SendToMacCard.swift */; }; + K10000000000000000000002 /* DeepLinkURLParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = K20000000000000000000002 /* DeepLinkURLParsing.swift */; }; AA5200000000000000000011 /* ADEWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5200000000000000000001 /* ADEWidgetBundle.swift */; }; AA5200000000000000000012 /* ADEWorkspaceWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5200000000000000000002 /* ADEWorkspaceWidget.swift */; }; AA5200000000000000000013 /* ADEWorkspaceWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5200000000000000000003 /* ADEWorkspaceWidgetViews.swift */; }; @@ -244,6 +245,7 @@ AA5300000000000000000012 /* DeepLinkRouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DeepLinkRouter.swift; path = ADE/App/DeepLinkRouter.swift; sourceTree = ""; }; AA5300000000000000000013 /* NotificationCategories.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationCategories.swift; path = ADE/App/NotificationCategories.swift; sourceTree = ""; }; K20000000000000000000001 /* SendToMacCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SendToMacCard.swift; path = ADE/Views/Deeplinks/SendToMacCard.swift; sourceTree = ""; }; + K20000000000000000000002 /* DeepLinkURLParsing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DeepLinkURLParsing.swift; path = ADE/App/DeepLinkURLParsing.swift; sourceTree = ""; }; AA5200000000000000000001 /* ADEWidgetBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEWidgetBundle.swift; path = ADEWidgets/ADEWidgetBundle.swift; sourceTree = ""; }; AA5200000000000000000002 /* ADEWorkspaceWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEWorkspaceWidget.swift; path = ADEWidgets/ADEWorkspaceWidget.swift; sourceTree = ""; }; AA5200000000000000000003 /* ADEWorkspaceWidgetViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEWorkspaceWidgetViews.swift; path = ADEWidgets/ADEWorkspaceWidgetViews.swift; sourceTree = ""; }; @@ -685,6 +687,7 @@ 73B17472FAC08845853BC6B4 /* ContentView.swift */, AA5300000000000000000011 /* AppDelegate.swift */, AA5300000000000000000012 /* DeepLinkRouter.swift */, + K20000000000000000000002 /* DeepLinkURLParsing.swift */, AA5300000000000000000013 /* NotificationCategories.swift */, ); name = App; @@ -1159,6 +1162,7 @@ F300000000000000000000F4 /* PerSessionOverrideView.swift in Sources */, AA5300000000000000000001 /* AppDelegate.swift in Sources */, AA5300000000000000000002 /* DeepLinkRouter.swift in Sources */, + K10000000000000000000002 /* DeepLinkURLParsing.swift in Sources */, AA5300000000000000000003 /* NotificationCategories.swift in Sources */, K10000000000000000000001 /* SendToMacCard.swift in Sources */, ); diff --git a/apps/ios/ADE/App/DeepLinkRouter.swift b/apps/ios/ADE/App/DeepLinkRouter.swift index 4e9ab10f1..372935dfe 100644 --- a/apps/ios/ADE/App/DeepLinkRouter.swift +++ b/apps/ios/ADE/App/DeepLinkRouter.swift @@ -1,6 +1,6 @@ import Foundation -/// Central router for `ade://` URLs and deep-link requests posted from the +/// Central router for ADE URLs and deep-link requests posted from the /// notification delegate. Existing tab/navigation views listen to /// `.adeDeepLinkRequested` and flip their selection when fired; new /// cross-machine shapes (lane / repo / extended pr / linear-issue) instead @@ -23,8 +23,12 @@ final class DeepLinkRouter { /// * `ade://pr///` /// * `ade://linear-issue/[?branch=]` /// + /// Also accepts the web mirror used by CLI / agent handoff output: + /// `https://ade.app/open?type=&...`. + /// /// Unknown hosts are ignored rather than crashing on malformed input. func handle(_ url: URL) { + if routeHttpsOpenURL(url) { return } guard url.scheme?.lowercased() == "ade" else { return } let host = url.host?.lowercased() let pathComponents = url.pathComponents.filter { $0 != "/" } @@ -78,6 +82,36 @@ final class DeepLinkRouter { } } + private func routeHttpsOpenURL(_ url: URL) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme?.lowercased() == "https", + components.host?.lowercased() == "ade.app", + components.path == "/open" else { + return false + } + + let query = ADEDeepLinkURLParsing.adeQueryValues(from: components) + switch query["type"]?.lowercased() { + case "lane": + guard query["id"]?.isEmpty == false else { return true } + postSendToMac(url: url) + case "branch": + guard ADEDeepLinkURLParsing.splitRepo(query["repo"]) != nil, + query["branch"]?.isEmpty == false else { return true } + postSendToMac(url: url) + case "pr": + guard ADEDeepLinkURLParsing.splitRepo(query["repo"]) != nil, + ADEDeepLinkURLParsing.positiveInteger(query["number"]) != nil else { return true } + postSendToMac(url: url) + case "linear-issue": + guard query["issue"]?.isEmpty == false else { return true } + postSendToMac(url: url) + default: + break + } + return true + } + /// Synthesise a deep link from a notification payload's `sessionId` / /// `prNumber` keys. Used when the user taps the notification body or a /// default action we do not special-case into a remote command. diff --git a/apps/ios/ADE/App/DeepLinkURLParsing.swift b/apps/ios/ADE/App/DeepLinkURLParsing.swift new file mode 100644 index 000000000..ec7d62db6 --- /dev/null +++ b/apps/ios/ADE/App/DeepLinkURLParsing.swift @@ -0,0 +1,32 @@ +import Foundation + +enum ADEDeepLinkURLParsing { + static func splitRepo(_ value: String?) -> (owner: String, repo: String)? { + guard let value else { return nil } + let pieces = value.split(separator: "/", omittingEmptySubsequences: false).map(String.init) + guard pieces.count == 2, + !pieces[0].isEmpty, + !pieces[1].isEmpty else { + return nil + } + return (pieces[0], pieces[1]) + } + + static func positiveInteger(_ value: String?) -> Int? { + guard let value, + let number = Int(value), + number > 0 else { + return nil + } + return number + } + + static func adeQueryValues(from components: URLComponents) -> [String: String] { + var query: [String: String] = [:] + for item in components.queryItems ?? [] { + guard let value = item.value else { continue } + query[item.name.lowercased()] = value + } + return query + } +} diff --git a/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift b/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift index 69416eaec..52670d710 100644 --- a/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift +++ b/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift @@ -1,6 +1,6 @@ import SwiftUI -/// Categorises an `ade://...` URL that iOS can't open natively so the +/// Categorises an ADE URL that iOS can't open natively so the /// `SendToMacCard` can render a short, human description ("Lane shared with /// you" / "Branch feat-x in acme/widget" / "ADE-123") without re-parsing the /// URL inside the view body. @@ -8,6 +8,7 @@ struct SendToMacTarget: Equatable, Identifiable { enum Kind: Equatable { case lane(id: String) case repoBranch(owner: String, repo: String, branch: String) + case pr(owner: String, repo: String, number: Int) case linearIssue(identifier: String, branch: String?) case other } @@ -20,11 +21,15 @@ struct SendToMacTarget: Equatable, Identifiable { /// from spawning duplicate sheets. var id: String { url.absoluteString } - /// Best-effort parse of an `ade://...` URL. Unknown shapes fall back to - /// `.other` so the card can still render a generic "Open this on your Mac" - /// message rather than refusing to display. + /// Best-effort parse of ADE's custom scheme and HTTPS mirror. Unknown + /// shapes fall back to `.other` so the card can still render a generic + /// "Open this on your Mac" message rather than refusing to display. init(url: URL) { self.url = url + if let kind = SendToMacTarget.parseHttpsOpenURL(url) { + self.kind = kind + return + } let host = url.host?.lowercased() let parts = url.pathComponents.filter { $0 != "/" } switch host { @@ -48,6 +53,16 @@ struct SendToMacTarget: Equatable, Identifiable { } else { self.kind = .other } + case "pr": + if parts.count >= 3, + !parts[0].isEmpty, + !parts[1].isEmpty, + let number = Int(parts[2]), + number > 0 { + self.kind = .pr(owner: parts[0], repo: parts[1], number: number) + } else { + self.kind = .other + } case "linear-issue": // `ade://linear-issue/[?branch=]` — Linear hand-off. // The branch hint is optional; the desktop resolves the actual lane @@ -68,10 +83,45 @@ struct SendToMacTarget: Equatable, Identifiable { } } + private static func parseHttpsOpenURL(_ url: URL) -> Kind? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme?.lowercased() == "https", + components.host?.lowercased() == "ade.app", + components.path == "/open" else { + return nil + } + let query = ADEDeepLinkURLParsing.adeQueryValues(from: components) + switch query["type"]?.lowercased() { + case "lane": + guard let id = query["id"], !id.isEmpty else { return .other } + return .lane(id: id) + case "branch": + guard let repo = ADEDeepLinkURLParsing.splitRepo(query["repo"]), + let branch = query["branch"], + !branch.isEmpty else { + return .other + } + return .repoBranch(owner: repo.owner, repo: repo.repo, branch: branch) + case "pr": + guard let repo = ADEDeepLinkURLParsing.splitRepo(query["repo"]), + let number = ADEDeepLinkURLParsing.positiveInteger(query["number"]) else { + return .other + } + return .pr(owner: repo.owner, repo: repo.repo, number: number) + case "linear-issue": + guard let identifier = query["issue"], !identifier.isEmpty else { return .other } + let branch = query["branch"]?.trimmingCharacters(in: .whitespacesAndNewlines) + return .linearIssue(identifier: identifier, branch: branch?.isEmpty == false ? branch : nil) + default: + return .other + } + } + var headline: String { switch kind { case .lane: return "Lane shared with you" case .repoBranch(_, _, _): return "Branch shared with you" + case .pr: return "Pull request shared with you" case .linearIssue: return "Linear issue shared with you" case .other: return "Shared from your Mac" } @@ -83,6 +133,8 @@ struct SendToMacTarget: Equatable, Identifiable { return "Lane \(shortenedLaneId(id))" case .repoBranch(let owner, let repo, let branch): return "Branch \(branch) in \(owner)/\(repo)" + case .pr(let owner, let repo, let number): + return "#\(number) in \(owner)/\(repo)" case .linearIssue(let identifier, let branch): if let branch, !branch.isEmpty { return "\(identifier) on \(branch)" @@ -181,6 +233,7 @@ struct SendToMacCard: View { switch target.kind { case .lane: return "square.stack.3d.up" case .repoBranch: return "arrow.triangle.branch" + case .pr: return "arrow.triangle.merge" case .linearIssue: return "smallcircle.filled.circle" case .other: return "link" } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index b75a61fc9..684267155 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -79,6 +79,52 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.requestedWorkSessionNavigation?.sessionId, "session-123") } + @MainActor + func testDeepLinkRouterSendsHttpsAdePrLinksToMac() throws { + let expected = "https://ade.app/open?type=pr&repo=arul/ADE&number=42" + let received = expectation(description: "send to Mac request posted") + var postedURL: String? + let token = NotificationCenter.default.addObserver( + forName: .adeSendToMacRequested, + object: nil, + queue: nil + ) { note in + postedURL = note.userInfo?["url"] as? String + received.fulfill() + } + defer { NotificationCenter.default.removeObserver(token) } + + DeepLinkRouter.shared.handle(try XCTUnwrap(URL(string: expected))) + + wait(for: [received], timeout: 1) + XCTAssertEqual(postedURL, expected) + } + + func testSendToMacTargetParsesHttpsAdePrLinks() throws { + let target = SendToMacTarget( + url: try XCTUnwrap(URL(string: "https://ade.app/open?type=pr&repo=arul/ADE&number=42")) + ) + + guard case .pr(let owner, let repo, let number) = target.kind else { + return XCTFail("Expected PR Send-to-Mac target") + } + XCTAssertEqual(owner, "arul") + XCTAssertEqual(repo, "ADE") + XCTAssertEqual(number, 42) + XCTAssertEqual(target.headline, "Pull request shared with you") + XCTAssertEqual(target.detail, "#42 in arul/ADE") + } + + func testDeepLinkRepoParserRejectsMalformedRepoValues() throws { + let valid = try XCTUnwrap(ADEDeepLinkURLParsing.splitRepo("arul/ADE")) + + XCTAssertEqual(valid.owner, "arul") + XCTAssertEqual(valid.repo, "ADE") + XCTAssertNil(ADEDeepLinkURLParsing.splitRepo("arul/ADE/extra")) + XCTAssertNil(ADEDeepLinkURLParsing.splitRepo("arul/")) + XCTAssertNil(ADEDeepLinkURLParsing.splitRepo("/ADE")) + } + @MainActor func testTerminalEmulatorSkipsDuplicateRevisionRenders() { let view = ADETerminalTextView(frame: CGRect(x: 0, y: 0, width: 320, height: 300)) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a9f5cc451..f6dfe69cc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -443,7 +443,7 @@ ade.updates.* - Every handler is wrapped with a timeout — 30 seconds by default, with explicit longer budgets for known long operations such as direct lane delete, iOS Simulator launch/control, macOS VM provisioning/control, App Control, and built-in browser actions. Runtime-dispatched actions use the runtime-call channel budget; the timeout wrapper no longer inspects the action payload to give `lane.delete` a special runtime-dispatch override. - Every handler emits structured tracing: `ipc.invoke.begin`, `ipc.invoke.done`, `ipc.invoke.failed` with call ID, channel, window ID, duration, and summarized args/results. - `AppContext` indirection: handlers close over a context pointer that swaps atomically on project switch, so IPC channels remain registered across project transitions. -- **Multi-window shell** — the app can host multiple `BrowserWindow` instances (for example when opening another project in a dedicated window). Handler tracing already carries **window ID** so logs and diagnostics distinguish which renderer surface invoked a channel; `main.ts` ties each window to its **set** of open project roots before routing into services. Two maps in `main.ts` drive this: `windowProjectRoots` tracks the active foreground project per window, and `windowProjectTabRoots` tracks every project root that window currently has open as a tab. Project-scoped event broadcasts (`emitToProjectWindows`) deliver to any window whose active **or** open-tab set contains the project, so background tabs keep receiving live updates. `ade.app.getWindowSession` returns `{ project, binding, openProjectTabs }` for the requesting window; the renderer mirrors its open-tab list back to main with `ade.app.setWindowProjectTabs({ rootPaths })` so the main process can keep those project contexts warm and clean up on window close. +- **Multi-window shell** — the app can host multiple `BrowserWindow` instances (for example when opening another project in a dedicated window). Handler tracing already carries **window ID** so logs and diagnostics distinguish which renderer surface invoked a channel; `main.ts` ties each window to its **set** of open project roots before routing into services. Two maps in `main.ts` drive this: `windowProjectRoots` tracks the active foreground project per window, and `windowProjectTabRoots` tracks every project root that window currently has open as a tab. Project-scoped event broadcasts (`emitToProjectWindows`) deliver to any window whose active **or** open-tab set contains the project, so background tabs keep receiving live updates. `ade.app.getWindowSession` returns `{ project, binding, openProjectTabs }` for the requesting window; the renderer mirrors its open-tab list back to main with `ade.app.setWindowProjectTabs({ rootPaths })` so the main process can keep those project contexts warm and clean up on window close. Renderer tab switches use cached project/lane snapshots for warm activation, retain caches for every open tab root even if a project is absent from recents, keep Work and Lanes mounted after first visit, and cover cold switches with a project-transition veil. - **Project context retention.** `MAX_WARM_IDLE_PROJECT_CONTEXTS = 100` is a soft cap for project contexts with no user work. `hasActiveProjectWorkloads(ctx)` protects any context that has live chat sessions (via `agentChatService.hasRetainableSessions()` — any session the user hasn't explicitly closed or deleted, not just mid-turn ones), live PTYs (`ptyService.hasLiveSessions()`), active missions, or queued tests. Eviction is best-effort and never tears down a context with work; the cap exists only as a safety valve against opening hundreds of empty projects in a long session. ### 5.4 Event subscriptions (push, not poll) @@ -1043,7 +1043,7 @@ Post-packaging hardening (`apps/desktop/scripts/`): - `runtimeBinaryPermissions.cjs` — restores exec bits on `node-pty` spawn helpers, Codex vendor binaries, Claude SDK ripgrep helpers; patches `node-pty` `unixTerminal.js` for ASAR-unpacked paths. - `after-pack-runtime-fixes.cjs` — electron-builder after-pack hook. Covers both platforms: runs the permissions pass on macOS and stages CLI wrappers + runtime shims on Windows. -- `validate-mac-artifacts.mjs` / `validate-win-artifacts.mjs` — per-platform artifact validators; confirm expected binaries, release signing state, bundled ADE CLI help, and isolated ADE Code TUI help. They also fail if the bundled TUI references `__dirname` / `__filename` without ESM shims. Windows signing verification is opt-in with `--require-signed` or `ADE_REQUIRE_WIN_SIGNING=1`. +- `validate-mac-artifacts.mjs` / `validate-win-artifacts.mjs` — per-platform artifact validators; confirm expected binaries, release signing state, bundled ADE CLI help, isolated ADE Code TUI help, and every required bundled ADE Agent Skill `SKILL.md`. They also fail if the bundled TUI references `__dirname` / `__filename` without ESM shims. Windows signing verification is opt-in with `--require-signed` or `ADE_REQUIRE_WIN_SIGNING=1`. - `notarize-mac-dmg.mjs` — Apple notarization. ### 14.5 Documentation diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 6e3c2c199..491846b2f 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -23,13 +23,13 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `scripts/tui-web.mjs` | Dev browser mirror for `ade code`: ensures the dev runtime, spawns one PTY, serves xterm.js + WebSocket bridge (`npm run dev:code:web`). | | `apps/ade-cli/src/cli.ts` | Resolves the built or source TUI entry and forwards the parsed launch context to `runAdeCodeCli`. | | `apps/ade-cli/src/tuiClient/cli.tsx` | TUI entry: argv parsing, project discovery, connection bootstrap, Ink mount. Built to `apps/ade-cli/dist/tuiClient/cli.mjs`. | -| `apps/ade-cli/src/tuiClient/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle, slash command dispatch. Owns the `Ctrl+Y` "copy ADE deeplink" handler which resolves the focused lane / PR row through `buildDeeplinkForRow` and copies the canonical `ade://...` URL to the system clipboard. | +| `apps/ade-cli/src/tuiClient/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle, slash command dispatch. Owns the `Ctrl+Y` "copy ADE deeplink" handler which resolves the focused lane / PR row through `buildDeeplinkForRow` and copies the canonical `ade://...` URL to the system clipboard. Also backs `/skills` by listing Agent Skill roots from project, user, inherited, and bundled ADE locations, independent of the active provider. | | `apps/ade-cli/src/tuiClient/deeplinkRow.ts` | Pure helper used by the `Ctrl+Y` keybinding. Maps the focused lane or PR row (including parsing a GitHub PR URL when the right pane only carries the URL) onto a `DeeplinkTarget` and returns the built `ade://` URL. Tested in `tuiClient/__tests__/deeplinkKeybind.test.ts`. | | `apps/ade-cli/src/commands/deeplinks.ts` | `ade open`, `ade link`, and `ade linear install` subcommands. Shares the parser + builder with the desktop main process so URLs round-trip across both surfaces. See [features/deeplinks/README.md](../deeplinks/README.md). | | `apps/ade-cli/src/tuiClient/connection.ts` | Resolves attached vs embedded mode, runs the `ade/initialize` handshake, registers the project with `projects.add`, wraps subsequent requests with `projectId`. | | `apps/ade-cli/src/tuiClient/jsonRpcClient.ts` | Socket client: connect, request/response, `chat/event` notifications. | | `apps/ade-cli/src/tuiClient/adeApi.ts` | Typed wrappers over `AdeCodeConnection.action` / `actionList` for lanes, chat, models, navigation, provider readiness, API-key status, OpenCode diagnostics, project slash-command discovery, lane diff stats (`listLaneDiffStats`), per-lane PR summaries (`listPrsByLane`), the Claude steer family (`steerChatMessage`, `cancelSteerMessage`, `editSteerMessage`, `dispatchSteerMessage`), the provider-grouped model catalog (`getModelCatalog(args?: AgentChatModelCatalogArgs)` → `AgentChatModelCatalog`), and the cross-surface model-picker favorites / recents (`getModelPickerFavorites`, `toggleModelPickerFavorite`, `getModelPickerRecents`, `pushModelPickerRecent`) backed by the top-level `modelPicker.*` JSON-RPC methods on `adeRpcServer`. | -| `apps/ade-cli/src/tuiClient/commands.ts` / `linearCommands.ts` | Slash command catalog and routing. `commands.ts` ships `/lane delete` (right-pane confirmation form that destroys the active lane) and `/effort` (reasoning-effort-only picker, a narrower companion to `/model`). `linearCommands.ts` requires a sub-command — bare `/linear` returns the usage hint instead of silently picking `workflows`. | +| `apps/ade-cli/src/tuiClient/commands.ts` / `linearCommands.ts` | Slash command catalog and routing. `commands.ts` ships `/lane delete` (right-pane confirmation form that destroys the active lane), `/effort` (reasoning-effort-only picker, a narrower companion to `/model`), and provider-agnostic `/skills` for Agent Skill discovery. `linearCommands.ts` requires a sub-command — bare `/linear` returns the usage hint instead of silently picking `workflows`. | | `apps/ade-cli/src/tuiClient/rightPaneFormatters.ts` | Pure formatters for right-pane result panes (PR summary / review / checks / comments, memory search, Linear status, system details). Keeps `app.tsx` free of ad-hoc rendering helpers. | | `apps/ade-cli/src/tuiClient/format.ts` | Transcript rendering helpers for the TUI. | | `apps/ade-cli/src/tuiClient/aggregate.ts` | Pure derivations on top of the chat event stream. Produces `AggregatedBlock`s (assistant text, tool-calls / files-changed / plan / memory / compaction groups, runtime-activity rows for subagent and activity envelopes, queued steers) and `derivePendingSteers`, consumed by `ChatView` and the right-pane steer view. | @@ -124,7 +124,7 @@ Heartbeats are kept alive with `startTuiHeartbeat` so the runtime knows the chat ## Slash commands -`commands.ts` exports the built-in slash command catalog. `placement` decides whether the command runs inline in the chat or opens the right pane. The TUI also discovers project command files and Codex prompts before a chat exists, then refreshes against server-provided `AgentChatSlashCommand`s from the active runtime via `getSlashCommands`. Provider/runtime commands win over same-named built-ins except for local terminal controls such as `/login`, `/quit`, and `/clear`. +`commands.ts` exports the built-in slash command catalog. `placement` decides whether the command runs inline in the chat or opens the right pane. The TUI also discovers project command files, Codex prompts, and Agent Skill roots before a chat exists, then refreshes against server-provided `AgentChatSlashCommand`s from the active runtime via `getSlashCommands`. Provider/runtime commands win over same-named built-ins except for local terminal controls such as `/login`, `/quit`, and `/clear`. Inline (acts on chat or shell): @@ -154,7 +154,7 @@ Right pane (open the contextual drawer): | `/plugin [reload\|native args]` | List, reload, or manage Claude plugins (Claude only). | | `/agents` | List Claude agents from user/project config (Claude only). | | `/info` | Open the Chat Info pane for the active chat (plan, Codex goal, subagents). | -| `/skills` | List Claude skills from user/project config (Claude only). | +| `/skills` | List Agent Skills from project, user, inherited, and bundled ADE roots. | | `/context` | Show Claude context usage breakdown (Claude only). | | `/init` | Generate AGENTS.md and Claude pointer files (Claude only). | | `/status` | Project, lane, runtime state summary. | @@ -186,7 +186,7 @@ Inline chat commands (run through the active Claude SDK session, Claude only): | `/fast [on\|off]` | Toggle Claude fast mode through the active SDK session. | | `/goal [\|clear\|pause\|resume]` | Set, pause, resume, or clear the chat goal. Token-budget management is intentionally not exposed — when a Codex thread reports `budget_limited`, ADE auto-clears the runtime budget and the goal banner stays in the active state. | -Claude-only commands only appear in the slash palette when the active chat's provider is `claude`. The palette filters built-in entries by their `providers` whitelist so a Codex / OpenCode / Cursor chat does not show parity affordances that have no backing call. +Claude-only commands only appear in the slash palette when the active chat's provider is `claude`. The palette filters built-in entries by their `providers` whitelist so a Codex / OpenCode / Cursor chat does not show parity affordances that have no backing call. `/skills` is deliberately provider-agnostic because it only reads markdown package roots and does not call a provider runtime. Several slash commands forward to a desktop route when issued from `ade code`: diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index 3ca2a7853..d6b042eff 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -21,10 +21,10 @@ registry / ADE CLI integration that all three share. | `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` | CTO-only tools (spawnChat, mission control, worker management, Linear dispatch). | | `apps/desktop/src/main/services/agentTools/agentToolsService.ts` | Detects external CLI tools (Claude Code, Codex, Cursor, Aider, Continue) on PATH. | | `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md), the `ade --socket app-control ...` driver for live Electron apps, and the `ade --socket browser ...` driver for the in-app browser (`browser panel`, `browser open [--no-panel]`, `browser new-tab --background`, `browser switch`, `browser close`, plus selection / inspect commands). `ade chat create --provider codex --model --fast` opts a new Codex session into the fast service tier; `ade shell start --lane --chat-session ` (or `ADE_CHAT_SESSION_ID` from the env) attaches a tracked shell to an existing chat so `ade --socket terminal read --chat-session "$ADE_CHAT_SESSION_ID" --text` resolves to it. | -| `apps/ade-cli/src/adeRpcServer.ts` | Private ADE action RPC: registers actions, handles JSON-RPC, applies session-identity-based filtering, and builds lane-scoped ADE guidance / `ADE_AGENT_SKILLS_DIRS` for worker CLI launches. | +| `apps/ade-cli/src/adeRpcServer.ts` | Private ADE action RPC: registers actions, handles JSON-RPC, applies session-identity-based filtering, builds lane-scoped ADE guidance / `ADE_AGENT_SKILLS_DIRS` for worker CLI launches, and returns GitHub + ADE PR URLs from PR creation tools when available. | | `apps/desktop/src/main/services/cli/adeCliService.ts` | Desktop-side install / status / uninstall surface for the `ade` launcher. Owns the install-target path resolution and the optional shell-rc PATH append. | -| `apps/desktop/src/shared/adeCliGuidance.ts` | Canonical agent-prompt guidance builder for finding and using `ade`, reading bundled ADE skills on demand, using socket-backed live surfaces, registering proof, and cleaning up started processes. | -| `apps/desktop/src/shared/agentSkillRoots.ts` | Resolves and formats the ADE Agent Skills roots injected into prompts and CLI environments. Active lane worktrees are preferred before inherited app / packaged roots so launched agents see lane-local bundled skills first. | +| `apps/desktop/src/shared/adeCliGuidance.ts` | Canonical agent-prompt guidance builder for finding and using `ade`, reading Agent Skills on demand, naming the bundled ADE skills, using socket-backed live surfaces, registering proof, and cleaning up started processes. Injected into Work chats, CLI launches, ADE Code/TUI sessions, CTO/mission workers, and mobile-started runtime work. | +| `apps/desktop/src/shared/agentSkillRoots.ts` | Resolves and formats Agent Skill roots injected into prompts and CLI environments: lane/current-working-directory ancestors, user homes, inherited `ADE_AGENT_SKILLS_DIRS`, packaged ADE resources, and source fallbacks across `.claude`, `.agents`, `.ade`, and `.codex` skill directories. | | `apps/desktop/src/shared/ctoPersonalityPresets.ts` | CTO personality overlays (`strategic`, `professional`, `hands_on`, `casual`, `minimal`, `custom`). | | `apps/desktop/src/shared/types/agents.ts` | `AgentIdentity`, `AgentCoreMemory`, `AgentRole`, `AdapterType`, adapter configs. | | `apps/desktop/src/shared/types/cto.ts` | `CtoIdentity`, `CtoCoreMemory`, `CtoCapabilityMode`, `CtoPersonalityPreset`. | diff --git a/docs/features/agents/tool-registration.md b/docs/features/agents/tool-registration.md index 5b249c488..0d6064fb1 100644 --- a/docs/features/agents/tool-registration.md +++ b/docs/features/agents/tool-registration.md @@ -10,7 +10,7 @@ filtering before exposing the final list. | Path | Role | |---|---| -| `apps/ade-cli/src/adeRpcServer.ts` | Private ADE action RPC. Defines action specs, session identity, role-based filtering, the executor, and lane-scoped ADE guidance / skill-root env for worker CLI launches. | +| `apps/ade-cli/src/adeRpcServer.ts` | Private ADE action RPC. Defines action specs, session identity, role-based filtering, the executor, and lane-scoped ADE guidance / skill-root env for worker CLI launches. `create_pr_from_lane` returns the PR payload plus GitHub and ADE PR URLs when they can be derived. | | `apps/ade-cli/src/bootstrap.ts` | Builds `AdeRuntime` from desktop services for headless CLI execution. | | `apps/ade-cli/src/cli.ts` | User-facing `ade` command, text/JSON formatters, command plans, and socket/headless client wiring. | | `apps/ade-cli/src/jsonrpc.ts` | JSON-RPC server and socket transport helpers. | @@ -19,8 +19,8 @@ filtering before exposing the final list. | `apps/desktop/src/main/services/orchestrator/coordinatorTools.ts` | Coordinator tool set for the mission orchestrator. | | `apps/desktop/src/main/services/agentTools/agentToolsService.ts` | External CLI detection (Claude Code, Codex, Cursor, Aider, Continue). | | `apps/desktop/src/main/services/cli/adeCliService.ts` | Desktop-side CLI install / status / uninstall. Resolves the launcher target (`$HOME/.local/bin/ade` on POSIX, `%LOCALAPPDATA%\ADE\bin\ade.cmd` on Windows) and, on POSIX install, appends a marked `export PATH=...` block to the user's shell rc when the install dir isn't already on `$PATH`. | -| `apps/desktop/src/shared/adeCliGuidance.ts` | ADE guidance builders injected into agent system prompts and inline CLI preambles. Tells the agent how to find `ade` (PATH → `$ADE_CLI_PATH` → `$ADE_CLI_BIN_DIR/ade` → `node apps/ade-cli/dist/cli.cjs ...`), to use bundled ADE skills, to try `ade doctor` / typed commands / `ade actions list` before reporting an ADE task as blocked, and to track and clean up stale or finished processes it starts. | -| `apps/desktop/src/shared/agentSkillRoots.ts` | Resolves lane-aware ADE Agent Skills roots, formats the prompt line, and joins roots for `ADE_AGENT_SKILLS_DIRS`. | +| `apps/desktop/src/shared/adeCliGuidance.ts` | ADE guidance builders injected into agent system prompts and inline CLI preambles. Tells the agent how to find `ade` (PATH → `$ADE_CLI_PATH` → `$ADE_CLI_BIN_DIR/ade` → `node apps/ade-cli/dist/cli.cjs ...`), which bundled ADE skills exist, how Agent Skills are shaped (`/SKILL.md` plus optional `references/`, `scripts/`, `assets/`), which ADE-hosted surfaces receive the guidance, to try `ade doctor` / typed commands / `ade actions list` before reporting an ADE task as blocked, and to track and clean up stale or finished processes it starts. | +| `apps/desktop/src/shared/agentSkillRoots.ts` | Resolves generic Agent Skill roots for prompts and `ADE_AGENT_SKILLS_DIRS`: ancestor and home `.claude/skills`, `.agents/skills`, `.ade/skills`, `.codex/skills`, inherited env roots, packaged resources, and source fallbacks. | ## Two-path tool dispatch @@ -234,10 +234,14 @@ prompts should prefer documented commands such as `ade lanes list`, `apps/desktop/src/shared/adeCliGuidance.ts` builds the canonical text the chat / agent system prompt embeds whenever a session has CLI access. Callers pass skill roots from `agentSkillRoots.ts`, usually -using the active lane worktree as `cwd`, so a lane-local -`apps/desktop/resources/agent-skills` root appears before inherited -environment or packaged app roots. The same root list is joined into -`ADE_AGENT_SKILLS_DIRS` for ADE-launched CLI sessions. +using the active lane worktree as `cwd`, so lane-local +`.claude/skills`, `.agents/skills`, `.ade/skills`, `.codex/skills`, +and bundled ADE resources appear before inherited environment, +packaged app, and source-fallback roots. The same full root list is +joined into `ADE_AGENT_SKILLS_DIRS` for ADE-launched CLI sessions, +headless worker launches, Work-tab CLI launches, ADE Code/TUI +sessions, CTO/mission worker prompts, and mobile-started work that +runs through ADE's runtime. The guidance tells the agent that `ade` *should* be available, and gives it an ordered fallback chain when `command -v ade` fails: diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 856b6efce..dd9d00304 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -11,7 +11,7 @@ machinery layered on top. | Path | Role | |---|---| -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, and active-workload detection used by project/window close guards. Lane naming runs through the session-intelligence prompt path, retries the requested/configured/default title models, then falls back to a prompt slug with an optional temporary suffix for uniqueness. Tracks Codex Fast Mode (`codexFastMode: boolean`) per session and forwards it as `serviceTier: "fast" \| null` on every Codex `thread/start` and `turn/start` JSON-RPC call (see [Agent Routing](agent-routing.md#codex-service-tiers-fast-mode)). Builds ADE guidance from the active lane worktree so bundled Agent Skills roots are lane-scoped in persistent system/developer prompts and any provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns call `stopTask` for active subagents before emitting stopped subagent results. Large orchestrator file. | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, slash-command discovery/merge, and active-workload detection used by project/window close guards. Lane naming runs through the session-intelligence prompt path, retries the requested/configured/default title models, then falls back to a prompt slug with an optional temporary suffix for uniqueness. Tracks Codex Fast Mode (`codexFastMode: boolean`) per session and forwards it as `serviceTier: "fast" \| null` on every Codex `thread/start` and `turn/start` JSON-RPC call (see [Agent Routing](agent-routing.md#codex-service-tiers-fast-mode)). Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns call `stopTask` for active subagents before emitting stopped subagent results. Large orchestrator file. | | `apps/desktop/src/main/services/chat/runtimeEvents.ts` | Canonical cross-runtime event vocabulary (`turn.*`, `content.delta`, `tool.*`, `subagent.*`, teammate/task events, compaction boundaries) plus shims between legacy `AgentChatEvent` rows and the canonical runtime envelope. Claude emits canonical subagent events alongside the legacy rows while the other adapters migrate. | | `apps/ade-cli/src/tuiClient/` | Terminal **Work** chat TUI (Ink + React): same action/RPC contracts as desktop, **attached** (socket) or **embedded** (headless runtime via `ade-cli`). See [ADE Code](../ade-code/README.md). | | `apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts` | Main-process broker for the in-app web browser. Owns a single `persist:ade-browser` partition, multiple `WebContentsView` tabs (cap 10), bounds + visibility against the renderer-supplied frame, debugger-protocol attachment for inspect-mode hit tests, screenshot capture, and emission of `BuiltInBrowserContextItem`s for selected page elements. Window-open requests from a page are handled via `setWindowOpenHandler` returning `action: "allow"` + a `createWindow` factory: a new internal tab is created and its `webContents` is returned to Chromium so the popup keeps its real `window.opener` relationship with the opener tab (important for OAuth flows that postMessage back to the parent). The browser session runs with the standard Electron Chrome UA — no header rewriting — but its permission handlers allow a narrow set of Google sign-in capabilities (`hid`, `serial`, `usb`, `storage-access`, `top-level-storage-access`) for requests that originate at `accounts.google.com` so the Google flow's WebAuthn / storage-access probes do not bounce. All other permissions are denied. Backs the `ade.builtInBrowser.*` IPC surface and is consumed by both `ChatBuiltInBrowserPanel` (sidebar Browser tab) and `openExternal.ts` (links inside the renderer route through the built-in browser when the protocol is `http`/`https`/`about:blank`). | @@ -20,7 +20,7 @@ machinery layered on top. | `apps/desktop/src/main/services/chat/claudeInputPump.ts` | Async iterable input pump that feeds live user turns into the Claude Agent SDK `query()` stream. | | `apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts` | Tracks Claude SDK subprocesses and tears them down on runtime shutdown. | | `apps/desktop/src/main/services/chat/claudeOutputStyles.ts` | Discovers Claude output styles and plugins from project/user roots. Project roots are walked directly, while user-installed marketplace plugins are loaded only from Claude's installed-plugin registry when enabled in settings, so cache/source copies do not leak into ADE sessions. | -| `apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts` | Discovers project and user Claude slash surfaces by walking ancestor `.claude` roots, reading `.claude/commands/**/*.md`, `~/.claude/commands/**/*.md`, and `.claude/skills/*/SKILL.md` / `~/.claude/skills/*/SKILL.md` entries with command frontmatter. Consumed by `agentChatService` to enrich both the `chat.slashCommands` response and Claude system prompt with local command/skill metadata. | +| `apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts` | Discovers Claude-compatible command files plus Agent Skill entries. Command discovery walks ancestor and home `.claude/commands/**/*.md`; skill discovery uses `getAgentSkillRootCandidates()` so `.claude/skills`, `.agents/skills`, `.ade/skills`, `.codex/skills`, inherited env roots, and bundled ADE resources can surface `*/SKILL.md` command metadata. Consumed by `agentChatService` to enrich `chat.slashCommands` and provider prompt context with local command/skill metadata. | | `apps/desktop/src/main/services/chat/chatTextBatching.ts` | Batches streaming assistant text fragments (100 ms) before emission to reduce renderer re-renders. | | `apps/desktop/src/main/services/chat/sessionRecovery.ts` | Version-2 persisted-state reconstruction when sessions resume from disk. | | `apps/desktop/src/main/services/chat/cursorSdkPool.ts` | Cursor SDK adapter: spawns and pools `cursorSdkWorker.ts` Node workers per session, sends turns, brokers permission/hook callbacks, maps SDK events to chat events, and handles teardown. The connection envelope now carries `modelParams` (a list of `CursorSdkModelParameterValue`s — e.g. `{ id: "reasoning", value: "high" }`) so per-model variant parameters discovered through `cursorModelsDiscovery` flow into the SDK boot. | @@ -40,7 +40,7 @@ machinery layered on top. | `apps/desktop/src/shared/chatTranscript.ts` | Pure JSON-lines parser for `AgentChatEventEnvelope` values. Used by both the main process and the renderer. | | `apps/desktop/src/shared/chatSubagents.ts` | Cross-target subagent helpers: `buildSubagentPaneRows`, `selectedSubagentSnapshot`, `subagentIndexForPaneLine`, `subagentPaneSelectableLineOffsets`, `buildSubagentTranscriptEvents`, `isLifecycleEventForSnapshot`, plus the `latestPlan` derivation. Both the desktop `ChatSubagentsPanel` and the `apps/ade-cli/src/tuiClient/subagentPane.ts` / `chatInfo.ts` modules re-export from here so the desktop pane and the terminal TUI render the same roster, transcript filter, and plan summary. | | `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), permission modes, pending input, completion reports, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. | -| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Mounts `AgentQuestionModal` when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer when the sidebar's `attachChatSessionId` matches. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware ADE skill roots. Proof remains chat-scoped and stays on the chat header. | +| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Mounts `AgentQuestionModal` when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. Draft chats scope their last-launch config by project/lane/surface/draft-kind and mark local model/reasoning/permission edits as touched so late lane-session hydration cannot overwrite the user's draft selection. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer when the sidebar's `attachChatSessionId` matches. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware Agent Skill roots. Proof remains chat-scoped and stays on the chat header. | | `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. | | `apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx` | Git / PR quick-action toolbar above the composer. If the lane already has a linked PR, the PR button opens that PR; otherwise it routes to the PR workspace with a create-PR handoff (`create=1&sourceLaneId=&target=primary`). | | `apps/desktop/src/renderer/lib/visualContextFormatting.ts` | Serializes iOS, App Control, built-in browser, macOS VM, and attachment context into prompt text. Automatic macOS VM capability context is prompt-intent gated (`ADE VM`, `macOS VM`, Lume, isolated macOS GUI, etc.) so ordinary sends do not query or inject VM state. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 6e9a00e9a..2ef11d38c 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -11,7 +11,7 @@ stream plus session metadata. | Path | Role | |---|---| -| `AgentChatPane.tsx` | Top-level pane; IPC wiring, session state, presentation profile resolution, lane navigation, parallel launch orchestration, mounting of sub-panels and composer. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts so inactive-but-visible tiles stay current. | +| `AgentChatPane.tsx` | Top-level pane; IPC wiring, session state, presentation profile resolution, lane navigation, parallel launch orchestration, mounting of sub-panels and composer. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts so inactive-but-visible tiles stay current. Draft chats preserve user-touched model/reasoning/permission controls across late lane-session hydration. | | `AgentChatMessageList.tsx` | Virtualized message list (`@tanstack/react-virtual`). Renders transcript rows and turn dividers, and keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. | | `AgentChatComposer.tsx` | Text input, attachments, model selector, permission controls, slash commands, pending-input answering, and parallel model-slot controls. | | `ChatSurfaceShell.tsx` | Floating chat header, body, footer layout. Backdrop-blur glass-morphism styling. | @@ -104,16 +104,19 @@ and a footer that contains the composer. isn't connected. - **File attach picker** opened with the `@` key. Runs a debounced `ade.agentChat.fileSearch` and discards stale results. -- **Slash commands.** Local commands (`/clear`, `/login`) are always - available and resolved renderer-side. SDK commands and project/user - Claude commands discovered by `claudeSlashCommandDiscovery` merge in - through `ade.agentChat.slashCommands`; discovery walks ancestor - `.claude` roots and reads `.claude/commands`, `~/.claude/commands`, - `.claude/skills/*/SKILL.md`, and `~/.claude/skills/*/SKILL.md` command - metadata so both command files and local skills can appear in the - picker. Only `/clear` with `source: "local"` is intercepted client-side - — every other command is sent to the agent verbatim so provider-native - commands still flow. The composer also +- **Slash commands.** Local commands (`/clear`, `/login`) are available + where the current provider owns them and are resolved renderer-side. + SDK commands, Codex prompt files (`.codex/prompts/**/*.md`), Claude + command files (`.claude/commands/**/*.md`), and Agent Skill entries + from `.claude/skills`, `.agents/skills`, `.ade/skills`, + `.codex/skills`, inherited `ADE_AGENT_SKILLS_DIRS`, and bundled ADE + skill roots merge in through `ade.agentChat.slashCommands`. + Claude sessions use Claude SDK/runtime commands plus Claude-compatible + command/skill files; Codex, Droid, Cursor, and OpenCode also expose + the filesystem-backed prompt/skill list when their native runtimes do + not auto-list it. Only `/clear` with `source: "local"` is intercepted + client-side — every other command is sent to the agent verbatim so + provider-native commands still flow. The composer also decides whether a leading-slash draft is a command or just a sentence via `isProviderSlashCommandInput` (heuristics in `shared/chatSlashCommands.ts`): `"/rebase the lane?"` is treated as diff --git a/docs/features/computer-use/app-control.md b/docs/features/computer-use/app-control.md index e719dd9fb..2a6163d72 100644 --- a/docs/features/computer-use/app-control.md +++ b/docs/features/computer-use/app-control.md @@ -88,7 +88,7 @@ The companion **chat terminal** surface lives at `ade.terminal.*` and shares the `apps/ade-cli/src/bootstrap.ts` constructs an `AppControlService` for headless mode using the same `resolveLaneId` strategy as the desktop main process. -The agent guidance built by `apps/desktop/src/shared/adeCliGuidance.ts` tells agents to use socket-backed ADE CLI surfaces when live desktop state matters, to rely on the relevant bundled ADE skill for detailed App Control steps, and to register proof artifacts through `ade proof ...` after captures. +The agent guidance built by `apps/desktop/src/shared/adeCliGuidance.ts` tells agents to use socket-backed ADE CLI surfaces when live desktop state matters, to read the relevant Agent Skill for detailed App Control steps, and to register proof artifacts through `ade proof ...` after captures. ### Action registry diff --git a/docs/features/deeplinks/README.md b/docs/features/deeplinks/README.md index 923604c06..31b9e876d 100644 --- a/docs/features/deeplinks/README.md +++ b/docs/features/deeplinks/README.md @@ -90,6 +90,12 @@ ADE CLI — outbound + inbound: - `ade linear install` writes `~/.linear/coding-tools.json` so Linear's "Open issue in coding tool" dropdown can launch ADE. Backs up the previous file alongside. +- `apps/ade-cli/src/cli.ts` and `apps/ade-cli/src/adeRpcServer.ts` — + PR creation formatters/tools include the ADE HTTPS PR URL next to the + GitHub URL when the PR number and repo are known. Agents should use + that `adeUrl` in final handoffs; if a PR was adopted through another + path, `ade link pr --no-clipboard` mints the + same URL. - `apps/ade-cli/src/tuiClient/deeplinkRow.ts` — pure helper used by the TUI's `Ctrl+Y` keybinding. Resolves the focused row (lane / PR) to a canonical `ade://` URL, including parsing GitHub PR URLs to lift @@ -203,6 +209,13 @@ is first created, the footer initially carries the branch link only; once the PR number is known, a follow-up patch re-renders the block with the PR link included. +Agent closeout still includes explicit links. `ade prs create` and the +private `create_pr_from_lane` action return both `githubUrl` and +`adeUrl` when available, and the text formatter prints them as separate +rows. The GitHub PR body footer is automatic, but the final chat or TUI +handoff should still include the GitHub URL and the ADE HTTPS PR URL so +the user can jump directly to either GitHub or the ADE PRs tab. + ## Channel handling Only the **Stable** channel claims `ade://` as the OS-default handler. diff --git a/docs/features/ios-simulator/README.md b/docs/features/ios-simulator/README.md index eb5507ce9..2efc6b9eb 100644 --- a/docs/features/ios-simulator/README.md +++ b/docs/features/ios-simulator/README.md @@ -56,7 +56,7 @@ remote-localhost connection. | `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Mounts `ChatIosSimulatorPanel` behind a header toggle (`iosSimulatorAvailable`), brokers the screenshot-attachment + context-item flow into the composer, and gates the toggle on `iosSimulatorStatus.supported`. | | `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Renders `IosElementContextItem[]` as inline composer chips: switches to a contenteditable rich-input variant when the user has attached one or more iOS elements, serialises chip nodes back into the prompt on submit, and pairs each element with its captured screenshot when one was added in the same gesture. | | `apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx` | Includes iOS-element instance handling: `createIosContextInstanceId`, `getIosContextAttachmentPath`, and `formatIosElementContextForPrompt` (the prompt-side serialisation). | -| `apps/desktop/src/shared/adeCliGuidance.ts` | Points agents at bundled ADE skills and socket-backed live surfaces; the iOS simulator workflow is covered by the `ade-ios-simulator` skill and the `ade ios-sim` CLI group. | +| `apps/desktop/src/shared/adeCliGuidance.ts` | Points agents at Agent Skill roots and socket-backed live surfaces; the iOS simulator workflow is covered by the bundled `ade-ios-simulator` skill and the `ade ios-sim` CLI group. | | `apps/ade-cli/src/cli.ts` | `ade ios-sim` (aliased `ade ios`, `ade simulator`) subcommand: status / devices / apps / launch / shutdown / actions / screenshot / snapshot / inspector / inspect / preview-status / previews / preview-render / preview-open / window-start / live-start / preview-start / stream-status / stream-stop / select / tap / drag / swipe / type, with focused `ade help ios-sim ` pages for agent discovery. `live-start` requests the `auto` backend so the service picks `iosurface-indigo` first when available, then falls back through window/idb/simctl paths; `--backend` accepts every explicit backend value. | | `apps/ios/ADE/Debug/ADEInspectorKit/ADEInspectable.swift` | The Swift side: the `.adeInspectable("componentId", ...)` view modifier and the `.adeInspectorHost()` host modifier that publish per-frame element snapshots (component id, source file/line, accessibility identifier, point + pixel frames) into `Documents/ade-inspector-elements.json` inside the running app's data container. DEBUG-only — release builds compile to a no-op. | diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index e4e985b95..46b81995b 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -75,9 +75,10 @@ Renderer components: | File | Responsibility | |------|---------------| +| `renderer/components/app/App.tsx` | Project tab host and route keep-alive shell. Keeps the Work surface mounted after first visit and now does the same for `/lanes`, parking the inactive Lanes surface with `inert` / `aria-hidden` instead of unmounting it. During cold project switches it renders a transition veil over the old project surface until the target project hydrates. | | `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR. The pure selectors in `lanePageModel.ts` prefer live same-branch GitHub repo inventory over terminal/stale ADE rows, then fall back to ADE-linked PR rows, so externally created PRs and open-after-closed branch reuse stay visible; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. The page forces one GitHub snapshot refresh on project/branch-signature changes and otherwise uses cached snapshot/event refreshes to avoid repeated PR polling from the Lanes tab. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat/process buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` through `useAppStore().laneDeleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). On mount/project switch it hydrates active backend delete progress when available, but also keeps stored active delete progress long enough to move selection away and queue a refresh if the backend list is missing, stale, or temporarily failed. Batch deletes still run selected child lanes before their selected parents, but each batch deletes one lane at a time and records per-lane failures; a parent remains blocked if a selected descendant fails. Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures and non-fatal cleanup warnings through `laneActionError`. Work-tab action deeplinks scrub `action`, `laneId`, and `laneIds` after handling so modal routing cannot also rewrite split selection state. | | `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR matching, same-repo GitHub PR guardrails for fork branch-name collisions, ADE-vs-GitHub PR tag precedence, terminal-state GitHub overrides for stale ADE PR rows, deep-link lane selection, action-deeplink query cleanup, create-lane request normalization, delete-start selection fallback, parent-before-child-safe batch delete planning, and `runLaneDeleteBatchSequentially` for serialized per-batch teardown. | -| `renderer/state/appStore.ts` | Shared renderer project/lane state. Stores `laneDeleteProgressByLaneId` so in-flight lane deletion UI survives local `LanesPage` remounts and project metadata updates; the map clears only when the project root changes or the project is closed/reset. | +| `renderer/state/appStore.ts` | Shared renderer project/lane state. Stores `laneDeleteProgressByLaneId` so in-flight lane deletion UI survives local `LanesPage` remounts and project metadata updates; the map clears only when the project root changes or the project is closed/reset. Warm project-tab switches restore cached lanes/snapshots, lane selection, focused session, and loading state before the backend round trip finishes, and cache pruning retains Work/lane/session state for all open project tabs in addition to the active and recent projects. | | `renderer/components/lanes/laneUtils.ts` | Pure lane list/filter helpers plus default pane trees, including the work-focused tiling tree used by parallel chat launch deep links. | | `renderer/components/lanes/laneColorPalette.ts` | Curated lane color palette split into `LANE_CLASSIC_COLORS` and `LANE_RAINBOW_COLORS`, then combined as `LANE_COLOR_PALETTE`, plus helpers (`getLaneAccent`, `colorsInUse`, `nextAvailableColor`, `laneColorName`). The first 8 classic hexes form `LANE_FALLBACK_COLORS`, the legacy index-based fallback used for lanes that don't have an explicit color assigned. | | `renderer/components/lanes/LaneAccentDot.tsx` | Tiny accent dot used everywhere a lane is mentioned (lane list, tabs, PR rows, AppShell PR toasts). Resolves color via `getLaneAccent` so a lane without an explicit color falls back to a deterministic fallback hex. | @@ -87,6 +88,7 @@ Renderer components: | `renderer/components/lanes/LaneDiffPane.tsx` | Lane diff list + per-file stage/unstage/discard; file content uses shared `AdeDiffViewer` (commit comparisons read-only; working-tree file can be editable when unstaged) | | `renderer/components/lanes/LaneGitActionsPane.tsx` | Commit, stash, fetch, sync, push, recent commits. Stashing includes untracked files when the unstaged set contains untracked paths, and stash restore uses the ordinal `stash@{N}` ref returned by `git stash list`. After commit/stash operations it refreshes changes, lane git status, and git metadata while skipping snapshot decorations (`refreshLanes({ includeStatus: true, includeSnapshots: false })`). Seeds its `autoRebaseStatus` from the `autoRebaseStatusSnapshot` prop that `LanesPage` passes from the lane list (`laneSnapshot.autoRebaseStatus`), so opening a lane does not trigger a per-lane probe. A fallback `refreshAutoRebaseStatus` runs only when the snapshot is `undefined`, after a 3.5 s delay, and only while the document is visible. | | `renderer/components/lanes/LaneWorkPane.tsx` | Terminal/chat toggle work surface | +| `renderer/components/lanes/useLaneWorkSessions.ts` | Hook behind the lane Work pane's chat/session list. Tracks the latest lane id, project root, and scope key in refs so a refresh that was queued during a lane or project switch replays against the newest target and ignores stale rows from the old scope. | | `renderer/components/lanes/LaneRebaseBanner.tsx` | Inline banner driven by `rebaseSuggestionService` | | `renderer/components/lanes/LaneEnvInitProgress.tsx` | Env init step progress inside create dialog | | `renderer/components/lanes/CreateLaneDialog.tsx`, `AttachLaneDialog.tsx`, `MultiAttachWorktreeDialog.tsx`, `LaneDialogShell.tsx` | Lane creation / attach dialogs and shared dialog chrome. `LaneDialogShell` is viewport-centered (`top-1/2 -translate-y-1/2`), capped at `min(92dvh, calc(100vh-1rem))`, and renders a sticky header strip plus a single scrollable body — every lane modal (create, attach, multi-attach, manage) inherits this layout so long content scrolls instead of overflowing the dialog. The "import existing branch" path inside `CreateLaneDialog` swaps the dialog body for `BranchPickerView` when the user opens the picker; the "Connect Linear issue" affordance in the always-open Advanced section swaps it for `LinearIssuePickerView`. The dialog title/description/icon switch in lockstep with the active sub-view, and connecting a Linear issue auto-flips the create mode out of `existing` (the import-branch tab is locked while an issue is attached). | diff --git a/docs/features/project-home/README.md b/docs/features/project-home/README.md index 4c082285c..babf14873 100644 --- a/docs/features/project-home/README.md +++ b/docs/features/project-home/README.md @@ -64,6 +64,14 @@ Renderer: Related pages for the broader "home" experience: +- `apps/desktop/src/renderer/components/app/App.tsx` — project tab host, + project-scoped route keep-alive, and cold-switch transition veil. The + shell keeps Work and Lanes mounted after first visit so switching away + and back preserves warm renderer state. +- `apps/desktop/src/renderer/state/appStore.ts` — shared project-tab + state. Warm project switches restore cached project/lane snapshots and + lane selection immediately, and cache pruning retains Work/lane/session + state for every open project tab root. - `apps/desktop/src/renderer/components/app/AppShell.tsx` — top-level nav, routes `/run` to `RunPage`. - `apps/desktop/src/renderer/components/app/TabNav.tsx` — nav rail diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index 90f063b32..82a1a2e20 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -52,6 +52,14 @@ Services. The canonical implementations run inside the runtime daemon; the desktop main-process files below stay as fallback targets for the legacy in-process IPC path. +CLI and agent entry points: + +| File | Responsibility | +|------|---------------| +| `apps/ade-cli/src/cli.ts` | User-facing `ade prs` commands and text formatters. `ade prs create --text` prints both the GitHub PR URL and the ADE HTTPS PR URL when repo owner/name and PR number are available. | +| `apps/ade-cli/src/adeRpcServer.ts` | Private action/RPC wrapper for PR tools. `create_pr_from_lane` returns `{ pr, githubUrl, adeUrl }` so agents can include both links in closeout. | +| `apps/desktop/src/main/services/ai/tools/workflowTools.ts`, `ctoOperatorTools.ts` | Managed chat/CTO PR creation tools return both `githubUrl` and `adeUrl` alongside the PR object. | + Service files (`apps/desktop/src/main/services/prs/`): | File | Responsibility | diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index f10378292..fe93f2dfd 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -26,7 +26,11 @@ The wire transport is the same JSON-RPC the local daemon answers. The remote-run local or remote runtime is bound, while most other local-runtime actions may use guarded Electron IPC fallbacks for safe daemon failures. When `ADE_DISABLE_LOCAL_RUNTIME_DAEMON=1`, local-bound windows skip local runtime - actions and event polling and use guarded Electron IPC fallbacks. + actions and event polling and use guarded Electron IPC fallbacks. During a + project switch, preload records a pending local binding for the target root + and includes `rootPath` on local runtime action/sync/event calls so early + renderer requests hit the destination daemon project instead of the previous + window session binding. - `apps/ade-cli/src/multiProjectRpcServer.ts` — runtime-level project catalog and sync methods plus project-scoped action dispatch. - `apps/ade-cli/src/services/projects/` — machine project registry and diff --git a/docs/features/remote-runtime/internal-architecture.md b/docs/features/remote-runtime/internal-architecture.md index 93088c2b7..62475652d 100644 --- a/docs/features/remote-runtime/internal-architecture.md +++ b/docs/features/remote-runtime/internal-architecture.md @@ -78,7 +78,7 @@ The sync command registry labels descriptors as `runtime` or `project` scope. Pr ## Local daemon routing -Local desktop windows go through the runtime binding before falling back to legacy Electron-hosted handlers. `callProjectRuntimeActionOr` and `callProjectRuntimeSyncOr` in `apps/desktop/src/preload/preload.ts` try the runtime path first and fall back to the in-process IPC only on a safe local-runtime fallback error. File actions use the stricter `callProjectFileRuntimeActionOr`: remote runtime first, strict local runtime second, and legacy Electron IPC only when no runtime route exists. This prevents a failed runtime-bound file write/read from being retried against the desktop's local filesystem when the bound project is owned by a daemon or remote host. Usage and budget reads use the remote runtime only for remote-bound windows; local-bound windows keep using desktop usage IPC. The exception is `ADE_DISABLE_LOCAL_RUNTIME_DAEMON=1`: preload returns "not handled" for local runtime calls immediately, so local windows use the fallback handlers directly. +Local desktop windows go through the runtime binding before falling back to legacy Electron-hosted handlers. `callProjectRuntimeActionOr` and `callProjectRuntimeSyncOr` in `apps/desktop/src/preload/preload.ts` try the runtime path first and fall back to the in-process IPC only on a safe local-runtime fallback error. File actions use the stricter `callProjectFileRuntimeActionOr`: remote runtime first, strict local runtime second, and legacy Electron IPC only when no runtime route exists. This prevents a failed runtime-bound file write/read from being retried against the desktop's local filesystem when the bound project is owned by a daemon or remote host. Usage and budget reads use the remote runtime only for remote-bound windows; local-bound windows keep using desktop usage IPC. During `project.switchToPath`, preload temporarily binds local runtime calls to the requested root and main-process `runtimeBridge.ts` honors the explicit `rootPath` over the window session binding for local action, sync, and event-stream calls. That keeps early lane/chat/file reads from racing against the previous project while the backend switch is still pending. The exception is `ADE_DISABLE_LOCAL_RUNTIME_DAEMON=1`: preload returns "not handled" for local runtime calls immediately, so local windows use the fallback handlers directly. The runtime path covers: diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 1de4ded7f..83d188c61 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -168,7 +168,8 @@ Canonical files (`apps/ade-cli/src/services/sync/`): command when no project is open or when the caller did not bundle a matching `projectId` (see *Scope enforcement* below). Mobile / controller CLI launches resolve the target lane worktree before - building provider argv/env so ADE Agent Skills roots stay lane-aware. + building provider argv/env so Agent Skill roots and + `ADE_AGENT_SKILLS_DIRS` stay lane-aware. Lane reparent commands parse the optional `stackBaseBranchRef` override and forward it to the host lane service so controllers can pick a specific branch to stack onto instead of always using the diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 3ff760c4c..8b8b2a93e 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -253,8 +253,9 @@ A handful have more logic: shell command (the `shell` provider takes no startup payload at all). The host resolves the requested lane worktree before building that launch payload, so ADE guidance and `ADE_AGENT_SKILLS_DIRS` prefer - the lane's bundled `agent-skills` root instead of whichever project - root the daemon process happened to start from. + lane-local `.claude` / `.agents` / `.ade` / `.codex` skill dirs and + bundled ADE resources instead of whichever project root the daemon + process happened to start from. Claude launches mint a pre-assigned `--session-id` upfront via `randomUUID()` so continuation works as soon as the row exists. After `ptyService.create` returns, any `initialInput` diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 6a6c97648..7e9a70f16 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -296,7 +296,9 @@ Renderer surfaces: lane worktree when known: Claude gets `buildAdeCliAgentGuidance(...)` through `--append-system-prompt`, while every other provider receives a leading prompt from `buildAdeCliInlineGuidance(...)`. Launch env also - carries `ADE_AGENT_SKILLS_DIRS` when a bundled skills root is known. + carries `ADE_AGENT_SKILLS_DIRS` when skill roots are known, including + lane/user `.claude`, `.agents`, `.ade`, `.codex` skill dirs plus + bundled ADE resources. The legacy `buildTrackedCliStartupCommand` and `defaultTrackedCliStartupCommand` are now thin wrappers over `buildTrackedCliLaunchCommand` for @@ -345,10 +347,13 @@ Renderer surfaces: imports. - `apps/desktop/src/shared/adeCliGuidance.ts` — single source of truth for ADE session guidance injected into tracked CLI launches. Exposes - builders plus compatibility constants; callers pass lane-aware skill - roots so prompt text can point agents at the right bundled ADE skills. + builders plus the canonical bundled ADE skill list; callers pass + lane-aware skill roots so prompt text can point agents at the active + Agent Skills search path and explain the `/SKILL.md` + package shape. - `apps/desktop/src/shared/agentSkillRoots.ts` — resolves candidate - ADE Agent Skills roots from the active lane worktree, inherited + Agent Skill roots from the active lane worktree, ancestor and home + `.claude` / `.agents` / `.ade` / `.codex` directories, inherited `ADE_AGENT_SKILLS_DIRS`, packaged resources, and source fallbacks, then formats the prompt line / env var value. - `apps/desktop/src/renderer/components/terminals/workSurfaceVisibility.ts` diff --git a/docs/playbooks/ship-lane.md b/docs/playbooks/ship-lane.md index 4f54f2d35..1cd10c0c6 100644 --- a/docs/playbooks/ship-lane.md +++ b/docs/playbooks/ship-lane.md @@ -170,7 +170,7 @@ The `ade` surface evolves. Don't assume flag names or output shapes from this pl - **"PR already exists for lane"** → recover the existing PR number via `ade prs list --lane ` (or equivalent) and skip to Phase 0.4. - **Auth error** (token expired, permission denied) → exit with `status: blocked`, `exitReason: "ade-auth-failed"`. Do NOT silently fall back; surface to the user. - **Genuine internal error, reproducible after retry** → only now fall back. -6. **Capture the PR number.** The create command's output format varies (JSON, plain number, URL). If you can't extract it reliably, run `ade prs list --lane --json` as a cross-check. If ADE's output is opaque after reasonable effort, `gh pr view --json number -q .number` is a safe cross-check since the PR now exists on GitHub. +6. **Capture the PR number and links.** `ade prs create --text` prints separate GitHub URL and ADE URL rows when available; JSON output carries the same data as `githubUrl` and `adeUrl`. If the create output is missing the ADE URL but you have the repo and PR number, run `ade link pr --no-clipboard` to mint it. If you can't extract the PR number reliably, run `ade prs list --lane --json` as a cross-check. If ADE's output is opaque after reasonable effort, `gh pr view --json number -q .number` is a safe cross-check since the PR now exists on GitHub. Only after steps 1–6 have been genuinely attempted should the fallback run: