diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 5d9e58c25..20c33639b 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -354,6 +354,9 @@ ade --role cto github app-auth clear # remove the stored GitHub App ade open ade://lane/ ade open --linear-issue ADE-123 --branch arul/ade-123-fix ade link lane +ade link file src/index.ts --line 42 --lane +ade link commit abc1234 --lane --no-envelope +ade link artifact proof-artifact-id ade link branch owner/repo my-branch --pr 42 ade link pr owner/repo 42 --ade ade link linear-issue ADE-123 --branch arul/ade-123-fix diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 9c6cb94b6..41f1834b1 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1037,6 +1037,71 @@ describe("adeRpcServer", () => { expect(navigate).not.toHaveBeenCalled(); }); + it("rejects app/navigate file targets that are not repo-relative", async () => { + const { runtime } = createRuntime(); + const navigate = vi.fn(async () => ({ ok: true, mode: "desktop", windowId: 7 })); + runtime.appNavigationService = { navigate }; + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + await initialize(handler, { role: "cto" }); + + // Traversal, absolute paths, and drive letters must never reach the + // renderer's path composition — the RPC path bypasses parseDeeplink. + for (const path of ["../../.ssh/config", "/etc/passwd", "C:/windows/system32", "src/../../secret"]) { + await expect(handler({ + jsonrpc: "2.0", + id: 3, + method: "app/navigate", + params: { source: "ade-code", target: { kind: "file", path } }, + })).rejects.toMatchObject({ + code: JsonRpcErrorCode.invalidParams, + message: "app/navigate target 'file' requires a repo-relative path.", + }); + } + expect(navigate).not.toHaveBeenCalled(); + + // A valid repo-relative path still routes through. + const ok = await handler({ + jsonrpc: "2.0", + id: 4, + method: "app/navigate", + params: { source: "ade-code", target: { kind: "file", path: "src/app.ts", line: 3 } }, + }); + expect(ok).toBeTruthy(); + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith({ + source: "ade-code", + target: { kind: "file", path: "src/app.ts", line: 3 }, + }); + }); + + it("rejects app/navigate commit targets with malformed shas", async () => { + const { runtime } = createRuntime(); + const navigate = vi.fn(async () => ({ ok: true, mode: "desktop", windowId: 7 })); + runtime.appNavigationService = { navigate }; + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + await initialize(handler, { role: "cto" }); + + await expect(handler({ + jsonrpc: "2.0", + id: 5, + method: "app/navigate", + params: { source: "ade-code", target: { kind: "commit", sha: "not-a-sha" } }, + })).rejects.toMatchObject({ code: JsonRpcErrorCode.invalidParams }); + expect(navigate).not.toHaveBeenCalled(); + + const ok = await handler({ + jsonrpc: "2.0", + id: 6, + method: "app/navigate", + params: { source: "ade-code", target: { kind: "commit", sha: "ABC1234" } }, + }); + expect(ok).toBeTruthy(); + expect(navigate).toHaveBeenCalledWith({ + source: "ade-code", + target: { kind: "commit", sha: "abc1234" }, + }); + }); + it("treats requested privileged roles as external without trusted env identity", async () => { const { runtime } = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 1641d402a..51d3c28c6 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -23,7 +23,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 { buildDeeplink, isValidCommitSha, isValidRepoRelativePath } from "../../desktop/src/shared/deeplinks"; import { ADE_AGENT_SKILLS_DIRS_ENV, getAdeAgentSkillRootsForPrompt, @@ -4691,6 +4691,9 @@ async function readResource(runtime: AdeRuntime, uri: string): Promise= 0) normalizedTarget.event = target.event; + if (typeof target.offset === "number" && Number.isSafeInteger(target.offset) && target.offset >= 0) normalizedTarget.offset = target.offset; + } + if (kind === "file") { + // Same repo-relative rules as parseDeeplink: RPC callers must not be + // able to smuggle traversal/absolute paths past the URL parser. + const filePath = asOptionalTrimmedString(target.path) ?? ""; + if (!isValidRepoRelativePath(filePath)) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'file' requires a repo-relative path."); + } + normalizedTarget.path = filePath; + if (typeof target.line === "number" && Number.isSafeInteger(target.line) && target.line > 0) normalizedTarget.line = target.line; + } + if (kind === "commit") { + const sha = asOptionalTrimmedString(target.sha) ?? ""; + if (!isValidCommitSha(sha)) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, "app/navigate target 'commit' requires a 7-40 hex sha."); + } + normalizedTarget.sha = sha.toLowerCase(); + } + if (kind === "artifact") { + normalizedTarget.artifactId = asOptionalTrimmedString(target.artifactId); + } + if (kind === "work" || kind === "chat" || kind === "lane" || kind === "commit" || kind === "artifact") { + const envelope = safeObject(target.envelope); + const repoOwner = asOptionalTrimmedString(envelope.repoOwner); + const repoName = asOptionalTrimmedString(envelope.repoName); + const branch = asOptionalTrimmedString(envelope.branch); + const linearIssue = asOptionalTrimmedString(envelope.linearIssue); + const normalizedEnvelope: Record = {}; + if (repoOwner) normalizedEnvelope.repoOwner = repoOwner; + if (repoName) normalizedEnvelope.repoName = repoName; + if (branch) normalizedEnvelope.branch = branch; + if (typeof envelope.prNumber === "number" && Number.isSafeInteger(envelope.prNumber) && envelope.prNumber > 0) { + normalizedEnvelope.prNumber = envelope.prNumber; + } + if (linearIssue) normalizedEnvelope.linearIssue = linearIssue; + if (Object.keys(normalizedEnvelope).length > 0) normalizedTarget.envelope = normalizedEnvelope; + } if (kind === "pr") { const prId = asOptionalTrimmedString(target.prId); if (prId) normalizedTarget.prId = prId; diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index e8e7114d4..ad6f3b7e4 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -462,8 +462,24 @@ export async function createAdeRuntime(args: { const searchServiceHolder: { current: SearchService | null } = { current: null }; let linearIssueTrackerRef: ReturnType | null = null; let githubServiceRef: ReturnType | null = null; + let laneServiceRef: ReturnType | null = null; + let prServiceRef: ReturnType | null = null; const publishLinearChatLink = createLinearChatLinkPublisher({ getIssueTracker: () => linearIssueTrackerRef, + resolveEnvelope: async ({ laneId }) => { + const repo = await githubServiceRef?.getRepoOrThrow().catch(() => null); + if (!repo) return null; + const lanes = await laneServiceRef?.list({ includeArchived: false, includeStatus: false }).catch(() => []); + const lane = lanes?.find((candidate) => candidate.id === laneId) ?? null; + const branch = lane?.branchRef?.replace(/^refs\/heads\//, "") ?? null; + const pr = prServiceRef?.getForLane(laneId) ?? null; + return { + repoOwner: repo.owner, + repoName: repo.name, + branch, + prNumber: pr?.githubPrNumber ?? null, + }; + }, log: (event, fields) => logger.warn(event, fields), }); const laneTeardownDeps: LaneDeleteTeardownDeps = {}; @@ -505,6 +521,7 @@ export async function createAdeRuntime(args: { linkedAt, repoOwner: repo?.owner ?? null, repoName: repo?.name ?? null, + prNumber: prServiceRef?.getForLane(lane.id)?.githubPrNumber ?? null, postInitialComment: true, log: (event, fields) => logger.warn(event, fields), })) @@ -521,6 +538,7 @@ export async function createAdeRuntime(args: { teardownDeps: laneTeardownDeps, logger, }); + laneServiceRef = laneService; await laneService.ensurePrimaryLane(); const sessionService = createSessionService({ db }); @@ -927,6 +945,7 @@ export async function createAdeRuntime(args: { }); linearIssueTrackerRef = headlessLinearServices.linearIssueTracker; githubServiceRef = headlessLinearServices.githubService as ReturnType; + prServiceRef = headlessLinearServices.prService; laneTeardownDeps.fileWatcherService = { countActiveForWorkspace: (id) => headlessLinearServices.fileService.countActiveWatchersForWorkspace(id), stopAllForWorkspace: (id) => headlessLinearServices.fileService.stopAllWatchersForWorkspace(id), @@ -1385,6 +1404,10 @@ export async function createAdeRuntime(args: { agentChatService, prService: headlessLinearServices.prService ?? null, gitService, + repoSlug: async () => { + const status = await headlessLinearServices.githubService.getRemoteStatus().catch(() => ({ repo: null })); + return status.repo ?? null; + }, fileService: headlessLinearServices.fileService ?? null, artifactBroker: computerUseArtifactBrokerService, linearIssueTracker: headlessLinearServices.linearIssueTracker ?? null, diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index f7aa8bf5c..b899af6c7 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -15,13 +15,14 @@ import { } from "./cursorCloud"; import { CliDeeplinkUsageError, - runDeeplinkCommand, + runDeeplinkCommandAsync, + type LinkEnvelopeContext, } from "./commands/deeplinks"; import { CliSkillUsageError, runSkillCommand, } from "./commands/skill"; -import { buildDeeplink } from "../../desktop/src/shared/deeplinks"; +import { buildDeeplink, type DeeplinkEnvelope } from "../../desktop/src/shared/deeplinks"; import { SEARCH_DOC_KINDS } from "../../desktop/src/shared/types/search"; import { deriveDeterministicLaneNameFromPrompt } from "../../desktop/src/shared/laneNameFallback"; import { @@ -467,7 +468,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade new chat --mode chat|cli --prompt "fix" Start an ADE Work chat or tracked CLI session $ ade desktop Launch the installed desktop app $ ade open Open an ade:// or ade-app.dev deeplink via the OS - $ ade link lane | session | branch | pr | linear-issue + $ ade link lane | session | file | commit | artifact | branch | pr | linear-issue Build a shareable deeplink (copies to clipboard) $ ade linear install Register ADE as Linear's "Open in coding tool" target $ ade skill list | show Browse ADE's bundled agent skills (local) @@ -1068,11 +1069,14 @@ const HELP_BY_COMMAND: Record = { link: `${ADE_BANNER} ADE Link - Build a shareable deeplink URL for a lane, Work session, branch, PR, or Linear issue. + Build a shareable deeplink URL for a lane, Work session, file, commit, artifact, branch, PR, or Linear issue. The URL is printed and (unless --no-clipboard) copied to the clipboard. $ ade link lane $ ade link session [--lane ] + $ ade link file [--line ] [--lane ] + $ ade link commit [--lane ] + $ ade link artifact $ ade link branch [--pr ] $ ade link pr $ ade link linear-issue [--branch ] @@ -1080,6 +1084,7 @@ const HELP_BY_COMMAND: Record = { Flags: --ade Emit the custom "ade://" form. Defaults to the https mirror. + --no-envelope Skip best-effort repo/branch/PR envelope lookup. --no-clipboard Print the URL but do not copy it to the system clipboard. `, skill: `${ADE_BANNER} @@ -16157,6 +16162,81 @@ async function runGithubAppLogin( } } +function createLinkEnvelopeResolver( + options: GlobalOptions, +): (context: LinkEnvelopeContext) => Promise { + const action = async ( + connection: CliConnection, + domain: string, + name: string, + args: JsonObject = {}, + ): Promise => { + const raw = await connection.request("ade/actions/call", { + name: "run_ade_action", + arguments: { domain, action: name, args }, + }); + return unwrapActionEnvelope(unwrapToolResult(raw)); + }; + + const directAction = async ( + connection: CliConnection, + name: string, + args: JsonObject = {}, + ): Promise => { + const raw = await connection.request("ade/actions/call", { name, arguments: args }); + return unwrapToolResult(raw); + }; + + const records = (value: unknown, keys: string[]): Record[] => { + const unwrapped = unwrapActionEnvelope(value); + if (Array.isArray(unwrapped)) return unwrapped.filter(isRecord); + if (!isRecord(unwrapped)) return []; + for (const key of keys) { + const nested = unwrapped[key]; + if (Array.isArray(nested)) return nested.filter(isRecord); + } + return []; + }; + + return async (context) => { + let connection: CliConnection | null = null; + try { + connection = await createConnection( + { ...options, headless: false, requireSocket: true }, + { autoRegisterProject: false }, + ); + const lanesValue = await directAction(connection, "list_lanes", { includeArchived: false }).catch(() => null); + const lane = records(lanesValue, ["lanes", "items", "result"]) + .find((candidate) => asString(candidate.id) === context.laneId) ?? null; + const githubValue = await action(connection, "github", "getRemoteStatus").catch(() => null); + const repo = isRecord(githubValue) && isRecord(githubValue.repo) ? githubValue.repo : null; + const prsValue = await action(connection, "pr", "listAll", { laneId: context.laneId }).catch(() => null); + const pr = records(prsValue, ["prs", "items", "result"]) + .find((candidate) => asString(candidate.laneId) === context.laneId) ?? null; + + const branch = asString(lane?.branchRef)?.replace(/^refs\/heads\//, "") ?? null; + const laneIssue = isRecord(lane?.linearIssue) ? lane.linearIssue : null; + const linearIssue = asString(laneIssue?.identifier); + const prNumber = typeof pr?.githubPrNumber === "number" && Number.isSafeInteger(pr.githubPrNumber) + ? pr.githubPrNumber + : null; + const envelope: DeeplinkEnvelope = {}; + const owner = asString(repo?.owner); + const name = asString(repo?.name); + if (owner) envelope.repoOwner = owner; + if (name) envelope.repoName = name; + if (branch) envelope.branch = branch; + if (prNumber && prNumber > 0) envelope.prNumber = prNumber; + if (linearIssue) envelope.linearIssue = linearIssue; + return Object.keys(envelope).length > 0 ? envelope : null; + } catch { + return null; + } finally { + if (connection) await Promise.resolve(connection.close()).catch(() => undefined); + } + }; +} + async function executePlan( plan: CliPlan & { kind: "execute" }, options: GlobalOptions, @@ -16338,7 +16418,9 @@ async function runCli( } if (plan.kind === "deeplink") { try { - const result = runDeeplinkCommand(plan.rest); + const result = await runDeeplinkCommandAsync(plan.rest, { + resolveEnvelope: createLinkEnvelopeResolver(parsed.options), + }); return { output: result.output, exitCode: result.exitCode }; } catch (error) { if (error instanceof CliDeeplinkUsageError) { diff --git a/apps/ade-cli/src/commands/deeplinks.test.ts b/apps/ade-cli/src/commands/deeplinks.test.ts index 37e51fe12..e02bf5289 100644 --- a/apps/ade-cli/src/commands/deeplinks.test.ts +++ b/apps/ade-cli/src/commands/deeplinks.test.ts @@ -1,11 +1,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CliDeeplinkUsageError, runDeeplinkCommand, + runDeeplinkCommandAsync, runLinearInstall, runLinkCommand, runOpenCommand, @@ -33,6 +34,56 @@ describe("ade link", () => { expect(r.output).toContain(`lane=${UUID}`); }); + it("emits file, commit, and artifact links", () => { + const file = runLinkCommand(["file", "src/index.ts", "--line", "12", "--lane", UUID, "--ade", "--no-clipboard"]); + expect(file.output).toContain(`ade://file/src/index.ts?line=12&lane=${UUID}`); + + const commit = runLinkCommand(["commit", "abc1234", "--lane", UUID, "--ade", "--no-clipboard"]); + expect(commit.output).toContain(`ade://commit/abc1234?lane=${UUID}`); + + const artifact = runLinkCommand(["artifact", "proof-123", "--ade", "--no-clipboard"]); + expect(artifact.output).toContain("ade://artifact/proof-123"); + }); + + it("adds envelopes through the async link command", async () => { + const r = await runDeeplinkCommandAsync(["link", "commit", "abc1234", "--lane", UUID, "--ade", "--no-clipboard"], { + resolveEnvelope: async () => ({ + repoOwner: "owner", + repoName: "repo", + branch: "feat", + prNumber: 42, + }), + }); + expect(r.output).toContain("ade://commit/abc1234?"); + expect(r.output).toContain("repo=owner%2Frepo"); + expect(r.output).toContain("branch=feat"); + expect(r.output).toContain("pr=42"); + }); + + it("skips async envelope lookup when --no-envelope is set", async () => { + const resolveEnvelope = vi.fn(async () => ({ repoOwner: "owner", repoName: "repo" })); + const r = await runDeeplinkCommandAsync( + ["link", "lane", UUID, "--ade", "--no-envelope", "--no-clipboard"], + { resolveEnvelope }, + ); + expect(resolveEnvelope).not.toHaveBeenCalled(); + expect(r.output).toContain(`ade://lane/${UUID}`); + expect(r.output).not.toContain("repo="); + }); + + it("refuses to mint links the shared parser rejects", () => { + // Traversal file paths, absolute paths, and malformed shas must be + // rejected up front — the ade:// path form URL-normalizes dot segments, + // so `--ade` would otherwise silently mint a link to a DIFFERENT + // in-repo path instead of failing. + for (const form of [[], ["--ade"]]) { + expect(() => runLinkCommand(["file", "../secret", ...form, "--no-clipboard"])).toThrow(/repo-relative|invalid link/); + expect(() => runLinkCommand(["file", "/etc/passwd", ...form, "--no-clipboard"])).toThrow(/repo-relative|invalid link/); + expect(() => runLinkCommand(["file", "src/../../x", ...form, "--no-clipboard"])).toThrow(/repo-relative|invalid link/); + } + expect(() => runLinkCommand(["commit", "not-a-sha", "--no-clipboard"])).toThrow(/invalid link|sha/); + }); + it("emits a branch link", () => { const r = runLinkCommand(["branch", "a/b", "feat", "--no-clipboard"]); expect(r.output).toContain("https://ade-app.dev/open?type=branch"); diff --git a/apps/ade-cli/src/commands/deeplinks.ts b/apps/ade-cli/src/commands/deeplinks.ts index e48127ce6..5762049fc 100644 --- a/apps/ade-cli/src/commands/deeplinks.ts +++ b/apps/ade-cli/src/commands/deeplinks.ts @@ -15,7 +15,9 @@ import { ADE_DEEPLINK_HTTPS_PATH, buildDeeplink, isAdeDeeplinkHttpsHost, + isValidRepoRelativePath, parseDeeplink, + type DeeplinkEnvelope, type DeeplinkTarget, } from "../../../desktop/src/shared/deeplinks"; import { copyToClipboard } from "../lib/clipboard"; @@ -27,6 +29,17 @@ export type DeeplinkCliResult = { exitCode: number; }; +export type LinkEnvelopeContext = { + targetKind: "lane" | "session" | "commit"; + laneId: string; +}; + +export type DeeplinkCommandOptions = { + resolveEnvelope?: ( + context: LinkEnvelopeContext, + ) => DeeplinkEnvelope | null | Promise; +}; + const HELP_OPEN = [ "Usage:", " ade open ", @@ -44,6 +57,9 @@ const HELP_LINK = [ "Usage:", " ade link lane ", " ade link session [--lane ]", + " ade link file [--line ] [--lane ]", + " ade link commit [--lane ]", + " ade link artifact ", " ade link branch [--pr ]", " ade link pr ", " ade link linear-issue [--branch ]", @@ -51,6 +67,7 @@ const HELP_LINK = [ "", "Options:", " --ade Emit the custom `ade://` form (default: https)", + " --no-envelope Skip best-effort repo/branch/PR envelope lookup", " --no-clipboard Print the URL but don't copy to clipboard", ].join("\n"); @@ -80,6 +97,28 @@ export function runDeeplinkCommand(rest: string[]): DeeplinkCliResult { } } +export async function runDeeplinkCommandAsync( + rest: string[], + options: DeeplinkCommandOptions = {}, +): Promise { + if (rest.length === 0) { + return { output: `${HELP_OPEN}\n${HELP_LINK}\n${HELP_LINEAR}\n`, exitCode: 0 }; + } + const [verb, ...verbArgs] = rest; + switch (verb) { + case "open": + return runOpenCommand(verbArgs); + case "link": + return runLinkCommandAsync(verbArgs, options); + case "linear": + return runLinearCommand(verbArgs); + default: + throw new CliDeeplinkUsageError( + `Unknown deeplink subcommand: ${verb}. Try 'ade open ', 'ade link ...', or 'ade linear install'.`, + ); + } +} + // --------------------------------------------------------------------------- // ade open // --------------------------------------------------------------------------- @@ -185,23 +224,63 @@ function looksLikeAdeOpenUrl(rawUrl: string): boolean { // --------------------------------------------------------------------------- export function runLinkCommand(args: string[]): DeeplinkCliResult { + const plan = buildLinkPlan(args); + if ("result" in plan) return plan.result; + return finishLink(buildDeeplink(plan.target, { form: plan.form }), plan.skipClipboard); +} + +export async function runLinkCommandAsync( + args: string[], + options: DeeplinkCommandOptions = {}, +): Promise { + const plan = buildLinkPlan(args); + if ("result" in plan) return plan.result; + let target = plan.target; + if (!plan.noEnvelope && plan.envelopeContext && options.resolveEnvelope) { + const envelope = await Promise.resolve(options.resolveEnvelope(plan.envelopeContext)).catch(() => null); + if (envelope) { + if (target.kind === "lane") target = { ...target, envelope }; + if (target.kind === "session") target = { ...target, envelope }; + if (target.kind === "commit") target = { ...target, envelope }; + } + } + return finishLink(buildDeeplink(target, { form: plan.form }), plan.skipClipboard); +} + +type LinkPlan = + | { result: DeeplinkCliResult } + | { + target: DeeplinkTarget; + form: "ade" | "https"; + skipClipboard: boolean; + noEnvelope: boolean; + envelopeContext?: LinkEnvelopeContext; + }; + +function buildLinkPlan(args: string[]): LinkPlan { if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { - return { output: `${HELP_LINK}\n`, exitCode: 0 }; + return { result: { output: `${HELP_LINK}\n`, exitCode: 0 } }; } const flags = extractFlags(args, { - booleans: ["ade", "no-clipboard"], - valued: ["pr", "branch", "lane"], + booleans: ["ade", "no-envelope", "no-clipboard"], + valued: ["pr", "branch", "lane", "line"], }); const positional = flags.positional; const form = flags.booleans.has("ade") ? "ade" : "https"; const skipClipboard = flags.booleans.has("no-clipboard"); - const emit = (target: DeeplinkTarget): DeeplinkCliResult => - finishLink(buildDeeplink(target, { form }), skipClipboard); + const noEnvelope = flags.booleans.has("no-envelope"); + const plan = (target: DeeplinkTarget, envelopeContext?: LinkEnvelopeContext): LinkPlan => ({ + target, + form, + skipClipboard, + noEnvelope, + ...(envelopeContext ? { envelopeContext } : {}), + }); // `ade link ` — accept a deeplink and re-emit it in the chosen form. if (positional.length === 1) { const parsed = parseDeeplink(positional[0]); - if (parsed.ok) return emit(parsed.target); + if (parsed.ok) return plan(parsed.target); } const verb = positional[0]; @@ -210,7 +289,7 @@ export function runLinkCommand(args: string[]): DeeplinkCliResult { if (!laneId) { throw new CliDeeplinkUsageError("ade link lane "); } - return emit({ kind: "lane", laneId }); + return plan({ kind: "lane", laneId }, { targetKind: "lane", laneId }); } if (verb === "session") { const sessionId = positional[1]; @@ -218,9 +297,49 @@ export function runLinkCommand(args: string[]): DeeplinkCliResult { throw new CliDeeplinkUsageError("ade link session [--lane ]"); } const laneId = flags.valued.get("lane"); - return emit(laneId - ? { kind: "session", sessionId, laneId } - : { kind: "session", sessionId }); + return plan( + laneId ? { kind: "session", sessionId, laneId } : { kind: "session", sessionId }, + laneId ? { targetKind: "session", laneId } : undefined, + ); + } + if (verb === "file") { + const filePath = positional[1]; + if (!filePath) { + throw new CliDeeplinkUsageError("ade link file [--line ] [--lane ]"); + } + // Validate BEFORE building: the ade:// path form URL-normalizes dot + // segments, so a post-build round-trip would silently accept ../secret + // as a link to a different in-repo path. + if (!isValidRepoRelativePath(filePath)) { + throw new CliDeeplinkUsageError("ade link file requires a repo-relative path (no leading /, no .. segments)"); + } + const lineRaw = flags.valued.get("line"); + const line = lineRaw != null ? parsePositiveInteger(lineRaw, "--line") : undefined; + const laneId = flags.valued.get("lane"); + return plan({ + kind: "file", + path: filePath, + ...(line != null ? { line } : {}), + ...(laneId ? { laneId } : {}), + }); + } + if (verb === "commit") { + const sha = positional[1]; + if (!sha) { + throw new CliDeeplinkUsageError("ade link commit [--lane ]"); + } + const laneId = flags.valued.get("lane"); + return plan( + { kind: "commit", sha, ...(laneId ? { laneId } : {}) }, + laneId ? { targetKind: "commit", laneId } : undefined, + ); + } + if (verb === "artifact") { + const artifactId = positional[1]; + if (!artifactId) { + throw new CliDeeplinkUsageError("ade link artifact "); + } + return plan({ kind: "artifact", artifactId }); } if (verb === "branch") { const repo = positional[1]; @@ -233,7 +352,7 @@ export function runLinkCommand(args: string[]): DeeplinkCliResult { const { repoOwner, repoName } = parseRepoSlug(repo); const prNumberRaw = flags.valued.get("pr"); const prNumber = prNumberRaw != null ? parsePositiveInteger(prNumberRaw, "--pr") : undefined; - return emit(prNumber != null + return plan(prNumber != null ? { kind: "branch", repoOwner, repoName, branch, prNumber } : { kind: "branch", repoOwner, repoName, branch }); } @@ -245,7 +364,7 @@ export function runLinkCommand(args: string[]): DeeplinkCliResult { } const { repoOwner, repoName } = parseRepoSlug(repo); const prNumber = parsePositiveInteger(numberRaw, "PR number"); - return emit({ kind: "pr", repoOwner, repoName, prNumber }); + return plan({ kind: "pr", repoOwner, repoName, prNumber }); } if (verb === "linear-issue") { const issueIdentifier = positional[1]; @@ -253,7 +372,7 @@ export function runLinkCommand(args: string[]): DeeplinkCliResult { throw new CliDeeplinkUsageError("ade link linear-issue [--branch ]"); } const branchHint = flags.valued.get("branch"); - return emit(branchHint + return plan(branchHint ? { kind: "linear-issue", issueIdentifier, branch: branchHint } : { kind: "linear-issue", issueIdentifier }); } @@ -282,6 +401,13 @@ function parsePositiveInteger(value: string, label: string): number { } function finishLink(url: string, skipClipboard: boolean): DeeplinkCliResult { + // Round-trip gate: never print/copy a link the shared parser would reject + // (e.g. `ade link file ../secret` or a malformed commit sha). + const roundTrip = parseDeeplink(url); + if (!roundTrip.ok) { + const reason = "reason" in roundTrip.error ? roundTrip.error.reason : roundTrip.error.kind; + throw new CliDeeplinkUsageError(`refusing to mint an invalid link (${reason}): ${url}`); + } let clipboardNote = ""; if (!skipClipboard) { if (copyToClipboard(url)) { diff --git a/apps/ade-cli/src/tuiClient/deeplinkRow.ts b/apps/ade-cli/src/tuiClient/deeplinkRow.ts index 70e2290c7..615f402de 100644 --- a/apps/ade-cli/src/tuiClient/deeplinkRow.ts +++ b/apps/ade-cli/src/tuiClient/deeplinkRow.ts @@ -6,13 +6,21 @@ // unit-test the dispatch path without rendering the whole app. // --------------------------------------------------------------------------- -import { buildDeeplink, type DeeplinkTarget } from "../../../desktop/src/shared/deeplinks"; +import { buildDeeplink, type DeeplinkEnvelope, type DeeplinkTarget } from "../../../desktop/src/shared/deeplinks"; /** * Minimal lane shape needed to build a lane deeplink. Subset of `LaneSummary` * so tests can construct fixtures without pulling the full type. */ -export type DeeplinkLaneRow = { id: string }; +export type DeeplinkLaneRow = { + id: string; + repoOwner?: string | null; + repoName?: string | null; + branchRef?: string | null; + branch?: string | null; + prNumber?: number | null; + linearIssue?: { identifier?: string | null } | null; +}; /** * Minimal PR shape needed to build a PR deeplink. We accept either an explicit @@ -61,7 +69,19 @@ export function buildDeeplinkForRow(row: DeeplinkRow): string | null { if (row.kind === "lane") { if (!row.lane.id) return null; - const target: DeeplinkTarget = { kind: "lane", laneId: row.lane.id }; + const branch = (row.lane.branch ?? row.lane.branchRef ?? "").replace(/^refs\/heads\//, ""); + const envelope: DeeplinkEnvelope | undefined = row.lane.repoOwner && row.lane.repoName + ? { + repoOwner: row.lane.repoOwner, + repoName: row.lane.repoName, + ...(branch ? { branch } : {}), + ...(row.lane.prNumber ? { prNumber: row.lane.prNumber } : {}), + ...(row.lane.linearIssue?.identifier ? { linearIssue: row.lane.linearIssue.identifier } : {}), + } + : undefined; + // Most TUI lane rows only carry the lane id; when richer repo/branch fields + // are absent, keep Ctrl+Y local-only rather than doing hidden lookups here. + const target: DeeplinkTarget = { kind: "lane", laneId: row.lane.id, ...(envelope ? { envelope } : {}) }; return buildDeeplink(target, { form: "ade" }); } const pr = row.pr; diff --git a/apps/desktop/resources/ade-cli-help.txt b/apps/desktop/resources/ade-cli-help.txt index 05abcf468..7508f87d9 100644 --- a/apps/desktop/resources/ade-cli-help.txt +++ b/apps/desktop/resources/ade-cli-help.txt @@ -20,7 +20,7 @@ _ ____ _____ $ ade new chat --mode chat|cli --prompt "fix" Start an ADE Work chat or tracked CLI session $ ade desktop Launch the installed desktop app $ ade open Open an ade:// or ade-app.dev deeplink via the OS - $ ade link lane | session | branch | pr | linear-issue + $ ade link lane | session | file | commit | artifact | branch | pr | linear-issue Build a shareable deeplink (copies to clipboard) $ ade linear install Register ADE as Linear's "Open in coding tool" target $ ade skill list | show Browse ADE's bundled agent skills (local) @@ -126,7 +126,7 @@ _ ____ _____ $ ade new chat --mode chat|cli --prompt "fix" Start an ADE Work chat or tracked CLI session $ ade desktop Launch the installed desktop app $ ade open Open an ade:// or ade-app.dev deeplink via the OS - $ ade link lane | session | branch | pr | linear-issue + $ ade link lane | session | file | commit | artifact | branch | pr | linear-issue Build a shareable deeplink (copies to clipboard) $ ade linear install Register ADE as Linear's "Open in coding tool" target $ ade skill list | show Browse ADE's bundled agent skills (local) @@ -232,7 +232,7 @@ _ ____ _____ $ ade new chat --mode chat|cli --prompt "fix" Start an ADE Work chat or tracked CLI session $ ade desktop Launch the installed desktop app $ ade open Open an ade:// or ade-app.dev deeplink via the OS - $ ade link lane | session | branch | pr | linear-issue + $ ade link lane | session | file | commit | artifact | branch | pr | linear-issue Build a shareable deeplink (copies to clipboard) $ ade linear install Register ADE as Linear's "Open in coding tool" target $ ade skill list | show Browse ADE's bundled agent skills (local) @@ -1158,7 +1158,7 @@ _ ____ _____ $ ade new chat --mode chat|cli --prompt "fix" Start an ADE Work chat or tracked CLI session $ ade desktop Launch the installed desktop app $ ade open Open an ade:// or ade-app.dev deeplink via the OS - $ ade link lane | session | branch | pr | linear-issue + $ ade link lane | session | file | commit | artifact | branch | pr | linear-issue Build a shareable deeplink (copies to clipboard) $ ade linear install Register ADE as Linear's "Open in coding tool" target $ ade skill list | show Browse ADE's bundled agent skills (local) diff --git a/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md b/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md index 4e60fa241..ca417e4f1 100644 --- a/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-deeplinks/SKILL.md @@ -1,6 +1,6 @@ --- name: ade-deeplinks -description: Use this skill when an agent needs to mint, share, or open ADE deeplinks (lane, work session, branch, PR, Linear issue) so users — or the agent itself — can jump straight to a specific ADE surface from anywhere (GitHub PR description, Linear issue, Slack, email, terminal, mobile). +description: Use this skill when an agent needs to mint, share, or open ADE deeplinks (lane, work session, file, commit, artifact, branch, PR, Linear issue) so users — or the agent itself — can jump straight to a specific ADE surface from anywhere (GitHub PR description, Linear issue, Slack, email, terminal, mobile). --- # ADE deeplinks @@ -12,13 +12,19 @@ identical semantics: ``` ade://lane/ # local-only — focuses an existing lane -ade://session/[?lane=] # local-only — opens a Work session +ade://session/[?lane=&event=&offset=] # Work session + anchors +ade://file/[?line=&lane=] # Files tab +ade://commit/[?lane=] # Lanes git detail, or GitHub fallback with envelope +ade://artifact/ # local proof/history artifact ade://repo///branch/ # cross-machine — find or offer-to-create lane ade://pr/// # PR detail view ade://linear-issue/[?branch=] # Linear handoff — opens the Linear pane https://ade-app.dev/open?type=lane&id= https://ade-app.dev/open?type=session&id=[&lane=] +https://ade-app.dev/open?type=file&path=[&line=&lane=] +https://ade-app.dev/open?type=commit&sha=[&lane=] +https://ade-app.dev/open?type=artifact&id= https://ade-app.dev/open?type=branch&repo=&branch=[&pr=] https://ade-app.dev/open?type=pr&repo=&number= https://ade-app.dev/open?type=linear-issue&issue=[&branch=] @@ -29,8 +35,18 @@ OpenGraph card in Slack/Discord/iMessage/Gmail/Linear). The web landing page tries the `ade://` upgrade and falls back to an install card. Both forms parse to the same target shape. -**Lane links are local** — the UUID is meaningful only on the machine that -created the lane. **Branch links are portable** — they fetch/import the remote +**Lane, session, commit, and artifact ids are local** — the UUID/id is +meaningful only on the machine that created it. ADE can attach an envelope to +local links as query params: + +``` +repo=/&branch=&pr=&linear= +``` + +The envelope does not change the primary target; it gives receivers a portable +fallback chain. If the local id is unknown, ADE can offer to switch to the +matching project, create/open the branch or PR, open the Linear issue, or open a +commit on GitHub. **Branch links are portable** — they fetch/import the remote branch as a lane if the receiver does not have it yet. **Linear-issue links are portable** — they open ADE's Linear pane for the issue and show setup if the active project has not connected Linear. @@ -41,6 +57,10 @@ active project has not connected Linear. | ------------------------------------------------------------- | ------------------------------------ | | Jump back to MY lane from another terminal on the same Mac | `ade://lane/` | | Jump back to a Work session on this Mac | `ade://session/` | +| Jump to a chat event or terminal scrollback byte offset | `ade://session/?event=` / `?offset=` | +| Open a file and reveal a line | `ade://file/src/app.ts?line=42` | +| Open a commit from a local lane or GitHub fallback | `ade://commit/?lane=&repo=owner/repo` | +| Open a local proof artifact/history entry | `ade://artifact/` | | Share a branch with a teammate or your other devices | `https://ade-app.dev/open?type=branch&…` | | Drop into a PR's detail tab | `https://ade-app.dev/open?type=pr&…` | | Linear "Open in coding tool" hand-off (opens Linear pane) | `https://ade-app.dev/open?type=linear-issue&…` | @@ -50,6 +70,9 @@ active project has not connected Linear. ```bash ade link lane # local lane link ade link session [--lane ] # local Work session link +ade link file [--line N] [--lane ] # file link +ade link commit [--lane ] # commit link +ade link artifact # proof/history artifact ade link branch [--pr ] # cross-machine branch ade link pr # PR detail ade link linear-issue [--branch ] # Linear hand-off @@ -57,11 +80,21 @@ ade link # round-trip an exist # Flags --ade # emit ade:// instead of https:// (default: https) +--no-envelope # skip best-effort repo/branch/PR envelope lookup --no-clipboard # print without copying ``` Every form copies to the clipboard by default and prints the URL to stdout. -Use `--no-clipboard` in scripts. +When a live ADE runtime is bound, `ade link lane`, `session --lane`, and +`commit --lane` attach repo/branch/PR envelope params best-effort. Use +`--no-envelope` to skip that lookup and `--no-clipboard` in scripts. + +Concrete examples: + +```bash +ade link file apps/desktop/src/shared/deeplinks.ts --line 12 --ade --no-clipboard +ade link commit abc1234 --lane 550e8400-e29b-41d4-a716-446655440000 --no-clipboard +``` ## Opening a deeplink — `ade open` @@ -102,6 +135,15 @@ existing `app/navigate` method: { "method": "app/navigate", "params": { "target": { "kind": "work", "sessionId": "", "laneId": "" } }} +{ "method": "app/navigate", "params": { + "target": { "kind": "file", "path": "src/app.ts", "line": 42, "laneId": "" } +}} +{ "method": "app/navigate", "params": { + "target": { "kind": "commit", "sha": "", "laneId": "", "envelope": { "repoOwner": "anthropics", "repoName": "claude-code", "branch": "feat-x" } } +}} +{ "method": "app/navigate", "params": { + "target": { "kind": "artifact", "artifactId": "" } +}} { "method": "app/navigate", "params": { "target": { "kind": "branch", "repoOwner": "anthropics", "repoName": "claude-code", "branch": "feat-x" } }} @@ -120,10 +162,10 @@ something a user pasted). ## Auto-attached deeplinks ADE automatically appends an "Open in ADE" footer to PR descriptions it -creates or adopts (idempotent via an HTML marker), and pushes the same -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. +creates or adopts (idempotent via an HTML marker), and pushes ADE lane/chat +links with repo envelopes 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. Linear card/comment matrix: @@ -178,6 +220,9 @@ link for returning to the selected terminal/chat session on the same desktop. ade link branch anthropics/claude-code feat-deeplinks # share with teammates ade link pr anthropics/claude-code 1234 # PR detail ade link linear-issue ADE-512 --branch arul/ade-512-feat # Linear hand-off +ade link file apps/desktop/src/shared/deeplinks.ts --line 12 # source location +ade link commit abc1234 --lane # commit detail +ade link artifact proof-123 # proof/history artifact ade link session --lane # Work session ade link lane "$(ade lanes list --text | head -2 | tail -1 | awk '{print $1}')" # current lane diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index ec2a9dcd9..07e9e54de 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -2149,6 +2149,7 @@ app.whenReady().then(async () => { let prPollingServiceRef: ReturnType | null = null; let testServiceRef: ReturnType | null = null; + let laneServiceRef: ReturnType | null = null; let gitServiceRef: ReturnType | null = null; let linearIssueTrackerRef: LinearIssueTracker | null = null; @@ -2156,6 +2157,20 @@ app.whenReady().then(async () => { const linearLiveStatusLaunchKeys = new Set(); const publishLinearChatCard = createLinearChatLinkPublisher({ getIssueTracker: () => linearIssueTrackerRef, + resolveEnvelope: async ({ laneId }) => { + const repo = await githubService.getRepoOrThrow().catch(() => null); + if (!repo) return null; + const lanes = await laneServiceRef?.list({ includeArchived: false, includeStatus: false }).catch(() => []); + const lane = lanes?.find((candidate) => candidate.id === laneId) ?? null; + const branch = lane?.branchRef?.replace(/^refs\/heads\//, "") ?? null; + const pr = prServiceRef?.getForLane(laneId) ?? null; + return { + repoOwner: repo.owner, + repoName: repo.name, + branch, + prNumber: pr?.githubPrNumber ?? null, + }; + }, log: (event, fields) => logger.warn(event, fields), }); const publishLinearChatLink = ({ laneId, sessionId, sessionTitle, issue, linkedAt }: { @@ -2283,6 +2298,7 @@ app.whenReady().then(async () => { linkedAt, repoOwner: repo?.owner ?? null, repoName: repo?.name ?? null, + prNumber: prServiceRef?.getForLane(lane.id)?.githubPrNumber ?? null, postInitialComment: true, log: (event, fields) => logger.warn(event, fields), })) @@ -2299,6 +2315,7 @@ app.whenReady().then(async () => { teardownDeps: laneTeardownDeps, logger, }); + laneServiceRef = laneService; await measureProjectInitStep("lane.ensure_primary", () => laneService.ensurePrimaryLane(), ); @@ -3256,6 +3273,10 @@ app.whenReady().then(async () => { agentChatService, prService, gitService, + repoSlug: async () => { + const status = await githubService.getRemoteStatus().catch(() => ({ repo: null })); + return status.repo ?? null; + }, fileService, artifactBroker: computerUseArtifactBrokerService, linearIssueTracker, diff --git a/apps/desktop/src/main/services/cto/linearLaneCardService.test.ts b/apps/desktop/src/main/services/cto/linearLaneCardService.test.ts index 7661ccf38..baaf0de8d 100644 --- a/apps/desktop/src/main/services/cto/linearLaneCardService.test.ts +++ b/apps/desktop/src/main/services/cto/linearLaneCardService.test.ts @@ -133,7 +133,7 @@ describe("linearLaneCardService", () => { })); }); - it("uses the cross-machine ADE deeplink when repo is known", () => { + it("uses a lane deeplink with a portable envelope when repo is known", () => { const attachment = buildLinearLaneCardAttachment({ lane: makeLane(), issue: makeIssue(), @@ -141,10 +141,13 @@ describe("linearLaneCardService", () => { linkedAt: "2026-05-12T20:05:00.000Z", repoOwner: "anthropics", repoName: "claude-code", + prNumber: 42, }); - expect(attachment.url).toContain("https://ade-app.dev/open?type=branch"); + expect(attachment.url).toContain("https://ade-app.dev/open?type=lane"); + expect(attachment.url).toContain("id=lane-1"); expect(attachment.url).toContain("repo=anthropics%2Fclaude-code"); expect(attachment.url).toContain("branch=abc-42-fix-flaky-sync-run"); + expect(attachment.url).toContain("pr=42"); expect(attachment.title).toBe("Open in ADE: ABC-42 Fix flaky sync run"); }); @@ -156,7 +159,8 @@ describe("linearLaneCardService", () => { repoName: "claude-code", }); expect(body).toContain("Open in ADE"); - expect(body).toContain("https://ade-app.dev/open?type=branch"); + expect(body).toContain("https://ade-app.dev/open?type=lane"); + expect(body).toContain("repo=anthropics%2Fclaude-code"); }); it("builds and publishes an ADE Linear-pane attachment", async () => { @@ -219,6 +223,25 @@ describe("linearLaneCardService", () => { })); }); + it("attaches repo, branch, and PR envelope to chat session links when provided", () => { + const attachment = buildLinearChatSessionAttachment({ + issue: makeIssue(), + laneId: "lane-1", + sessionId: "session-1", + sessionTitle: "Investigate sync flakes", + repoOwner: "anthropics", + repoName: "claude-code", + branch: "abc-42-fix-flaky-sync-run", + prNumber: 42, + }); + + expect(attachment.url).toContain("ade://session/session-1?"); + expect(attachment.url).toContain("repo=anthropics%2Fclaude-code"); + expect(attachment.url).toContain("branch=abc-42-fix-flaky-sync-run"); + expect(attachment.url).toContain("pr=42"); + expect(attachment.url).not.toContain("linear="); + }); + it("dedupes chat session card publishing per issue and session", async () => { const createIssueAttachment = vi.fn(async () => ({ id: "attachment-chat", url: "ade://session/session-1?lane=lane-1" })); const publisher = createLinearChatLinkPublisher({ diff --git a/apps/desktop/src/main/services/cto/linearLaneCardService.ts b/apps/desktop/src/main/services/cto/linearLaneCardService.ts index 8adce85ff..faa24fc52 100644 --- a/apps/desktop/src/main/services/cto/linearLaneCardService.ts +++ b/apps/desktop/src/main/services/cto/linearLaneCardService.ts @@ -60,17 +60,21 @@ function buildCardUrl(args: { branch: string; repoOwner?: string | null; repoName?: string | null; + prNumber?: number | null; }): string { - // Prefer the cross-machine ADE deeplink (so the attachment is actually - // clickable from Linear and lands in another teammate's ADE). Fall back to + // Prefer the machine-local lane link with a portable envelope. Fall back to // the historical Linear-issue-hash URL when we don't know the repo. if (args.repoOwner && args.repoName) { return buildDeeplink( { - kind: "branch", - repoOwner: args.repoOwner, - repoName: args.repoName, - branch: args.branch, + kind: "lane", + laneId: args.laneId, + envelope: { + repoOwner: args.repoOwner, + repoName: args.repoName, + branch: args.branch, + ...(args.prNumber ? { prNumber: args.prNumber } : {}), + }, }, { form: "https" }, ); @@ -85,6 +89,7 @@ export function buildLinearLaneCardAttachment(args: { linkedAt?: string | null; repoOwner?: string | null; repoName?: string | null; + prNumber?: number | null; }): IssueTrackerIssueAttachmentInput { const linkedAt = args.linkedAt?.trim() || args.lane.createdAt || new Date().toISOString(); const branch = args.issue.branchName?.trim() || args.lane.branchRef; @@ -98,6 +103,7 @@ export function buildLinearLaneCardAttachment(args: { branch, repoOwner: args.repoOwner ?? null, repoName: args.repoName ?? null, + prNumber: args.prNumber ?? null, }); const hasDeeplink = Boolean(args.repoOwner && args.repoName); @@ -144,15 +150,20 @@ export function buildLinearLaneInitialComment(args: { issue: LaneLinearIssue; repoOwner?: string | null; repoName?: string | null; + prNumber?: number | null; }): string | null { if (!args.repoOwner || !args.repoName) return null; const branch = args.issue.branchName?.trim() || args.lane.branchRef; const url = buildDeeplink( { - kind: "branch", - repoOwner: args.repoOwner, - repoName: args.repoName, - branch, + kind: "lane", + laneId: args.lane.id, + envelope: { + repoOwner: args.repoOwner, + repoName: args.repoName, + branch, + ...(args.prNumber ? { prNumber: args.prNumber } : {}), + }, }, { form: "https" }, ); @@ -208,6 +219,10 @@ export function buildLinearChatSessionAttachment(args: { sessionId: string; sessionTitle?: string | null; linkedAt?: string | null; + repoOwner?: string | null; + repoName?: string | null; + branch?: string | null; + prNumber?: number | null; }): IssueTrackerIssueAttachmentInput { const linkedAt = args.linkedAt?.trim() || new Date().toISOString(); const url = buildDeeplink( @@ -215,6 +230,16 @@ export function buildLinearChatSessionAttachment(args: { kind: "session", sessionId: args.sessionId, laneId: args.laneId, + ...(args.repoOwner && args.repoName + ? { + envelope: { + repoOwner: args.repoOwner, + repoName: args.repoName, + ...(args.branch ? { branch: args.branch } : {}), + ...(args.prNumber ? { prNumber: args.prNumber } : {}), + }, + } + : {}), }, { form: "ade" }, ); @@ -328,6 +353,7 @@ export async function publishLinearLaneCard(args: { /** Optional: when known, used to render the cross-machine ADE deeplink. */ repoOwner?: string | null; repoName?: string | null; + prNumber?: number | null; /** When true, also post a one-time comment so timeline-watchers see the link. */ postInitialComment?: boolean; /** Optional log hook used for the (best-effort) comment-create step. */ @@ -341,6 +367,7 @@ export async function publishLinearLaneCard(args: { linkedAt: args.linkedAt, repoOwner: args.repoOwner ?? null, repoName: args.repoName ?? null, + prNumber: args.prNumber ?? null, }), ); @@ -350,6 +377,7 @@ export async function publishLinearLaneCard(args: { issue: args.issue, repoOwner: args.repoOwner ?? null, repoName: args.repoName ?? null, + prNumber: args.prNumber ?? null, }); if (body) { try { @@ -411,6 +439,10 @@ export async function publishLinearChatSessionCard(args: { sessionId: string; sessionTitle?: string | null; linkedAt?: string | null; + repoOwner?: string | null; + repoName?: string | null; + branch?: string | null; + prNumber?: number | null; }): Promise<{ url: string; id?: string }> { return await args.issueTracker.createIssueAttachment( buildLinearChatSessionAttachment({ @@ -419,12 +451,20 @@ export async function publishLinearChatSessionCard(args: { sessionId: args.sessionId, sessionTitle: args.sessionTitle, linkedAt: args.linkedAt, + repoOwner: args.repoOwner ?? null, + repoName: args.repoName ?? null, + branch: args.branch ?? null, + prNumber: args.prNumber ?? null, }), ); } export function createLinearChatLinkPublisher(args: { getIssueTracker: () => IssueTracker | null | undefined; + resolveEnvelope?: (args: { + laneId: string; + issue: LinearIssueCardIssue; + }) => Promise<{ repoOwner: string; repoName: string; branch?: string | null; prNumber?: number | null } | null>; log?: (event: string, fields: Record) => void; }) { const publishKeys = new Set(); @@ -446,14 +486,21 @@ export function createLinearChatLinkPublisher(args: { const key = `${issue.id}:${sessionId}`; if (publishKeys.has(key)) return; publishKeys.add(key); - void publishLinearChatSessionCard({ - issueTracker, - issue, - laneId, - sessionId, - sessionTitle, - linkedAt, - }).catch((error) => { + void (async () => { + const envelope = await args.resolveEnvelope?.({ laneId, issue }).catch(() => null) ?? null; + await publishLinearChatSessionCard({ + issueTracker, + issue, + laneId, + sessionId, + sessionTitle, + linkedAt, + repoOwner: envelope?.repoOwner ?? null, + repoName: envelope?.repoName ?? null, + branch: envelope?.branch ?? null, + prNumber: envelope?.prNumber ?? null, + }); + })().catch((error) => { publishKeys.delete(key); args.log?.("linear.chat_session_card_publish_failed", { laneId, diff --git a/apps/desktop/src/main/services/deeplinks/projectNavigationDispatch.test.ts b/apps/desktop/src/main/services/deeplinks/projectNavigationDispatch.test.ts deleted file mode 100644 index bbc9a9bd1..000000000 --- a/apps/desktop/src/main/services/deeplinks/projectNavigationDispatch.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { AppNavigationRequest } from "../../../shared/types"; -import { selectWindowForProjectNavigation } from "./projectNavigationWindowSelection"; - -/** - * Regression guard for iOS sync deeplink routing: navigation must target the - * sync-host project window, not the globally focused window. - */ -describe("project-scoped deeplink dispatch contract", () => { - it("prefers project-root dispatch over focused-window dispatch for sync:ios", () => { - const focusedDispatch = vi.fn(); - const projectDispatch = vi.fn(); - let dispatchAppNavigationForProjectRoot: - | ((targetProjectRoot: string, request: AppNavigationRequest) => void) - | null = projectDispatch; - - const projectRoot = "/projects/beta"; - const request: AppNavigationRequest = { - target: { kind: "lane", laneId: "lane-in-beta" }, - source: "deeplink:sync:ios", - }; - - const dispatchSyncDeeplink = (req: AppNavigationRequest) => { - if (dispatchAppNavigationForProjectRoot) { - dispatchAppNavigationForProjectRoot(projectRoot, req); - return; - } - focusedDispatch(req); - }; - - dispatchSyncDeeplink(request); - - expect(projectDispatch).toHaveBeenCalledWith(projectRoot, request); - expect(focusedDispatch).not.toHaveBeenCalled(); - }); - - it("falls back to focused-window dispatch when project dispatch is unavailable", () => { - const focusedDispatch = vi.fn(); - const getProjectDispatch = (): - | ((targetProjectRoot: string, request: AppNavigationRequest) => void) - | null => null; - - const request: AppNavigationRequest = { - target: { kind: "lane", laneId: "lane-1" }, - source: "deeplink:sync:ios", - }; - - const dispatchSyncDeeplink = (req: AppNavigationRequest) => { - const projectDispatch = getProjectDispatch(); - if (projectDispatch) { - projectDispatch("/projects/beta", req); - return; - } - focusedDispatch(req); - }; - - dispatchSyncDeeplink(request); - - expect(focusedDispatch).toHaveBeenCalledWith(request); - }); - - it("activates an existing project tab before opening a duplicate window", async () => { - const activeProjectRoots = new Map([[1, "/projects/alpha"]]); - const openProjectTabRoots = new Map>([ - [1, new Set(["/projects/alpha", "/projects/beta"])], - ]); - const activateProjectTab = vi.fn((windowId: number, root: string) => { - activeProjectRoots.set(windowId, root); - }); - const openWindow = vi.fn(async (root: string) => { - activeProjectRoots.set(2, root); - openProjectTabRoots.set(2, new Set([root])); - return 2; - }); - - const deliverToProject = async (targetProjectRoot: string): Promise => { - const selection = selectWindowForProjectNavigation( - targetProjectRoot, - [...activeProjectRoots].map(([id, root]) => ({ - id, - activeProjectRoot: root, - openProjectRoots: openProjectTabRoots.get(id) ?? new Set(), - })), - ); - if (selection) { - if (selection.activateProjectRoot) { - activateProjectTab(selection.windowId, targetProjectRoot); - } - return selection.windowId; - } - return await openWindow(targetProjectRoot); - }; - - await expect(deliverToProject("/projects/beta")).resolves.toBe(1); - expect(activateProjectTab).toHaveBeenCalledWith(1, "/projects/beta"); - expect(openWindow).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/desktop/src/main/services/deeplinks/protocolHandler.test.ts b/apps/desktop/src/main/services/deeplinks/protocolHandler.test.ts index d6c4df484..a650902b7 100644 --- a/apps/desktop/src/main/services/deeplinks/protocolHandler.test.ts +++ b/apps/desktop/src/main/services/deeplinks/protocolHandler.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; +import type { AppNavigationRequest } from "../../../shared/types"; import { deeplinkToNavigationTarget, handleDeeplinkUrl } from "./protocolHandler"; +import { selectWindowForProjectNavigation } from "./projectNavigationWindowSelection"; const UUID = "550e8400-e29b-41d4-a716-446655440000"; @@ -9,6 +11,7 @@ describe("deeplinkToNavigationTarget", () => { expect(deeplinkToNavigationTarget({ kind: "lane", laneId: UUID })).toEqual({ kind: "lane", laneId: UUID, + envelope: null, }); }); @@ -17,6 +20,35 @@ describe("deeplinkToNavigationTarget", () => { kind: "work", sessionId: "session-1", laneId: UUID, + envelope: null, + event: null, + offset: null, + }); + }); + + it("maps file targets", () => { + expect(deeplinkToNavigationTarget({ kind: "file", path: "src/app.ts", line: 12, laneId: UUID })).toEqual({ + kind: "file", + path: "src/app.ts", + line: 12, + laneId: UUID, + }); + }); + + it("maps commit targets", () => { + expect(deeplinkToNavigationTarget({ kind: "commit", sha: "abc1234", laneId: UUID })).toEqual({ + kind: "commit", + sha: "abc1234", + laneId: UUID, + envelope: null, + }); + }); + + it("maps artifact targets", () => { + expect(deeplinkToNavigationTarget({ kind: "artifact", artifactId: "artifact-1" })).toEqual({ + kind: "artifact", + artifactId: "artifact-1", + envelope: null, }); }); @@ -80,7 +112,7 @@ describe("handleDeeplinkUrl", () => { expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledWith( expect.objectContaining({ - target: { kind: "lane", laneId: UUID }, + target: { kind: "lane", laneId: UUID, envelope: null }, source: "deeplink:test", }), ); @@ -125,3 +157,98 @@ describe("handleDeeplinkUrl", () => { ); }); }); + +/** + * Regression guard for iOS sync deeplink routing: navigation must target the + * sync-host project window, not the globally focused window. + */ +describe("project-scoped deeplink dispatch contract", () => { + it("prefers project-root dispatch over focused-window dispatch for sync:ios", () => { + const focusedDispatch = vi.fn(); + const projectDispatch = vi.fn(); + let dispatchAppNavigationForProjectRoot: + | ((targetProjectRoot: string, request: AppNavigationRequest) => void) + | null = projectDispatch; + + const projectRoot = "/projects/beta"; + const request: AppNavigationRequest = { + target: { kind: "lane", laneId: "lane-in-beta" }, + source: "deeplink:sync:ios", + }; + + const dispatchSyncDeeplink = (req: AppNavigationRequest) => { + if (dispatchAppNavigationForProjectRoot) { + dispatchAppNavigationForProjectRoot(projectRoot, req); + return; + } + focusedDispatch(req); + }; + + dispatchSyncDeeplink(request); + + expect(projectDispatch).toHaveBeenCalledWith(projectRoot, request); + expect(focusedDispatch).not.toHaveBeenCalled(); + }); + + it("falls back to focused-window dispatch when project dispatch is unavailable", () => { + const focusedDispatch = vi.fn(); + const getProjectDispatch = (): + | ((targetProjectRoot: string, request: AppNavigationRequest) => void) + | null => null; + + const request: AppNavigationRequest = { + target: { kind: "lane", laneId: "lane-1" }, + source: "deeplink:sync:ios", + }; + + const dispatchSyncDeeplink = (req: AppNavigationRequest) => { + const projectDispatch = getProjectDispatch(); + if (projectDispatch) { + projectDispatch("/projects/beta", req); + return; + } + focusedDispatch(req); + }; + + dispatchSyncDeeplink(request); + + expect(focusedDispatch).toHaveBeenCalledWith(request); + }); + + it("activates an existing project tab before opening a duplicate window", async () => { + const activeProjectRoots = new Map([[1, "/projects/alpha"]]); + const openProjectTabRoots = new Map>([ + [1, new Set(["/projects/alpha", "/projects/beta"])], + ]); + const activateProjectTab = vi.fn((windowId: number, root: string) => { + activeProjectRoots.set(windowId, root); + }); + const openWindow = vi.fn(async (root: string) => { + activeProjectRoots.set(2, root); + openProjectTabRoots.set(2, new Set([root])); + return 2; + }); + + const deliverToProject = async (targetProjectRoot: string): Promise => { + const selection = selectWindowForProjectNavigation( + targetProjectRoot, + [...activeProjectRoots].map(([id, root]) => ({ + id, + activeProjectRoot: root, + openProjectRoots: openProjectTabRoots.get(id) ?? new Set(), + })), + ); + if (selection) { + if (selection.activateProjectRoot) { + activateProjectTab(selection.windowId, targetProjectRoot); + } + return selection.windowId; + } + return await openWindow(targetProjectRoot); + }; + + await expect(deliverToProject("/projects/beta")).resolves.toBe(1); + expect(activateProjectTab).toHaveBeenCalledWith(1, "/projects/beta"); + expect(openWindow).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/services/deeplinks/protocolHandler.ts b/apps/desktop/src/main/services/deeplinks/protocolHandler.ts index 4719cc22f..c5cbdf157 100644 --- a/apps/desktop/src/main/services/deeplinks/protocolHandler.ts +++ b/apps/desktop/src/main/services/deeplinks/protocolHandler.ts @@ -209,12 +209,35 @@ export function handleDeeplinkUrl( export function deeplinkToNavigationTarget(target: DeeplinkTarget): AppNavigationTarget { switch (target.kind) { case "lane": - return { kind: "lane", laneId: target.laneId }; + return { kind: "lane", laneId: target.laneId, envelope: target.envelope ?? null }; case "session": return { kind: "work", sessionId: target.sessionId, laneId: target.laneId ?? null, + envelope: target.envelope ?? null, + event: target.event ?? null, + offset: target.offset ?? null, + }; + case "file": + return { + kind: "file", + path: target.path, + line: target.line ?? null, + laneId: target.laneId ?? null, + }; + case "commit": + return { + kind: "commit", + sha: target.sha, + laneId: target.laneId ?? null, + envelope: target.envelope ?? null, + }; + case "artifact": + return { + kind: "artifact", + artifactId: target.artifactId, + envelope: target.envelope ?? null, }; case "pr": return { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 979049520..e4996de80 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -8,6 +8,7 @@ import type { Server as NetServer } from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { IPC } from "../../../shared/ipc"; +import { findRecentProjectForRepo } from "../projects/repoProjectResolver"; import { getModelById } from "../../../shared/modelRegistry"; import { appendEvent as perfAppend, isRunActive as isPerfRunActive } from "../perf/perfLog"; import { buildPrAiResolutionContextKey } from "../../../shared/types"; @@ -3780,6 +3781,18 @@ export function registerIpc({ listRecentProjectSummaries() ); + ipcMain.handle( + IPC.projectFindForRepo, + async (_event, arg: { repoOwner?: string; repoName?: string } = {}): Promise<{ rootPath: string; displayName: string } | null> => { + const repoOwner = typeof arg?.repoOwner === "string" ? arg.repoOwner.trim() : ""; + const repoName = typeof arg?.repoName === "string" ? arg.repoName.trim() : ""; + if (!repoOwner || !repoName) return null; + // One tested implementation: parses each recent project's git origin + // from .git/config (no git subprocess), cached by config mtime. + return findRecentProjectForRepo(listLocalRecentProjectSummaries(), { repoOwner, repoName }); + }, + ); + registerRuntimeBridge({ appVersion: app.getVersion(), bindRemoteProject, diff --git a/apps/desktop/src/main/services/projects/repoProjectResolver.test.ts b/apps/desktop/src/main/services/projects/repoProjectResolver.test.ts new file mode 100644 index 000000000..620d177ab --- /dev/null +++ b/apps/desktop/src/main/services/projects/repoProjectResolver.test.ts @@ -0,0 +1,147 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { parseGithubRemoteUrl } from "../../../shared/githubRemote"; +import type { RecentProjectSummary } from "../../../shared/types"; +import { + clearRepoProjectResolverCacheForTests, + findRecentProjectForRepo, + readOriginRemoteUrlFromGitConfig, +} from "./repoProjectResolver"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function writeGitConfig(rootPath: string, remoteUrl: string, options?: { gitFile?: boolean }): void { + if (options?.gitFile) { + const gitDir = path.join(rootPath, "..", `${path.basename(rootPath)}.gitdir`); + tempDirs.push(gitDir); + fs.mkdirSync(gitDir, { recursive: true }); + fs.writeFileSync(path.join(rootPath, ".git"), `gitdir: ${path.relative(rootPath, gitDir)}\n`, "utf8"); + fs.writeFileSync(path.join(gitDir, "config"), gitConfig(remoteUrl), "utf8"); + return; + } + + const gitDir = path.join(rootPath, ".git"); + fs.mkdirSync(gitDir, { recursive: true }); + fs.writeFileSync(path.join(gitDir, "config"), gitConfig(remoteUrl), "utf8"); +} + +function gitConfig(remoteUrl: string): string { + return [ + "[core]", + "\trepositoryformatversion = 0", + "[remote \"upstream\"]", + "\turl = git@github.com:other/upstream.git", + "[remote \"origin\"]", + `\turl = ${remoteUrl}`, + "\tfetch = +refs/heads/*:refs/remotes/origin/*", + "", + ].join("\n"); +} + +function recent(rootPath: string, displayName: string, overrides: Partial = {}): RecentProjectSummary { + return { + rootPath, + displayName, + lastOpenedAt: "2026-07-06T12:00:00.000Z", + exists: true, + ...overrides, + }; +} + +afterEach(() => { + clearRepoProjectResolverCacheForTests(); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("parseGithubRemoteUrl", () => { + it("parses ssh and https GitHub origin URLs", () => { + expect(parseGithubRemoteUrl("git@github.com:Acme/ADE.git")).toEqual({ + owner: "Acme", + repo: "ADE", + }); + expect(parseGithubRemoteUrl("https://github.com/acme/ade")).toEqual({ + owner: "acme", + repo: "ade", + }); + }); + + it("ignores non-GitHub or nested remote URLs", () => { + expect(parseGithubRemoteUrl("https://example.com/acme/ade.git")).toBeNull(); + expect(parseGithubRemoteUrl("git@github.com:acme/ade/extra.git")).toBeNull(); + }); +}); + +describe("readOriginRemoteUrlFromGitConfig", () => { + it("returns the origin remote URL from a git config", () => { + expect(readOriginRemoteUrlFromGitConfig(gitConfig("https://github.com/acme/ade.git"))) + .toBe("https://github.com/acme/ade.git"); + }); + + it("returns null when origin is missing", () => { + expect(readOriginRemoteUrlFromGitConfig("[remote \"upstream\"]\n\turl = git@github.com:acme/ade.git\n")) + .toBeNull(); + }); +}); + +describe("findRecentProjectForRepo", () => { + it("returns the first local recent project whose origin matches the repo", () => { + const first = makeTempDir("ade-repo-resolver-first-"); + const second = makeTempDir("ade-repo-resolver-second-"); + writeGitConfig(first, "git@github.com:acme/ade.git"); + writeGitConfig(second, "https://github.com/acme/ade.git"); + + expect(findRecentProjectForRepo( + [recent(first, "ADE first"), recent(second, "ADE second")], + { repoOwner: "ACME", repoName: "ade" }, + )).toEqual({ + rootPath: first, + displayName: "ADE first", + }); + }); + + it("handles worktree-style .git files with gitdir pointers", () => { + const root = makeTempDir("ade-repo-resolver-worktree-"); + writeGitConfig(root, "git@github.com:acme/portable.git", { gitFile: true }); + + expect(findRecentProjectForRepo( + [recent(root, "Portable")], + { repoOwner: "acme", repoName: "portable" }, + )).toEqual({ + rootPath: root, + displayName: "Portable", + }); + }); + + it("skips missing and remote recent projects", () => { + const root = makeTempDir("ade-repo-resolver-skip-"); + writeGitConfig(root, "git@github.com:acme/ade.git"); + + expect(findRecentProjectForRepo( + [ + recent(root, "Missing", { exists: false }), + recent(root, "Remote", { + kind: "remote", + remote: { + targetId: "target-1", + projectId: "project-1", + runtimeName: "Studio", + hostname: "studio.local", + }, + }), + ], + { repoOwner: "acme", repoName: "ade" }, + )).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/services/projects/repoProjectResolver.ts b/apps/desktop/src/main/services/projects/repoProjectResolver.ts new file mode 100644 index 000000000..541340bb7 --- /dev/null +++ b/apps/desktop/src/main/services/projects/repoProjectResolver.ts @@ -0,0 +1,127 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { + githubRepoSlugsEqual, + parseGithubRemoteUrl, + type GithubRepoSlug, +} from "../../../shared/githubRemote"; +import type { + ProjectFindForRepoArgs, + ProjectFindForRepoResult, + RecentProjectSummary, +} from "../../../shared/types"; + +type OriginCacheEntry = { + configPath: string; + mtimeMs: number; + slug: GithubRepoSlug | null; +}; + +const originCache = new Map(); + +export function clearRepoProjectResolverCacheForTests(): void { + originCache.clear(); +} + +function resolveGitDir(rootPath: string): string | null { + const gitPath = path.join(rootPath, ".git"); + let stat: fs.Stats; + try { + stat = fs.statSync(gitPath); + } catch { + return null; + } + + if (stat.isDirectory()) return gitPath; + if (!stat.isFile()) return null; + + try { + const content = fs.readFileSync(gitPath, "utf8"); + const match = content.match(/^gitdir:\s*(.+)\s*$/im); + const rawGitDir = match?.[1]?.trim(); + return rawGitDir ? path.resolve(rootPath, rawGitDir) : null; + } catch { + return null; + } +} + +function resolveGitConfigPath(rootPath: string): string | null { + const gitDir = resolveGitDir(rootPath); + return gitDir ? path.join(gitDir, "config") : null; +} + +export function readOriginRemoteUrlFromGitConfig(configText: string): string | null { + let inOriginRemote = false; + + for (const rawLine of configText.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#") || line.startsWith(";")) continue; + + const section = line.match(/^\[([^\]]+)\]$/); + if (section) { + inOriginRemote = /^remote\s+"origin"$/i.test(section[1]?.trim() ?? ""); + continue; + } + + if (!inOriginRemote) continue; + const url = line.match(/^url\s*=\s*(.+)$/i); + if (url) return url[1]?.trim() || null; + } + + return null; +} + +function readGithubOriginSlug(rootPath: string): GithubRepoSlug | null { + const configPath = resolveGitConfigPath(rootPath); + if (!configPath) return null; + + let stat: fs.Stats; + try { + stat = fs.statSync(configPath); + } catch { + return null; + } + + const cached = originCache.get(rootPath); + if ( + cached + && cached.configPath === configPath + && cached.mtimeMs === stat.mtimeMs + ) { + return cached.slug; + } + + let slug: GithubRepoSlug | null = null; + try { + const remoteUrl = readOriginRemoteUrlFromGitConfig(fs.readFileSync(configPath, "utf8")); + slug = parseGithubRemoteUrl(remoteUrl); + } catch { + slug = null; + } + + originCache.set(rootPath, { configPath, mtimeMs: stat.mtimeMs, slug }); + return slug; +} + +export function findRecentProjectForRepo( + recentProjects: RecentProjectSummary[], + repo: ProjectFindForRepoArgs, +): ProjectFindForRepoResult { + const targetSlug: GithubRepoSlug = { + owner: repo.repoOwner, + repo: repo.repoName, + }; + + for (const project of recentProjects) { + if (project.remote || project.kind === "remote" || project.exists === false) continue; + const slug = readGithubOriginSlug(project.rootPath); + if (!githubRepoSlugsEqual(slug, targetSlug)) continue; + return { + rootPath: project.rootPath, + displayName: project.displayName, + }; + } + + return null; +} diff --git a/apps/desktop/src/main/services/search/searchIndexDb.ts b/apps/desktop/src/main/services/search/searchIndexDb.ts index 99ee168cd..110a4935e 100644 --- a/apps/desktop/src/main/services/search/searchIndexDb.ts +++ b/apps/desktop/src/main/services/search/searchIndexDb.ts @@ -17,7 +17,7 @@ const { DatabaseSync } = require("node:sqlite") as { * cheap as deleting the file. Bump the schema version for any DDL change — * mismatches drop and recreate the database instead of migrating. */ -export const SEARCH_INDEX_SCHEMA_VERSION = 2; +export const SEARCH_INDEX_SCHEMA_VERSION = 4; export const SEARCH_INDEX_DB_FILENAME = "search-index.db"; diff --git a/apps/desktop/src/main/services/search/searchService.test.ts b/apps/desktop/src/main/services/search/searchService.test.ts index b63f90dc6..3e100177e 100644 --- a/apps/desktop/src/main/services/search/searchService.test.ts +++ b/apps/desktop/src/main/services/search/searchService.test.ts @@ -49,12 +49,12 @@ describe("searchService", () => { let service: SearchService; let sessions: TerminalSessionSummary[]; - const writeChatLine = (sessionId: string, event: Record, timestamp: string) => { + const writeChatLine = (sessionId: string, event: Record, timestamp: string, sequence?: number) => { const dir = path.join(root, "transcripts", "chat"); fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync( path.join(dir, `${sessionId}.jsonl`), - `${JSON.stringify({ sessionId, timestamp, event })}\n` + `${JSON.stringify({ sessionId, timestamp, event, ...(sequence != null ? { sequence } : {}) })}\n` ); }; @@ -131,6 +131,67 @@ describe("searchService", () => { expect(hit!.deepLink).toContain("event=0"); }); + it("uses persisted chat envelope sequence for deep link anchors", async () => { + const session = makeSession({ id: "chat-sequence", title: "Sequence links" }); + sessions.push(session); + writeChatLine( + "chat-sequence", + { type: "user_message", text: "please inspect the anchored sequence" }, + "2026-07-05T10:00:00.000Z", + 41 + ); + service.notifyChatEvent("chat-sequence"); + await service.processPendingNow(); + + const result = await service.query({ query: "anchored sequence" }); + const hit = result.results.find((item) => item.kind === "chat" && item.sessionId === "chat-sequence"); + expect(hit).toBeTruthy(); + expect(hit!.id).toBe("chat:chat-sequence:0"); + expect(hit!.deepLink).toContain("event=41"); + }); + + it("adds repo envelope params to session deep links when repoSlug is available", async () => { + service.dispose(); + service = createSearchService({ + cacheDir: path.join(root, "cache"), + transcriptsDir: path.join(root, "transcripts"), + chatTranscriptsDir: path.join(root, "transcripts", "chat"), + repoSlug: async () => ({ owner: "owner", name: "repo" }), + sessions: { + list: async () => sessions, + get: async (id) => sessions.find((s) => s.id === id) ?? null + }, + lanes: { + list: async () => [ + { + id: "lane-1", + name: "universal-search", + laneType: "workspace", + branchRef: "refs/heads/ade/universal-search" + } as never + ] + }, + now: () => NOW + }); + sessions.push(makeSession({ id: "chat-repo", title: "Repo chat" })); + writeChatLine("chat-repo", { type: "user_message", text: "portable envelope message" }, "2026-07-05T10:00:00.000Z"); + service.notifyChatEvent("chat-repo"); + await service.processPendingNow(); + + const hit = (await service.query({ query: "portable envelope" })).results.find((item) => item.kind === "chat"); + expect(hit?.deepLink).toContain("repo=owner%2Frepo"); + expect(hit?.deepLink).toContain("branch=ade%2Funiversal-search"); + + // Delegated lane results carry the same portable envelope. + const laneHit = (await service.query({ query: "universal-search kind:lane" })).results.find( + (item) => item.kind === "lane" + ); + expect(laneHit).toBeTruthy(); + expect(laneHit!.deepLink).toContain("ade://lane/lane-1"); + expect(laneHit!.deepLink).toContain("repo=owner%2Frepo"); + expect(laneHit!.deepLink).toContain("branch=ade%2Funiversal-search"); + }); + it("indexes only new lines on subsequent appends (incremental cursor)", async () => { sessions.push(makeSession({ id: "chat-2", title: "Chat two" })); writeChatLine("chat-2", { type: "user_message", text: "first message alpha" }, "2026-07-05T10:00:00.000Z"); @@ -415,6 +476,79 @@ describe("searchService classification and lane-git dedup", () => { expect(branchIds).toEqual(["branch:lane-primary:ade/feature"]); service.dispose(); }); + + it("emits canonical commit deep links", async () => { + const lanes = [ + { + id: "lane-work", + name: "feature", + laneType: "worktree", + baseRef: "main", + branchRef: "ade/feature", + worktreePath: "/tmp/b", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0 }, + color: null, + icon: null, + tags: [], + createdAt: "2026-07-01T00:00:00.000Z" + } + ] as never[]; + const service = createSearchService({ + cacheDir: path.join(root, "cache"), + transcriptsDir: path.join(root, "transcripts"), + chatTranscriptsDir: path.join(root, "transcripts", "chat"), + repoSlug: async () => ({ owner: "owner", name: "repo" }), + sessions: { list: async () => [] }, + lanes: { list: async () => lanes as never }, + git: { + listRecentCommits: async () => [ + { + sha: "abc123456789", + shortSha: "abc1234", + subject: "Wire canonical commits", + authorName: "Ada", + authoredAt: "2026-07-05T00:00:00.000Z" + } as never + ], + listBranches: async () => [] + }, + now: () => NOW + }); + + service.notifyLaneActivity("lane-work"); + await service.processPendingNow(); + const commit = (await service.query({ query: "canonical commits kind:commit" })).results[0]; + expect(commit?.deepLink).toMatch(/^ade:\/\/commit\//); + expect(commit?.deepLink).toContain("repo=owner%2Frepo"); + service.dispose(); + }); + + it("emits canonical artifact deep links", async () => { + const service = createSearchService({ + cacheDir: path.join(root, "cache"), + transcriptsDir: path.join(root, "transcripts"), + chatTranscriptsDir: path.join(root, "transcripts", "chat"), + sessions: { list: async () => [] }, + artifacts: { + list: () => [{ + id: "artifact-123", + title: "Proof artifact", + description: "screenshot evidence", + createdAt: "2026-07-05T00:00:00.000Z" + }] + }, + now: () => NOW + }); + + const artifact = (await service.query({ query: "screenshot kind:artifact" })).results[0]; + expect(artifact?.deepLink).toMatch(/^ade:\/\/artifact\//); + service.dispose(); + }); }); describe("searchService caller scoping", () => { @@ -780,6 +914,42 @@ describe("searchService review fixes (PR #709)", () => { service.dispose(); }); + it("returns nothing when a supplied kinds array is entirely invalid", async () => { + const service = createSearchService({ + cacheDir: path.join(root, "cache"), + transcriptsDir: path.join(root, "transcripts"), + chatTranscriptsDir: path.join(root, "transcripts", "chat"), + sessions: { list: async () => [makeSession({ id: "c2", title: "Something" })] }, + now: () => NOW + }); + const chatDir = path.join(root, "transcripts", "chat"); + fs.mkdirSync(chatDir, { recursive: true }); + fs.appendFileSync( + path.join(chatDir, "c2.jsonl"), + `${JSON.stringify({ sessionId: "c2", timestamp: "2026-07-05T10:00:00.000Z", event: { type: "user_message", text: "findable text" } })}\n` + ); + service.notifyChatEvent("c2"); + await service.processPendingNow(); + + // All-invalid kinds must not silently broaden to the default kind set. + const allInvalid = await service.query({ + query: "findable", + kinds: ["termnal" as never] + }); + expect(allInvalid.results).toEqual([]); + expect(allInvalid.totalByKind).toEqual({}); + expect(allInvalid.nextCursor).toBeNull(); + // A mixed array keeps the valid entries. + const mixed = await service.query({ + query: "findable", + kinds: ["termnal" as never, "chat"] + }); + expect(mixed.results.length).toBeGreaterThan(0); + // Omitted kinds still hits the default set. + expect((await service.query({ query: "findable" })).results.length).toBeGreaterThan(0); + service.dispose(); + }); + it("searches the requested lane's files when a lane filter is present", async () => { const quickOpen = vi.fn(async (_query: string, _limit: number, laneId?: string | null) => laneId === "lane-42" ? [{ path: "lane42/file.ts" }] : [{ path: "primary/file.ts" }] diff --git a/apps/desktop/src/main/services/search/searchService.ts b/apps/desktop/src/main/services/search/searchService.ts index 0eca6554f..63ff2ecac 100644 --- a/apps/desktop/src/main/services/search/searchService.ts +++ b/apps/desktop/src/main/services/search/searchService.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { Buffer } from "node:buffer"; -import { buildDeeplink } from "../../../shared/deeplinks"; +import { buildDeeplink, type DeeplinkEnvelope } from "../../../shared/deeplinks"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import type { AgentChatEventEnvelope } from "../../../shared/types/chat"; import type { LaneSummary } from "../../../shared/types/lanes"; @@ -111,6 +111,7 @@ export type SearchServiceDeps = { transcriptsDir: string; chatTranscriptsDir: string; logger?: Logger | null; + repoSlug?: () => Promise<{ owner: string; name: string } | null>; sessions: { list: () => Promise; get?: (sessionId: string) => Promise; @@ -177,10 +178,18 @@ function sessionUpdatedAt(session: TerminalSessionSummary): string { return session.lastActivityAt || session.endedAt || session.startedAt || ""; } -function sessionDeepLink(session: Pick): string { +function sessionDeepLink( + session: Pick, + envelope?: DeeplinkEnvelope | null, +): string { try { return buildDeeplink( - { kind: "session", sessionId: session.id, laneId: session.laneId || undefined }, + { + kind: "session", + sessionId: session.id, + laneId: session.laneId || undefined, + ...(envelope ? { envelope } : {}), + }, { form: "ade" } ); } catch { @@ -246,6 +255,55 @@ export function createSearchService(deps: SearchServiceDeps) { return (ensureDb().db.prepare(sql).get(...params) as T | undefined) ?? null; }; + let repoSlugPass: Promise<{ owner: string; name: string } | null> | null = null; + let laneBranchPass: Promise> | null = null; + + const stripBranchRef = (branchRef: string | null | undefined): string | null => { + const trimmed = (branchRef ?? "").trim(); + if (!trimmed) return null; + return trimmed.replace(/^refs\/heads\//, ""); + }; + + const resolveRepoSlug = async (): Promise<{ owner: string; name: string } | null> => { + if (!deps.repoSlug) return null; + if (!repoSlugPass) { + repoSlugPass = deps.repoSlug().catch(() => null); + } + return repoSlugPass; + }; + + const resolveLaneBranchMap = async (): Promise> => { + if (!deps.lanes) return new Map(); + if (!laneBranchPass) { + laneBranchPass = deps.lanes.list() + .then((lanes) => { + const map = new Map(); + for (const lane of lanes) { + const branch = stripBranchRef(lane.branchRef); + if (branch) map.set(lane.id, branch); + } + return map; + }) + .catch(() => new Map()); + } + return laneBranchPass; + }; + + const envelopeForLane = async ( + laneId: string | null | undefined, + branchHint?: string | null, + ): Promise => { + const repo = await resolveRepoSlug(); + if (!repo) return null; + const branchMap = await resolveLaneBranchMap(); + const branch = stripBranchRef(branchHint) ?? (laneId ? branchMap.get(laneId) ?? null : null); + return { + repoOwner: repo.owner, + repoName: repo.name, + ...(branch ? { branch } : {}), + }; + }; + // --------------------------------------------------------------------- // Ingestion queue // --------------------------------------------------------------------- @@ -292,6 +350,8 @@ export function createSearchService(deps: SearchServiceDeps) { if (disposed) return; const dueNow = [...queue.entries()].filter(([, entry]) => entry.dueAt <= Date.now()); if (dueNow.length === 0) break; + repoSlugPass = null; + laneBranchPass = null; for (const [key, entry] of dueNow) { queue.delete(key); try { @@ -449,7 +509,7 @@ export function createSearchService(deps: SearchServiceDeps) { } }; - const upsertSessionMetaDoc = (session: TerminalSessionSummary, kind: "chat" | "terminal"): void => { + const upsertSessionMetaDoc = (session: TerminalSessionSummary, kind: "chat" | "terminal", deepLink: string): void => { const prefix = kind === "chat" ? "chat" : "term"; const bodyParts = [session.title, session.goal ?? "", session.summary ?? ""].filter(Boolean); upsertDoc({ @@ -462,7 +522,7 @@ export function createSearchService(deps: SearchServiceDeps) { title: session.title, rankTitle: session.title, snippetSource: session.summary || session.lastOutputPreview || session.goal || null, - deepLink: sessionDeepLink(session), + deepLink, updatedAt: sessionUpdatedAt(session), body: sanitizeIndexedText(bodyParts.join("\n")) }); @@ -501,7 +561,8 @@ export function createSearchService(deps: SearchServiceDeps) { if (!isChatSession(session)) return; const sourceId = `chat:${sessionId}`; const filePath = chatTranscriptPathFor(session); - withTransaction(() => upsertSessionMetaDoc(session, "chat")); + const baseLink = sessionDeepLink(session, await envelopeForLane(session.laneId)); + withTransaction(() => upsertSessionMetaDoc(session, "chat", baseLink)); if (!filePath) return; let source = getSource(sourceId); @@ -557,7 +618,6 @@ export function createSearchService(deps: SearchServiceDeps) { const envelopes = parseAgentChatTranscript(buf.subarray(0, consumed).toString("utf8")); const laneId = session.laneId || null; const laneName = session.laneName || null; - const baseLink = sessionDeepLink(session); let docSeq = source.docSeq; withTransaction(() => { @@ -567,6 +627,14 @@ export function createSearchService(deps: SearchServiceDeps) { const text = chatEventSearchText(envelope); if (!text) continue; const sanitized = sanitizeIndexedText(text); + // Anchor by the persisted envelope sequence when present — the + // renderer matches loaded envelopes by `sequence`, which a tail-paged + // transcript can resolve; the docSeq ordinal only aligns when the + // full history is loaded. + const anchorSeq = + typeof envelope.sequence === "number" && envelope.sequence >= 0 + ? envelope.sequence + : seq; upsertDoc({ docId: `chat:${sessionId}:${seq}`, kind: "chat", @@ -576,7 +644,7 @@ export function createSearchService(deps: SearchServiceDeps) { title: session.title, rankTitle: null, snippetSource: sanitized.slice(0, 240), - deepLink: withQueryParam(baseLink, "event", seq), + deepLink: withQueryParam(baseLink, "event", anchorSeq), updatedAt: envelope.timestamp, body: sanitized }); @@ -603,7 +671,8 @@ export function createSearchService(deps: SearchServiceDeps) { // legacy value like "other" — never index it as a terminal. if (isChatSession(session)) return; const sourceId = `term:${sessionId}`; - withTransaction(() => upsertSessionMetaDoc(session, "terminal")); + const baseLink = sessionDeepLink(session, await envelopeForLane(session.laneId)); + withTransaction(() => upsertSessionMetaDoc(session, "terminal", baseLink)); const filePath = terminalTranscriptPathFor(session); if (!filePath) return; @@ -635,7 +704,6 @@ export function createSearchService(deps: SearchServiceDeps) { const { chunks, consumedBytes } = chunkTerminalTranscript(buf, source.cursor, { force }); if (consumedBytes === 0) return; - const baseLink = sessionDeepLink(session); let docSeq = source.docSeq; withTransaction(() => { for (const chunk of chunks) { @@ -762,9 +830,14 @@ export function createSearchService(deps: SearchServiceDeps) { // ignore } } + const laneEnvelope = await envelopeForLane(laneId, lane.branchRef); + const repo = await resolveRepoSlug(); let laneLink: string; try { - laneLink = buildDeeplink({ kind: "lane", laneId }, { form: "ade" }); + laneLink = buildDeeplink( + { kind: "lane", laneId, ...(laneEnvelope ? { envelope: laneEnvelope } : {}) }, + { form: "ade" }, + ); } catch { laneLink = `ade://lane/${encodeURIComponent(laneId)}`; } @@ -780,7 +853,10 @@ export function createSearchService(deps: SearchServiceDeps) { title: commit.subject, rankTitle: commit.subject, snippetSource: `${commit.shortSha} ${commit.authorName}`, - deepLink: withQueryParam(laneLink, "commit", commit.sha), + deepLink: buildDeeplink( + { kind: "commit", sha: commit.sha, laneId, ...(laneEnvelope ? { envelope: laneEnvelope } : {}) }, + { form: "ade" }, + ), updatedAt: commit.authoredAt, body: sanitizeIndexedText( [commit.subject, commit.authorName, commit.sha, commit.shortSha].join("\n") @@ -797,7 +873,12 @@ export function createSearchService(deps: SearchServiceDeps) { title: branch.name, rankTitle: branch.name, snippetSource: branch.lastCommitMessage ?? null, - deepLink: withQueryParam(laneLink, "branch", branch.name), + deepLink: repo + ? buildDeeplink( + { kind: "branch", repoOwner: repo.owner, repoName: repo.name, branch: branch.name }, + { form: "ade" }, + ) + : withQueryParam(laneLink, "branch", branch.name), updatedAt: lane.createdAt, body: sanitizeIndexedText([branch.name, branch.lastCommitMessage ?? ""].join("\n")) }); @@ -885,7 +966,12 @@ export function createSearchService(deps: SearchServiceDeps) { // --------------------------------------------------------------------- const effectiveKinds = (args: SearchQueryArgs, parsed: ParsedSearchQuery): SearchDocKind[] => { - const fromArgs = (args.kinds ?? []).filter(isSearchDocKind); + const suppliedKinds = args.kinds ?? []; + const fromArgs = suppliedKinds.filter(isSearchDocKind); + // A supplied kinds array whose entries are all invalid (e.g. ["termnal"]) + // must not silently broaden into the default all-kind set — mirror the + // inline `kind:bogus` reject in query() and match nothing instead. + if (suppliedKinds.length > 0 && fromArgs.length === 0) return []; const fromQuery = parsed.kinds; if (fromArgs.length > 0 && fromQuery.length > 0) { const intersection = fromArgs.filter((kind) => fromQuery.includes(kind)); @@ -1090,7 +1176,11 @@ export function createSearchService(deps: SearchServiceDeps) { if (parsed.sinceIso && lane.createdAt < parsed.sinceIso) continue; let deepLink: string; try { - deepLink = buildDeeplink({ kind: "lane", laneId: lane.id }, { form: "ade" }); + const envelope = await envelopeForLane(lane.id, lane.branchRef); + deepLink = buildDeeplink( + { kind: "lane", laneId: lane.id, ...(envelope ? { envelope } : {}) }, + { form: "ade" } + ); } catch { deepLink = `ade://lane/${encodeURIComponent(lane.id)}`; } @@ -1140,7 +1230,10 @@ export function createSearchService(deps: SearchServiceDeps) { laneId, laneName: null, sessionId: null, - deepLink: `ade://files?path=${encodeURIComponent(item.path)}`, + deepLink: buildDeeplink( + { kind: "file", path: item.path, ...(laneId ? { laneId } : {}) }, + { form: "ade" } + ), updatedAt: "", bm25: 0, snippet: item.path, @@ -1164,7 +1257,10 @@ export function createSearchService(deps: SearchServiceDeps) { laneId, laneName: null, sessionId: null, - deepLink: `ade://files?path=${encodeURIComponent(match.path)}&line=${match.line}`, + deepLink: buildDeeplink( + { kind: "file", path: match.path, line: match.line, ...(laneId ? { laneId } : {}) }, + { form: "ade" } + ), updatedAt: "", bm25: 0, snippet: match.preview.slice(0, 240), @@ -1201,7 +1297,7 @@ export function createSearchService(deps: SearchServiceDeps) { laneId: null, laneName: null, sessionId: null, - deepLink: `ade://proof?artifactId=${encodeURIComponent(artifact.id)}`, + deepLink: buildDeeplink({ kind: "artifact", artifactId: artifact.id }, { form: "ade" }), updatedAt: artifact.createdAt ?? "", bm25: 0, snippet, diff --git a/apps/desktop/src/main/services/search/searchServiceWiring.ts b/apps/desktop/src/main/services/search/searchServiceWiring.ts index 8a50f359d..4f7386ed1 100644 --- a/apps/desktop/src/main/services/search/searchServiceWiring.ts +++ b/apps/desktop/src/main/services/search/searchServiceWiring.ts @@ -50,6 +50,7 @@ export type ProjectSearchServiceArgs = { listRecentCommits: (args: { laneId: string; limit?: number }) => Promise; listBranches: (args: { laneId: string }) => Promise; } | null; + repoSlug?: () => Promise<{ owner: string; name: string } | null>; fileService?: { quickOpen: (args: FilesQuickOpenArgs) => Promise; searchText: (args: FilesSearchTextArgs) => Promise; @@ -93,6 +94,7 @@ export function createProjectSearchService(args: ProjectSearchServiceArgs): Sear transcriptsDir: args.transcriptsDir, chatTranscriptsDir: args.chatTranscriptsDir, logger: args.logger, + repoSlug: args.repoSlug, sessions: { list: async () => sessionService.list({ limit: null }), get: async (sessionId) => sessionService.get(sessionId) diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 0ae97e4c7..29fcb4c58 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -695,6 +695,10 @@ declare global { args?: ClearLocalAdeDataArgs, ) => Promise; listRecent: () => Promise; + findForRepo: (args: { + repoOwner: string; + repoName: string; + }) => Promise<{ rootPath: string; displayName: string } | null>; closeCurrent: () => Promise; switchToPath: (rootPath: string) => Promise; forgetRecent: (keyOrRootPath: string) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 6a13b30ed..5f9db7833 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3335,6 +3335,11 @@ contextBridge.exposeInMainWorld("ade", { ), listRecent: async (): Promise => ipcRenderer.invoke(IPC.projectListRecent), + findForRepo: async (args: { + repoOwner: string; + repoName: string; + }): Promise<{ rootPath: string; displayName: string } | null> => + ipcRenderer.invoke(IPC.projectFindForRepo, args), closeCurrent: async (): Promise => clearAround( () => { diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index efeafc0b3..39317b166 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3279,6 +3279,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { clearedAt: new Date().toISOString(), }), listRecent: resolved([]), + findForRepo: resolved(null), closeCurrent: resolved(undefined), resolveIcon: resolvedArg({ dataUrl: null, diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 678fe15ce..a0ebab493 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -11,7 +11,11 @@ import { import { useShallow } from "zustand/react/shallow"; import { AppShell } from "./AppShell"; -import { InboundDeeplinkModal } from "./InboundDeeplinkModal"; +import { + InboundDeeplinkModal, + type InboundDeeplinkDispatchOptions, + type InboundDeeplinkTarget, +} from "./InboundDeeplinkModal"; import { ClipboardDeeplinkBanner } from "./ClipboardDeeplinkBanner"; import { CrossRepoPrBanner } from "./CrossRepoPrBanner"; import { RunPage } from "../run/RunPage"; @@ -99,7 +103,18 @@ import { getDirtyFileTextForWindow } from "../../lib/dirtyWorkspaceBuffers"; import { getAiStatusCached } from "../../lib/aiDiscoveryCache"; import { dispatchWorkSurfaceRevealed } from "../terminals/workSurfaceVisibility"; import { ADE_OPEN_BUILT_IN_BROWSER_EVENT } from "../../lib/openExternal"; -import type { AppNavigationRequest, OpenProjectBinding, ProjectInfo } from "../../../shared/types"; +import { + githubRepoSlugsEqual, + parseGithubRemoteUrl, + type GithubRepoSlug, +} from "../../../shared/githubRemote"; +import { isValidRepoRelativePath } from "../../../shared/deeplinks"; +import type { + AppNavigationRequest, + AppNavigationTarget, + OpenProjectBinding, + ProjectInfo, +} from "../../../shared/types"; // Use path-based routes on http(s) (Vite in Chrome, Cursor Simple Browser, etc.). // Use hash routes for non-http(s) surfaces (e.g. packaged Electron `file://`) where @@ -907,81 +922,251 @@ function AppNavigationBridge() { const project = useAppStore((s) => s.project); const lanes = useAppStore((s) => s.lanes); const refreshLanes = useAppStore((s) => s.refreshLanes); - const [inboundBranch, setInboundBranch] = React.useState<{ - repoOwner: string; - repoName: string; - branch: string; - prNumber?: number | null; - } | null>(null); + const [inboundTarget, setInboundTarget] = React.useState(null); + // Refs keep async dispatch paths reading FRESH state: the switch-project + // modal re-dispatches after `switchToPath`, and a callback captured before + // the switch would otherwise close over the previous project's lanes/root. + const lanesRef = React.useRef(lanes); + lanesRef.current = lanes; + const projectRootRef = React.useRef(project?.rootPath ?? null); + projectRootRef.current = project?.rootPath ?? null; + + const resolveActiveProjectRepo = React.useCallback(async (): Promise => { + const lane = lanesRef.current[0]; + if (!lane) return null; + try { + const remote = await window.ade?.git?.getOriginRemote?.({ laneId: lane.id }); + return parseGithubRemoteUrl(remote?.remoteUrl ?? null); + } catch { + return null; + } + }, []); - React.useEffect(() => { - const onNavigate = window.ade?.app?.onNavigate; - if (!onNavigate) return; - return onNavigate((request: AppNavigationRequest) => { - const target = request.target; - if (target.kind === "chat" || target.kind === "work") { - const params = new URLSearchParams(); - if (target.sessionId) params.set("sessionId", target.sessionId); - if (target.laneId) params.set("laneId", target.laneId); - navigate(`/work${params.toString() ? `?${params.toString()}` : ""}`); - return; + const resolvePortableFallback = React.useCallback(async ( + entity: "chat" | "lane" | "commit", + original: AppNavigationTarget, + options: InboundDeeplinkDispatchOptions, + ): Promise => { + if (options.forceLocal || options.suppressUnresolved) return false; + const envelope = original.kind === "work" + || original.kind === "chat" + || original.kind === "lane" + || original.kind === "commit" + || original.kind === "artifact" + ? original.envelope ?? null + : null; + if (!envelope?.repoOwner || !envelope.repoName) { + setInboundTarget({ kind: "foreign", entity, envelope, original }); + return true; + } + const targetRepo: GithubRepoSlug = { owner: envelope.repoOwner, repo: envelope.repoName }; + const activeRepo = await resolveActiveProjectRepo(); + if (githubRepoSlugsEqual(activeRepo, targetRepo)) { + setInboundTarget({ kind: "foreign", entity, envelope, original }); + return true; + } + + const projectApi = window.ade?.project as typeof window.ade.project & { + findForRepo?: (args: { repoOwner: string; repoName: string }) => Promise<{ + rootPath: string; + displayName: string; + } | null>; + }; + const match = await projectApi?.findForRepo?.({ + repoOwner: envelope.repoOwner, + repoName: envelope.repoName, + }).catch(() => null); + if (match?.rootPath && match.rootPath !== projectRootRef.current) { + setInboundTarget({ kind: "switch-project", entity, project: match, original }); + return true; + } + setInboundTarget({ kind: "foreign", entity, envelope, original }); + return true; + }, [resolveActiveProjectRepo]); + + const dispatchTarget = React.useCallback(async ( + target: AppNavigationTarget, + options: InboundDeeplinkDispatchOptions = {}, + ): Promise => { + const laneById = (laneId: string | null | undefined) => + laneId ? lanesRef.current.find((lane) => lane.id === laneId) ?? null : null; + + if (target.kind === "chat" || target.kind === "work") { + if (target.sessionId && !options.forceLocal) { + const localSession = await window.ade?.sessions?.get?.(target.sessionId).catch(() => null); + if (!localSession) { + const handled = await resolvePortableFallback("chat", target, options); + if (handled) return true; + } } - if (target.kind === "lane") { - const params = new URLSearchParams(); - params.set("laneId", target.laneId); - if (target.sessionId) params.set("sessionId", target.sessionId); - navigate(`/lanes?${params.toString()}`); - return; + const params = new URLSearchParams(); + if (target.sessionId) params.set("sessionId", target.sessionId); + if (target.laneId) params.set("laneId", target.laneId); + if (target.event != null) params.set("event", String(target.event)); + if (target.offset != null) params.set("offset", String(target.offset)); + navigate(`/work${params.toString() ? `?${params.toString()}` : ""}`); + return true; + } + + if (target.kind === "file") { + // Defense in depth: targets normally arrive parser-validated, but the + // app/navigate RPC path bypasses URL parsing — never compose a path + // that could escape the resolved root. + if (!isValidRepoRelativePath(target.path)) return true; + let lane = laneById(target.laneId); + if (target.laneId && !lane) { + // An explicit lane must never silently degrade to the project root — + // the same relative path exists in every worktree. Lanes may still be + // loading (cold start, just-switched project): refresh and wait + // briefly before giving up. + void refreshLanes({ includeStatus: false }).catch(() => undefined); + for (let attempt = 0; attempt < 6 && !lane; attempt += 1) { + await new Promise((resolve) => window.setTimeout(resolve, 500)); + lane = laneById(target.laneId); + } + if (!lane) { + if (options.suppressUnresolved) return false; + navigate("/files"); + return true; + } } - if (target.kind === "pr") { + const root = lane?.worktreePath || projectRootRef.current || ""; + const localPath = root + ? `${root.replace(/\/+$/, "")}/${target.path.replace(/^\/+/, "")}` + : target.path; + const params = new URLSearchParams(); + params.set("externalPath", localPath); + params.set("externalOpen", String(Date.now())); + if (target.line != null) params.set("line", String(target.line)); + navigate(`/files?${params.toString()}`); + return true; + } + + if (target.kind === "commit") { + const lane = laneById(target.laneId); + if (lane) { const params = new URLSearchParams(); - if (target.prId) params.set("prId", target.prId); - if (target.prNumber != null) params.set("pr", String(target.prNumber)); - if (target.laneId) params.set("laneId", target.laneId); - // Forward repo identity so the PRs tab can detect cross-project - // deeplinks (and offer to switch projects) instead of silently - // showing an empty filter. - if (target.repoOwner) params.set("repoOwner", target.repoOwner); - if (target.repoName) params.set("repoName", target.repoName); - navigate(`/prs${params.toString() ? `?${params.toString()}` : ""}`); - return; - } - if (target.kind === "branch") { - setInboundBranch({ - repoOwner: target.repoOwner, - repoName: target.repoName, - branch: target.branch, - prNumber: target.prNumber ?? null, - }); - return; + params.set("laneId", lane.id); + params.set("focus", "single"); + // LanesPage consumes commitSha and opens the Git detail for that commit. + params.set("commitSha", target.sha); + navigate(`/lanes?${params.toString()}`); + return true; } - if (target.kind === "linear-issue") { - requestLinearIssueQuickView({ - issueIdentifier: target.issueIdentifier, - branch: target.branch ?? null, - source: "deeplink", - }); - return; + if (!options.forceLocal) { + const handled = await resolvePortableFallback("commit", target, options); + if (handled) return true; + // During switch-project retries the lane list may still be loading — + // report unhandled so the caller retries instead of dropping the sha. + if (options.suppressUnresolved) return false; } - if (target.kind === "files-external") { - const params = new URLSearchParams(); - params.set("externalPath", target.path); - params.set("externalOpen", String(Date.now())); - navigate(`/files?${params.toString()}`); - return; + // Last resort: keep the lane hint + sha in the route so LanesPage can + // apply them once lanes load, instead of landing on a bare list. + const params = new URLSearchParams(); + if (target.laneId) { + params.set("laneId", target.laneId); + params.set("focus", "single"); + params.set("commitSha", target.sha); } - if (target.kind === "route") { - navigate(target.route.startsWith("/") ? target.route : `/${target.route}`); + navigate(params.toString() ? `/lanes?${params.toString()}` : "/lanes"); + return true; + } + + if (target.kind === "artifact") { + // History does not expose a stable focused-artifact URL yet; route to the + // local proof/history surface optimistically. + navigate("/history"); + return true; + } + + if (target.kind === "lane") { + const lane = laneById(target.laneId); + if (!lane && !options.forceLocal) { + const handled = await resolvePortableFallback("lane", target, options); + if (handled) return true; } + const params = new URLSearchParams(); + params.set("laneId", target.laneId); + if (target.sessionId) params.set("sessionId", target.sessionId); + navigate(`/lanes?${params.toString()}`); + return true; + } + + if (target.kind === "pr") { + const params = new URLSearchParams(); + if (target.prId) params.set("prId", target.prId); + if (target.prNumber != null) params.set("pr", String(target.prNumber)); + if (target.laneId) params.set("laneId", target.laneId); + // Forward repo identity so the PRs tab can detect cross-project + // deeplinks (and offer to switch projects) instead of silently + // showing an empty filter. + if (target.repoOwner) params.set("repoOwner", target.repoOwner); + if (target.repoName) params.set("repoName", target.repoName); + navigate(`/prs${params.toString() ? `?${params.toString()}` : ""}`); + return true; + } + + if (target.kind === "branch") { + setInboundTarget({ + kind: "branch", + repoOwner: target.repoOwner, + repoName: target.repoName, + branch: target.branch, + prNumber: target.prNumber ?? null, + }); + return true; + } + + if (target.kind === "linear-issue") { + requestLinearIssueQuickView({ + issueIdentifier: target.issueIdentifier, + branch: target.branch ?? null, + source: "deeplink", + }); + return true; + } + + if (target.kind === "files-external") { + const params = new URLSearchParams(); + params.set("externalPath", target.path); + params.set("externalOpen", String(Date.now())); + navigate(`/files?${params.toString()}`); + return true; + } + + if (target.kind === "route") { + navigate(target.route.startsWith("/") ? target.route : `/${target.route}`); + return true; + } + + return false; + }, [navigate, refreshLanes, resolvePortableFallback]); + + // The modal's post-switch retry loop must always call the LATEST dispatcher + // (fresh lanes/project), not the instance captured when the card rendered. + const dispatchTargetRef = React.useRef(dispatchTarget); + dispatchTargetRef.current = dispatchTarget; + const dispatchLatest = React.useCallback( + (target: AppNavigationTarget, options?: InboundDeeplinkDispatchOptions) => + dispatchTargetRef.current(target, options), + [], + ); + + React.useEffect(() => { + const onNavigate = window.ade?.app?.onNavigate; + if (!onNavigate) return; + return onNavigate((request: AppNavigationRequest) => { + void dispatchLatest(request.target); }); - }, [navigate]); + }, [dispatchLatest]); - if (!inboundBranch) return null; + if (!inboundTarget) return null; return ( setInboundBranch(null)} + onClose={() => setInboundTarget(null)} + onDispatchTarget={dispatchLatest} projectOpen={Boolean(project?.rootPath)} onLaneOpened={(laneId) => { const params = new URLSearchParams({ laneId }); diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index cefe845a9..95ad05648 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -33,6 +33,7 @@ import type { RemoteRuntimeProjectRecord, } from "../../../shared/types"; import type { SearchResultItem } from "../../../shared/types/search"; +import { parseDeeplink } from "../../../shared/deeplinks"; import { extractError } from "../../lib/format"; import { requestLinearIssueQuickView } from "../../lib/linearIssueQuickViewNavigation"; import { @@ -46,6 +47,7 @@ import { fadeScale } from "../../lib/motion"; import { PROJECT_BROWSER_CLOSE_EVENT } from "../../lib/projectBrowserEvents"; import { useAppStore } from "../../state/appStore"; import { cn } from "../ui/cn"; +import { setPendingSessionAnchor } from "../terminals/pendingSessionAnchors"; import { readStoredPrsRoute } from "../prs/prsRouteState"; import { AddProjectChooser } from "../projects/AddProjectChooser"; import { CloneProjectForm } from "../projects/CloneProjectForm"; @@ -1123,6 +1125,16 @@ export function CommandPalette({ case "terminal": { const sessionId = item.sessionId; if (!sessionId) break; + // Message/chunk hits carry an anchor in their deep link (event seq + // for chat, byte offset for terminal scrollback) — hand it off so + // the session's content surface positions to the hit. + const linkTarget = parseDeeplink(item.deepLink); + if (linkTarget.ok && linkTarget.target.kind === "session") { + setPendingSessionAnchor(sessionId, { + event: linkTarget.target.event, + offset: linkTarget.target.offset, + }); + } // The Work tab stays mounted (keep-alive), so its select-session // listener focuses the target; the navigate switches the visible tab. window.dispatchEvent( @@ -1152,10 +1164,24 @@ export function CommandPalette({ navigate(`/prs?prId=${encodeURIComponent(prId)}`); break; } - case "commit": + case "commit": { + const parsed = item.deepLink ? parseDeeplink(item.deepLink) : null; + const target = parsed?.ok && parsed.target.kind === "commit" ? parsed.target : null; + const laneId = target?.laneId ?? item.laneId ?? null; + if (laneId && target?.sha) { + navigate( + `/lanes?laneId=${encodeURIComponent(laneId)}&focus=single&commitSha=${encodeURIComponent(target.sha)}`, + ); + } else if (item.laneId) { + navigate( + `/lanes?laneId=${encodeURIComponent(item.laneId)}&focus=single`, + ); + } else { + navigate("/lanes"); + } + break; + } case "branch": { - // Commit/branch deep-anchoring inside lane detail isn't wired yet; - // opening the owning lane is the correct v1 behavior. if (item.laneId) { navigate( `/lanes?laneId=${encodeURIComponent(item.laneId)}&focus=single`, @@ -1169,7 +1195,7 @@ export function CommandPalette({ // The Files tab only opens absolute paths via its external-open param, // and result paths are repo-relative — resolve against the matched // lane's worktree when the search was lane-scoped, else the project - // root. Line anchoring isn't supported there, so v1 opens at the top. + // root. Content hits carry a line anchor the editor reveals on open. const relative = relativeFilePathForResult(item); const laneWorktree = item.laneId ? lanes.find((lane) => lane.id === item.laneId)?.worktreePath ?? null @@ -1179,11 +1205,12 @@ export function CommandPalette({ const separator = root.includes("\\") ? "\\" : "/"; const absolute = `${root}${ root.endsWith(separator) ? "" : separator - }${relative}`; + }${relative.path}`; + const line = relative.line && relative.line > 0 ? `&line=${relative.line}` : ""; navigate( `/files?externalPath=${encodeURIComponent( absolute, - )}&externalOpen=${Date.now()}`, + )}&externalOpen=${Date.now()}${line}`, ); } else { navigate("/files"); diff --git a/apps/desktop/src/renderer/components/app/InboundDeeplinkModal.test.tsx b/apps/desktop/src/renderer/components/app/InboundDeeplinkModal.test.tsx index a87779be9..918e989cf 100644 --- a/apps/desktop/src/renderer/components/app/InboundDeeplinkModal.test.tsx +++ b/apps/desktop/src/renderer/components/app/InboundDeeplinkModal.test.tsx @@ -118,4 +118,28 @@ describe("InboundDeeplinkModal", () => { expect(createButton.disabled).toBe(true); expect(importBranch).not.toHaveBeenCalled(); }); + + it("offers to open foreign commit links on GitHub when repo envelope is present", () => { + const openExternal = vi.fn(async () => undefined); + globalThis.window.ade = { + app: { openExternal }, + } as any; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /open commit on github/i })); + expect(openExternal).toHaveBeenCalledWith("https://github.com/acme/ade/commit/abc1234"); + }); }); diff --git a/apps/desktop/src/renderer/components/app/InboundDeeplinkModal.tsx b/apps/desktop/src/renderer/components/app/InboundDeeplinkModal.tsx index d8c53c980..d7d01c3c2 100644 --- a/apps/desktop/src/renderer/components/app/InboundDeeplinkModal.tsx +++ b/apps/desktop/src/renderer/components/app/InboundDeeplinkModal.tsx @@ -10,9 +10,13 @@ import { primaryButton, } from "../lanes/laneDesignTokens"; import type { + AppNavigationTarget, CreateLaneFromPrBranchPreflightResult, LaneSummary, } from "../../../shared/types"; +import type { DeeplinkEnvelope } from "../../../shared/deeplinks"; +import { openExternalUrl } from "../../lib/openExternal"; +import { requestLinearIssueQuickView } from "../../lib/linearIssueQuickViewNavigation"; export type InboundBranchDeeplink = { repoOwner: string; @@ -21,10 +25,34 @@ export type InboundBranchDeeplink = { prNumber?: number | null; }; +export type InboundDeeplinkTarget = + | ({ kind: "branch" } & InboundBranchDeeplink) + | { + kind: "foreign"; + entity: "chat" | "lane" | "commit"; + envelope: DeeplinkEnvelope | null; + original: AppNavigationTarget; + } + | { + kind: "switch-project"; + entity: "chat" | "lane" | "commit"; + project: { rootPath: string; displayName: string }; + original: AppNavigationTarget; + }; + +export type InboundDeeplinkDispatchOptions = { + suppressUnresolved?: boolean; + forceLocal?: boolean; +}; + export type InboundDeeplinkModalProps = { - target: InboundBranchDeeplink; + target: InboundDeeplinkTarget | InboundBranchDeeplink; onClose: () => void; onLaneOpened: (laneId: string) => void; + onDispatchTarget?: ( + target: AppNavigationTarget, + options?: InboundDeeplinkDispatchOptions, + ) => Promise | boolean; lanes: LaneSummary[]; projectOpen?: boolean; }; @@ -49,11 +77,7 @@ function normalizeBranchForCompare(branch: string): string { function laneOwnedByBranch(lanes: LaneSummary[], branch: string): LaneSummary | null { const normalized = normalizeBranchForCompare(branch); return ( - lanes.find((lane) => { - const ref = lane.branchRef ?? ""; - const laneBranch = normalizeBranchForCompare(ref); - return laneBranch === normalized; - }) ?? null + lanes.find((lane) => normalizeBranchForCompare(lane.branchRef ?? "") === normalized) ?? null ); } @@ -61,46 +85,85 @@ function displayBranchName(branch: string): string { return normalizeBranchForCompare(branch) || branch; } +function normalizeInboundTarget(target: InboundDeeplinkTarget | InboundBranchDeeplink): InboundDeeplinkTarget { + return "kind" in target ? target : { kind: "branch", ...target }; +} + +function originalTargetLabel(target: AppNavigationTarget, entity: "chat" | "lane" | "commit"): string { + if ((target.kind === "work" || target.kind === "chat") && target.sessionId) return `Chat ${target.sessionId}`; + if (target.kind === "lane") return "Lane"; + if (target.kind === "commit") return `Commit ${target.sha}`; + return entity === "chat" ? "Chat" : entity === "commit" ? "Commit" : "Lane"; +} + +function targetEnvelope(target: AppNavigationTarget): DeeplinkEnvelope | null { + if ( + (target.kind === "work" + || target.kind === "chat" + || target.kind === "lane" + || target.kind === "commit" + || target.kind === "artifact") + && target.envelope + ) { + return target.envelope; + } + return null; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + export function InboundDeeplinkModal({ target, onClose, onLaneOpened, + onDispatchTarget, lanes, projectOpen = true, }: InboundDeeplinkModalProps): React.ReactElement | null { + const [currentTarget, setCurrentTarget] = React.useState(() => normalizeInboundTarget(target)); const [preflight, setPreflight] = React.useState(null); const [loading, setLoading] = React.useState(false); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); - const isBranchOnly = !target.prNumber; + const branchTarget = currentTarget.kind === "branch" ? currentTarget : null; + const isBranchOnly = branchTarget ? !branchTarget.prNumber : false; const laneImportAvailable = typeof window.ade?.lanes?.importBranch === "function"; + React.useEffect(() => { + setCurrentTarget(normalizeInboundTarget(target)); + setPreflight(null); + setLoading(false); + setBusy(false); + setError(null); + }, [target]); + const existingLane = React.useMemo( - () => laneOwnedByBranch(lanes, target.branch), - [lanes, target.branch], + () => branchTarget ? laneOwnedByBranch(lanes, branchTarget.branch) : null, + [branchTarget, lanes], ); React.useEffect(() => { - // If a lane already exists for this branch, jump straight to it — no modal. + if (!branchTarget) return; if (existingLane) { onLaneOpened(existingLane.id); onClose(); } - }, [existingLane, onLaneOpened, onClose]); + }, [branchTarget, existingLane, onLaneOpened, onClose]); React.useEffect(() => { + if (!branchTarget) return; if (existingLane) return; if (!projectOpen) { setPreflight(null); - setError(`Open the ADE project for ${target.repoOwner}/${target.repoName} before creating a lane from this deeplink.`); + setError(`Open the ADE project for ${branchTarget.repoOwner}/${branchTarget.repoName} before creating a lane from this deeplink.`); setLoading(false); return; } if (isBranchOnly) { setPreflight(null); - setError(laneImportAvailable - ? null - : "This ADE surface cannot create lanes from branch deeplinks."); + setError(laneImportAvailable ? null : "This ADE surface cannot create lanes from branch deeplinks."); setLoading(false); return; } @@ -115,52 +178,213 @@ export function InboundDeeplinkModal({ setPreflight(null); void prsApi .preflightCreateLaneFromPrBranch({ - repoOwner: target.repoOwner, - repoName: target.repoName, - githubPrNumber: target.prNumber ?? undefined, + repoOwner: branchTarget.repoOwner, + repoName: branchTarget.repoName, + githubPrNumber: branchTarget.prNumber ?? undefined, }) .then((result) => { - if (cancelled) return; - setPreflight(result); + if (!cancelled) setPreflight(result); }) .catch((err) => { - if (cancelled) return; - setError(formatActionError(err)); + if (!cancelled) setError(formatActionError(err)); }) .finally(() => { - if (cancelled) return; - setLoading(false); + if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [existingLane, isBranchOnly, laneImportAvailable, projectOpen, target.repoOwner, target.repoName, target.prNumber]); + }, [branchTarget, existingLane, isBranchOnly, laneImportAvailable, projectOpen]); if (existingLane) return null; + const renderFrame = (subtitle: string, body: React.ReactNode, footer: React.ReactNode): React.ReactElement => ( +
{ + if (event.target === event.currentTarget && !busy) onClose(); + }} + > +
+
+ +
{subtitle}
+
+
+ {body} + {error ?
{error}
: null} +
+
+ {footer} +
+
+
+ ); + + if (!branchTarget) { + if (currentTarget.kind === "switch-project") { + const envelope = targetEnvelope(currentTarget.original); + const repoLabel = envelope?.repoOwner && envelope.repoName ? `${envelope.repoOwner}/${envelope.repoName}` : null; + const rows: Array = [ + ["Project", currentTarget.project.displayName], + ...(repoLabel ? [["Repo", repoLabel] as const] : []), + ["Opens", originalTargetLabel(currentTarget.original, currentTarget.entity)], + ]; + const onSwitchProject = async () => { + const switchToPath = window.ade?.project?.switchToPath; + if (!switchToPath || !onDispatchTarget) { + setError("Project switching is not available in this build."); + return; + } + setBusy(true); + setError(null); + try { + await switchToPath(currentTarget.project.rootPath); + } catch (err) { + setError(formatActionError(err)); + setBusy(false); + return; + } + onClose(); + void (async () => { + for (let attempt = 0; attempt < 5; attempt += 1) { + if (attempt > 0) await sleep(500); + const handled = await onDispatchTarget(currentTarget.original, { + suppressUnresolved: attempt < 4, + forceLocal: attempt === 4, + }); + if (handled) return; + } + })().catch(() => undefined); + }; + + return renderFrame( + "This link belongs to another ADE project on this machine.", +
+ {rows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
, + <> + + + , + ); + } + + if (currentTarget.kind !== "foreign") return null; + const envelope = currentTarget.envelope; + const repoLabel = envelope?.repoOwner && envelope.repoName ? `${envelope.repoOwner}/${envelope.repoName}` : null; + const headline = repoLabel + ? `This ${currentTarget.entity} lives in ${repoLabel} on another machine.` + : `This ${currentTarget.entity} lives on another machine.`; + const hasRepo = Boolean(envelope?.repoOwner && envelope.repoName); + const actions: React.ReactNode[] = []; + if (hasRepo && envelope?.branch) { + actions.push( + , + ); + } + if (hasRepo && envelope?.prNumber != null) { + actions.push( + , + ); + } + if (hasRepo && currentTarget.original.kind === "commit") { + const commitSha = currentTarget.original.sha; + actions.push( + , + ); + } + if (envelope?.linearIssue) { + actions.push( + , + ); + } + + return renderFrame( + repoLabel ? "This link points at a machine-local ADE id." : "This link does not resolve on this machine.", + <> +
{headline}
+ {!repoLabel ?
The link did not carry anything this machine can open.
: null} + {actions.length > 0 ?
{actions}
: null} + , + , + ); + } + const blocking = preflight?.preflight.blockingConflict?.message ?? null; - const canConfirm = - isBranchOnly - ? projectOpen && !loading && !busy && laneImportAvailable - : projectOpen && Boolean(preflight?.preflight.canCreate) && !loading && !busy && !error; + const canConfirm = isBranchOnly + ? projectOpen && !loading && !busy && laneImportAvailable + : projectOpen && Boolean(preflight?.preflight.canCreate) && !loading && !busy && !error; const rows: Array = []; if (preflight?.preflight) { const p = preflight.preflight; rows.push(["Repo", `${p.repoOwner}/${p.repoName}`]); - if (p.headBranch) { - rows.push(["Branch", p.baseBranch ? `${p.headBranch} → ${p.baseBranch}` : p.headBranch]); - } + if (p.headBranch) rows.push(["Branch", p.baseBranch ? `${p.headBranch} -> ${p.baseBranch}` : p.headBranch]); rows.push(["PR", `#${p.githubPrNumber} ${p.title}`.trim()]); rows.push(["Target lane", p.targetLaneName]); } else { - rows.push(["Repo", `${target.repoOwner}/${target.repoName}`]); - rows.push(["Branch", target.branch]); - if (target.prNumber) { - rows.push(["PR", `#${target.prNumber}`]); - } else { - rows.push(["Action", "Fetch remote branch and create a local lane"]); - } + rows.push(["Repo", `${branchTarget.repoOwner}/${branchTarget.repoName}`]); + rows.push(["Branch", branchTarget.branch]); + if (branchTarget.prNumber) rows.push(["PR", `#${branchTarget.prNumber}`]); + else rows.push(["Action", "Fetch remote branch and create a local lane"]); } const onConfirm = async () => { @@ -174,11 +398,8 @@ export function InboundDeeplinkModal({ return; } try { - const branch = target.branch.trim(); - const lane = await lanesApi.importBranch({ - branchRef: branch, - name: displayBranchName(branch), - }); + const branch = branchTarget.branch.trim(); + const lane = await lanesApi.importBranch({ branchRef: branch, name: displayBranchName(branch) }); if (lane.id) onLaneOpened(lane.id); onClose(); } catch (err) { @@ -188,7 +409,7 @@ export function InboundDeeplinkModal({ } return; } - if (!target.prNumber) return; + if (!branchTarget.prNumber) return; const prsApi = window.ade?.prs; if (!prsApi?.createLaneFromPrBranch) { setError("Inbound deeplinks are not available in this build."); @@ -197,9 +418,9 @@ export function InboundDeeplinkModal({ } try { const result = await prsApi.createLaneFromPrBranch({ - repoOwner: target.repoOwner, - repoName: target.repoName, - githubPrNumber: target.prNumber, + repoOwner: branchTarget.repoOwner, + repoName: branchTarget.repoName, + githubPrNumber: branchTarget.prNumber, }); if (result.lane?.id) onLaneOpened(result.lane.id); onClose(); @@ -213,137 +434,44 @@ export function InboundDeeplinkModal({ return (
{ if (event.target === event.currentTarget && !busy) onClose(); }} > -
+
- +
- {isBranchOnly - ? "A branch was shared with you. ADE can fetch it and create the lane locally." - : "A branch was shared with you. Create a lane to start working on it locally."} + {isBranchOnly ? "A branch was shared with you. ADE can fetch it and create the lane locally." : "A branch was shared with you. Create a lane to start working on it locally."}
{loading ? (
- {isBranchOnly - ? "Preparing branch import..." - : "Checking branch ownership and remote availability..."} + {isBranchOnly ? "Preparing branch import..." : "Checking branch ownership and remote availability..."}
) : (
{rows.map(([label, value]) => ( -
+
{label}
-
- {value} -
+
{value}
))}
)} {blocking ? ( -
+
{blocking}
) : null} - {error ? ( -
- {error} -
- ) : null} + {error ?
{error}
: null}
-
- - +
diff --git a/apps/desktop/src/renderer/components/app/commandPaletteSearch.tsx b/apps/desktop/src/renderer/components/app/commandPaletteSearch.tsx index 92b2fdb93..94c332cac 100644 --- a/apps/desktop/src/renderer/components/app/commandPaletteSearch.tsx +++ b/apps/desktop/src/renderer/components/app/commandPaletteSearch.tsx @@ -26,6 +26,7 @@ import type { SearchMatchRange, SearchResultItem, } from "../../../shared/types/search"; +import { parseDeeplink } from "../../../shared/deeplinks"; import { relativeTimeCompact } from "../../lib/format"; import { cn } from "../ui/cn"; @@ -137,21 +138,22 @@ export function highlightRanges( return nodes; } -// Recover the repo-relative file path from a `file` result's deepLink (falling -// back to its doc id, which is `file:` or `file::`). +// Recover the repo-relative file path (+ optional line anchor) from a `file` +// result's deepLink (falling back to its doc id, which is `file:` or +// `file::`). export function relativeFilePathForResult( item: SearchResultItem, -): string | null { - try { - const parsed = new URL(item.deepLink); - const path = parsed.searchParams.get("path"); - if (path) return path; - } catch { - // Non-URL deepLink; fall through to id parsing. +): { path: string; line: number | null } | null { + const parsed = parseDeeplink(item.deepLink); + if (parsed.ok && parsed.target.kind === "file") { + return { path: parsed.target.path, line: parsed.target.line ?? null }; } let rest = item.id.startsWith("file:") ? item.id.slice(5) : item.id; + const lineMatch = rest.match(/:(\d+)$/); rest = rest.replace(/:\d+$/, ""); - return rest.length > 0 ? rest : null; + return rest.length > 0 + ? { path: rest, line: lineMatch ? Number(lineMatch[1]) : null } + : null; } export type EntitySection = { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 099cd39b8..477163bbc 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -49,12 +49,18 @@ import { calculateVirtualWindow, calculateVirtualWindowAnchoredToEnd, deriveTurnModelState, + findAnchoredChatEventIndex, formatElapsedSeconds, reconcileMeasuredScrollTop, + resolveAnchoredChatRowIndex, shouldAbsorbProgrammaticScrollEvent, shouldStickToBottomAfterScroll, looksLikeWireframe, } from "./AgentChatMessageList"; +import { + collapseChatTranscriptEvents, + groupConsecutiveWorkLogRows, +} from "./chatTranscriptRows"; function findButtonByTextContent(matcher: RegExp): HTMLButtonElement { // Option buttons carry role="radio"/"checkbox" for accessibility, so search @@ -1402,6 +1408,49 @@ describe("AgentChatMessageList transcript rendering", () => { expect(win.offsetTop).toBe(0); }); + it("resolves chat deeplink anchors by envelope sequence before ordinal fallback", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "user_message", text: "first", messageId: "user-1" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + sequence: 41, + event: { type: "user_message", text: "target", messageId: "user-2" }, + }, + ]; + const groupedRows = groupConsecutiveWorkLogRows(collapseChatTranscriptEvents(events)); + + expect(findAnchoredChatEventIndex({ events, anchorEvent: 41, hasFullHistory: false })).toBe(1); + expect(resolveAnchoredChatRowIndex({ events, groupedRows, anchorEvent: 41, hasFullHistory: false })).toBe(1); + expect(findAnchoredChatEventIndex({ events, anchorEvent: 1, hasFullHistory: false })).toBe(-1); + expect(findAnchoredChatEventIndex({ events, anchorEvent: 1, hasFullHistory: true })).toBe(1); + }); + + it("maps anchors inside merged text events to the containing rendered row", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + sequence: 40, + event: { type: "text", text: "hello", messageId: "assistant-1", turnId: "turn-1" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + sequence: 41, + event: { type: "text", text: " world", messageId: "assistant-1", turnId: "turn-1" }, + }, + ]; + const groupedRows = groupConsecutiveWorkLogRows(collapseChatTranscriptEvents(events)); + + expect(groupedRows).toHaveLength(1); + expect(resolveAnchoredChatRowIndex({ events, groupedRows, anchorEvent: 41, hasFullHistory: false })).toBe(0); + }); + it("formats turn elapsed time as working-for seconds then minutes", () => { expect(formatElapsedSeconds(0)).toBe("0s"); expect(formatElapsedSeconds(42)).toBe("42s"); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 8182fbb19..cf0d581ff 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -69,6 +69,7 @@ import type { ChatSubagentSnapshot } from "./chatExecutionSummary"; import { ChatWorkLogBlock } from "./ChatWorkLogBlock"; import { ChatStatusGlyph } from "./chatStatusVisuals"; import { + collapseChatTranscriptEvents, collapseChatTranscriptEventsIncremental, formatStructuredValue, groupConsecutiveWorkLogRows, @@ -98,6 +99,7 @@ import { CodexPlanCard } from "./codex/CodexPlanCard"; import { CodexImageGenerationCard } from "./codex/CodexImageGenerationCard"; import { CodexImageViewLine } from "./codex/CodexImageViewLine"; import { CodexContextCompactionChip } from "./codex/CodexContextCompactionChip"; +import { peekPendingSessionAnchor, takePendingSessionAnchor } from "../terminals/pendingSessionAnchors"; /** * Threaded into MarkdownBlock only for Claude-family sessions. When present, a @@ -3779,6 +3781,7 @@ type EventRowProps = { sessionId?: string | null; runtimeName?: string | null; mosaic?: MosaicRenderContext; + anchored?: boolean; }; const EventRow = React.memo(function EventRow({ @@ -3807,12 +3810,19 @@ const EventRow = React.memo(function EventRow({ sessionId, runtimeName, mosaic, + anchored, }: EventRowProps) { const workLogAnimate = Boolean(turnActive) && !sessionEnded && Boolean(isLatestWorkLog); return ( -
+
{showTurnDivider ? (
@@ -4116,6 +4126,51 @@ export function reconcileMeasuredScrollTop({ return scrollTop; } +export function findAnchoredChatEventIndex({ + events, + anchorEvent, + hasFullHistory, +}: { + events: AgentChatEventEnvelope[]; + anchorEvent: number; + hasFullHistory: boolean; +}): number { + if (!Number.isInteger(anchorEvent) || anchorEvent < 0) return -1; + const sequenceIndex = events.findIndex((envelope) => envelope.sequence === anchorEvent); + if (sequenceIndex >= 0) return sequenceIndex; + if (!hasFullHistory) return -1; + return anchorEvent < events.length ? anchorEvent : -1; +} + +export function resolveAnchoredChatRowIndex({ + events, + groupedRows, + anchorEvent, + hasFullHistory, +}: { + events: AgentChatEventEnvelope[]; + groupedRows: TranscriptGroupedEnvelope[]; + anchorEvent: number; + hasFullHistory: boolean; +}): number { + const eventIndex = findAnchoredChatEventIndex({ events, anchorEvent, hasFullHistory }); + if (eventIndex < 0) return -1; + const targetRows = groupConsecutiveWorkLogRows( + collapseChatTranscriptEvents(events.slice(0, eventIndex + 1)), + ); + const targetRow = targetRows[targetRows.length - 1]; + if (!targetRow) return -1; + return groupedRows.findIndex((row) => row.key === targetRow.key); +} + +type PendingChatEventAnchor = { + event: number; + loadRequests: number; + waitingForOlderHistory: boolean; + sawOlderHistoryLoading: boolean; + lastEventsLength: number; +}; + function AgentChatMessageListMain({ events, showStreamingIndicator = false, @@ -4203,6 +4258,10 @@ function AgentChatMessageListMain({ const [scrollTop, setScrollTop] = useState(0); const [containerHeight, setContainerHeight] = useState(0); const [measurementTick, setMeasurementTick] = useState(0); + const [anchoredRowKey, setAnchoredRowKey] = useState(null); + const pendingChatEventAnchorRef = useRef(null); + const anchorHighlightTimerRef = useRef | null>(null); + const anchorCorrectionRafRef = useRef(null); // Map of row key → measured height (filled in lazily as rows render). // Keeping this keyed by row identity prevents stale measurements from a // previous row at the same index from creating phantom scroll space. @@ -4241,6 +4300,22 @@ function AgentChatMessageListMain({ onApprovalRef.current = onApproval; }, [onApproval]); + useLayoutEffect(() => { + pendingChatEventAnchorRef.current = null; + setAnchoredRowKey(null); + }, [sessionId]); + + useEffect(() => () => { + if (anchorHighlightTimerRef.current) { + clearTimeout(anchorHighlightTimerRef.current); + anchorHighlightTimerRef.current = null; + } + if (anchorCorrectionRafRef.current !== null) { + cancelAnimationFrame(anchorCorrectionRafRef.current); + anchorCorrectionRafRef.current = null; + } + }, []); + const handleApproval = useCallback((itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => { onApprovalRef.current?.(itemId, decision, responseText, answers); }, []); @@ -4479,6 +4554,125 @@ function AgentChatMessageListMain({ return key ? (measuredHeights.current.get(key) ?? ESTIMATED_ROW_HEIGHT) : ESTIMATED_ROW_HEIGHT; }, [groupedRowKeys]); + const scrollToRowIndexNearTop = useCallback((rowIndex: number) => { + const el = scrollRef.current; + if (!el || rowIndex < 0 || rowIndex >= groupedRows.length) return false; + const offsets = computeRowStartOffsets(groupedRows.length, rowHeight, timelineRowGapPx); + const targetTop = computeScrollTopForRow(rowIndex, offsets); + const maxScroll = Math.max(0, el.scrollHeight - el.clientHeight); + const clamped = Math.max(0, Math.min(maxScroll, targetTop)); + stickToBottomRef.current = false; + setStickToBottom(false); + scrollFollowFramesRef.current = 0; + if (scrollRafRef.current !== null) { + cancelAnimationFrame(scrollRafRef.current); + scrollRafRef.current = null; + } + const before = el.scrollTop; + el.scrollTop = clamped; + if (el.scrollTop !== before) { + programmaticScrollTargetRef.current = el.scrollTop; + } + setScrollTop(el.scrollTop); + return true; + }, [groupedRows.length, rowHeight, timelineRowGapPx]); + + const scheduleAnchoredRowCorrection = useCallback((rowKey: string) => { + if (anchorCorrectionRafRef.current !== null) { + cancelAnimationFrame(anchorCorrectionRafRef.current); + anchorCorrectionRafRef.current = null; + } + let remainingFrames = 2; + const run = () => { + anchorCorrectionRafRef.current = null; + const rowIndex = groupedRowKeys.indexOf(rowKey); + if (rowIndex >= 0) scrollToRowIndexNearTop(rowIndex); + remainingFrames -= 1; + if (remainingFrames > 0) { + anchorCorrectionRafRef.current = requestAnimationFrame(run); + } + }; + anchorCorrectionRafRef.current = requestAnimationFrame(run); + }, [groupedRowKeys, scrollToRowIndexNearTop]); + + useLayoutEffect(() => { + if (!sessionId || events.length === 0) return; + let pending = pendingChatEventAnchorRef.current; + if (!pending) { + const queued = peekPendingSessionAnchor(sessionId); + if (queued?.event == null) return; + const consumed = takePendingSessionAnchor(sessionId); + if (consumed?.event == null) return; + pending = { + event: consumed.event, + loadRequests: 0, + waitingForOlderHistory: false, + sawOlderHistoryLoading: false, + lastEventsLength: events.length, + }; + pendingChatEventAnchorRef.current = pending; + } + + if (pending.waitingForOlderHistory) { + if (loadingOlderHistory) { + pending.sawOlderHistoryLoading = true; + } else if (!pending.sawOlderHistoryLoading && events.length === pending.lastEventsLength) { + return; + } else { + pending.waitingForOlderHistory = false; + pending.sawOlderHistoryLoading = false; + } + } + + const rowIndex = resolveAnchoredChatRowIndex({ + events, + groupedRows, + anchorEvent: pending.event, + hasFullHistory: !hasOlderHistory, + }); + if (rowIndex >= 0) { + const rowKey = groupedRows[rowIndex]?.key ?? null; + pendingChatEventAnchorRef.current = null; + if (rowKey) { + setAnchoredRowKey(rowKey); + if (anchorHighlightTimerRef.current) clearTimeout(anchorHighlightTimerRef.current); + anchorHighlightTimerRef.current = setTimeout(() => { + anchorHighlightTimerRef.current = null; + setAnchoredRowKey((current) => (current === rowKey ? null : current)); + }, 2000); + } + scrollToRowIndexNearTop(rowIndex); + if (rowKey) scheduleAnchoredRowCorrection(rowKey); + return; + } + + if (!hasOlderHistory) { + pendingChatEventAnchorRef.current = null; + return; + } + if (loadingOlderHistory) return; + if (pending.loadRequests >= 10) { + pendingChatEventAnchorRef.current = null; + return; + } + pending.loadRequests += 1; + pending.waitingForOlderHistory = true; + pending.sawOlderHistoryLoading = false; + pending.lastEventsLength = events.length; + onLoadOlderHistory?.(); + }, [ + sessionId, + events, + groupedRows, + hasOlderHistory, + loadingOlderHistory, + onLoadOlderHistory, + location.key, + location.search, + scrollToRowIndexNearTop, + scheduleAnchoredRowCorrection, + ]); + /** Callback from MeasuredEventRow when it measures its real DOM height. */ const measureFlushTimer = useRef | null>(null); useEffect(() => () => { @@ -4751,6 +4945,7 @@ function AgentChatMessageListMain({ const isLatestWorkLog = index === latestWorkLogIndex; const rowTurnActive = Boolean(currentTurn && activeTurnId && currentTurn === activeTurnId) && !sessionEnded; + const anchored = envelope.key === anchoredRowKey; if (virtualized) { return ( @@ -4783,6 +4978,7 @@ function AgentChatMessageListMain({ sessionId={sessionId} runtimeName={runtimeName} mosaic={mosaic} + anchored={anchored} /> ); } @@ -4814,9 +5010,10 @@ function AgentChatMessageListMain({ sessionId={sessionId} runtimeName={runtimeName} mosaic={mosaic} + anchored={anchored} /> ); - }, [activeTurnId, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onInsertDraft, onRevealChatTerminal, onRewindFiles, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName, mosaic]); + }, [activeTurnId, anchoredRowKey, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onInsertDraft, onRevealChatTerminal, onRewindFiles, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName, mosaic]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { diff --git a/apps/desktop/src/renderer/components/files/FilesTab.tsx b/apps/desktop/src/renderer/components/files/FilesTab.tsx index 1eddbffa9..d93e82039 100644 --- a/apps/desktop/src/renderer/components/files/FilesTab.tsx +++ b/apps/desktop/src/renderer/components/files/FilesTab.tsx @@ -16,6 +16,7 @@ export function FilesTab(props: FilesTabProps) { {...props} externalOpenPath={params.get("externalPath")} externalOpenNonce={params.get("externalOpen")} + externalOpenLine={params.get("line")} /> ); } diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx index e62974a13..bd44b3261 100644 --- a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx @@ -111,12 +111,14 @@ export function FilesWorkbench({ active = true, externalOpenPath, externalOpenNonce, + externalOpenLine, }: { preferredLaneId?: string | null; embedded?: boolean; active?: boolean; externalOpenPath?: string | null; externalOpenNonce?: string | null; + externalOpenLine?: string | null; }) { const project = useAppStore((s) => s.project); const projectRootPath = project?.rootPath ?? ""; @@ -787,18 +789,21 @@ export function FilesWorkbench({ useEffect(() => { if (!active || !externalOpenPath) return; - const key = `${externalOpenNonce ?? ""}:${externalOpenPath}`; + const key = `${externalOpenNonce ?? ""}:${externalOpenPath}:${externalOpenLine ?? ""}`; if (handledExternalOpenRef.current === key) return; handledExternalOpenRef.current = key; void openExternalPathRequest(externalOpenPath, key); - }, [active, externalOpenPath, externalOpenNonce, openExternalPathRequest]); + }, [active, externalOpenPath, externalOpenLine, externalOpenNonce, openExternalPathRequest]); useEffect(() => { if (!active || !pendingWorkspaceOpen || workspaceId !== pendingWorkspaceOpen.workspaceId) return; const pending = pendingWorkspaceOpen; setPendingWorkspaceOpen(null); if (pending.pathType === "file" && pending.path) { - void openFile(pending.path, { preview: false }); + const line = externalOpenLine && /^\d+$/.test(externalOpenLine) + ? Number(externalOpenLine) + : undefined; + void openFile(pending.path, { preview: false, line }); return; } setSelectedNodePath(pending.path); @@ -815,7 +820,7 @@ export function FilesWorkbench({ } else { void refreshRoot({ preserveLoadedChildren: false }); } - }, [active, loadDirectoryPath, openFile, pendingWorkspaceOpen, refreshRoot, workspaceId]); + }, [active, externalOpenLine, loadDirectoryPath, openFile, pendingWorkspaceOpen, refreshRoot, workspaceId]); /* ---- Group/tab handlers ---- */ const handleCloseTab = useCallback( diff --git a/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx b/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx index c294770d4..87a4423d4 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx @@ -188,11 +188,30 @@ export function LaneContextMenu({ style={menuItemStyle} onClick={() => { onClose(); - const url = buildDeeplink( - { kind: "lane", laneId: laneContextMenu.laneId }, - { form: "ade" }, - ); - window.ade.app.writeClipboardText(url).catch(() => {}); + void (async () => { + const repo = await fetchRepoForCopy(); + const pr = await window.ade.prs.getForLane(ctxLane.id).catch(() => null); + const branch = branchNameFromRef(ctxLane.branchRef); + const url = buildDeeplink( + { + kind: "lane", + laneId: laneContextMenu.laneId, + ...(repo + ? { + envelope: { + repoOwner: repo.owner, + repoName: repo.name, + ...(branch ? { branch } : {}), + ...(pr?.githubPrNumber ? { prNumber: pr.githubPrNumber } : {}), + ...(ctxLane.linearIssue?.identifier ? { linearIssue: ctxLane.linearIssue.identifier } : {}), + }, + } + : {}), + }, + { form: "ade" }, + ); + await window.ade.app.writeClipboardText(url).catch(() => {}); + })(); }} > Copy ADE Lane Link diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index 26e7c8128..d3aa69351 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -13,6 +13,7 @@ import { } from "../../state/appStore"; import { WORK_SURFACE_REVEALED_EVENT } from "./workSurfaceVisibility"; import { openUrlInAdeBrowser } from "../../lib/openExternal"; +import { peekPendingSessionAnchor, takePendingSessionAnchor } from "./pendingSessionAnchors"; import type { PtyDataEvent, PtyExitEvent, @@ -116,6 +117,7 @@ type CachedRuntime = { invalidFitRetryTimer: ReturnType | null; fitWarningLogged: boolean; replayMode: boolean; + replayLoadedBytes: number | null; }; const HYDRATE_TAIL_BYTES = 2_000_000; @@ -333,6 +335,67 @@ function hasRenderableTerminalText(data: string): boolean { return /\S/.test(withoutControlSequences); } +function utf8ByteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(1, value)); +} + +function takePendingTerminalOffsetAnchor(sessionId: string): number | null { + const anchor = peekPendingSessionAnchor(sessionId); + if (anchor?.offset == null) return null; + const consumed = takePendingSessionAnchor(sessionId); + return consumed?.offset ?? null; +} + +function canMapReplayOffset(tailLoadedBytes: number | null): tailLoadedBytes is number { + // readTranscriptTail returns only text, not total file size. If the replay hit + // the cap, the tail's start byte is unknown, so leave replay at its default. + return typeof tailLoadedBytes === "number" + && tailLoadedBytes > 0 + && tailLoadedBytes < REPLAY_TRANSCRIPT_MAX_BYTES; +} + +function replayOffsetTargetLine(args: { + offset: number; + tailLoadedBytes: number; + bufferLength: number; + rows: number; +}): number { + const tailStartByte = 0; + const fraction = clamp01((args.offset - tailStartByte) / args.tailLoadedBytes); + return Math.round(fraction * Math.max(0, args.bufferLength - args.rows)); +} + +function scheduleReplayOffsetScroll(runtime: CachedRuntime, offset: number): void { + const tailLoadedBytes = runtime.replayLoadedBytes; + if (!canMapReplayOffset(tailLoadedBytes)) return; + requestAnimationFrame(() => { + if (runtime.disposed) return; + try { + const targetLine = replayOffsetTargetLine({ + offset, + tailLoadedBytes, + bufferLength: runtime.term.buffer.active.length, + rows: runtime.term.rows, + }); + runtime.term.scrollToLine(targetLine); + runtime.term.refresh(0, Math.max(0, runtime.term.rows - 1)); + } catch { + // Best-effort positioning only; replay remains usable if xterm rejects it. + } + }); +} + +function consumePendingTerminalOffsetAnchor(runtime: CachedRuntime): void { + const offset = takePendingTerminalOffsetAnchor(runtime.sessionId); + if (offset == null || !runtime.replayMode) return; + scheduleReplayOffsetScroll(runtime, offset); +} + function terminalDomHasRenderableText(runtime: CachedRuntime): boolean { const rows = runtime.term.element?.querySelector(".xterm-rows") ?? runtime.host.querySelector(".xterm-rows"); @@ -1393,7 +1456,7 @@ function subscribeRuntimePtyExit(runtime: CachedRuntime): () => void { function flushHydrationData( runtime: CachedRuntime, tail: string, - options: { appendPending?: boolean; replay?: boolean } = {}, + options: { appendPending?: boolean; replay?: boolean; scrollToBottom?: boolean } = {}, ) { // Replay mode already stripped alt-screen/clear-screen sequences before this // point, so the entire transcript should be written verbatim. Trimming to a @@ -1424,7 +1487,9 @@ function flushHydrationData( requestAnimationFrame(() => { try { runtime.term.refresh(0, Math.max(0, runtime.term.rows - 1)); - runtime.term.scrollToBottom(); + if (options.scrollToBottom ?? true) { + runtime.term.scrollToBottom(); + } } catch { // ignore } @@ -1633,7 +1698,15 @@ function startHydration(runtime: CachedRuntime) { } catch { // ignore options assignment failures after disposal } - flushHydrationData(runtime, data.text, { appendPending: false, replay: true }); + runtime.replayLoadedBytes = utf8ByteLength(data.text); + const offsetAnchor = takePendingTerminalOffsetAnchor(runtime.sessionId); + const shouldApplyOffsetAnchor = offsetAnchor != null && canMapReplayOffset(runtime.replayLoadedBytes); + flushHydrationData(runtime, data.text, { + appendPending: false, + replay: true, + scrollToBottom: !shouldApplyOffsetAnchor, + }); + if (shouldApplyOffsetAnchor) scheduleReplayOffsetScroll(runtime, offsetAnchor); runtime.hydrationCompleted = true; // Surface the exited badge for disposed sessions that never fire // pty.onExit (the PTY is already gone before this view mounted). @@ -1645,6 +1718,8 @@ function startHydration(runtime: CachedRuntime) { // Disposed sessions never receive live PTY data, so no backfill polling. return; } + runtime.replayLoadedBytes = null; + void takePendingTerminalOffsetAnchor(runtime.sessionId); const preferLivePending = runtime.displayedLiveDataBeforeHydration && data.source !== "snapshot"; if (preferLivePending) { runtime.pendingHydrationChunks.length = 0; @@ -1917,7 +1992,8 @@ function createRuntime(args: { pendingWebGLRestore: false, invalidFitRetryTimer: null, fitWarningLogged: false, - replayMode: false + replayMode: false, + replayLoadedBytes: null }; // Capture-phase paste listener on host: intercepts ALL paste sources (Cmd+V, @@ -2201,6 +2277,7 @@ export function TerminalView({ }, 320); startHydration(runtime); + if (runtime.hydrationCompleted) consumePendingTerminalOffsetAnchor(runtime); const obs = new ResizeObserver(() => { clearTextureAtlas(runtime); @@ -2364,6 +2441,12 @@ export function TerminalView({ }; }, [imagePasteMode, runtimeProjectRevision, runtimeProjectRoot, ptyId, sessionId]); + useEffect(() => { + const runtime = runtimeRef.current; + if (!runtime || runtime.disposed || !runtime.hydrationCompleted) return; + consumePendingTerminalOffsetAnchor(runtime); + }); + useEffect(() => { const runtime = runtimeRef.current; if (!runtime || runtime.disposed) return; diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index e749b96c6..08f89a5bb 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -10,6 +10,7 @@ import { SessionInfoPopover, type InfoPopoverState } from "./SessionInfoPopover" import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import type { AgentChatSession, TerminalSessionSummary } from "../../../shared/types"; import { buildDeeplink } from "../../../shared/deeplinks"; +import { parseGithubRemoteUrl } from "../../../shared/githubRemote"; import type { AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; import { canBulkDeleteSession, canBulkStopSession, formatToolTypeLabel, isChatToolType } from "../../lib/sessions"; import { addSessionBesideTarget, removeSessionFromGrids } from "../../lib/workGrid"; @@ -1128,11 +1129,32 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { onGoToLane={handleGoToLane} onCopySessionId={(id) => navigator.clipboard.writeText(id).catch(() => {})} onCopySessionDeepLink={(session) => { - const href = buildDeeplink( - { kind: "session", sessionId: session.id, laneId: session.laneId }, - { form: "ade" }, - ); - navigator.clipboard.writeText(href).catch(() => {}); + void (async () => { + const lane = work.lanes.find((candidate) => candidate.id === session.laneId) ?? null; + const remote = session.laneId + ? await window.ade.git.getOriginRemote({ laneId: session.laneId }).catch(() => null) + : null; + const repo = parseGithubRemoteUrl(remote?.remoteUrl ?? null); + const branch = lane?.branchRef?.replace(/^refs\/heads\//, "") || remote?.branch || null; + const href = buildDeeplink( + { + kind: "session", + sessionId: session.id, + laneId: session.laneId || undefined, + ...(repo + ? { + envelope: { + repoOwner: repo.owner, + repoName: repo.repo, + ...(branch ? { branch } : {}), + }, + } + : {}), + }, + { form: "ade" }, + ); + await navigator.clipboard.writeText(href).catch(() => {}); + })(); }} onTogglePinned={(session) => work.togglePinnedSession(session.id)} pinnedSessionIds={work.pinnedSessionIds} diff --git a/apps/desktop/src/renderer/components/terminals/pendingSessionAnchors.ts b/apps/desktop/src/renderer/components/terminals/pendingSessionAnchors.ts new file mode 100644 index 000000000..23ac57275 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/pendingSessionAnchors.ts @@ -0,0 +1,33 @@ +/** + * One-shot "position this session's content when it next mounts" queue. + * Set by deeplink navigation (/work?sessionId=...&event=N / &offset=N) and by + * ⌘K search-result activation; consumed by the chat message list (event + * sequence anchor) or the terminal view (scrollback byte offset) for that + * session. Mirrors the Files tab's pendingReveals idiom so no anchor prop has + * to thread through the Work tab tree. + */ +export type SessionAnchor = { + /** Chat anchor: event sequence number of the message to scroll to. */ + event?: number; + /** Terminal anchor: byte offset into the session scrollback. */ + offset?: number; +}; + +const pending = new Map(); + +export function setPendingSessionAnchor(sessionId: string, anchor: SessionAnchor): void { + if (anchor.event == null && anchor.offset == null) return; + pending.set(sessionId, anchor); +} + +export function takePendingSessionAnchor(sessionId: string): SessionAnchor | null { + const anchor = pending.get(sessionId); + if (anchor == null) return null; + pending.delete(sessionId); + return anchor; +} + +/** Non-consuming read, for callers that need to check before the surface mounts. */ +export function peekPendingSessionAnchor(sessionId: string): SessionAnchor | null { + return pending.get(sessionId) ?? null; +} diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index df8377475..d5d3e354f 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -27,6 +27,7 @@ import { type WorkPtyLaunchResult, } from "./cliLaunch"; import { sortLanesForTabs } from "../lanes/laneUtils"; +import { setPendingSessionAnchor } from "./pendingSessionAnchors"; const DEFAULT_PROJECT_WORK_STATE: WorkProjectViewState = { openItemIds: [], @@ -678,7 +679,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) const stripUrlFilterParams = useCallback(() => { if (!isWorkRoute) return; const nextParams = new URLSearchParams(searchParams); - for (const key of ["laneId", "lane", "status", "sessionId"]) { + for (const key of ["laneId", "lane", "status", "sessionId", "event", "offset"]) { nextParams.delete(key); } // Use URLSearchParams.toString() as the stable comparison anchor: if stripping @@ -1112,13 +1113,24 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) appliedQuerySessionIdRef.current = null; return; } - if (appliedQuerySessionIdRef.current === sessionParam) return; + // Key the apply-once guard by session AND anchor: re-opening the same + // session at a different event/offset must set a fresh pending anchor. + const eventRaw = (searchParams.get("event") ?? "").trim(); + const offsetRaw = (searchParams.get("offset") ?? "").trim(); + const applyKey = `${sessionParam}|${eventRaw}|${offsetRaw}`; + if (appliedQuerySessionIdRef.current === applyKey) return; if (pendingProjectSwitchRef.current != null) return; const session = sessions.find((entry) => entry.id === sessionParam); if (!session) return; - appliedQuerySessionIdRef.current = sessionParam; + appliedQuerySessionIdRef.current = applyKey; + // Deeplink anchors (?event= for chat, ?offset= for terminal + // scrollback) are handed off one-shot to the session's content surface. + setPendingSessionAnchor(session.id, { + event: /^\d+$/.test(eventRaw) ? Number(eventRaw) : undefined, + offset: /^\d+$/.test(offsetRaw) ? Number(offsetRaw) : undefined, + }); selectLane(session.laneId); focusSession(session.id); setProjectViewState((prev) => { diff --git a/apps/desktop/src/shared/deeplinks.test.ts b/apps/desktop/src/shared/deeplinks.test.ts index f451668a5..29c3853b5 100644 --- a/apps/desktop/src/shared/deeplinks.test.ts +++ b/apps/desktop/src/shared/deeplinks.test.ts @@ -33,6 +33,26 @@ describe("parseDeeplink — ade:// scheme", () => { expect(target).toEqual({ kind: "session", sessionId: "session-123", laneId: UUID }); }); + it("parses session anchors and envelope params", () => { + const target = expectOk(parseDeeplink( + `ade://session/session-123?lane=${UUID}&event=4&offset=12&repo=owner%2Frepo&branch=feat&pr=42&linear=ADE-123`, + )); + expect(target).toEqual({ + kind: "session", + sessionId: "session-123", + laneId: UUID, + event: 4, + offset: 12, + envelope: { + repoOwner: "owner", + repoName: "repo", + branch: "feat", + prNumber: 42, + linearIssue: "ADE-123", + }, + }); + }); + it("rejects work session links with control characters", () => { const result = parseDeeplink("ade://session/session%0A123"); expect(result.ok).toBe(false); @@ -89,6 +109,66 @@ describe("parseDeeplink — ade:// scheme", () => { expect(result.ok).toBe(false); }); + it("parses file, commit, and artifact links", () => { + expect(expectOk(parseDeeplink(`ade://file/src/index.ts?line=10&lane=${UUID}`))).toEqual({ + kind: "file", + path: "src/index.ts", + line: 10, + laneId: UUID, + }); + expect(expectOk(parseDeeplink(`ade://commit/ABC1234?lane=${UUID}&repo=a%2Fb`))).toEqual({ + kind: "commit", + sha: "abc1234", + laneId: UUID, + envelope: { repoOwner: "a", repoName: "b" }, + }); + expect(expectOk(parseDeeplink("ade://artifact/proof-123"))).toEqual({ + kind: "artifact", + artifactId: "proof-123", + }); + }); + + it("rejects malformed session anchors", () => { + expect(parseDeeplink("ade://session/session-123?event=abc").ok).toBe(false); + expect(parseDeeplink("ade://session/session-123?offset=-5").ok).toBe(false); + }); + + it("rejects file links with traversal, absolute paths, or bad lines", () => { + // Raw AND percent-encoded `..` segments are collapsed by WHATWG URL + // normalization before the parser sees them — the ade:// path form can + // never escape the root. (The validator still guards the https form.) + const normalized = expectOk(parseDeeplink("ade://file/src/../../etc/passwd")); + expect(normalized).toEqual({ kind: "file", path: "etc/passwd" }); + const encoded = expectOk(parseDeeplink("ade://file/src/%2E%2E/secret")); + expect(encoded).toEqual({ kind: "file", path: "secret" }); + expect(parseDeeplink("ade://file/src/app.ts?line=0").ok).toBe(false); + expect(parseDeeplink("ade://file/src/app.ts?line=abc").ok).toBe(false); + // The https form carries the path as a query param (no URL normalization). + expect(parseDeeplink("https://ade-app.dev/open?type=file&path=../etc/passwd").ok).toBe(false); + expect(parseDeeplink("https://ade-app.dev/open?type=file&path=/etc/passwd").ok).toBe(false); + expect(parseDeeplink("https://ade-app.dev/open?type=file&path=C:/windows").ok).toBe(false); + }); + + it("rejects malformed commit shas", () => { + expect(parseDeeplink("ade://commit/xyz").ok).toBe(false); + expect(parseDeeplink("ade://commit/abc12").ok).toBe(false); + }); + + it("drops malformed envelope components without failing the link", () => { + const target = expectOk( + parseDeeplink(`ade://lane/${UUID}?repo=notaslash&branch=..%2Fbad&pr=0&linear=nope!`), + ); + expect(target).toEqual({ kind: "lane", laneId: UUID }); + const partial = expectOk( + parseDeeplink(`ade://session/s-1?repo=anthropics/claude-code&pr=abc`), + ); + expect(partial).toEqual({ + kind: "session", + sessionId: "s-1", + envelope: { repoOwner: "anthropics", repoName: "claude-code" }, + }); + }); + it("rejects unknown ade:// hosts", () => { const result = parseDeeplink("ade://surprise/anything"); expect(result.ok).toBe(false); @@ -139,6 +219,25 @@ describe("parseDeeplink — https://ade-app.dev/open", () => { expect(target).toEqual({ kind: "pr", repoOwner: "a", repoName: "b", prNumber: 99 }); }); + it("parses file, commit, artifact mirror links", () => { + expect(expectOk(parseDeeplink(`https://ade-app.dev/open?type=file&path=src%2Findex.ts&line=2&lane=${UUID}`))).toEqual({ + kind: "file", + path: "src/index.ts", + line: 2, + laneId: UUID, + }); + expect(expectOk(parseDeeplink(`https://ade-app.dev/open?type=commit&sha=abc1234&lane=${UUID}&repo=a%2Fb`))).toEqual({ + kind: "commit", + sha: "abc1234", + laneId: UUID, + envelope: { repoOwner: "a", repoName: "b" }, + }); + expect(expectOk(parseDeeplink("https://ade-app.dev/open?type=artifact&id=proof-123"))).toEqual({ + kind: "artifact", + artifactId: "proof-123", + }); + }); + it("parses legacy ade.app links for old PR bodies and Linear cards", () => { const target = expectOk(parseDeeplink("https://ade.app/open?type=pr&repo=a/b&number=99")); expect(target).toEqual({ kind: "pr", repoOwner: "a", repoName: "b", prNumber: 99 }); @@ -180,16 +279,40 @@ describe("buildDeeplink", () => { }); it("round-trips session links", () => { - const target = { kind: "session", sessionId: "session-123", laneId: UUID } as const; + const target = { + kind: "session", + sessionId: "session-123", + laneId: UUID, + event: 7, + offset: 20, + envelope: { repoOwner: "a", repoName: "b", branch: "feat" }, + } as const; const ade = buildDeeplink(target, { form: "ade" }); - expect(ade).toBe(`ade://session/session-123?lane=${UUID}`); expect(expectOk(parseDeeplink(ade))).toEqual(target); const https = buildDeeplink(target); - expect(https).toBe(`https://ade-app.dev/open?type=session&id=session-123&lane=${UUID}`); expect(expectOk(parseDeeplink(https))).toEqual(target); }); + it("round-trips file, commit, and artifact links", () => { + const file = { kind: "file", path: "src/app.ts", line: 3, laneId: UUID } as const; + expect(expectOk(parseDeeplink(buildDeeplink(file, { form: "ade" })))).toEqual(file); + expect(expectOk(parseDeeplink(buildDeeplink(file)))).toEqual(file); + + const commit = { + kind: "commit", + sha: "abc1234", + laneId: UUID, + envelope: { repoOwner: "a", repoName: "b", branch: "feat", prNumber: 7 }, + } as const; + expect(expectOk(parseDeeplink(buildDeeplink(commit, { form: "ade" })))).toEqual(commit); + expect(expectOk(parseDeeplink(buildDeeplink(commit)))).toEqual(commit); + + const artifact = { kind: "artifact", artifactId: "proof-123" } as const; + expect(expectOk(parseDeeplink(buildDeeplink(artifact, { form: "ade" })))).toEqual(artifact); + expect(expectOk(parseDeeplink(buildDeeplink(artifact)))).toEqual(artifact); + }); + it("round-trips branch (ade) with slash branches", () => { const target = { kind: "branch", repoOwner: "a", repoName: "b", branch: "users/me/x" } as const; const url = buildDeeplink(target, { form: "ade" }); diff --git a/apps/desktop/src/shared/deeplinks.ts b/apps/desktop/src/shared/deeplinks.ts index 158d17051..55d802eea 100644 --- a/apps/desktop/src/shared/deeplinks.ts +++ b/apps/desktop/src/shared/deeplinks.ts @@ -4,18 +4,27 @@ // // Two surface forms, identical semantics: // ade://lane/ -// ade://session/ -// ade://repo///branch/ +// ade://session/[?lane=&event=&offset=] +// ade://file/[?line=&lane=] +// ade://commit/[?lane=] +// ade://artifact/ +// ade://repo///branch/[?pr=] // ade://pr/// +// ade://linear-issue/[?branch=] // -// https://ade-app.dev/open?type=lane&id= -// https://ade-app.dev/open?type=session&id= -// https://ade-app.dev/open?type=branch&repo=&branch= -// https://ade-app.dev/open?type=pr&repo=&number= +// https://ade-app.dev/open?type=&... +// (param names: lane→id; session→id[+lane,event,offset]; file→path[+line,lane]; +// commit→sha[+lane]; artifact→id; branch→repo&branch[+pr]; pr→repo&number; +// linear-issue→issue[+branch]) // // The HTTPS form lives on apps/web; it attempts the ade:// upgrade in the // browser and falls back to an install/marketing card if no handler is // registered. Both forms parse to the same AppNavigationTarget shape. +// +// Machine-local targets (lane / session / commit / artifact) additionally +// carry a portable envelope (?repo=/&branch=..&pr=..&linear=..) +// so a receiver that cannot resolve the primary id can fall back to the +// branch, PR, or Linear issue — see DeeplinkEnvelope. export const ADE_DEEPLINK_SCHEME = "ade"; export const ADE_DEEPLINK_HTTPS_HOST = "ade-app.dev"; @@ -23,8 +32,40 @@ export const ADE_DEEPLINK_LEGACY_HTTPS_HOSTS = ["ade.app"] as const; export const ADE_DEEPLINK_HTTPS_PATH = "/open"; export const ADE_DEEPLINK_HTTPS_BASE_URL = `https://${ADE_DEEPLINK_HTTPS_HOST}${ADE_DEEPLINK_HTTPS_PATH}`; -export type DeeplinkLaneTarget = { kind: "lane"; laneId: string }; -export type DeeplinkSessionTarget = { kind: "session"; sessionId: string; laneId?: string }; +export type DeeplinkEnvelope = { + repoOwner?: string; + repoName?: string; + branch?: string; + prNumber?: number; + linearIssue?: string; +}; + +export type DeeplinkLaneTarget = { kind: "lane"; laneId: string; envelope?: DeeplinkEnvelope }; +export type DeeplinkSessionTarget = { + kind: "session"; + sessionId: string; + laneId?: string; + event?: number; + offset?: number; + envelope?: DeeplinkEnvelope; +}; +export type DeeplinkFileTarget = { + kind: "file"; + path: string; + line?: number; + laneId?: string; +}; +export type DeeplinkCommitTarget = { + kind: "commit"; + sha: string; + laneId?: string; + envelope?: DeeplinkEnvelope; +}; +export type DeeplinkArtifactTarget = { + kind: "artifact"; + artifactId: string; + envelope?: DeeplinkEnvelope; +}; export type DeeplinkBranchTarget = { kind: "branch"; repoOwner: string; @@ -53,6 +94,9 @@ export type DeeplinkLinearIssueTarget = { export type DeeplinkTarget = | DeeplinkLaneTarget | DeeplinkSessionTarget + | DeeplinkFileTarget + | DeeplinkCommitTarget + | DeeplinkArtifactTarget | DeeplinkBranchTarget | DeeplinkPrTarget | DeeplinkLinearIssueTarget; @@ -67,6 +111,7 @@ const LINEAR_ID_RE = /^[A-Za-z][A-Za-z0-9]{0,9}-\d{1,9}$/; // Branch refs are permissive but ADE rejects traversal + control chars. const BRANCH_BAD_RE = /(^|\/)\.\.($|\/)|[\x00-\x1f\x7f]/; const OPAQUE_ID_BAD_RE = /[\x00-\x1f\x7f]/; +const COMMIT_SHA_RE = /^[0-9a-f]{7,40}$/i; function isValidUuid(value: string): boolean { return UUID_RE.test(value); @@ -98,6 +143,63 @@ function isValidOpaqueId(value: string): boolean { return true; } +/** + * Repo-relative file path rule shared by the parser AND by trusted-input + * boundaries that bypass URL parsing (the `app/navigate` RPC, renderer + * dispatch). Rejects traversal, absolute paths, drive letters, backslashes, + * and control characters. + */ +export function isValidRepoRelativePath(value: string): boolean { + if (!value || value.length > 1024) return false; + if (value.startsWith("/") || value.endsWith("/")) return false; + if (value.includes("\\") || /^[A-Za-z]:/.test(value)) return false; + if (OPAQUE_ID_BAD_RE.test(value)) return false; + return value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== ".."); +} + +/** Commit sha rule shared with non-URL boundaries (7-40 hex chars). */ +export function isValidCommitSha(value: string): boolean { + return COMMIT_SHA_RE.test(value); +} + +function parseNonNegativeIntParam(raw: string | null): number | undefined | null { + if (raw == null) return undefined; + if (!/^\d{1,15}$/.test(raw)) return null; + const value = Number(raw); + return Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function appendEnvelopeParams(params: URLSearchParams, envelope: DeeplinkEnvelope | undefined): void { + if (!envelope) return; + if (envelope.repoOwner && envelope.repoName) params.set("repo", `${envelope.repoOwner}/${envelope.repoName}`); + if (envelope.branch) params.set("branch", envelope.branch); + if (envelope.prNumber != null) params.set("pr", String(envelope.prNumber)); + if (envelope.linearIssue) params.set("linear", envelope.linearIssue); +} + +function readEnvelopeParams(searchParams: URLSearchParams): DeeplinkEnvelope | undefined { + const envelope: DeeplinkEnvelope = {}; + const repo = searchParams.get("repo"); + if (repo) { + const slash = repo.indexOf("/"); + if (slash > 0 && slash < repo.length - 1) { + const owner = repo.slice(0, slash); + const name = repo.slice(slash + 1); + if (isValidGhOwner(owner) && isValidGhRepo(name)) { + envelope.repoOwner = owner; + envelope.repoName = name; + } + } + } + const branch = searchParams.get("branch"); + if (branch && isValidBranch(branch)) envelope.branch = branch; + const pr = parseNonNegativeIntParam(searchParams.get("pr")); + if (typeof pr === "number" && pr >= 1) envelope.prNumber = pr; + const linear = searchParams.get("linear"); + if (linear && isValidLinearIdentifier(linear)) envelope.linearIssue = linear; + return Object.keys(envelope).length > 0 ? envelope : undefined; +} + export function isAdeDeeplinkHttpsHost(host: string): boolean { const normalized = host.trim().toLowerCase(); return normalized === ADE_DEEPLINK_HTTPS_HOST @@ -105,8 +207,6 @@ export function isAdeDeeplinkHttpsHost(host: string): boolean { } function encodeBranchSegment(branch: string): string { - // Branches can contain `/`; we keep them as-is so the URL stays readable, - // and rely on encodeURIComponent for any other reserved characters. return branch .split("/") .map((segment) => encodeURIComponent(segment)) @@ -126,19 +226,12 @@ function decodeBranchPath(path: string): string { .join("/"); } -// --------------------------------------------------------------------------- -// Builders -// --------------------------------------------------------------------------- - export type BuildOptions = { - /** "ade" = custom scheme, "https" = web mirror. Defaults to "https". */ form?: "ade" | "https"; }; export function buildDeeplink(target: DeeplinkTarget, options: BuildOptions = {}): string { - const form = options.form ?? "https"; - if (form === "ade") return buildAdeUrl(target); - return buildHttpsUrl(target); + return (options.form ?? "https") === "ade" ? buildAdeUrl(target) : buildHttpsUrl(target); } export function buildAdePrUrl(pr: { @@ -156,11 +249,45 @@ export function buildAdePrUrl(pr: { function buildAdeUrl(target: DeeplinkTarget): string { switch (target.kind) { - case "lane": - return `${ADE_DEEPLINK_SCHEME}://lane/${encodeURIComponent(target.laneId)}`; + case "lane": { + const params = new URLSearchParams(); + appendEnvelopeParams(params, target.envelope); + const qs = params.toString(); + const base = `${ADE_DEEPLINK_SCHEME}://lane/${encodeURIComponent(target.laneId)}`; + return qs ? `${base}?${qs}` : base; + } case "session": { + const params = new URLSearchParams(); + if (target.laneId) params.set("lane", target.laneId); + if (target.event != null) params.set("event", String(target.event)); + if (target.offset != null) params.set("offset", String(target.offset)); + appendEnvelopeParams(params, target.envelope); + const qs = params.toString(); const base = `${ADE_DEEPLINK_SCHEME}://session/${encodeURIComponent(target.sessionId)}`; - return target.laneId ? `${base}?lane=${encodeURIComponent(target.laneId)}` : base; + return qs ? `${base}?${qs}` : base; + } + case "file": { + const params = new URLSearchParams(); + if (target.line != null) params.set("line", String(target.line)); + if (target.laneId) params.set("lane", target.laneId); + const qs = params.toString(); + const base = `${ADE_DEEPLINK_SCHEME}://file/${encodeBranchSegment(target.path)}`; + return qs ? `${base}?${qs}` : base; + } + case "commit": { + const params = new URLSearchParams(); + if (target.laneId) params.set("lane", target.laneId); + appendEnvelopeParams(params, target.envelope); + const qs = params.toString(); + const base = `${ADE_DEEPLINK_SCHEME}://commit/${encodeURIComponent(target.sha)}`; + return qs ? `${base}?${qs}` : base; + } + case "artifact": { + const params = new URLSearchParams(); + appendEnvelopeParams(params, target.envelope); + const qs = params.toString(); + const base = `${ADE_DEEPLINK_SCHEME}://artifact/${encodeURIComponent(target.artifactId)}`; + return qs ? `${base}?${qs}` : base; } case "branch": { const base = `${ADE_DEEPLINK_SCHEME}://repo/${encodeURIComponent(target.repoOwner)}/${encodeURIComponent(target.repoName)}/branch/${encodeBranchSegment(target.branch)}`; @@ -181,11 +308,32 @@ function buildHttpsUrl(target: DeeplinkTarget): string { case "lane": params.set("type", "lane"); params.set("id", target.laneId); + appendEnvelopeParams(params, target.envelope); break; case "session": params.set("type", "session"); params.set("id", target.sessionId); if (target.laneId) params.set("lane", target.laneId); + if (target.event != null) params.set("event", String(target.event)); + if (target.offset != null) params.set("offset", String(target.offset)); + appendEnvelopeParams(params, target.envelope); + break; + case "file": + params.set("type", "file"); + params.set("path", target.path); + if (target.line != null) params.set("line", String(target.line)); + if (target.laneId) params.set("lane", target.laneId); + break; + case "commit": + params.set("type", "commit"); + params.set("sha", target.sha); + if (target.laneId) params.set("lane", target.laneId); + appendEnvelopeParams(params, target.envelope); + break; + case "artifact": + params.set("type", "artifact"); + params.set("id", target.artifactId); + appendEnvelopeParams(params, target.envelope); break; case "branch": params.set("type", "branch"); @@ -207,10 +355,6 @@ function buildHttpsUrl(target: DeeplinkTarget): string { return `${ADE_DEEPLINK_HTTPS_BASE_URL}?${params.toString()}`; } -// --------------------------------------------------------------------------- -// Parser -// --------------------------------------------------------------------------- - export type ParseError = | { kind: "empty" } | { kind: "unsupported_scheme"; scheme: string } @@ -232,9 +376,7 @@ export function parseDeeplink(rawUrl: string): ParseResult { } catch { return { ok: false, error: { kind: "malformed", reason: "not a URL" }, rawUrl }; } - if (url.protocol === `${ADE_DEEPLINK_SCHEME}:`) { - return parseAdeUrl(url, rawUrl); - } + if (url.protocol === `${ADE_DEEPLINK_SCHEME}:`) return parseAdeUrl(url, rawUrl); if (url.protocol === "https:") { if (!isAdeDeeplinkHttpsHost(url.hostname)) { return { ok: false, error: { kind: "unsupported_host", host: url.hostname }, rawUrl }; @@ -252,8 +394,6 @@ export function parseDeeplink(rawUrl: string): ParseResult { } function parseAdeUrl(url: URL, rawUrl: string): ParseResult { - // URL.host is the first path-like segment for non-special schemes. - // For ade://lane/, host = "lane" and pathname = "/". const host = url.host.toLowerCase(); const pathSegments = url.pathname.split("/").filter(Boolean); @@ -262,7 +402,8 @@ function parseAdeUrl(url: URL, rawUrl: string): ParseResult { if (!laneId || !isValidUuid(laneId)) { return { ok: false, error: { kind: "malformed", reason: "invalid lane id" }, rawUrl }; } - return { ok: true, target: { kind: "lane", laneId }, rawUrl }; + const envelope = readEnvelopeParams(url.searchParams); + return { ok: true, target: { kind: "lane", laneId, ...(envelope ? { envelope } : {}) }, rawUrl }; } if (host === "session") { @@ -270,86 +411,44 @@ function parseAdeUrl(url: URL, rawUrl: string): ParseResult { if (!isValidOpaqueId(sessionId)) { return { ok: false, error: { kind: "malformed", reason: "invalid session id" }, rawUrl }; } - const laneId = url.searchParams.get("lane") ?? undefined; - if (laneId != null && !isValidUuid(laneId)) { - return { ok: false, error: { kind: "malformed", reason: "invalid lane id" }, rawUrl }; - } - return { - ok: true, - target: { kind: "session", sessionId, ...(laneId ? { laneId } : {}) }, - rawUrl, - }; + return buildSessionTarget(sessionId, url.searchParams, rawUrl); + } + + if (host === "file") return buildFileTarget(decodeBranchPath(pathSegments.join("/")), url.searchParams, rawUrl); + + if (host === "commit") { + const sha = pathSegments[0] ? safeDecode(pathSegments[0]) : ""; + return buildCommitTarget(sha, url.searchParams, rawUrl); + } + + if (host === "artifact") { + const artifactId = pathSegments[0] ? safeDecode(pathSegments[0]) : ""; + return buildArtifactTarget(artifactId, url.searchParams, rawUrl); } if (host === "repo") { - // Path: //branch/ if (pathSegments.length < 4 || pathSegments[2] !== "branch") { - return { - ok: false, - error: { kind: "malformed", reason: "expected repo///branch/" }, - rawUrl, - }; + return { ok: false, error: { kind: "malformed", reason: "expected repo///branch/" }, rawUrl }; } - const owner = safeDecode(pathSegments[0]); - const repo = safeDecode(pathSegments[1]); - const branch = decodeBranchPath(pathSegments.slice(3).join("/")); - if (!isValidGhOwner(owner)) { - return { ok: false, error: { kind: "malformed", reason: "invalid owner" }, rawUrl }; - } - if (!isValidGhRepo(repo)) { - return { ok: false, error: { kind: "malformed", reason: "invalid repo" }, rawUrl }; - } - if (!isValidBranch(branch)) { - return { ok: false, error: { kind: "malformed", reason: "invalid branch" }, rawUrl }; - } - const prRaw = url.searchParams.get("pr"); - const prNumber = prRaw ? Number(prRaw) : undefined; - if (prRaw != null && (!Number.isInteger(prNumber) || prNumber == null || prNumber < 1)) { - return { ok: false, error: { kind: "malformed", reason: "invalid pr number" }, rawUrl }; - } - return { - ok: true, - target: { kind: "branch", repoOwner: owner, repoName: repo, branch, prNumber }, + return buildBranchTarget( + safeDecode(pathSegments[0]), + safeDecode(pathSegments[1]), + decodeBranchPath(pathSegments.slice(3).join("/")), + url.searchParams.get("pr"), rawUrl, - }; + ); } if (host === "pr") { - // Path: // if (pathSegments.length !== 3) { - return { - ok: false, - error: { kind: "malformed", reason: "expected pr///" }, - rawUrl, - }; - } - const owner = safeDecode(pathSegments[0]); - const repo = safeDecode(pathSegments[1]); - const number = Number(pathSegments[2]); - if (!isValidGhOwner(owner)) { - return { ok: false, error: { kind: "malformed", reason: "invalid owner" }, rawUrl }; - } - if (!isValidGhRepo(repo)) { - return { ok: false, error: { kind: "malformed", reason: "invalid repo" }, rawUrl }; - } - if (!Number.isInteger(number) || number < 1) { - return { ok: false, error: { kind: "malformed", reason: "invalid pr number" }, rawUrl }; + return { ok: false, error: { kind: "malformed", reason: "expected pr///" }, rawUrl }; } - return { - ok: true, - target: { kind: "pr", repoOwner: owner, repoName: repo, prNumber: number }, - rawUrl, - }; + return buildPrTarget(safeDecode(pathSegments[0]), safeDecode(pathSegments[1]), pathSegments[2], rawUrl); } if (host === "linear-issue") { - // Path: if (pathSegments.length < 1) { - return { - ok: false, - error: { kind: "malformed", reason: "expected linear-issue/" }, - rawUrl, - }; + return { ok: false, error: { kind: "malformed", reason: "expected linear-issue/" }, rawUrl }; } const issueIdentifier = safeDecode(pathSegments[0]); if (!isValidLinearIdentifier(issueIdentifier)) { @@ -361,11 +460,7 @@ function parseAdeUrl(url: URL, rawUrl: string): ParseResult { } return { ok: true, - target: { - kind: "linear-issue", - issueIdentifier, - ...(branchParam ? { branch: branchParam } : {}), - }, + target: { kind: "linear-issue", issueIdentifier, ...(branchParam ? { branch: branchParam } : {}) }, rawUrl, }; } @@ -377,26 +472,20 @@ function parseHttpsParams(url: URL, rawUrl: string): ParseResult { const type = (url.searchParams.get("type") ?? "").toLowerCase(); if (type === "lane") { const laneId = url.searchParams.get("id") ?? ""; - if (!isValidUuid(laneId)) { - return { ok: false, error: { kind: "malformed", reason: "invalid lane id" }, rawUrl }; - } - return { ok: true, target: { kind: "lane", laneId }, rawUrl }; + if (!isValidUuid(laneId)) return { ok: false, error: { kind: "malformed", reason: "invalid lane id" }, rawUrl }; + const envelope = readEnvelopeParams(url.searchParams); + return { ok: true, target: { kind: "lane", laneId, ...(envelope ? { envelope } : {}) }, rawUrl }; } if (type === "session") { const sessionId = url.searchParams.get("id") ?? ""; if (!isValidOpaqueId(sessionId)) { return { ok: false, error: { kind: "malformed", reason: "invalid session id" }, rawUrl }; } - const laneId = url.searchParams.get("lane") ?? undefined; - if (laneId != null && !isValidUuid(laneId)) { - return { ok: false, error: { kind: "malformed", reason: "invalid lane id" }, rawUrl }; - } - return { - ok: true, - target: { kind: "session", sessionId, ...(laneId ? { laneId } : {}) }, - rawUrl, - }; + return buildSessionTarget(sessionId, url.searchParams, rawUrl); } + if (type === "file") return buildFileTarget(url.searchParams.get("path") ?? "", url.searchParams, rawUrl); + if (type === "commit") return buildCommitTarget(url.searchParams.get("sha") ?? "", url.searchParams, rawUrl); + if (type === "artifact") return buildArtifactTarget(url.searchParams.get("id") ?? "", url.searchParams, rawUrl); if (type === "branch" || type === "pr") { const repoCombined = url.searchParams.get("repo") ?? ""; const slash = repoCombined.indexOf("/"); @@ -405,37 +494,10 @@ function parseHttpsParams(url: URL, rawUrl: string): ParseResult { } const owner = repoCombined.slice(0, slash); const repo = repoCombined.slice(slash + 1); - if (!isValidGhOwner(owner)) { - return { ok: false, error: { kind: "malformed", reason: "invalid owner" }, rawUrl }; - } - if (!isValidGhRepo(repo)) { - return { ok: false, error: { kind: "malformed", reason: "invalid repo" }, rawUrl }; - } if (type === "branch") { - const branch = url.searchParams.get("branch") ?? ""; - if (!isValidBranch(branch)) { - return { ok: false, error: { kind: "malformed", reason: "invalid branch" }, rawUrl }; - } - const prRaw = url.searchParams.get("pr"); - const prNumber = prRaw ? Number(prRaw) : undefined; - if (prRaw != null && (!Number.isInteger(prNumber) || prNumber == null || prNumber < 1)) { - return { ok: false, error: { kind: "malformed", reason: "invalid pr number" }, rawUrl }; - } - return { - ok: true, - target: { kind: "branch", repoOwner: owner, repoName: repo, branch, prNumber }, - rawUrl, - }; + return buildBranchTarget(owner, repo, url.searchParams.get("branch") ?? "", url.searchParams.get("pr"), rawUrl); } - const number = Number(url.searchParams.get("number") ?? ""); - if (!Number.isInteger(number) || number < 1) { - return { ok: false, error: { kind: "malformed", reason: "invalid pr number" }, rawUrl }; - } - return { - ok: true, - target: { kind: "pr", repoOwner: owner, repoName: repo, prNumber: number }, - rawUrl, - }; + return buildPrTarget(owner, repo, url.searchParams.get("number") ?? "", rawUrl); } if (type === "linear-issue") { const issueIdentifier = url.searchParams.get("issue") ?? ""; @@ -448,17 +510,118 @@ function parseHttpsParams(url: URL, rawUrl: string): ParseResult { } return { ok: true, - target: { - kind: "linear-issue", - issueIdentifier, - ...(branchParam ? { branch: branchParam } : {}), - }, + target: { kind: "linear-issue", issueIdentifier, ...(branchParam ? { branch: branchParam } : {}) }, rawUrl, }; } return { ok: false, error: { kind: "unknown_type", type }, rawUrl }; } +/** Shared branch-target assembly for the ade:// and https:// parse paths. */ +function buildBranchTarget( + owner: string, + repo: string, + branch: string, + prRaw: string | null, + rawUrl: string, +): ParseResult { + if (!isValidGhOwner(owner)) return { ok: false, error: { kind: "malformed", reason: "invalid owner" }, rawUrl }; + if (!isValidGhRepo(repo)) return { ok: false, error: { kind: "malformed", reason: "invalid repo" }, rawUrl }; + if (!isValidBranch(branch)) return { ok: false, error: { kind: "malformed", reason: "invalid branch" }, rawUrl }; + const prNumber = prRaw ? Number(prRaw) : undefined; + if (prRaw != null && (!Number.isInteger(prNumber) || prNumber == null || prNumber < 1)) { + return { ok: false, error: { kind: "malformed", reason: "invalid pr number" }, rawUrl }; + } + return { ok: true, target: { kind: "branch", repoOwner: owner, repoName: repo, branch, prNumber }, rawUrl }; +} + +/** Shared pr-target assembly for the ade:// and https:// parse paths. */ +function buildPrTarget(owner: string, repo: string, numberRaw: string, rawUrl: string): ParseResult { + if (!isValidGhOwner(owner)) return { ok: false, error: { kind: "malformed", reason: "invalid owner" }, rawUrl }; + if (!isValidGhRepo(repo)) return { ok: false, error: { kind: "malformed", reason: "invalid repo" }, rawUrl }; + const number = Number(numberRaw); + if (!Number.isInteger(number) || number < 1) { + return { ok: false, error: { kind: "malformed", reason: "invalid pr number" }, rawUrl }; + } + return { ok: true, target: { kind: "pr", repoOwner: owner, repoName: repo, prNumber: number }, rawUrl }; +} + +function buildSessionTarget(sessionId: string, searchParams: URLSearchParams, rawUrl: string): ParseResult { + const laneId = searchParams.get("lane") ?? undefined; + if (laneId != null && !isValidUuid(laneId)) { + return { ok: false, error: { kind: "malformed", reason: "invalid lane id" }, rawUrl }; + } + const event = parseNonNegativeIntParam(searchParams.get("event")); + if (event === null) return { ok: false, error: { kind: "malformed", reason: "invalid event anchor" }, rawUrl }; + const offset = parseNonNegativeIntParam(searchParams.get("offset")); + if (offset === null) return { ok: false, error: { kind: "malformed", reason: "invalid offset anchor" }, rawUrl }; + const envelope = readEnvelopeParams(searchParams); + return { + ok: true, + target: { + kind: "session", + sessionId, + ...(laneId ? { laneId } : {}), + ...(event != null ? { event } : {}), + ...(offset != null ? { offset } : {}), + ...(envelope ? { envelope } : {}), + }, + rawUrl, + }; +} + +function buildFileTarget(path: string, searchParams: URLSearchParams, rawUrl: string): ParseResult { + if (!isValidRepoRelativePath(path)) { + return { ok: false, error: { kind: "malformed", reason: "invalid file path" }, rawUrl }; + } + const laneId = searchParams.get("lane") ?? undefined; + if (laneId != null && !isValidUuid(laneId)) { + return { ok: false, error: { kind: "malformed", reason: "invalid lane id" }, rawUrl }; + } + const line = parseNonNegativeIntParam(searchParams.get("line")); + if (line === null || line === 0) { + return { ok: false, error: { kind: "malformed", reason: "invalid line number" }, rawUrl }; + } + return { + ok: true, + target: { kind: "file", path, ...(line != null ? { line } : {}), ...(laneId ? { laneId } : {}) }, + rawUrl, + }; +} + +function buildCommitTarget(sha: string, searchParams: URLSearchParams, rawUrl: string): ParseResult { + if (!COMMIT_SHA_RE.test(sha)) { + return { ok: false, error: { kind: "malformed", reason: "invalid commit sha" }, rawUrl }; + } + const laneId = searchParams.get("lane") ?? undefined; + if (laneId != null && !isValidUuid(laneId)) { + return { ok: false, error: { kind: "malformed", reason: "invalid lane id" }, rawUrl }; + } + const envelope = readEnvelopeParams(searchParams); + return { + ok: true, + target: { + kind: "commit", + sha: sha.toLowerCase(), + ...(laneId ? { laneId } : {}), + ...(envelope ? { envelope } : {}), + }, + rawUrl, + }; +} + +function buildArtifactTarget(artifactId: string, searchParams: URLSearchParams, rawUrl: string): ParseResult { + if (!isValidOpaqueId(artifactId)) { + return { ok: false, error: { kind: "malformed", reason: "invalid artifact id" }, rawUrl }; + } + const envelope = readEnvelopeParams(searchParams); + return { + ok: true, + target: { kind: "artifact", artifactId, ...(envelope ? { envelope } : {}) }, + rawUrl, + }; +} + function safeDecode(value: string): string { try { return decodeURIComponent(value); @@ -467,11 +630,6 @@ function safeDecode(value: string): string { } } -// --------------------------------------------------------------------------- -// Predicates -// --------------------------------------------------------------------------- - -/** True if `rawUrl` looks like one of our deeplink forms — used for clipboard sniffing. */ export function looksLikeAdeDeeplink(rawUrl: string): boolean { if (!rawUrl || typeof rawUrl !== "string") return false; const trimmed = rawUrl.trim(); @@ -487,13 +645,18 @@ export function looksLikeAdeDeeplink(rawUrl: string): boolean { } } -/** Convenience: human-readable summary of a target, used for toasts. */ export function describeTarget(target: DeeplinkTarget): string { switch (target.kind) { case "lane": return "lane link"; case "session": return "work session"; + case "file": + return target.line ? `${target.path}:${target.line}` : target.path; + case "commit": + return `commit ${target.sha}`; + case "artifact": + return `artifact ${target.artifactId}`; case "branch": return `${target.repoOwner}/${target.repoName}@${target.branch}`; case "pr": diff --git a/apps/desktop/src/shared/githubRemote.ts b/apps/desktop/src/shared/githubRemote.ts new file mode 100644 index 000000000..95e94f53a --- /dev/null +++ b/apps/desktop/src/shared/githubRemote.ts @@ -0,0 +1,49 @@ +export type GithubRepoSlug = { + owner: string; + repo: string; +}; + +function stripGitSuffix(value: string): string { + return value.endsWith(".git") ? value.slice(0, -4) : value; +} + +function parsePathParts(pathname: string): GithubRepoSlug | null { + const parts = pathname + .replace(/^\/+/, "") + .replace(/\/+$/, "") + .split("/") + .filter(Boolean); + if (parts.length !== 2) return null; + const owner = parts[0]?.trim() ?? ""; + const repo = stripGitSuffix(parts[1]?.trim() ?? ""); + return owner && repo ? { owner, repo } : null; +} + +export function parseGithubRemoteUrl(remoteUrl: string | null | undefined): GithubRepoSlug | null { + const value = typeof remoteUrl === "string" ? remoteUrl.trim() : ""; + if (!value) return null; + + const scpLike = value.match(/^(?:[^@]+@)?github\.com:([^/]+)\/(.+?)\/?$/i); + if (scpLike) { + const owner = scpLike[1]?.trim() ?? ""; + const repo = stripGitSuffix(scpLike[2]?.trim() ?? ""); + return owner && repo && !repo.includes("/") ? { owner, repo } : null; + } + + try { + const url = new URL(value); + if (url.hostname.toLowerCase() !== "github.com") return null; + return parsePathParts(url.pathname); + } catch { + return null; + } +} + +export function githubRepoSlugsEqual( + left: GithubRepoSlug | null | undefined, + right: GithubRepoSlug | null | undefined, +): boolean { + if (!left || !right) return false; + return left.owner.toLowerCase() === right.owner.toLowerCase() + && left.repo.toLowerCase() === right.repo.toLowerCase(); +} diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index d7ddbeb64..030717e55 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -37,6 +37,7 @@ export const IPC = { projectOpenAdeFolder: "ade.project.openAdeFolder", projectClearLocalData: "ade.project.clearLocalData", projectListRecent: "ade.project.listRecent", + projectFindForRepo: "ade.project.findForRepo", projectCreateLocal: "ade.project.createLocal", projectClone: "ade.project.clone", projectGetDefaultParentDir: "ade.project.getDefaultParentDir", diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index 380f7a1cb..7bcd11cd3 100644 --- a/apps/desktop/src/shared/types/core.ts +++ b/apps/desktop/src/shared/types/core.ts @@ -2,6 +2,8 @@ // Core / project-wide types // --------------------------------------------------------------------------- +import type { DeeplinkEnvelope } from "../deeplinks"; + export type LocalRuntimeServiceInstallState = | "not_attempted" | "installing" @@ -170,11 +172,32 @@ export type AppNavigationTarget = kind: "work" | "chat"; sessionId?: string | null; laneId?: string | null; + envelope?: DeeplinkEnvelope | null; + event?: number | null; + offset?: number | null; + } + | { + kind: "file"; + path: string; + line?: number | null; + laneId?: string | null; + } + | { + kind: "commit"; + sha: string; + laneId?: string | null; + envelope?: DeeplinkEnvelope | null; + } + | { + kind: "artifact"; + artifactId: string; + envelope?: DeeplinkEnvelope | null; } | { kind: "lane"; laneId: string; sessionId?: string | null; + envelope?: DeeplinkEnvelope | null; } | { kind: "pr"; @@ -326,6 +349,17 @@ export type RecentProjectSummary = { pinned?: boolean; }; +export type ProjectFindForRepoArgs = { + repoOwner: string; + repoName: string; +}; + +/** A known local project (active or recent) whose git origin matches the repo. */ +export type ProjectFindForRepoResult = { + rootPath: string; + displayName: string; +} | null; + export type CreateProjectInput = { name: string; parentDir: string; diff --git a/apps/ios/ADE/ADE.entitlements b/apps/ios/ADE/ADE.entitlements index fe61d9696..96fcc4588 100644 --- a/apps/ios/ADE/ADE.entitlements +++ b/apps/ios/ADE/ADE.entitlements @@ -8,6 +8,10 @@ aps-environment development + com.apple.developer.associated-domains + + applinks:ade-app.dev + com.apple.security.application-groups group.com.ade.ios diff --git a/apps/ios/ADE/App/ADEApp.swift b/apps/ios/ADE/App/ADEApp.swift index ceb87b125..03a6fddf6 100644 --- a/apps/ios/ADE/App/ADEApp.swift +++ b/apps/ios/ADE/App/ADEApp.swift @@ -56,6 +56,10 @@ struct ADEApp: App { .onOpenURL { url in DeepLinkRouter.shared.handle(url) } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + guard let url = activity.webpageURL else { return } + DeepLinkRouter.shared.handle(url) + } .onReceive(NotificationCenter.default.publisher(for: .adeSendToMacRequested)) { note in // Parse the URL out of the payload posted by `DeepLinkRouter`. We // accept either a `URL` or a `String` so callers don't have to diff --git a/apps/ios/ADE/App/DeepLinkRouter.swift b/apps/ios/ADE/App/DeepLinkRouter.swift index d2c5b63d2..9c88044bc 100644 --- a/apps/ios/ADE/App/DeepLinkRouter.swift +++ b/apps/ios/ADE/App/DeepLinkRouter.swift @@ -19,12 +19,15 @@ final class DeepLinkRouter { /// `ade://pr/` forms plus the four new desktop-originated shapes: /// /// * `ade://lane/` + /// * `ade://file/[?line=&lane=]` + /// * `ade://commit/[?lane=]` + /// * `ade://artifact/` /// * `ade://repo///branch/` /// * `ade://pr///` /// * `ade://linear-issue/[?branch=]` /// /// Also accepts the web mirror used by CLI / agent handoff output: - /// `https://ade-app.dev/open?type=&...`. + /// `https://ade-app.dev/open?type=&...`. /// /// Unknown hosts are ignored rather than crashing on malformed input. func handle(_ url: URL) { @@ -37,8 +40,10 @@ final class DeepLinkRouter { .map { $0.removingPercentEncoding ?? $0 } switch host { case "session": - guard let sessionId = pathComponents.first, !sessionId.isEmpty else { return } - post(kind: "session", identifier: sessionId) + guard let sessionId = pathComponents.first, + ADEDeepLinkURLParsing.isValidOpaqueId(sessionId), + let anchors = sessionAnchors(from: url) else { return } + post(kind: "session", identifier: sessionId, event: anchors.event, offset: anchors.offset) case "pr": // Two accepted shapes today: // `ade://pr/` (compact local link) @@ -57,7 +62,20 @@ final class DeepLinkRouter { // Lanes are a local-only desktop concept — the iOS client has no // counterpart UI, so we surface a "Send to your Mac" card instead of // trying to navigate. - guard let laneId = pathComponents.first, !laneId.isEmpty else { return } + guard let laneId = pathComponents.first, + ADEDeepLinkURLParsing.isValidUUID(laneId) else { return } + postSendToMac(url: url) + case "file": + let path = pathComponents.joined(separator: "/") + guard isValidFileTarget(path: path, url: url) else { return } + postSendToMac(url: url) + case "commit": + guard let sha = pathComponents.first, + isValidCommitTarget(sha: sha, url: url) else { return } + postSendToMac(url: url) + case "artifact": + guard let artifactId = pathComponents.first, + ADEDeepLinkURLParsing.isValidOpaqueId(artifactId) else { return } postSendToMac(url: url) case "repo": // `ade://repo///branch/` — also cross-machine. @@ -65,9 +83,8 @@ final class DeepLinkRouter { // an empty send-to-mac sheet. guard pathComponents.count >= 4, pathComponents[2].lowercased() == "branch", - !pathComponents[0].isEmpty, - !pathComponents[1].isEmpty, - !pathComponents[3].isEmpty + ADEDeepLinkURLParsing.splitRepo("\(pathComponents[0])/\(pathComponents[1])") != nil, + ADEDeepLinkURLParsing.isValidBranch(pathComponents.dropFirst(3).joined(separator: "/")) else { return } postSendToMac(url: url) case "linear-issue": @@ -77,7 +94,8 @@ final class DeepLinkRouter { // so we bounce the link to the paired Mac. We validate the identifier // shape so a stray `ade://linear-issue/` doesn't pop an empty sheet. guard let identifier = pathComponents.first, - !identifier.isEmpty + ADEDeepLinkURLParsing.isValidLinearIdentifier(identifier), + isValidLinearIssueBranch(url: url) else { return } postSendToMac(url: url) default: @@ -120,21 +138,43 @@ final class DeepLinkRouter { let query = ADEDeepLinkURLParsing.adeQueryValues(from: components) switch query["type"]?.lowercased() { case "lane": - guard query["id"]?.isEmpty == false else { return true } + guard ADEDeepLinkURLParsing.isValidUUID(query["id"]) else { return true } postSendToMac(url: url) case "session": - guard let sessionId = query["id"], !sessionId.isEmpty else { return true } + guard let sessionId = query["id"], + ADEDeepLinkURLParsing.isValidOpaqueId(sessionId), + let anchors = sessionAnchors(from: query) else { return true } + post(kind: "session", identifier: sessionId, event: anchors.event, offset: anchors.offset) + case "file": + guard isValidFileTarget(path: query["path"] ?? "", query: query) else { return true } + postSendToMac(url: url) + case "commit": + guard isValidCommitTarget(sha: query["sha"] ?? "", query: query) else { return true } + postSendToMac(url: url) + case "artifact": + guard ADEDeepLinkURLParsing.isValidOpaqueId(query["id"]) else { return true } postSendToMac(url: url) case "branch": guard ADEDeepLinkURLParsing.splitRepo(query["repo"]) != nil, - query["branch"]?.isEmpty == false else { return true } + ADEDeepLinkURLParsing.isValidBranch(query["branch"]), + query["pr"] == nil || ADEDeepLinkURLParsing.positiveInteger(query["pr"]) != nil else { return true } postSendToMac(url: url) case "pr": - guard ADEDeepLinkURLParsing.splitRepo(query["repo"]) != nil, - ADEDeepLinkURLParsing.positiveInteger(query["number"]) != nil else { return true } + guard let number = ADEDeepLinkURLParsing.positiveInteger(query["number"]) else { return true } + // PR numbers are only unique within a repository, and the local + // workspace snapshot carries no repo identity — when the link names a + // repo, hand off to the paired Mac (whose resolution ladder verifies + // the repo) instead of guessing a local PR by bare number. + if query["repo"]?.isEmpty ?? true { + post(kind: "pr", identifier: "\(number)", prNumber: number) + return true + } + guard ADEDeepLinkURLParsing.splitRepo(query["repo"]) != nil else { return true } postSendToMac(url: url) case "linear-issue": - guard query["issue"]?.isEmpty == false else { return true } + guard ADEDeepLinkURLParsing.isValidLinearIdentifier(query["issue"]), + ADEDeepLinkURLParsing.isValidBranch(query["branch"] ?? "main") + || query["branch"] == nil else { return true } postSendToMac(url: url) default: break @@ -169,14 +209,21 @@ final class DeepLinkRouter { } } - private func post(kind: String, identifier: String, prNumber: Int? = nil) { + private func post(kind: String, identifier: String, prNumber: Int? = nil, event: Int? = nil, offset: Int? = nil) { + var userInfo: [String: Any] = ["kind": kind, "identifier": identifier] + if let event { userInfo["event"] = event } + if let offset { userInfo["offset"] = offset } NotificationCenter.default.post( name: .adeDeepLinkRequested, object: nil, - userInfo: ["kind": kind, "identifier": identifier] + userInfo: userInfo ) if kind == "session" { - SyncService.shared?.requestedWorkSessionNavigation = WorkSessionNavigationRequest(sessionId: identifier) + SyncService.shared?.requestedWorkSessionNavigation = WorkSessionNavigationRequest( + sessionId: identifier, + event: event, + offset: offset + ) } if kind == "pr" { let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines) @@ -191,6 +238,60 @@ final class DeepLinkRouter { } } + private func sessionAnchors(from url: URL) -> (event: Int?, offset: Int?)? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return (nil, nil) + } + return sessionAnchors(from: ADEDeepLinkURLParsing.adeQueryValues(from: components)) + } + + private func sessionAnchors(from query: [String: String]) -> (event: Int?, offset: Int?)? { + if let lane = query["lane"], !ADEDeepLinkURLParsing.isValidUUID(lane) { + return nil + } + let event = ADEDeepLinkURLParsing.nonNegativeInteger(query["event"]) + if query["event"] != nil && event == nil { return nil } + let offset = ADEDeepLinkURLParsing.nonNegativeInteger(query["offset"]) + if query["offset"] != nil && offset == nil { return nil } + return (event, offset) + } + + private func isValidFileTarget(path: String, url: URL) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } + return isValidFileTarget(path: path, query: ADEDeepLinkURLParsing.adeQueryValues(from: components)) + } + + private func isValidFileTarget(path: String, query: [String: String]) -> Bool { + guard ADEDeepLinkURLParsing.isValidRepoRelativePath(path) else { return false } + if let lane = query["lane"], !ADEDeepLinkURLParsing.isValidUUID(lane) { + return false + } + if let line = query["line"], ADEDeepLinkURLParsing.positiveInteger(line) == nil { + return false + } + return true + } + + private func isValidCommitTarget(sha: String, url: URL) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } + return isValidCommitTarget(sha: sha, query: ADEDeepLinkURLParsing.adeQueryValues(from: components)) + } + + private func isValidCommitTarget(sha: String, query: [String: String]) -> Bool { + guard ADEDeepLinkURLParsing.isValidCommitSha(sha) else { return false } + if let lane = query["lane"], !ADEDeepLinkURLParsing.isValidUUID(lane) { + return false + } + return true + } + + private func isValidLinearIssueBranch(url: URL) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return true } + let query = ADEDeepLinkURLParsing.adeQueryValues(from: components) + guard let branch = query["branch"] else { return true } + return ADEDeepLinkURLParsing.isValidBranch(branch) + } + /// Cross-machine deep links (lane / repo-branch / linear-issue) post on the /// send-to-mac channel so the presentation layer can pop the confirmation /// card. We pass the raw URL through so the card can render the target diff --git a/apps/ios/ADE/App/DeepLinkURLParsing.swift b/apps/ios/ADE/App/DeepLinkURLParsing.swift index fbc7f590d..aa3481fe0 100644 --- a/apps/ios/ADE/App/DeepLinkURLParsing.swift +++ b/apps/ios/ADE/App/DeepLinkURLParsing.swift @@ -1,8 +1,31 @@ import Foundation +struct ADEDeeplinkEnvelope: Equatable { + var repoOwner: String? + var repoName: String? + var branch: String? + var prNumber: Int? + var linearIssue: String? + + var repoSlug: String? { + guard let repoOwner, let repoName else { return nil } + return "\(repoOwner)/\(repoName)" + } + + var isEmpty: Bool { + repoOwner == nil && repoName == nil && branch == nil && prNumber == nil && linearIssue == nil + } +} + +enum ADEDeeplinkForm { + case ade + case https +} + enum ADEDeepLinkURLParsing { static let canonicalWebHost = "ade-app.dev" static let legacyWebHosts: Set = ["ade.app"] + private static let pathValueAllowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) static func isADEWebHost(_ value: String?) -> Bool { guard let value else { return false } @@ -15,7 +38,9 @@ enum ADEDeepLinkURLParsing { let pieces = value.split(separator: "/", omittingEmptySubsequences: false).map(String.init) guard pieces.count == 2, !pieces[0].isEmpty, - !pieces[1].isEmpty else { + !pieces[1].isEmpty, + isValidGithubOwner(pieces[0]), + isValidGithubRepo(pieces[1]) else { return nil } return (pieces[0], pieces[1]) @@ -23,6 +48,7 @@ enum ADEDeepLinkURLParsing { static func positiveInteger(_ value: String?) -> Int? { guard let value, + value.range(of: #"^\d{1,15}$"#, options: .regularExpression) != nil, let number = Int(value), number > 0 else { return nil @@ -30,6 +56,16 @@ enum ADEDeepLinkURLParsing { return number } + static func nonNegativeInteger(_ value: String?) -> Int? { + guard let value else { return nil } + guard value.range(of: #"^\d{1,15}$"#, options: .regularExpression) != nil, + 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 ?? [] { @@ -38,4 +74,99 @@ enum ADEDeepLinkURLParsing { } return query } + + static func envelope(from query: [String: String]) -> ADEDeeplinkEnvelope? { + var envelope = ADEDeeplinkEnvelope() + if let repo = splitRepo(query["repo"]) { + envelope.repoOwner = repo.owner + envelope.repoName = repo.repo + } + if let branch = query["branch"], isValidBranch(branch) { + envelope.branch = branch + } + if let prNumber = positiveInteger(query["pr"]) { + envelope.prNumber = prNumber + } + if let linear = query["linear"], isValidLinearIdentifier(linear) { + envelope.linearIssue = linear + } + return envelope.isEmpty ? nil : envelope + } + + static func isValidUUID(_ value: String?) -> Bool { + guard let value, + value.count == 36, + UUID(uuidString: value) != nil else { + return false + } + return true + } + + static func isValidOpaqueId(_ value: String?) -> Bool { + guard let value else { return false } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.count <= 512 else { return false } + return !containsControlCharacter(trimmed) + } + + static func isValidRepoRelativePath(_ value: String?) -> Bool { + guard let value, !value.isEmpty, value.count <= 1024 else { return false } + if value.hasPrefix("/") || value.hasSuffix("/") || value.contains("\\") { return false } + if value.range(of: #"^[A-Za-z]:"#, options: .regularExpression) != nil { return false } + if containsControlCharacter(value) { return false } + return value.split(separator: "/", omittingEmptySubsequences: false).allSatisfy { segment in + segment != "" && segment != "." && segment != ".." + } + } + + static func isValidCommitSha(_ value: String?) -> Bool { + guard let value else { return false } + return value.range(of: #"^[0-9a-fA-F]{7,40}$"#, options: .regularExpression) != nil + } + + static func isValidLinearIdentifier(_ value: String?) -> Bool { + guard let value else { return false } + return value.range(of: #"^[A-Za-z][A-Za-z0-9]{0,9}-\d{1,9}$"#, options: .regularExpression) != nil + } + + static func isValidBranch(_ value: String?) -> Bool { + guard let value, !value.isEmpty, value.count <= 255 else { return false } + if value.hasPrefix("/") || value.hasSuffix("/") || value.hasSuffix(".lock") { return false } + if containsControlCharacter(value) { return false } + return !value.split(separator: "/", omittingEmptySubsequences: false).contains("..") + } + + static func encodedPathSegment(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: pathValueAllowed) ?? value + } + + static func encodedPath(_ value: String) -> String { + value + .split(separator: "/", omittingEmptySubsequences: false) + .map { encodedPathSegment(String($0)) } + .joined(separator: "/") + } + + static func queryString(_ items: [(String, String?)]) -> String { + items.compactMap { name, value -> String? in + guard let value, !value.isEmpty else { return nil } + return "\(encodedPathSegment(name))=\(encodedPathSegment(value))" + } + .joined(separator: "&") + } + + private static func isValidGithubOwner(_ value: String) -> Bool { + value.range(of: #"^[A-Za-z0-9][A-Za-z0-9-]{0,38}$"#, options: .regularExpression) != nil + } + + private static func isValidGithubRepo(_ value: String) -> Bool { + guard value != ".", value != ".." else { return false } + return value.range(of: #"^[A-Za-z0-9_.][A-Za-z0-9_.-]{0,99}$"#, options: .regularExpression) != nil + } + + private static func containsControlCharacter(_ value: String) -> Bool { + value.unicodeScalars.contains { scalar in + scalar.value < 0x20 || scalar.value == 0x7f + } + } } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index dccfa9539..d86dbe86b 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1223,10 +1223,17 @@ struct WorkLaneNavigationRequest: Equatable, Identifiable { struct WorkSessionNavigationRequest: Equatable, Identifiable { let id: String let sessionId: String + /// Optional anchors parsed from ADE session deeplinks. The Work view keeps + /// them for parity with desktop, but currently ignores them because iOS has + /// no route-level chat/terminal scroll hook. + let event: Int? + let offset: Int? - init(sessionId: String) { + init(sessionId: String, event: Int? = nil, offset: Int? = nil) { self.id = UUID().uuidString self.sessionId = sessionId + self.event = event + self.offset = offset } } diff --git a/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift b/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift index 3bd3fb400..af13c7b17 100644 --- a/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift +++ b/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit /// Categorises an ADE URL that iOS can't open natively so the /// `SendToMacCard` can render a short, human description ("Lane shared with @@ -7,6 +8,10 @@ import SwiftUI struct SendToMacTarget: Equatable, Identifiable { enum Kind: Equatable { case lane(id: String) + case session(id: String) + case file(path: String, line: Int?) + case commit(sha: String) + case artifact(id: String) case repoBranch(owner: String, repo: String, branch: String) case pr(owner: String, repo: String, number: Int) case linearIssue(identifier: String, branch: String?) @@ -15,6 +20,7 @@ struct SendToMacTarget: Equatable, Identifiable { var url: URL var kind: Kind + var envelope: ADEDeeplinkEnvelope? /// `Identifiable` conformance powers the `.sheet(item:)` binding in /// `ADEApp`; using the URL string keeps repeat shares of the same link @@ -26,12 +32,15 @@ struct SendToMacTarget: Equatable, Identifiable { /// "Open this on your Mac" message rather than refusing to display. init(url: URL) { self.url = url + self.envelope = SendToMacTarget.parseEnvelope(url) if let kind = SendToMacTarget.parseHttpsOpenURL(url) { self.kind = kind return } let host = url.host?.lowercased() - let parts = url.pathComponents.filter { $0 != "/" } + let parts = url.pathComponents + .filter { $0 != "/" } + .map { $0.removingPercentEncoding ?? $0 } switch host { case "lane": if let id = parts.first, !id.isEmpty { @@ -39,6 +48,34 @@ struct SendToMacTarget: Equatable, Identifiable { } else { self.kind = .other } + case "session": + if let id = parts.first, ADEDeepLinkURLParsing.isValidOpaqueId(id) { + self.kind = .session(id: id) + } else { + self.kind = .other + } + case "file": + let path = parts.joined(separator: "/") + if ADEDeepLinkURLParsing.isValidRepoRelativePath(path) { + let line = URLComponents(url: url, resolvingAgainstBaseURL: false) + .map(ADEDeepLinkURLParsing.adeQueryValues(from:)) + .flatMap { ADEDeepLinkURLParsing.positiveInteger($0["line"]) } + self.kind = .file(path: path, line: line) + } else { + self.kind = .other + } + case "commit": + if let sha = parts.first, ADEDeepLinkURLParsing.isValidCommitSha(sha) { + self.kind = .commit(sha: sha) + } else { + self.kind = .other + } + case "artifact": + if let id = parts.first, ADEDeepLinkURLParsing.isValidOpaqueId(id) { + self.kind = .artifact(id: id) + } else { + self.kind = .other + } case "repo": if parts.count >= 4, parts[2].lowercased() == "branch", @@ -95,6 +132,18 @@ struct SendToMacTarget: Equatable, Identifiable { case "lane": guard let id = query["id"], !id.isEmpty else { return .other } return .lane(id: id) + case "session": + guard let id = query["id"], ADEDeepLinkURLParsing.isValidOpaqueId(id) else { return .other } + return .session(id: id) + case "file": + guard let path = query["path"], ADEDeepLinkURLParsing.isValidRepoRelativePath(path) else { return .other } + return .file(path: path, line: ADEDeepLinkURLParsing.positiveInteger(query["line"])) + case "commit": + guard let sha = query["sha"], ADEDeepLinkURLParsing.isValidCommitSha(sha) else { return .other } + return .commit(sha: sha) + case "artifact": + guard let id = query["id"], ADEDeepLinkURLParsing.isValidOpaqueId(id) else { return .other } + return .artifact(id: id) case "branch": guard let repo = ADEDeepLinkURLParsing.splitRepo(query["repo"]), let branch = query["branch"], @@ -117,9 +166,18 @@ struct SendToMacTarget: Equatable, Identifiable { } } + private static func parseEnvelope(_ url: URL) -> ADEDeeplinkEnvelope? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } + return ADEDeepLinkURLParsing.envelope(from: ADEDeepLinkURLParsing.adeQueryValues(from: components)) + } + var headline: String { switch kind { case .lane: return "Lane shared with you" + case .session: return "Chat shared with you" + case .file: return "File shared with you" + case .commit: return "Commit shared with you" + case .artifact: return "Artifact 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" @@ -130,7 +188,27 @@ struct SendToMacTarget: Equatable, Identifiable { var detail: String { switch kind { case .lane(let id): + if let repo = envelope?.repoSlug { + return "This lane lives in \(repo) on another machine." + } return "Lane \(shortenedLaneId(id))" + case .session(let id): + if let repo = envelope?.repoSlug { + return "This chat lives in \(repo) on another machine." + } + return "Session \(id)" + case .file(let path, let line): + return line.map { "\(path):\($0)" } ?? path + case .commit(let sha): + if let repo = envelope?.repoSlug { + return "Commit \(sha.prefix(12)) in \(repo)" + } + return "Commit \(sha.prefix(12))" + case .artifact(let id): + if let repo = envelope?.repoSlug { + return "Artifact \(shortenedOpaqueId(id)) in \(repo)" + } + return "Artifact \(shortenedOpaqueId(id))" case .repoBranch(let owner, let repo, let branch): return "Branch \(branch) in \(owner)/\(repo)" case .pr(let owner, let repo, let number): @@ -151,6 +229,35 @@ struct SendToMacTarget: Equatable, Identifiable { guard id.count > 8, let dash = id.firstIndex(of: "-") else { return id } return String(id[.. String { + guard id.count > 12 else { return id } + return "\(id.prefix(12))..." + } + + var usesMonospacedDetail: Bool { + switch kind { + case .lane, .session, .commit, .artifact: + return envelope?.repoSlug == nil + case .file, .repoBranch, .pr, .linearIssue, .other: + return true + } + } + + var envelopePullRequestURL: URL? { + guard let owner = envelope?.repoOwner, + let repo = envelope?.repoName, + let prNumber = envelope?.prNumber, + prNumber > 0 else { + return nil + } + return URL(string: "https://github.com/\(owner)/\(repo)/pull/\(prNumber)") + } + + var envelopeLinearURL: URL? { + guard let linearIssue = envelope?.linearIssue else { return nil } + return URL(string: "https://linear.app/issue/\(linearIssue)") + } } /// Sheet shown when iOS receives a cross-machine deep link (lane / repo / @@ -214,7 +321,7 @@ struct SendToMacCard: View { .font(.system(.subheadline, design: .rounded).weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) Text(target.detail) - .font(.system(.footnote, design: .monospaced)) + .font(.system(.footnote, design: target.usesMonospacedDetail ? .monospaced : .rounded)) .foregroundStyle(ADEColor.textSecondary) .lineLimit(2) .truncationMode(.middle) @@ -233,6 +340,10 @@ struct SendToMacCard: View { private var targetSymbol: String { switch target.kind { case .lane: return "square.stack.3d.up" + case .session: return "bubble.left.and.bubble.right" + case .file: return "doc.text" + case .commit: return "point.topleft.down.curvedto.point.bottomright.up" + case .artifact: return "shippingbox" case .repoBranch: return "arrow.triangle.branch" case .pr: return "arrow.triangle.merge" case .linearIssue: return "smallcircle.filled.circle" @@ -325,6 +436,8 @@ struct SendToMacCard: View { } Text(sendButtonTitle) .font(.system(.body, design: .rounded).weight(.semibold)) + .lineLimit(2) + .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, 13) @@ -334,6 +447,8 @@ struct SendToMacCard: View { .buttonStyle(.plain) .disabled(isSending || sendCompleted) + externalActions + if let sendStatusMessage { Text(sendStatusMessage) .font(.system(.footnote, design: .rounded)) @@ -358,6 +473,48 @@ struct SendToMacCard: View { } } + @ViewBuilder + private var externalActions: some View { + if let url = target.envelopePullRequestURL, + let prNumber = target.envelope?.prNumber { + externalActionButton( + title: "Open PR #\(prNumber) on GitHub", + symbol: "arrow.up.right.square", + url: url + ) + } + if let url = target.envelopeLinearURL { + externalActionButton( + title: "Open in Linear", + symbol: "smallcircle.filled.circle", + url: url + ) + } + } + + private func externalActionButton(title: String, symbol: String, url: URL) -> some View { + Button { + openExternal(url) + } label: { + HStack(spacing: 8) { + Image(systemName: symbol) + .font(.system(size: 14, weight: .semibold)) + Text(title) + .font(.system(.body, design: .rounded).weight(.medium)) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .foregroundStyle(ADEColor.textPrimary) + .background(ADEColor.cardBackground, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(ADEColor.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + private var sendButtonTitle: String { if isSending { return "Sending…" } if sendCompleted { @@ -365,6 +522,12 @@ struct SendToMacCard: View { return "Sent" } if case .dropped = sendOutcome { return "Try again" } + if let repo = target.envelope?.repoSlug, + let branch = target.envelope?.branch, + !repo.isEmpty, + !branch.isEmpty { + return "Send to Mac to create a lane from \(branch)" + } return "Send to Mac" } @@ -406,4 +569,8 @@ struct SendToMacCard: View { sendCompleted = false } } + + private func openExternal(_ url: URL) { + UIApplication.shared.open(url) + } } diff --git a/apps/ios/ADE/Views/Lanes/LaneDeeplinkHelpers.swift b/apps/ios/ADE/Views/Lanes/LaneDeeplinkHelpers.swift index 0cb9d3cd2..7659a3430 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDeeplinkHelpers.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDeeplinkHelpers.swift @@ -1,17 +1,165 @@ import Foundation enum LaneDeeplinkHelpers { - static func laneLink(laneId: String) -> String { - "ade://lane/\(laneId)" - } - - static func branchLink(repoOwner: String, repoName: String, branch: String) -> String { - let encodedBranch = branch - .split(separator: "/") - .map { segment in - segment.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment) - } - .joined(separator: "/") - return "ade://repo/\(repoOwner)/\(repoName)/branch/\(encodedBranch)" + static func laneLink( + laneId: String, + envelope: ADEDeeplinkEnvelope? = nil, + form: ADEDeeplinkForm = .ade + ) -> String { + switch form { + case .ade: + let base = "ade://lane/\(ADEDeepLinkURLParsing.encodedPathSegment(laneId))" + return appendQuery(to: base, items: envelopeItems(envelope)) + case .https: + return httpsOpenURL(items: [("type", "lane"), ("id", laneId)] + envelopeItems(envelope)) + } + } + + static func sessionLink( + sessionId: String, + laneId: String?, + envelope: ADEDeeplinkEnvelope? = nil, + event: Int? = nil, + offset: Int? = nil, + form: ADEDeeplinkForm = .https + ) -> String { + switch form { + case .ade: + let base = "ade://session/\(ADEDeepLinkURLParsing.encodedPathSegment(sessionId))" + return appendQuery( + to: base, + items: sessionItems(laneId: laneId, event: event, offset: offset) + envelopeItems(envelope) + ) + case .https: + return httpsOpenURL( + items: [("type", "session"), ("id", sessionId)] + + sessionItems(laneId: laneId, event: event, offset: offset) + + envelopeItems(envelope) + ) + } + } + + static func branchLink( + repoOwner: String, + repoName: String, + branch: String, + prNumber: Int? = nil, + form: ADEDeeplinkForm = .https + ) -> String { + switch form { + case .ade: + let base = [ + "ade://repo", + ADEDeepLinkURLParsing.encodedPathSegment(repoOwner), + ADEDeepLinkURLParsing.encodedPathSegment(repoName), + "branch", + ADEDeepLinkURLParsing.encodedPath(branch), + ].joined(separator: "/") + return appendQuery(to: base, items: [("pr", prNumber.map(String.init))]) + case .https: + return httpsOpenURL( + items: [ + ("type", "branch"), + ("repo", "\(repoOwner)/\(repoName)"), + ("branch", branch), + ("pr", prNumber.map(String.init)), + ] + ) + } + } + + static func prLink( + repoOwner: String, + repoName: String, + number: Int, + form: ADEDeeplinkForm = .https + ) -> String { + switch form { + case .ade: + return [ + "ade://pr", + ADEDeepLinkURLParsing.encodedPathSegment(repoOwner), + ADEDeepLinkURLParsing.encodedPathSegment(repoName), + String(number), + ].joined(separator: "/") + case .https: + return httpsOpenURL( + items: [ + ("type", "pr"), + ("repo", "\(repoOwner)/\(repoName)"), + ("number", String(number)), + ] + ) + } + } + + static func envelope( + lane: LaneSummary?, + pullRequest: PullRequestListItem? + ) -> ADEDeeplinkEnvelope? { + let branch = lane.map { normalizedPrBranchName($0.branchRef) }? + .trimmingCharacters(in: .whitespacesAndNewlines) + let issue = lane.flatMap(primaryLaneLinearIssue(for:))?.identifier + return envelope( + repoOwner: pullRequest?.repoOwner, + repoName: pullRequest?.repoName, + branch: branch, + prNumber: pullRequest?.githubPrNumber, + linearIssue: issue + ) + } + + static func envelope( + repoOwner: String? = nil, + repoName: String? = nil, + branch: String? = nil, + prNumber: Int? = nil, + linearIssue: String? = nil + ) -> ADEDeeplinkEnvelope? { + var envelope = ADEDeeplinkEnvelope() + let owner = repoOwner?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let repo = repoName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if ADEDeepLinkURLParsing.splitRepo("\(owner)/\(repo)") != nil { + envelope.repoOwner = owner + envelope.repoName = repo + } + let branch = branch?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if ADEDeepLinkURLParsing.isValidBranch(branch) { + envelope.branch = branch + } + if let prNumber, prNumber > 0 { + envelope.prNumber = prNumber + } + let linearIssue = linearIssue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if ADEDeepLinkURLParsing.isValidLinearIdentifier(linearIssue) { + envelope.linearIssue = linearIssue + } + return envelope.isEmpty ? nil : envelope + } + + private static func sessionItems(laneId: String?, event: Int?, offset: Int?) -> [(String, String?)] { + [ + ("lane", laneId?.trimmingCharacters(in: .whitespacesAndNewlines)), + ("event", event.map(String.init)), + ("offset", offset.map(String.init)), + ] + } + + private static func envelopeItems(_ envelope: ADEDeeplinkEnvelope?) -> [(String, String?)] { + [ + ("repo", envelope?.repoSlug), + ("branch", envelope?.branch), + ("pr", envelope?.prNumber.map(String.init)), + ("linear", envelope?.linearIssue), + ] + } + + private static func httpsOpenURL(items: [(String, String?)]) -> String { + appendQuery(to: "https://ade-app.dev/open", items: items) + } + + private static func appendQuery(to base: String, items: [(String, String?)]) -> String { + let query = ADEDeepLinkURLParsing.queryString(items) + return query.isEmpty ? base : "\(base)?\(query)" } } diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift index 4aa95522e..054823818 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift @@ -435,7 +435,13 @@ struct LaneDetailScreen: View { @MainActor private func copyLaneLink() { - let url = LaneDeeplinkHelpers.laneLink(laneId: laneId) + let url = LaneDeeplinkHelpers.laneLink( + laneId: laneId, + envelope: LaneDeeplinkHelpers.envelope( + lane: currentSnapshot.lane, + pullRequest: lanePullRequests.first + ) + ) UIPasteboard.general.string = url ADEHaptics.success() copiedLinkNotice = "Copied lane link" @@ -453,12 +459,20 @@ struct LaneDetailScreen: View { return } if let pr = lanePullRequests.first { - let url = LaneDeeplinkHelpers.branchLink(repoOwner: pr.repoOwner, repoName: pr.repoName, branch: branch) + let url = LaneDeeplinkHelpers.branchLink( + repoOwner: pr.repoOwner, + repoName: pr.repoName, + branch: branch, + prNumber: pr.githubPrNumber + ) UIPasteboard.general.string = url ADEHaptics.success() copiedLinkNotice = "Copied branch link" } else { - UIPasteboard.general.string = LaneDeeplinkHelpers.laneLink(laneId: laneId) + UIPasteboard.general.string = LaneDeeplinkHelpers.laneLink( + laneId: laneId, + envelope: LaneDeeplinkHelpers.envelope(lane: currentSnapshot.lane, pullRequest: nil) + ) ADEHaptics.success() copiedLinkNotice = "No GitHub remote — copied lane link instead" } diff --git a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift index b3ba4b11f..34036af04 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift @@ -736,6 +736,9 @@ struct PrDetailView: View { canReopen: canReopenCurrentPr, canOpenGitHub: canOpenCurrentPrInGitHub, hasGitHubUrl: !currentPr.githubUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + hasADELink: !currentPr.repoOwner.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !currentPr.repoName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && currentPr.githubPrNumber > 0, onDismiss: { actionsSheetPresented = false }, onEditTitle: { actionsSheetPresented = false @@ -772,6 +775,16 @@ struct PrDetailView: View { ADEHaptics.success() actionMessage = "URL copied." }, + onCopyAdeLink: { + actionsSheetPresented = false + UIPasteboard.general.string = LaneDeeplinkHelpers.prLink( + repoOwner: currentPr.repoOwner, + repoName: currentPr.repoName, + number: currentPr.githubPrNumber + ) + ADEHaptics.success() + actionMessage = "ADE link copied." + }, onRefresh: { actionsSheetPresented = false Task { await reload(refreshRemote: true) } @@ -1642,6 +1655,7 @@ private struct PrDetailActionsSheet: View { let canReopen: Bool let canOpenGitHub: Bool let hasGitHubUrl: Bool + let hasADELink: Bool let onDismiss: () -> Void let onEditTitle: () -> Void let onEditDescription: () -> Void @@ -1651,6 +1665,7 @@ private struct PrDetailActionsSheet: View { let onReopen: () -> Void let onOpenGitHub: () -> Void let onCopyUrl: () -> Void + let onCopyAdeLink: () -> Void let onRefresh: () -> Void var body: some View { @@ -1723,6 +1738,12 @@ private struct PrDetailActionsSheet: View { disabled: !hasGitHubUrl, action: onCopyUrl ) + PrDetailActionRow( + title: "Copy ADE link", + symbol: "link", + disabled: !hasADELink, + action: onCopyAdeLink + ) PrDetailActionRow(title: "Refresh", symbol: "arrow.clockwise", action: onRefresh) } .padding(16) diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index 7070ff5cf..94d6b0af5 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -246,7 +246,7 @@ struct WorkChatHeaderMenu: View, Equatable { } Button(action: onCopySessionDeepLink) { - Label(model.sessionDeepLinkCopied ? "Copied session deep link" : "Copy session deep link", + Label(model.sessionDeepLinkCopied ? "Copied session link" : "Copy session link", systemImage: model.sessionDeepLinkCopied ? "checkmark" : "link") } diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index b6ce10f7f..73f48e0ed 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -629,7 +629,7 @@ struct WorkSessionListRow: View { Button { onCopyDeepLink(session) } label: { - Label("Copy session deep link", systemImage: "link") + Label("Copy session link", systemImage: "link") } Button { onPin(session) diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index f59f5435f..7a73bfc50 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -322,9 +322,13 @@ extension WorkRootScreen { } func copySessionDeepLink(_ session: TerminalSessionSummary) { + let laneId = resolvedWorkNavigationLaneId(for: session, lanes: lanes) + let lane = lanes.first(where: { $0.id == laneId }) + let pullRequest = pullRequests.first(where: { $0.laneId == laneId }) UIPasteboard.general.string = workSessionDeepLink( sessionId: session.id, - laneId: resolvedWorkNavigationLaneId(for: session, lanes: lanes) + laneId: laneId, + envelope: LaneDeeplinkHelpers.envelope(lane: lane, pullRequest: pullRequest) ) } diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift index 0b81f2320..1e40a65e3 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift @@ -571,7 +571,12 @@ extension WorkSessionDestinationView { let laneId = (session ?? initialSession).map { resolvedWorkNavigationLaneId(for: $0, lanes: lanes) } - UIPasteboard.general.string = workSessionDeepLink(sessionId: sessionId, laneId: laneId) + let lane = laneId.flatMap { id in lanes.first(where: { $0.id == id }) } + UIPasteboard.general.string = workSessionDeepLink( + sessionId: sessionId, + laneId: laneId, + envelope: LaneDeeplinkHelpers.envelope(lane: lane, pullRequest: laneOpenPr) + ) sessionDeepLinkCopied = true Task { @MainActor in try? await Task.sleep(nanoseconds: 1_500_000_000) diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index da8619787..b1ab27883 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -73,16 +73,18 @@ func isStoppableRuntimeStatus(_ session: TerminalSessionSummary, status: String) return status == "active" || status == "awaiting-input" || status == "idle" } -func workSessionDeepLink(sessionId: String, laneId: String?) -> String { - let pathAllowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/?#[]@!$&'()*+,;=")) - let queryAllowed = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "#&=")) - let encodedSessionId = sessionId.addingPercentEncoding(withAllowedCharacters: pathAllowed) ?? sessionId - let trimmedLaneId = laneId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmedLaneId.isEmpty else { - return "ade://session/\(encodedSessionId)" - } - let encodedLaneId = trimmedLaneId.addingPercentEncoding(withAllowedCharacters: queryAllowed) ?? trimmedLaneId - return "ade://session/\(encodedSessionId)?lane=\(encodedLaneId)" +func workSessionDeepLink( + sessionId: String, + laneId: String?, + envelope: ADEDeeplinkEnvelope? = nil, + form: ADEDeeplinkForm = .https +) -> String { + LaneDeeplinkHelpers.sessionLink( + sessionId: sessionId, + laneId: laneId, + envelope: envelope, + form: form + ) } /// Whether the composer gates freeform typing behind a structured reply. Only a diff --git a/apps/web/api/open.ts b/apps/web/api/open.ts index 627ceb2d5..334016b5e 100644 --- a/apps/web/api/open.ts +++ b/apps/web/api/open.ts @@ -33,6 +33,9 @@ type VercelRes = { type OpenTarget = | { kind: "lane"; laneId: string } | { kind: "session"; sessionId: string; laneId?: string } + | { kind: "file"; path: string; line?: number; laneId?: string } + | { kind: "commit"; sha: string; laneId?: string } + | { kind: "artifact"; artifactId: string } | { kind: "branch"; repo: string; branch: string; pr?: number } | { kind: "pr"; repo: string; number: number } | { kind: "linear-issue"; issue: string; branch?: string } @@ -73,6 +76,11 @@ function pickQuery(value: string | string[] | undefined): string { return value ?? ""; } +function positiveInteger(raw: string): number | undefined { + const value = Number(raw); + return Number.isInteger(value) && value > 0 ? value : undefined; +} + function parseTarget(query: VercelQuery): OpenTarget { const type = pickQuery(query.type).toLowerCase(); if (type === "lane") { @@ -86,22 +94,40 @@ function parseTarget(query: VercelQuery): OpenTarget { return laneId ? { kind: "session", sessionId, laneId } : { kind: "session", sessionId }; } } + if (type === "file") { + const path = pickQuery(query.path); + if (path) { + const line = positiveInteger(pickQuery(query.line)); + const laneId = pickQuery(query.lane); + return { kind: "file", path, ...(line ? { line } : {}), ...(laneId ? { laneId } : {}) }; + } + } + if (type === "commit") { + const sha = pickQuery(query.sha); + if (sha) { + const laneId = pickQuery(query.lane); + return { kind: "commit", sha, ...(laneId ? { laneId } : {}) }; + } + } + if (type === "artifact") { + const artifactId = pickQuery(query.id); + if (artifactId) return { kind: "artifact", artifactId }; + } if (type === "branch") { const repo = pickQuery(query.repo); const branch = pickQuery(query.branch); if (repo && branch) { - const pr = Number(pickQuery(query.pr)); - return Number.isInteger(pr) && pr > 0 + const pr = positiveInteger(pickQuery(query.pr)); + return pr ? { kind: "branch", repo, branch, pr } : { kind: "branch", repo, branch }; } } if (type === "pr") { const repo = pickQuery(query.repo); - const numberRaw = pickQuery(query.number); - const num = Number(numberRaw); - if (repo && Number.isInteger(num) && num > 0) { - return { kind: "pr", repo, number: num }; + const number = positiveInteger(pickQuery(query.number)); + if (repo && number) { + return { kind: "pr", repo, number }; } } if (type === "linear-issue") { @@ -128,6 +154,23 @@ function describe(target: OpenTarget): { title: string; description: string } { ? `Open this ADE work session in worktree ${target.laneId.slice(0, 8)}…` : "Open this ADE work session on your desktop.", }; + case "file": + return { + title: "Open file in ADE", + description: target.line + ? `Open ${target.path} at line ${target.line} in ADE.` + : `Open ${target.path} in ADE.`, + }; + case "commit": + return { + title: "Open commit in ADE", + description: `Open commit ${target.sha.slice(0, 12)} in ADE.`, + }; + case "artifact": + return { + title: "Open artifact in ADE", + description: `Open proof artifact ${target.artifactId} in ADE.`, + }; case "branch": return { title: `${target.repo} · ${target.branch} — Open in ADE`, diff --git a/apps/web/public/.well-known/apple-app-site-association b/apps/web/public/.well-known/apple-app-site-association index d2be4108f..eb969b6ab 100644 --- a/apps/web/public/.well-known/apple-app-site-association +++ b/apps/web/public/.well-known/apple-app-site-association @@ -1,4 +1,22 @@ { + "applinks": { + "apps": [], + "details": [ + { + "appIDs": ["VQ372F39G6.com.ade.ios"], + "components": [ + { + "/": "/open*", + "comment": "ADE deeplink handoff" + }, + { + "/": "/pair*", + "comment": "ADE mobile pairing" + } + ] + } + ] + }, "appclips": { "apps": ["VQ372F39G6.com.ade.ios.Clip"] } diff --git a/apps/web/src/app/pages/OpenPage.tsx b/apps/web/src/app/pages/OpenPage.tsx index d96c46d6d..289722acc 100644 --- a/apps/web/src/app/pages/OpenPage.tsx +++ b/apps/web/src/app/pages/OpenPage.tsx @@ -8,43 +8,106 @@ import { Section } from "../../components/Section"; import { useDocumentTitle } from "../../lib/useDocumentTitle"; type OpenTarget = - | { kind: "lane"; laneId: string } - | { kind: "session"; sessionId: string; laneId?: string } + | { kind: "lane"; laneId: string; envelope?: OpenEnvelope } + | { kind: "session"; sessionId: string; laneId?: string; event?: number; offset?: number; envelope?: OpenEnvelope } + | { kind: "file"; path: string; line?: number; laneId?: string } + | { kind: "commit"; sha: string; laneId?: string; envelope?: OpenEnvelope } + | { kind: "artifact"; artifactId: string; envelope?: OpenEnvelope } | { kind: "branch"; repo: string; branch: string; pr?: number } | { kind: "pr"; repo: string; number: number } | { kind: "linear-issue"; issueIdentifier: string; branch?: string } | { kind: "unknown" }; +type OpenEnvelope = { + repo?: string; + branch?: string; + pr?: number; + linear?: string; +}; + const CANONICAL_OPEN_ORIGIN = "https://ade-app.dev"; +function positiveInteger(raw: string | null): number | undefined { + if (!raw) return undefined; + const value = Number(raw ?? ""); + return Number.isInteger(value) && value > 0 ? value : undefined; +} + +function nonNegativeInteger(raw: string | null): number | undefined { + if (!raw) return undefined; + const value = Number(raw ?? ""); + return Number.isInteger(value) && value >= 0 ? value : undefined; +} + +function readEnvelope(params: URLSearchParams): OpenEnvelope | undefined { + const envelope: OpenEnvelope = {}; + const repo = params.get("repo") ?? ""; + if (repo.includes("/")) envelope.repo = repo; + const branch = params.get("branch") ?? ""; + if (branch) envelope.branch = branch; + const pr = positiveInteger(params.get("pr")); + if (pr) envelope.pr = pr; + const linear = params.get("linear") ?? ""; + if (linear) envelope.linear = linear; + return Object.keys(envelope).length > 0 ? envelope : undefined; +} + function parseQuery(search: string): OpenTarget { const params = new URLSearchParams(search); const type = (params.get("type") ?? "").toLowerCase(); if (type === "lane") { const laneId = params.get("id") ?? ""; - if (laneId) return { kind: "lane", laneId }; + if (laneId) return { kind: "lane", laneId, envelope: readEnvelope(params) }; } if (type === "session") { const sessionId = params.get("id") ?? ""; if (sessionId) { const laneId = params.get("lane") ?? ""; - return laneId ? { kind: "session", sessionId, laneId } : { kind: "session", sessionId }; + const event = nonNegativeInteger(params.get("event")); + const offset = nonNegativeInteger(params.get("offset")); + return { + kind: "session", + sessionId, + ...(laneId ? { laneId } : {}), + ...(event != null ? { event } : {}), + ...(offset != null ? { offset } : {}), + envelope: readEnvelope(params), + }; + } + } + if (type === "file") { + const path = params.get("path") ?? ""; + if (path) { + const line = positiveInteger(params.get("line")); + const laneId = params.get("lane") ?? ""; + return { kind: "file", path, ...(line ? { line } : {}), ...(laneId ? { laneId } : {}) }; } } + if (type === "commit") { + const sha = params.get("sha") ?? ""; + if (sha) { + const laneId = params.get("lane") ?? ""; + return { kind: "commit", sha, ...(laneId ? { laneId } : {}), envelope: readEnvelope(params) }; + } + } + if (type === "artifact") { + const artifactId = params.get("id") ?? ""; + if (artifactId) return { kind: "artifact", artifactId, envelope: readEnvelope(params) }; + } if (type === "branch") { const repo = params.get("repo") ?? ""; const branch = params.get("branch") ?? ""; if (repo && branch) { - const pr = Number(params.get("pr") ?? ""); - return Number.isInteger(pr) && pr > 0 + const pr = positiveInteger(params.get("pr")); + return pr ? { kind: "branch", repo, branch, pr } : { kind: "branch", repo, branch }; } } if (type === "pr") { const repo = params.get("repo") ?? ""; - const number = Number(params.get("number") ?? ""); - if (repo && Number.isInteger(number) && number > 0) { + const number = positiveInteger(params.get("number")); + if (repo && number) { return { kind: "pr", repo, number }; } } @@ -62,21 +125,42 @@ function parseQuery(search: string): OpenTarget { function buildAdeUrl(target: OpenTarget): string | null { switch (target.kind) { - case "lane": - return `ade://lane/${encodeURIComponent(target.laneId)}`; + case "lane": { + const base = `ade://lane/${encodeURIComponent(target.laneId)}`; + return appendQuery(base, envelopeEntries(target.envelope)); + } case "session": { const base = `ade://session/${encodeURIComponent(target.sessionId)}`; - return target.laneId ? `${base}?lane=${encodeURIComponent(target.laneId)}` : base; + return appendQuery(base, [ + ["lane", target.laneId], + ["event", target.event], + ["offset", target.offset], + ...envelopeEntries(target.envelope), + ]); + } + case "file": { + const base = `ade://file/${encodePath(target.path)}`; + return appendQuery(base, [ + ["line", target.line], + ["lane", target.laneId], + ]); + } + case "commit": { + const base = `ade://commit/${encodeURIComponent(target.sha)}`; + return appendQuery(base, [ + ["lane", target.laneId], + ...envelopeEntries(target.envelope), + ]); + } + case "artifact": { + const base = `ade://artifact/${encodeURIComponent(target.artifactId)}`; + return appendQuery(base, envelopeEntries(target.envelope)); } case "branch": { const [owner, name] = target.repo.split("/"); if (!owner || !name) return null; - const branchSegments = target.branch - .split("/") - .map(encodeURIComponent) - .join("/"); - const base = `ade://repo/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branch/${branchSegments}`; - return target.pr ? `${base}?pr=${target.pr}` : base; + const base = `ade://repo/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/branch/${encodePath(target.branch)}`; + return appendQuery(base, [["pr", target.pr]]); } case "pr": { const [owner, name] = target.repo.split("/"); @@ -92,7 +176,31 @@ function buildAdeUrl(target: OpenTarget): string | null { } } -function describeTarget(target: OpenTarget): { title: string; summary: string } { +function encodePath(path: string): string { + return path.split("/").map(encodeURIComponent).join("/"); +} + +function envelopeEntries(envelope: OpenEnvelope | undefined): Array<[string, string | number | undefined]> { + if (!envelope) return []; + return [ + ["repo", envelope.repo], + ["branch", envelope.branch], + ["pr", envelope.pr], + ["linear", envelope.linear], + ]; +} + +function appendQuery(base: string, entries: Array<[string, string | number | undefined]>): string { + const params = new URLSearchParams(); + for (const [key, value] of entries) { + if (value == null || value === "") continue; + params.set(key, String(value)); + } + const query = params.toString(); + return query ? `${base}?${query}` : base; +} + +function describeTarget(target: OpenTarget): { title: string; summary: string; valueLabel?: string; value?: string } { switch (target.kind) { case "lane": return { @@ -104,6 +212,27 @@ function describeTarget(target: OpenTarget): { title: string; summary: string } title: "Open work session in ADE", summary: target.laneId ? `Session ${target.sessionId} · worktree ${target.laneId.slice(0, 8)}…` : `Session ${target.sessionId}`, }; + case "file": + return { + title: "Open file in ADE", + summary: target.line ? `Line ${target.line}` : "Repository file", + valueLabel: "File", + value: target.path, + }; + case "commit": + return { + title: "Open commit in ADE", + summary: "Git commit", + valueLabel: "Commit", + value: target.sha, + }; + case "artifact": + return { + title: "Open artifact in ADE", + summary: "Proof artifact", + valueLabel: "Artifact", + value: target.artifactId, + }; case "branch": return { title: `Open ${target.repo} in ADE`, @@ -142,7 +271,7 @@ export function OpenPage() { const location = useLocation(); const target = useMemo(() => parseQuery(location.search), [location.search]); const adeUrl = useMemo(() => buildAdeUrl(target), [target]); - const { title, summary } = describeTarget(target); + const { title, summary, valueLabel, value } = describeTarget(target); const [launchAttempted, setLaunchAttempted] = useState(false); useDocumentTitle(title); @@ -184,6 +313,12 @@ export function OpenPage() {

{title}

{summary}

+ {value ? ( +
+ {valueLabel ? {valueLabel}: : null} + {value} +
+ ) : null} {target.kind === "unknown" ? (
This link is missing required parameters. Verify the URL or generate a fresh link from ADE. diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 7e3f4d362..1a077977e 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -2,16 +2,13 @@ "ignoreCommand": "git diff --quiet HEAD^ HEAD -- .", "headers": [ { - "source": "/videos/(.*)", + "source": "/.well-known/apple-app-site-association", "headers": [ - { - "key": "Cache-Control", - "value": "public, max-age=0, s-maxage=31536000, must-revalidate" - } + { "key": "Content-Type", "value": "application/json" } ] }, { - "source": "/images/(.*)", + "source": "/videos/(.*)", "headers": [ { "key": "Cache-Control", @@ -20,11 +17,11 @@ ] }, { - "source": "/.well-known/apple-app-site-association", + "source": "/images/(.*)", "headers": [ { - "key": "Content-Type", - "value": "application/json" + "key": "Cache-Control", + "value": "public, max-age=0, s-maxage=31536000, must-revalidate" } ] } diff --git a/docs/features/deeplinks/README.md b/docs/features/deeplinks/README.md index f28a2e92a..3983cace5 100644 --- a/docs/features/deeplinks/README.md +++ b/docs/features/deeplinks/README.md @@ -7,18 +7,32 @@ issue. Two forms carry identical semantics: ``` ade://lane/ -ade://session/[?lane=] +ade://session/[?lane=&event=&offset=] +ade://file/[?line=&lane=] +ade://commit/[?lane=] +ade://artifact/ ade://repo///branch/[?pr=] ade://pr/// ade://linear-issue/[?branch=] https://ade-app.dev/open?type=lane&id= -https://ade-app.dev/open?type=session&id=[&lane=] +https://ade-app.dev/open?type=session&id=[&lane=&event=&offset=] +https://ade-app.dev/open?type=file&path=[&line=&lane=] +https://ade-app.dev/open?type=commit&sha=[&lane=] +https://ade-app.dev/open?type=artifact&id= https://ade-app.dev/open?type=branch&repo=/&branch=[&pr=] https://ade-app.dev/open?type=pr&repo=/&number= https://ade-app.dev/open?type=linear-issue&issue=[&branch=] ``` +Machine-local targets (lane / session / commit / artifact) additionally carry a +**portable envelope** as query params — `repo=/`, +`branch=`, `pr=`, `linear=` — populated by builders with +whatever they know at mint time and parsed leniently (a malformed component is +dropped, never failing the link). A receiver that cannot resolve the primary id +uses the envelope for real fallbacks; see "Portable envelopes and the +resolution ladder" below. + The HTTPS form lives on `apps/web` (Vercel) and acts as a marketing landing page plus an OS-level upgrade into the `ade://` form when an ADE client is registered. The `ade://` form routes directly through the OS to the running @@ -31,10 +45,15 @@ Shared contract: - `apps/desktop/src/shared/deeplinks.ts` — builder + parser shared across main, renderer, ADE CLI, and the web `/open` API route. Validates UUIDs, - GitHub owner/repo, Linear issue identifiers, and branch refs (rejects - traversal, control chars, trailing `.lock`). Exports `buildDeeplink`, - `parseDeeplink`, `looksLikeAdeDeeplink`, and `describeTarget` plus the - `DeeplinkTarget` union (`lane | session | branch | pr | linear-issue`). + GitHub owner/repo, Linear issue identifiers, branch refs (rejects + traversal, control chars, trailing `.lock`), repo-relative file paths, + commit shas, and session anchors. Exports `buildDeeplink`, `parseDeeplink`, + `looksLikeAdeDeeplink`, and `describeTarget` plus the `DeeplinkTarget` + union (`lane | session | file | commit | artifact | branch | pr | + linear-issue`) and the `DeeplinkEnvelope` shape. +- `apps/desktop/src/shared/githubRemote.ts` — one GitHub remote-URL parser + (`git@` and https forms) shared by the main-process repo resolver and the + renderer's active-project repo check. - `apps/desktop/src/shared/adeDeeplinkFooter.ts` — renders the branded "Open in ADE" footer block (markdown + small HTML subset) appended to GitHub PR descriptions and reused as Linear attachment subtitle. @@ -44,9 +63,28 @@ Shared contract: or updating PRs. - `apps/desktop/src/shared/types/core.ts` — `AppNavigationTarget` / `AppNavigationRequest` / `AppNavigationResult` carry the parsed deeplink - payload across IPC. Targets cover `lane`, `chat`/`work`, `pr` (with - optional repoOwner/repoName for not-yet-local PRs), `branch` (cross-machine - send-to-mac payload), `linear-issue`, and the generic `route` shape. + payload across IPC. Targets cover `lane`, `chat`/`work` (with `event` / + `offset` anchors and the envelope), `file`, `commit`, `artifact`, `pr` + (with optional repoOwner/repoName for not-yet-local PRs), `branch` + (cross-machine send-to-mac payload), `linear-issue`, and the generic + `route` shape; plus `ProjectFindForRepoArgs`/`Result` for the catalog + lookup. + +Desktop renderer — resolution ladder + anchors: + +- `apps/desktop/src/renderer/components/app/App.tsx` — + `AppNavigationBridge.dispatchTarget` owns the resolution ladder (local → + switch-project → foreign card) and the file-path → lane-worktree + resolution; `InboundDeeplinkModal.tsx` renders the branch / foreign / + switch-project cards. +- `apps/desktop/src/main/services/projects/repoProjectResolver.ts` — backs + the `project.findForRepo` IPC: parses recent projects' git origin from + `.git/config` (no git subprocess), cached by config mtime. +- `apps/desktop/src/renderer/components/terminals/pendingSessionAnchors.ts` + — one-shot per-session anchor queue between navigation (URL effect in + `useWorkSessions`, ⌘K palette) and the content surfaces + (`AgentChatMessageList` scroll-to-sequence + highlight, `TerminalView` + replay byte-fraction scroll). Desktop main process — protocol handler: @@ -130,26 +168,35 @@ Apps/web — landing page + OG unfurl: - `apps/web/vercel.json` — adds `/open → /api/open` rewrite ahead of the catch-all SPA rewrite. -iOS — inbound deeplinks, outbound link minting, and Send-to-Mac: +iOS — inbound deeplinks, Universal Links, outbound link minting, and +Send-to-Mac: - `apps/ios/ADE/Views/Lanes/LaneDeeplinkHelpers.swift` — outbound link - minting on the phone: builds `ade://lane/` and percent-encoded - `ade://repo///branch/` strings for the lane - detail's "Copy ADE lane link" / "Copy branch link" menu actions - (branch links resolve owner/repo from a linked PR; with no GitHub - remote the lane link is copied instead, with a notice). -- `apps/ios/ADE/App/DeepLinkRouter.swift` — parses inbound `ade://` URLs. - `ade://session/` and `ade://pr/` (and the longer - `ade://pr///` form) flip the active tab via - `.adeDeepLinkRequested`. `ade://lane/` and - `ade://repo///branch/` are local-only desktop - concepts and instead post `.adeSendToMacRequested` so the parent view - shows the "Send to your Mac" confirmation card. + minting on the phone: an envelope-aware builder mirroring the TS + `buildDeeplink` for the shapes iOS mints (lane / session / branch / PR). + Lane detail's "Copy ADE lane link" / "Copy branch link", the Work session + copy-link, and PR detail's "Copy ADE link" all mint https-form links with + envelope params (branch links resolve owner/repo from a linked PR; with no + GitHub remote the lane link is copied instead, with a notice). +- `apps/ios/ADE/App/DeepLinkRouter.swift` — parses inbound `ade://` URLs and + the https `/open` mirror. Session links (both forms) and compact + `ade://pr/` navigate locally via `.adeDeepLinkRequested`; session + `event`/`offset` anchors ride `WorkSessionNavigationRequest` (carried, not + yet scrolled to — no route-level scroll hook exists on iOS). + Lane / repo-branch / full-PR / linear-issue / file / commit / artifact + shapes validate then post `.adeSendToMacRequested`. +- Universal Links: `apps/ios/ADE/ADE.entitlements` carries + `applinks:ade-app.dev`; `ADEApp.swift` routes + `NSUserActivityTypeBrowsingWeb` activities into the router; the AASA file is + served from `apps/web/public/.well-known/apple-app-site-association` + (appID `VQ372F39G6.com.ade.ios`, claiming only `/open*` and `/pair*`) with + an `application/json` header set in `apps/web/vercel.json`. - `apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift` — SwiftUI sheet - bound to `.adeSendToMacRequested`. Parses the URL into a - `SendToMacTarget` (`lane`, `repoBranch`, `other`) for a human-readable - headline / detail and forwards the URL to a paired desktop through the - sync command surface. + bound to `.adeSendToMacRequested`. Parses the URL (including the portable + envelope) into a `SendToMacTarget` for a human-readable headline / detail, + forwards the URL to a paired desktop through the sync command surface, and + offers envelope fallbacks the phone can act on directly (open the PR on + GitHub, open the Linear issue). - `apps/ade-cli/src/services/sync/syncRemoteCommandService.ts` — receives the iOS "Send to your Mac" payload as the `deeplinks.open` sync command and feeds the URL through `handleDeeplinkUrl` so the desktop @@ -163,8 +210,11 @@ iOS — inbound deeplinks, outbound link minting, and Send-to-Mac: | Form | Target | Notes | |------|--------|-------| -| `ade://lane/` | `{ kind: "lane", laneId }` | UUID v4 required. | -| `ade://session/[?lane=]` | `{ kind: "session", sessionId, laneId? }` | Local Work tab session link. Routes to `/work` with the selected session. | +| `ade://lane/` | `{ kind: "lane", laneId, envelope? }` | UUID v4 required. | +| `ade://session/[?lane=&event=&offset=]` | `{ kind: "session", sessionId, laneId?, event?, offset?, envelope? }` | Local Work tab session link. Routes to `/work` with the selected session. `event` anchors the chat list to the message with that persisted envelope sequence (ordinal fallback when the full transcript is loaded); `offset` best-effort-positions a replayed terminal scrollback by byte fraction. | +| `ade://file/[?line=&lane=]` | `{ kind: "file", path, line?, laneId? }` | Opens the Files editor at the path — inside the lane worktree when `lane` is present, else the project root — and reveals `line`. Traversal-safe: WHATWG URL normalization collapses `..` in the ade:// path form and the validator rejects traversal/absolute paths in the https `path=` param. | +| `ade://commit/[?lane=]` | `{ kind: "commit", sha, laneId?, envelope? }` | Opens the owning lane's detail (`commitSha` param). Foreign fallback: the envelope's repo yields a GitHub commit URL. 7–40 hex chars. | +| `ade://artifact/` | `{ kind: "artifact", artifactId, envelope? }` | Local-only proof artifact; opens the history surface. | | `ade://repo///branch/[?pr=]` | `{ kind: "branch", repoOwner, repoName, branch, prNumber? }` | Cross-machine. Renderer routes to the lane that already owns the branch; otherwise it opens a create/import modal. PR-backed links use PR preflight, branch-only links fetch the remote branch and import it as a local lane. If no ADE project is open, the modal asks the user to open the matching project first. | | `ade://pr///` | `{ kind: "pr", repoOwner, repoName, prNumber }` | If the PR isn't yet local, the renderer jumps to the PRs tab pre-filtered or falls back to the create-lane-from-branch flow. | | `ade://linear-issue/[?branch=]` | `{ kind: "linear-issue", issueIdentifier, branch? }` | Linear hand-off. Opens ADE's Linear pane focused to the issue. If no project is open or this project is not connected to Linear, ADE shows a setup modal with the next action. | @@ -173,6 +223,44 @@ Validation lives in one place (`shared/deeplinks.ts`) so the parser, the TUI builders, and the web `/open` handler agree on what counts as malformed. +## Portable envelopes and the resolution ladder + +Lane, session, commit, and artifact ids are machine-local, so those links +carry a portable envelope (`repo` / `branch` / `pr` / `linear` query params) +populated at mint time. On open, the desktop resolves in a strict ladder +(`AppNavigationBridge.dispatchTarget` in `App.tsx`): + +1. **Local** — the id resolves in the active project → open it exactly, + anchors included. +2. **Another known project** — the envelope repo matches a different project + in the machine catalog (`project.findForRepo` IPC → + `main/services/projects/repoProjectResolver.ts`, which parses each recent + project's git origin from `.git/config` without spawning git, cached by + config mtime) → a card offers "Switch project and open", then re-dispatches + the original target after the switch. +3. **Foreign machine** — the id resolves nowhere → a fallback-only card + ("This chat/lane lives in `/` on another machine") offering + only the actions the envelope carries: create a lane from the branch + (the existing branch-import flow), open the PR or commit on GitHub, open + the Linear issue. There is deliberately no request-access or + shared-transcript path. + +All three cards are the one `InboundDeeplinkModal` (generalized to +`branch | foreign | switch-project` targets). Envelope parsing is lenient — +a malformed component is dropped so it can never break the primary target. + +### Session anchors + +Search results and shared links can point inside a session. The anchor rides +the URL (`event` / `offset`), crosses IPC on the work/chat +`AppNavigationTarget`, and is handed one-shot to the session's content surface +via `renderer/components/terminals/pendingSessionAnchors.ts` (set by +`useWorkSessions`' URL effect and the ⌘K palette; consumed by +`AgentChatMessageList` — scroll + brief highlight — and `TerminalView` — +byte-fraction scroll in replay mode). The search index emits chat anchors as +the persisted `envelope.sequence` so a tail-paged transcript can resolve them +without loading full history. + ## End-to-end flow ``` diff --git a/docs/features/search/README.md b/docs/features/search/README.md index 9882d8c67..d911327ab 100644 --- a/docs/features/search/README.md +++ b/docs/features/search/README.md @@ -4,9 +4,11 @@ One deterministic full-text index over everything ADE knows about a project — chat transcripts, terminal/CLI-session scrollback, PRs, commits, and branches — unioned at query time with cheap or fast-changing sources (lanes, workspace files, proof artifacts, Linear issues) that are delegated to their owning -service instead of being indexed. Every hit carries an `ade://` deep link back -to the exact surface (a chat message offset, a scrollback byte offset, a PR, a -commit, a lane, a file line). +service instead of being indexed. Every hit carries a canonical `ade://` deep +link back to the exact surface (a chat message sequence, a scrollback byte +offset, a PR, a commit, a lane, a file line, or a proof artifact). Session, +lane, and commit links include the portable deeplink envelope when the owning +lane can resolve repo / branch / PR / Linear context. The index is a **machine-local, disposable cache** at `.ade/cache/search-index.db` (SQLite + FTS5). It never lives inside `ade.db`, @@ -32,7 +34,7 @@ Main-process service (`apps/desktop/src/main/services/search/`): `notifyLaneActivity`), `processPendingNow` (tests/rebuild), `dispose`. - `searchIndexDb.ts` — opens/creates the disposable index DB. Owns the DDL (`docs`, `docs_fts` FTS5 virtual table, `sources`, `meta`), the - `SEARCH_INDEX_SCHEMA_VERSION` constant and the drop-and-recreate on schema + `SEARCH_INDEX_SCHEMA_VERSION = 4` constant and the drop-and-recreate on schema mismatch or corruption, WAL + `busy_timeout` pragmas, `clearSearchIndex` (wipe rows, keep schema), and the `createRequire`-anchored `node:sqlite` resolver (same pattern as `kvDb.ts`). @@ -243,7 +245,9 @@ duplicate IO and occasional `SQLITE_BUSY` retry noise in logs. - [Pull requests](../pull-requests/README.md) — PR title/body/comments are the `pr` source. - [Deeplinks](../deeplinks/README.md) — every result carries an `ade://` deep - link built through the shared deeplink contract. + link built through the shared deeplink contract; session results carry + `event` / `offset` anchors, and file / commit / artifact results use the + canonical shared URL builders. - [Files and Editor](../files-and-editor/README.md) — the file quick-open / content-search index backs the delegated `file` kind. - [System overview](../../ARCHITECTURE.md) — the `search` ADE action domain and diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 5ae887f1f..20af8d11b 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -71,12 +71,14 @@ apps/ios/ │ │ ├── RemoteProjectAddSheet.swift # Open/create/clone project flow │ │ │ # backed by runtime-scoped │ │ │ # project action envelopes -│ │ ├── DeepLinkRouter.swift # ade:// URL handler. ade://session/, -│ │ │ # ade://pr/, and ade://pr/// -│ │ │ # flip tabs via .adeDeepLinkRequested. -│ │ │ # ade://lane/ and -│ │ │ # ade://repo///branch/ -│ │ │ # are local-only desktop concepts — they +│ │ ├── DeepLinkRouter.swift # ade:// and https://ade-app.dev/open +│ │ │ # handler. Session and compact PR links +│ │ │ # flip tabs via .adeDeepLinkRequested; +│ │ │ # session event/offset anchors ride +│ │ │ # WorkSessionNavigationRequest. +│ │ │ # Lane/file/commit/artifact/repo-branch, +│ │ │ # full PR, and Linear issue links are +│ │ │ # local-only desktop concepts — they │ │ │ # post .adeSendToMacRequested instead so the │ │ │ # SendToMacCard sheet can bounce the URL to │ │ │ # a paired host via the deeplinks.open sync @@ -141,7 +143,8 @@ apps/ios/ │ │ │ # LaneBatchManageSheet, LaneManageSheet │ │ │ # (tabbed manage dialog + adopt-attached), │ │ │ # LaneMultiAttachSheet, LaneStackGraphSheet, -│ │ │ # LaneDeeplinkHelpers (ade:// lane/branch +│ │ │ # LaneDeeplinkHelpers (envelope-aware +│ │ │ # https/ade lane/session/branch/PR │ │ │ # link minting), │ │ │ # LaneEnvInitProgressView, etc. │ │ ├── Files/ # FilesRootScreen, FilesDirectoryScreen,