From 09bd117ab771d60c265478bee1ed3d62a827c38f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 11:54:50 -0400 Subject: [PATCH 1/8] Add Linear issue lane workflows --- apps/ade-cli/src/adeRpcServer.test.ts | 130 +++ apps/ade-cli/src/adeRpcServer.ts | 250 ++++- apps/ade-cli/src/cli.test.ts | 71 ++ apps/ade-cli/src/cli.ts | 67 +- apps/desktop/package-lock.json | 22 + apps/desktop/package.json | 1 + apps/desktop/resources/ade-cli-help.txt | 5 +- .../services/chat/agentChatService.test.ts | 120 ++- .../main/services/chat/agentChatService.ts | 93 +- .../src/main/services/cto/issueTracker.ts | 24 + .../src/main/services/cto/linearAuth.test.ts | 53 ++ .../src/main/services/cto/linearClient.ts | 446 ++++++++- .../main/services/cto/linearIssueTracker.ts | 8 + .../services/git/gitOperationsService.test.ts | 165 ++++ .../main/services/git/gitOperationsService.ts | 14 +- .../src/main/services/ipc/registerIpc.ts | 49 + .../src/main/services/lanes/laneService.ts | 263 +++++- .../src/main/services/prs/prService.test.ts | 53 ++ .../src/main/services/prs/prService.ts | 34 +- apps/desktop/src/main/services/state/kvDb.ts | 16 + .../sync/syncRemoteCommandService.test.ts | 2 + .../services/sync/syncRemoteCommandService.ts | 2 + apps/desktop/src/preload/global.d.ts | 9 + apps/desktop/src/preload/preload.ts | 12 + apps/desktop/src/renderer/browserMock.ts | 88 ++ .../components/app/LinearQuickViewButton.tsx | 853 ++++++++++++++++++ .../renderer/components/app/TopBar.test.tsx | 117 +++ .../src/renderer/components/app/TopBar.tsx | 3 + .../chat/AgentChatComposer.test.tsx | 143 +++ .../components/chat/AgentChatComposer.tsx | 184 +++- .../components/chat/AgentChatMessageList.tsx | 26 +- .../components/chat/AgentChatPane.test.ts | 11 + .../components/chat/AgentChatPane.tsx | 82 +- .../chat/ChatAttachmentTray.test.tsx | 56 ++ .../components/chat/ChatAttachmentTray.tsx | 94 +- .../components/lanes/CreateLaneDialog.tsx | 131 ++- .../components/lanes/LaneGitActionsPane.tsx | 4 + .../components/lanes/LaneStackPane.tsx | 15 +- .../components/lanes/LaneWorkPane.tsx | 7 + .../renderer/components/lanes/LanesPage.tsx | 108 ++- .../lanes/LinearIssueBadge.test.tsx | 96 ++ .../components/lanes/LinearIssueBadge.tsx | 237 +++++ .../components/lanes/LinearIssuePicker.tsx | 695 ++++++++++++++ .../renderer/components/lanes/linearBrand.tsx | 195 ++++ .../components/prs/CreatePrModal.test.tsx | 95 +- .../renderer/components/prs/CreatePrModal.tsx | 128 ++- .../components/terminals/WorkStartSurface.tsx | 8 +- .../components/terminals/WorkViewArea.tsx | 10 +- .../src/shared/chatContextAttachments.ts | 177 ++++ apps/desktop/src/shared/ipc.ts | 3 + apps/desktop/src/shared/linearIssueBranch.ts | 37 + apps/desktop/src/shared/linearMagicWords.ts | 46 + apps/desktop/src/shared/types/chat.ts | 13 + apps/desktop/src/shared/types/cto.ts | 105 ++- apps/desktop/src/shared/types/lanes.ts | 35 + apps/desktop/src/shared/types/linearSync.ts | 8 + apps/desktop/src/shared/types/prs.ts | 2 + 57 files changed, 5598 insertions(+), 123 deletions(-) create mode 100644 apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx create mode 100644 apps/desktop/src/renderer/components/lanes/LinearIssueBadge.test.tsx create mode 100644 apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx create mode 100644 apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx create mode 100644 apps/desktop/src/renderer/components/lanes/linearBrand.tsx create mode 100644 apps/desktop/src/shared/chatContextAttachments.ts create mode 100644 apps/desktop/src/shared/linearIssueBranch.ts create mode 100644 apps/desktop/src/shared/linearMagicWords.ts diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index a3cddf02e..3e65b525a 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -695,7 +695,52 @@ function createRuntime() { resolveRunAction: vi.fn(async (runId: string, action: string) => ({ id: runId, status: action })), cancelRun: vi.fn(async () => {}), } as any, + linearCredentialService: { + getStatus: vi.fn(() => ({ + tokenStored: true, + authMode: "manual", + tokenExpiresAt: null, + refreshTokenStored: false, + oauthConfigured: true, + })), + } as any, linearIssueTracker: { + getConnectionStatus: vi.fn(async () => ({ + connected: true, + viewerId: "user-1", + viewerName: "Arul", + message: null, + })), + getQuickView: vi.fn(async (connection: unknown) => ({ + connection, + organization: { + id: "org-1", + name: "ADE", + urlKey: "ade", + logoUrl: null, + gitBranchFormat: null, + createdIssueCount: 12, + roadmapEnabled: true, + customersEnabled: false, + releasesEnabled: false, + }, + viewer: { + id: "user-1", + name: "Arul", + displayName: "Arul", + email: "arul@example.com", + avatarUrl: null, + admin: true, + guest: false, + url: null, + }, + projects: [], + teams: [], + assignedIssues: [], + recentIssues: [], + fetchedAt: "2026-03-17T19:11:00.000Z", + sdk: { packageName: "@linear/sdk", surfaces: ["viewer", "organization"] }, + })), fetchIssueById: vi.fn(async (issueId: string) => ({ id: issueId, identifier: "LIN-1", @@ -1465,6 +1510,7 @@ describe("adeRpcServer", () => { "pr_rerun_failed_checks", "pr_reply_to_review_thread", "pr_resolve_review_thread", + "getLinearQuickView", "listLinearWorkflows", "getLinearRunStatus", "getLinearSyncDashboard", @@ -1523,6 +1569,29 @@ describe("adeRpcServer", () => { ); }); + it("returns the Linear quick view for cto callers", async () => { + const { runtime } = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); + + await initialize(handler, { callerId: "cto-1", role: "cto" }); + const result = await callTool(handler, "getLinearQuickView", {}); + + expect((runtime.linearIssueTracker as any).getConnectionStatus).toHaveBeenCalled(); + expect((runtime.linearIssueTracker as any).getQuickView).toHaveBeenCalledWith( + expect.objectContaining({ + connected: true, + tokenStored: true, + viewerId: "user-1", + }), + ); + expect(result.structuredContent).toEqual( + expect.objectContaining({ + organization: expect.objectContaining({ name: "ADE" }), + sdk: expect.objectContaining({ packageName: "@linear/sdk" }), + }), + ); + }); + it("forwards employeeOverride and laneId when resuming a Linear sync queue item", async () => { const { runtime } = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime, serverVersion: "test" }); @@ -3865,6 +3934,7 @@ describe("adeRpcServer", () => { title: "My PR", body: "Body text", draft: true, + closeLinearIssueOnMerge: true, }); expect(created?.isError).toBeUndefined(); expect(fixture.runtime.prService.createFromLane).toHaveBeenCalledWith({ @@ -3873,6 +3943,7 @@ describe("adeRpcServer", () => { title: "My PR", body: "Body text", draft: true, + closeLinearIssueOnMerge: true, }); const drafted = await callTool(handler, "create_pr_from_lane", { @@ -4259,6 +4330,65 @@ describe("adeRpcServer", () => { ); }); + it("passes branch and Linear issue data through create_lane", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + const linearIssue = { + id: "issue-1", + identifier: "ADE-123", + title: "Create linked lane", + description: null, + url: "https://linear.app/ade/issue/ADE-123/create-linked-lane", + projectId: "project-1", + projectSlug: "ade", + projectName: "ADE", + teamId: "team-1", + teamKey: "ADE", + teamName: "ADE", + stateId: "state-1", + stateName: "Todo", + stateType: "unstarted", + priority: 2, + priorityLabel: "high", + labels: ["desktop"], + assigneeId: null, + assigneeName: null, + creatorId: null, + creatorName: null, + dueDate: null, + estimate: null, + branchName: "ade-123-create-linked-lane", + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + secretToken: "do-not-forward", + }; + + await initialize(handler, { callerId: "orchestrator", role: "orchestrator" }); + const response = await callTool(handler, "create_lane", { + name: "new-feature", + baseBranch: "main", + branchName: "ade-123-create-linked-lane", + linearIssue, + }); + + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.laneService.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: "new-feature", + baseBranch: "main", + branchName: "ade-123-create-linked-lane", + linearIssue: expect.objectContaining({ + id: "issue-1", + identifier: "ADE-123", + title: "Create linked lane", + projectId: "project-1", + priorityLabel: "high", + }), + }) + ); + expect((fixture.runtime.laneService.create as any).mock.calls[0][0].linearIssue).not.toHaveProperty("secretToken"); + }); + it("routes simulate_integration as a read-only dry-merge", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 0edbb8999..987ca1c3a 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -38,9 +38,12 @@ import { type ComputerUseArtifactOwner, type DockLayout, type GraphPersistedState, + type LaneLinearIssue, type MergeMethod, } from "../../desktop/src/shared/types"; import type { PrActionRun, PrCheck, PrComment, PrReviewThread } from "../../desktop/src/shared/types/prs"; +import type { CtoLinearQuickView } from "../../desktop/src/shared/types/cto"; +import type { LinearConnectionStatus } from "../../desktop/src/shared/types/linearSync"; import { resolveAdeLayout } from "../../desktop/src/shared/adeLayout"; import { buildTrackedCliLaunchCommand, @@ -71,6 +74,59 @@ type ExecutableTool = { execute?: (args: Record) => Promise; }; +const LINEAR_ISSUE_TOOL_SCHEMA: Record = { + type: "object", + required: [ + "id", + "identifier", + "title", + "url", + "projectId", + "projectSlug", + "teamId", + "teamKey", + "stateId", + "stateName", + "stateType", + "priority", + "priorityLabel", + "labels", + "assigneeId", + "assigneeName", + "createdAt", + "updatedAt", + ], + additionalProperties: false, + properties: { + id: { type: "string", minLength: 1 }, + identifier: { type: "string", minLength: 1 }, + title: { type: "string", minLength: 1 }, + description: { anyOf: [{ type: "string" }, { type: "null" }] }, + url: { anyOf: [{ type: "string" }, { type: "null" }] }, + projectId: { type: "string", minLength: 1 }, + projectSlug: { type: "string", minLength: 1 }, + projectName: { anyOf: [{ type: "string" }, { type: "null" }] }, + teamId: { type: "string", minLength: 1 }, + teamKey: { type: "string", minLength: 1 }, + teamName: { anyOf: [{ type: "string" }, { type: "null" }] }, + stateId: { type: "string", minLength: 1 }, + stateName: { type: "string", minLength: 1 }, + stateType: { type: "string", minLength: 1 }, + priority: { type: "number" }, + priorityLabel: { type: "string", enum: ["urgent", "high", "normal", "low", "none"] }, + labels: { type: "array", items: { type: "string" } }, + assigneeId: { anyOf: [{ type: "string" }, { type: "null" }] }, + assigneeName: { anyOf: [{ type: "string" }, { type: "null" }] }, + creatorId: { anyOf: [{ type: "string" }, { type: "null" }] }, + creatorName: { anyOf: [{ type: "string" }, { type: "null" }] }, + dueDate: { anyOf: [{ type: "string" }, { type: "null" }] }, + estimate: { anyOf: [{ type: "number" }, { type: "null" }] }, + branchName: { anyOf: [{ type: "string" }, { type: "null" }] }, + createdAt: { type: "string", minLength: 1 }, + updatedAt: { type: "string", minLength: 1 }, + }, +}; + type SessionIdentity = { callerId: string; role: "cto" | "orchestrator" | "agent" | "external" | "evaluator"; @@ -193,7 +249,10 @@ const TOOL_SPECS: ToolSpec[] = [ properties: { name: { type: "string", minLength: 1 }, description: { type: "string" }, - parentLaneId: { type: "string" } + parentLaneId: { type: "string" }, + baseBranch: { type: "string" }, + branchName: { type: "string" }, + linearIssue: LINEAR_ISSUE_TOOL_SCHEMA } } }, @@ -1096,6 +1155,7 @@ const TOOL_SPECS: ToolSpec[] = [ title: { type: "string", minLength: 1 }, body: { type: "string" }, draft: { type: "boolean", default: false }, + closeLinearIssueOnMerge: { type: "boolean", default: false }, } } }, @@ -1834,6 +1894,11 @@ const CTO_OPERATOR_TOOL_SPECS: ToolSpec[] = [ ]; const CTO_LINEAR_SYNC_TOOL_SPECS: ToolSpec[] = [ + { + name: "getLinearQuickView", + description: "Read a compact Linear workspace, project, and issue quick view through the connected Linear SDK account.", + inputSchema: { type: "object", additionalProperties: false, properties: {} } + }, { name: "getLinearSyncDashboard", description: "Read the ADE Linear sync dashboard.", @@ -2069,6 +2134,7 @@ const READ_ONLY_TOOLS = new Set([ "listChats", "getChatStatus", "readChatTranscript", + "getLinearQuickView", "listLinearWorkflows", "getLinearRunStatus", "getLinearSyncDashboard", @@ -2274,6 +2340,87 @@ function asNumber(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } +function assertOptionalStringOrNull(value: unknown, field: string): string | null { + if (value == null) return null; + if (typeof value !== "string") { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `${field} must be a string or null`); + } + return value; +} + +function assertStringArray(value: unknown, field: string): string[] { + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `${field} must be an array of strings`); + } + return [...value]; +} + +function assertOptionalNumberOrNull(value: unknown, field: string): number | null { + if (value == null) return null; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `${field} must be a number or null`); + } + return value; +} + +function assertNumber(value: unknown, field: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `${field} must be a number`); + } + return value; +} + +function assertLinearPriorityLabel(value: unknown, field: string): LaneLinearIssue["priorityLabel"] { + if (value === "urgent" || value === "high" || value === "normal" || value === "low" || value === "none") { + return value; + } + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `${field} must be one of: urgent, high, normal, low, none`); +} + +function parseLaneLinearIssue(value: unknown, field = "linearIssue"): LaneLinearIssue { + const issue = safeObject(value); + if (Object.keys(issue).length === 0) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `${field} must be an object`); + } + return { + id: assertNonEmptyString(issue.id, `${field}.id`), + identifier: assertNonEmptyString(issue.identifier, `${field}.identifier`), + title: assertNonEmptyString(issue.title, `${field}.title`), + description: assertOptionalStringOrNull(issue.description, `${field}.description`), + url: assertOptionalStringOrNull(issue.url, `${field}.url`), + projectId: assertNonEmptyString(issue.projectId, `${field}.projectId`), + projectSlug: assertNonEmptyString(issue.projectSlug, `${field}.projectSlug`), + projectName: assertOptionalStringOrNull(issue.projectName, `${field}.projectName`), + teamId: assertNonEmptyString(issue.teamId, `${field}.teamId`), + teamKey: assertNonEmptyString(issue.teamKey, `${field}.teamKey`), + teamName: assertOptionalStringOrNull(issue.teamName, `${field}.teamName`), + stateId: assertNonEmptyString(issue.stateId, `${field}.stateId`), + stateName: assertNonEmptyString(issue.stateName, `${field}.stateName`), + stateType: assertNonEmptyString(issue.stateType, `${field}.stateType`), + priority: assertNumber(issue.priority, `${field}.priority`), + priorityLabel: assertLinearPriorityLabel(issue.priorityLabel, `${field}.priorityLabel`), + labels: assertStringArray(issue.labels, `${field}.labels`), + assigneeId: assertOptionalStringOrNull(issue.assigneeId, `${field}.assigneeId`), + assigneeName: assertOptionalStringOrNull(issue.assigneeName, `${field}.assigneeName`), + creatorId: assertOptionalStringOrNull(issue.creatorId, `${field}.creatorId`), + creatorName: assertOptionalStringOrNull(issue.creatorName, `${field}.creatorName`), + dueDate: assertOptionalStringOrNull(issue.dueDate, `${field}.dueDate`), + estimate: assertOptionalNumberOrNull(issue.estimate, `${field}.estimate`), + branchName: assertOptionalStringOrNull(issue.branchName, `${field}.branchName`), + createdAt: assertNonEmptyString(issue.createdAt, `${field}.createdAt`), + updatedAt: assertNonEmptyString(issue.updatedAt, `${field}.updatedAt`), + }; +} + +function projectLaneLinearIssue(value: unknown): LaneLinearIssue | null { + if (!value) return null; + try { + return parseLaneLinearIssue(value); + } catch { + return null; + } +} + function assertNonEmptyString(value: unknown, field: string): string { const text = asTrimmedString(value); if (!text.length) { @@ -2692,6 +2839,13 @@ function requireLinearSyncService(runtime: AdeRuntime): NonNullable { + if (!runtime.linearIssueTracker) { + throw new JsonRpcError(JsonRpcErrorCode.internalError, "linearIssueTracker is not available in this ADE runtime configuration"); + } + return runtime.linearIssueTracker; +} + function requireLinearIngressService(runtime: AdeRuntime): NonNullable { if (!runtime.linearIngressService) { throw new JsonRpcError(JsonRpcErrorCode.internalError, "linearIngressService is not available in this ADE runtime configuration"); @@ -2713,6 +2867,69 @@ function requireLinearRoutingService(runtime: AdeRuntime): NonNullable { + const credentialStatus = runtime.linearCredentialService?.getStatus() ?? { + tokenStored: false, + authMode: null, + tokenExpiresAt: null, + oauthConfigured: false, + }; + const tokenStored = Boolean(credentialStatus.tokenStored); + if (!runtime.linearIssueTracker || !tokenStored) { + return { + tokenStored, + connected: false, + viewerId: null, + viewerName: null, + checkedAt: nowIso(), + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: tokenStored ? "Linear tracker service unavailable." : "Linear token not configured.", + }; + } + try { + const status = await runtime.linearIssueTracker.getConnectionStatus(); + return { + tokenStored, + connected: status.connected, + viewerId: status.viewerId, + viewerName: status.viewerName, + checkedAt: nowIso(), + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: status.message, + }; + } catch (err) { + return { + tokenStored, + connected: false, + viewerId: null, + viewerName: null, + checkedAt: nowIso(), + authMode: credentialStatus.authMode, + oauthAvailable: credentialStatus.oauthConfigured, + tokenExpiresAt: credentialStatus.tokenExpiresAt, + message: err instanceof Error && err.message ? err.message : "Linear tracker error", + }; + } +} + +function emptyLinearQuickView(connection: LinearConnectionStatus): CtoLinearQuickView { + return { + connection, + organization: null, + viewer: null, + projects: [], + teams: [], + assignedIssues: [], + recentIssues: [], + fetchedAt: nowIso(), + sdk: { packageName: "@linear/sdk", surfaces: [] }, + }; +} + async function resolveDefaultLaneId(runtime: AdeRuntime): Promise { await runtime.laneService.ensurePrimaryLane().catch(() => {}); const lanes = await runtime.laneService.list({ includeArchived: false, includeStatus: false }); @@ -3073,6 +3290,7 @@ function mapLaneSummary(lane: Record): Record worktreePath: lane.worktreePath, archivedAt: lane.archivedAt, stackDepth: lane.stackDepth, + linearIssue: projectLaneLinearIssue(lane.linearIssue), status: lane.status }; } @@ -4657,6 +4875,23 @@ async function runTool(args: { throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unsupported tool: ${name}`); } + if (name === "getLinearQuickView") { + const connection = await buildCliLinearConnectionStatus(runtime); + if (!connection.connected) return emptyLinearQuickView(connection); + try { + return await requireLinearIssueTracker(runtime).getQuickView(connection); + } catch (err) { + return emptyLinearQuickView({ + ...connection, + connected: false, + viewerId: null, + viewerName: null, + checkedAt: nowIso(), + message: err instanceof Error && err.message ? err.message : "Linear tracker error", + }); + } + } + if (name === "getLinearSyncDashboard") { return requireLinearSyncService(runtime).getDashboard(); } @@ -4971,11 +5206,19 @@ async function runTool(args: { const nameArg = assertNonEmptyString(toolArgs.name, "name"); const description = asOptionalTrimmedString(toolArgs.description); const parentLaneId = asOptionalTrimmedString(toolArgs.parentLaneId); + const baseBranch = asOptionalTrimmedString(toolArgs.baseBranch); + const branchName = asOptionalTrimmedString(toolArgs.branchName); + const linearIssue = typeof toolArgs.linearIssue === "object" && toolArgs.linearIssue != null && !Array.isArray(toolArgs.linearIssue) + ? parseLaneLinearIssue(toolArgs.linearIssue) + : null; const lane = await runtime.laneService.create({ name: nameArg, ...(description ? { description } : {}), - ...(parentLaneId ? { parentLaneId } : {}) + ...(parentLaneId ? { parentLaneId } : {}), + ...(baseBranch ? { baseBranch } : {}), + ...(branchName ? { branchName } : {}), + ...(linearIssue ? { linearIssue } : {}) }); return { @@ -6227,10 +6470,12 @@ async function runTool(args: { const prSvc = requirePrService(runtime); let title = asOptionalTrimmedString(toolArgs.title); let body = typeof toolArgs.body === "string" ? toolArgs.body : null; + const closeLinearIssueOnMerge = asBoolean(toolArgs.closeLinearIssueOnMerge, false); if (!title || body == null) { const draft = await prSvc.draftDescription({ laneId, ...(baseBranch ? { baseBranch } : {}), + ...(closeLinearIssueOnMerge ? { closeLinearIssueOnMerge } : {}), }); title = title || asOptionalTrimmedString(draft.title) || `PR for ${laneId}`; body = body ?? asOptionalTrimmedString(draft.body) ?? ""; @@ -6242,6 +6487,7 @@ async function runTool(args: { body, draft, ...(baseBranch ? { baseBranch } : {}), + ...(closeLinearIssueOnMerge ? { closeLinearIssueOnMerge } : {}), }); return { pr }; } diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 4fa37279b..78d736ca2 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -1193,6 +1193,77 @@ describe("ADE CLI", () => { expect(lanesHelp.kind).toBe("help"); }); + it("maps PR create Linear close flag to the typed RPC tool", () => { + const plan = buildCliPlan([ + "prs", + "create", + "--lane", + "lane-1", + "--title", + "Linked PR", + "--body", + "Body", + "--close-linear-issue-on-merge", + ]); + + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toEqual({ + name: "create_pr_from_lane", + arguments: { + laneId: "lane-1", + title: "Linked PR", + body: "Body", + draft: false, + closeLinearIssueOnMerge: true, + }, + }); + }); + + it("maps lane create Linear issue JSON to the typed RPC tool", () => { + const plan = buildCliPlan([ + "lanes", + "create", + "--name", + "Linked lane", + "--base", + "main", + "--branch-name", + "ade-123-linked-lane", + "--linear-issue-json", + "{\"id\":\"issue-1\",\"identifier\":\"ADE-123\",\"title\":\"Linked lane\"}", + ]); + + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toEqual({ + name: "create_lane", + arguments: { + name: "Linked lane", + baseBranch: "main", + branchName: "ade-123-linked-lane", + linearIssue: { + id: "issue-1", + identifier: "ADE-123", + title: "Linked lane", + }, + }, + }); + }); + + it("maps Linear quick view to the typed RPC tool", () => { + const plan = buildCliPlan(["linear", "quick-view", "--text"]); + + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.label).toBe("Linear quick view"); + expect(plan.formatter).toBe("linear-quick-view"); + expect(plan.steps[0]?.params).toEqual({ + name: "getLinearQuickView", + arguments: {}, + }); + }); + it("shows focused ios-sim help for subcommand help flags", () => { const renderHelp = buildCliPlan(["ios-sim", "preview-render", "--help"]); expect(renderHelp.kind).toBe("help"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 1a5987da1..9af03cc2d 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -58,6 +58,7 @@ type FormatterId = | "status" | "doctor" | "auth" + | "linear-quick-view" | "lanes" | "lane-detail" | "git-status" @@ -761,6 +762,7 @@ const HELP_BY_COMMAND: Record = { $ ade lanes list --text Show lane stack graph and branch names $ ade lanes show --text Inspect one lane status $ ade lanes create --name Create a lane from the current project context + $ ade lanes create --linear-issue-json '{...}' Create a lane linked to a Linear issue $ ade lanes child --lane --name Create a child lane under a parent $ ade lanes import --branch Register an existing branch/worktree $ ade lanes archive Archive a lane in ADE @@ -778,7 +780,7 @@ const HELP_BY_COMMAND: Record = { $ ade git stage --lane src/file.ts Stage one file $ ade git stage-all --lane Stage all current changes $ ade git unstage --lane src/file.ts Unstage one file - $ ade git commit --lane [-m ] Commit, generating a message when omitted + $ ade git commit --lane [-m ] Commit, adding Refs on linked Linear lanes $ ade git push --lane --set-upstream Push through ADE $ ade git branches --lane --text List branches with last-commit metadata $ ade git user-identity --lane --text Read lane checkout's git user.name/email @@ -804,6 +806,7 @@ const HELP_BY_COMMAND: Record = { $ ade prs list --text List PRs known to ADE $ ade prs list-open --text List every open GitHub PR in the repo, keyed by head branch $ ade prs create --lane --base main Open and map a GitHub PR from a lane + $ ade prs create --lane --close-linear-issue-on-merge $ ade prs link --lane --url Map an existing GitHub PR to a lane $ ade prs checks --text Show check status $ ade prs comments --text Show unresolved review work @@ -1153,6 +1156,7 @@ const HELP_BY_COMMAND: Record = { linear: `${ADE_BANNER} Linear workflows + $ ade --role cto linear quick-view --text Show connected workspace, projects, and issues $ ade linear workflows --text List configured workflows $ ade linear sync dashboard --text Show sync dashboard $ ade linear sync run Trigger a sync run @@ -1986,6 +1990,10 @@ function buildLanePlan(args: string[]): CliPlan { input.name = requireValue(name, "name"); maybePut(input, "description", readValue(args, ["--description", "--desc"])); maybePut(input, "parentLaneId", readValue(args, ["--parent", "--parent-lane", "--parent-lane-id"]) ?? (sub === "child" ? readLaneId(args) : null)); + maybePut(input, "baseBranch", readValue(args, ["--base", "--base-branch"])); + maybePut(input, "branchName", readValue(args, ["--branch-name"])); + const linearIssueJson = readValue(args, ["--linear-issue-json"]); + if (linearIssueJson) input.linearIssue = parseJson(linearIssueJson, "--linear-issue-json"); if (sub === "child" && !input.parentLaneId) throw new CliUsageError("parent lane is required. Use --lane or --parent ."); return { kind: "execute", label: "lane create", steps: [actionCallStep("result", "create_lane", collectGenericObjectArgs(args, input))] }; } @@ -2241,6 +2249,11 @@ function buildPrPlan(args: string[]): CliPlan { maybePut(input, "title", readValue(args, ["--title"])); maybePut(input, "body", readValue(args, ["--body"])); input.draft = readFlag(args, ["--draft"]); + input.closeLinearIssueOnMerge = readFlag(args, [ + "--close-linear-issue-on-merge", + "--close-linear", + "--fixes-linear-issue", + ]); return { kind: "execute", label: "PR create", steps: [actionCallStep("result", "create_pr_from_lane", collectGenericObjectArgs(args, input))] }; } if (sub === "health") return { kind: "execute", label: "PR health", steps: [actionCallStep("result", "get_pr_health", withPr({ prId: prId ?? firstPositional(args) }))] }; @@ -3938,6 +3951,9 @@ function buildAutomationsPlan(args: string[]): CliPlan { function buildLinearPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "workflows"; + if (sub === "quick-view" || sub === "quick" || sub === "overview") { + return { kind: "execute", label: "Linear quick view", formatter: "linear-quick-view", steps: [actionCallStep("result", "getLinearQuickView", collectGenericObjectArgs(args))] }; + } if (sub === "workflows") return { kind: "execute", label: "Linear workflows", steps: [actionCallStep("result", "listLinearWorkflows", collectGenericObjectArgs(args))] }; if (sub === "run") { const mode = firstPositional(args) ?? "status"; @@ -6237,6 +6253,53 @@ function formatTerminalRead(value: unknown): string { return data.length ? `${header}\n\n${data}` : `${header}\n\n(no output)`; } +function formatLinearQuickView(value: unknown): string { + if (!isRecord(value)) return JSON.stringify(value, null, 2); + const connection = isRecord(value.connection) ? value.connection : {}; + const organization = isRecord(value.organization) ? value.organization : null; + const viewer = isRecord(value.viewer) ? value.viewer : null; + const projects = firstArray(value, ["projects"]); + const assignedIssues = firstArray(value, ["assignedIssues"]); + const recentIssues = firstArray(value, ["recentIssues"]); + const teams = firstArray(value, ["teams"]); + const header = renderKeyValues("Linear quick view", [ + ["connected", connection.connected], + ["auth", connection.authMode], + ["workspace", organization?.name ?? organization?.urlKey], + ["viewer", viewer?.displayName ?? viewer?.name ?? connection.viewerName], + ["projects", projects.length], + ["teams", teams.length], + ["assigned issues", assignedIssues.length], + ["recent issues", recentIssues.length], + ["checked", value.fetchedAt ?? connection.checkedAt], + ["message", connection.message], + ]); + const projectRows = projects.map((project) => [ + project.name, + project.statusName ?? project.statusType, + typeof project.progress === "number" ? `${Math.round(project.progress * 100)}%` : "", + project.issueCount, + ]); + const issueRows = [...assignedIssues, ...recentIssues] + .filter((issue, index, all) => all.findIndex((candidate) => candidate.id === issue.id) === index) + .slice(0, 12) + .map((issue) => [ + issue.identifier, + issue.title, + issue.stateName, + issue.projectName ?? issue.teamName ?? issue.teamKey, + ]); + return [ + header, + "", + "Projects", + renderTable(["project", "status", "progress", "issues"], projectRows, "(no projects)"), + "", + "Issues", + renderTable(["id", "title", "state", "area"], issueRows, "(no issues)"), + ].join("\n"); +} + function formatAppControlSelection(value: unknown): string { const item = firstRecord(value, ["item", "selection"]) ?? (isRecord(value) ? value : {}); const metadata = isRecord(item.metadata) ? item.metadata : {}; @@ -6318,6 +6381,8 @@ function formatTextOutput(value: unknown, formatter: FormatterId | undefined): s ["note", isRecord(value) ? value.note : null], ]); } + case "linear-quick-view": + return formatLinearQuickView(value); case "lanes": return renderLaneGraph(value); case "lane-detail": diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 49dec26e3..ca61b2249 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -16,6 +16,7 @@ "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/space-grotesk": "^5.2.10", "@huggingface/transformers": "^3.8.1", + "@linear/sdk": "^84.0.0", "@lobehub/fluent-emoji": "^4.1.0", "@lobehub/icons": "^5.2.0", "@lobehub/icons-static-svg": "^1.84.0", @@ -2239,6 +2240,15 @@ "react-dom": "^16 || ^17 || ^18 || ^19" } }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -3036,6 +3046,18 @@ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "license": "MIT" }, + "node_modules/@linear/sdk": { + "version": "84.0.0", + "resolved": "https://registry.npmjs.org/@linear/sdk/-/sdk-84.0.0.tgz", + "integrity": "sha512-jPtGlY06zG86ba6cL78d4JB9m61YMHo3L0luMrtVFgeOounI/GK3S3c6Ncjw7cxWzcsepoNZD10mQYoT81uKKA==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0" + }, + "engines": { + "node": ">=18.x" + } + }, "node_modules/@lit-labs/ssr-dom-shim": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cdbc0f386..a832f329e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -54,6 +54,7 @@ "@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/space-grotesk": "^5.2.10", "@huggingface/transformers": "^3.8.1", + "@linear/sdk": "^84.0.0", "@lobehub/fluent-emoji": "^4.1.0", "@lobehub/icons": "^5.2.0", "@lobehub/icons-static-svg": "^1.84.0", diff --git a/apps/desktop/resources/ade-cli-help.txt b/apps/desktop/resources/ade-cli-help.txt index af5bf09fe..310a97705 100644 --- a/apps/desktop/resources/ade-cli-help.txt +++ b/apps/desktop/resources/ade-cli-help.txt @@ -268,6 +268,7 @@ _ ____ _____ $ ade lanes list --text Show lane stack graph and branch names $ ade lanes show --text Inspect one lane status $ ade lanes create --name Create a lane from the current project context + $ ade lanes create --linear-issue-json '{...}' Create a lane linked to a Linear issue $ ade lanes child --lane --name Create a child lane under a parent $ ade lanes import --branch Register an existing branch/worktree $ ade lanes archive Archive a lane in ADE @@ -291,7 +292,7 @@ _ ____ _____ $ ade git stage --lane src/file.ts Stage one file $ ade git stage-all --lane Stage all current changes $ ade git unstage --lane src/file.ts Unstage one file - $ ade git commit --lane [-m ] Commit, generating a message when omitted + $ ade git commit --lane [-m ] Commit, adding Refs on linked Linear lanes $ ade git push --lane --set-upstream Push through ADE $ ade git branches --lane --text List branches with last-commit metadata $ ade git user-identity --lane --text Read lane checkout's git user.name/email @@ -349,6 +350,7 @@ _ ____ _____ $ ade prs list --text List PRs known to ADE $ ade prs list-open --text List every open GitHub PR in the repo, keyed by head branch $ ade prs create --lane --base main Open and map a GitHub PR from a lane + $ ade prs create --lane --close-linear-issue-on-merge $ ade prs link --lane --url Map an existing GitHub PR to a lane $ ade prs checks --text Show check status $ ade prs comments --text Show unresolved review work @@ -477,6 +479,7 @@ _ ____ _____ Linear workflows + $ ade linear quick-view --text Show connected workspace, projects, and issues $ ade linear workflows --text List configured workflows $ ade linear sync dashboard --text Show sync dashboard $ ade linear sync run Trigger a sync run diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 7cf3840f3..acf836871 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -585,7 +585,8 @@ import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { mapPermissionToClaude, mapPermissionToCodex } from "../orchestrator/permissionMapping"; import { acquireCursorSdkConnection } from "./cursorSdkPool"; import { acquireDroidAcpConnection } from "./droidAcpPool"; -import type { AgentChatEvent, AgentChatEventEnvelope, ComputerUseBackendStatus } from "../../../shared/types"; +import type { AgentChatEvent, AgentChatEventEnvelope, ComputerUseBackendStatus, LaneLinearIssue } from "../../../shared/types"; +import { makeLinearIssueContextAttachment } from "../../../shared/chatContextAttachments"; import { createDynamicOpenCodeModelDescriptor, replaceDynamicOpenCodeModelDescriptors, @@ -914,6 +915,38 @@ async function waitForSessionTitle(sessionService: ReturnType = {}): LaneLinearIssue { + return { + id: "issue-1", + identifier: "ADE-123", + title: "Attach Linear context to chat", + description: "Use this issue as prompt context.", + url: "https://linear.app/ade/issue/ADE-123/attach-linear-context-to-chat", + projectId: "project-1", + projectSlug: "ade", + projectName: "ADE", + teamId: "team-1", + teamKey: "ADE", + teamName: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 2, + priorityLabel: "high", + labels: ["desktop"], + assigneeId: "user-1", + assigneeName: "Arul", + creatorId: "user-2", + creatorName: "Annie", + dueDate: null, + estimate: null, + branchName: "ade-123-attach-linear-context-to-chat", + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + ...overrides, + }; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -4161,6 +4194,91 @@ describe("createAgentChatService", () => { expect(userMessage.event.attachments).toEqual([{ path: "note.txt", type: "file" }]); }); + it("injects Linear issue context into Codex prompts and public user events", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + const contextAttachment = makeLinearIssueContextAttachment(makeLaneLinearIssue(), "manual"); + + await service.sendMessage({ + sessionId: session.id, + text: "Plan the implementation.", + contextAttachments: [contextAttachment], + }); + + const userMessage = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: { type: "user_message"; contextAttachments?: unknown[] } } => + event.event.type === "user_message", + ); + expect(userMessage.event.contextAttachments).toHaveLength(1); + expect(userMessage.event.contextAttachments?.[0]).toMatchObject({ + type: "linear_issue", + issue: { + id: "issue-1", + identifier: "ADE-123", + title: "Attach Linear context to chat", + }, + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + const turnParams = turnStartRequest?.params as { input?: Array<{ text?: unknown }> } | undefined; + const textInput = turnParams?.input?.map((entry) => String(entry.text ?? "")).join("\n") ?? ""; + expect(textInput).toContain("Attached issue context"); + expect(textInput).toContain("- Identifier: ADE-123"); + expect(textInput).toContain("Attach Linear context to chat"); + expect(textInput).toContain("do not ask the user for a Linear API key"); + expect(textInput).toContain("Plan the implementation."); + }); + + it("dispatches context-only Linear issue sends with a fallback prompt", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "", + contextAttachments: [makeLinearIssueContextAttachment(makeLaneLinearIssue(), "manual")], + }); + + const userMessage = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: { type: "user_message"; text: string; contextAttachments?: unknown[] } } => + event.event.type === "user_message", + ); + expect(userMessage.event.text).toBe("Use the attached issue context."); + expect(userMessage.event.contextAttachments).toHaveLength(1); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + const turnParams = turnStartRequest?.params as { input?: Array<{ text?: unknown }> } | undefined; + const textInput = turnParams?.input?.map((entry) => String(entry.text ?? "")).join("\n") ?? ""; + expect(textInput).toContain("Attached issue context"); + expect(textInput).toContain("Use the attached issue context."); + }); + it("prefers the canonical turn-scoped Codex text stream when item-scoped deltas also arrive", async () => { const textEvents: Array<{ text: string; itemId?: string; turnId?: string }> = []; const { service } = createService({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 0eac27f3f..942607b4f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -94,6 +94,7 @@ import type { AgentChatExecutionMode, AgentChatEvent, AgentChatEventEnvelope, + AgentChatContextAttachment, AgentChatFileRef, AgentChatHandoffArgs, AgentChatHandoffResult, @@ -134,6 +135,10 @@ import type { TerminalToolType, CtoCapabilityMode, } from "../../../shared/types"; +import { + buildChatContextAttachmentPrompt, + normalizeChatContextAttachments, +} from "../../../shared/chatContextAttachments"; import { getDefaultModelDescriptor, getDynamicOpenCodeModelDescriptors, @@ -361,6 +366,7 @@ type PersistedPendingSteer = { steerId: string; text: string; attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; }; type PendingRpc = { @@ -437,6 +443,7 @@ type QueuedSteer = { steerId: string; text: string; attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; resolvedAttachments: ResolvedAgentChatFileRef[]; }; @@ -1170,6 +1177,7 @@ type PreparedSendMessage = { promptText: string; visibleText: string; attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; resolvedAttachments: ResolvedAgentChatFileRef[]; reasoningEffort?: string | null; interactionMode?: AgentChatInteractionMode | null; @@ -6318,6 +6326,7 @@ export function createAgentChatService(args: { steerId: s.steerId, text: s.text, ...(s.attachments.length ? { attachments: s.attachments } : {}), + ...(s.contextAttachments.length ? { contextAttachments: s.contextAttachments } : {}), })), } : prevPersisted?.pendingSteers?.length ? { pendingSteers: prevPersisted.pendingSteers } : {}), @@ -7540,6 +7549,7 @@ export function createAgentChatService(args: { text: string; displayText?: string; attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; turnId?: string; laneDirectiveKey?: string | null; onDispatched?: () => void; @@ -7552,6 +7562,7 @@ export function createAgentChatService(args: { ? { displayText: args.displayText.trim() } : {}), attachments: args.attachments, + ...(args.contextAttachments.length ? { contextAttachments: args.contextAttachments } : {}), ...(args.turnId ? { turnId: args.turnId } : {}), }); args.onDispatched?.(); @@ -7578,6 +7589,7 @@ export function createAgentChatService(args: { userText?: string; displayText?: string; attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; resolvedAttachments?: ResolvedAgentChatFileRef[]; laneDirectiveKey?: string | null; providerSlashCommand?: boolean; @@ -7597,6 +7609,7 @@ export function createAgentChatService(args: { } const runtime = managed.runtime; const attachments = args.attachments ?? []; + const contextAttachments = args.contextAttachments ?? []; const resolvedAttachments = args.resolvedAttachments ?? attachments.map((attachment) => ({ ...attachment, _resolvedPath: attachment.path, @@ -7617,6 +7630,7 @@ export function createAgentChatService(args: { text: userText, displayText, attachments, + contextAttachments, laneDirectiveKey: args.laneDirectiveKey, onDispatched: markDispatched, }); @@ -7865,6 +7879,7 @@ export function createAgentChatService(args: { userText?: string; displayText?: string; attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; resolvedAttachments?: ResolvedAgentChatFileRef[]; laneDirectiveKey?: string | null; providerSlashCommand?: boolean; @@ -7891,6 +7906,7 @@ export function createAgentChatService(args: { setSessionActive(managed); const attachments = args.attachments ?? []; + const contextAttachments = args.contextAttachments ?? []; const resolvedAttachments = args.resolvedAttachments ?? attachments.map((attachment) => ({ ...attachment, _resolvedPath: attachment.path, @@ -7902,6 +7918,7 @@ export function createAgentChatService(args: { text: userText, displayText, attachments, + contextAttachments, turnId, laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, @@ -8960,6 +8977,7 @@ export function createAgentChatService(args: { userText?: string; displayText?: string; attachments?: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; resolvedAttachments?: ResolvedAgentChatFileRef[]; laneDirectiveKey?: string | null; providerSlashCommand?: boolean; @@ -8987,6 +9005,7 @@ export function createAgentChatService(args: { runtime.interrupted = false; setSessionActive(managed); const attachments = args.attachments ?? []; + const contextAttachments = args.contextAttachments ?? []; const resolvedAttachments = args.resolvedAttachments ?? attachments.map((attachment) => ({ ...attachment, _resolvedPath: attachment.path, @@ -8998,6 +9017,7 @@ export function createAgentChatService(args: { text: userText, displayText, attachments, + contextAttachments, turnId, laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, @@ -11685,6 +11705,7 @@ export function createAgentChatService(args: { laneWorktreePath: executionContext.laneWorktreePath, }) : null, + buildChatContextAttachmentPrompt(nextSteer.contextAttachments) || null, ]); if (runtime.kind === "claude") { @@ -11692,6 +11713,7 @@ export function createAgentChatService(args: { promptText, displayText: trimmed, attachments: nextSteer.attachments, + contextAttachments: nextSteer.contextAttachments, resolvedAttachments: nextSteer.resolvedAttachments, laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }); @@ -11700,6 +11722,7 @@ export function createAgentChatService(args: { promptText, displayText: trimmed, attachments: nextSteer.attachments, + contextAttachments: nextSteer.contextAttachments, resolvedAttachments: nextSteer.resolvedAttachments, laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }); @@ -11708,6 +11731,7 @@ export function createAgentChatService(args: { promptText, displayText: trimmed, attachments: [], + contextAttachments: nextSteer.contextAttachments, resolvedAttachments: [], laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }); @@ -11716,6 +11740,7 @@ export function createAgentChatService(args: { promptText, displayText: trimmed, attachments: nextSteer.attachments, + contextAttachments: nextSteer.contextAttachments, resolvedAttachments: nextSteer.resolvedAttachments, laneDirectiveKey: shouldInjectLaneDirective ? laneDirectiveKey : null, }); @@ -11732,6 +11757,7 @@ export function createAgentChatService(args: { steerId: string, text: string, attachments: AgentChatFileRef[] = [], + contextAttachments: AgentChatContextAttachment[] = [], resolvedAttachments: ResolvedAgentChatFileRef[] = [], ): boolean => { if (runtime.pendingSteers.length >= MAX_PENDING_STEERS) { @@ -11744,11 +11770,12 @@ export function createAgentChatService(args: { }); return false; } - runtime.pendingSteers.push({ steerId, text, attachments, resolvedAttachments }); + runtime.pendingSteers.push({ steerId, text, attachments, contextAttachments, resolvedAttachments }); emitChatEvent(managed, { type: "user_message", text, ...(attachments.length ? { attachments } : {}), + ...(contextAttachments.length ? { contextAttachments } : {}), steerId, turnId: runtime.activeTurnId ?? undefined, deliveryState: "queued", @@ -11933,6 +11960,7 @@ export function createAgentChatService(args: { && typeof (a as AgentChatFileRef).path === "string" && ((a as AgentChatFileRef).type === "file" || (a as AgentChatFileRef).type === "image")) : []; + const contextAttachments = normalizeChatContextAttachments(entry.contextAttachments); let resolvedAttachments: ResolvedAgentChatFileRef[] = []; try { resolvedAttachments = attachments.map((attachment) => { @@ -11952,7 +11980,7 @@ export function createAgentChatService(args: { }); continue; } - out.push({ steerId: entry.steerId, text, attachments, resolvedAttachments }); + out.push({ steerId: entry.steerId, text, attachments, contextAttachments, resolvedAttachments }); if (out.length >= MAX_PENDING_STEERS) break; } return out; @@ -12633,6 +12661,7 @@ export function createAgentChatService(args: { text, displayText, attachments = [], + contextAttachments = [], reasoningEffort, executionMode, interactionMode, @@ -12640,7 +12669,11 @@ export function createAgentChatService(args: { cloudOverrides, allowActiveSession = false, }: AgentChatSendArgs & { allowActiveSession?: boolean }): PreparedSendMessage | null => { - const trimmed = text.trim(); + const publicContextAttachments = normalizeChatContextAttachments(contextAttachments); + const trimmedText = text.trim(); + const trimmed = trimmedText.length || !publicContextAttachments.length + ? trimmedText + : "Use the attached issue context."; if (!trimmed.length) return null; const slashCommand = extractLeadingSlashCommand(trimmed); const providerSlashCommand = isProviderSlashCommandInput(trimmed); @@ -12751,6 +12784,9 @@ export function createAgentChatService(args: { && !codexRuntimeSlashCommandNames.has(slashCommand) ? resolveCodexSlashCommandInvocation(managed.laneWorktreePath, trimmed) : null; + const contextAttachmentPrompt = providerSlashCommand + ? "" + : buildChatContextAttachmentPrompt(publicContextAttachments); const promptText = providerSlashCommand ? expandedClaudeSlashCommand?.promptText ?? expandedCodexSlashCommand?.promptText ?? trimmed : composeLaunchDirectives(trimmed, [ @@ -12766,6 +12802,7 @@ export function createAgentChatService(args: { buildComputerUseDirective( computerUseArtifactBrokerRef?.getBackendStatus() ?? null, ), + contextAttachmentPrompt || null, ]); const autoTitleSeed = providerSlashCommand ? expandedClaudeSlashCommand?.promptText ?? expandedCodexSlashCommand?.promptText ?? null @@ -12790,6 +12827,7 @@ export function createAgentChatService(args: { promptText, visibleText, attachments: publicAttachments, + contextAttachments: publicContextAttachments, resolvedAttachments, reasoningEffort, interactionMode: managed.session.provider === "claude" ? managed.session.interactionMode ?? "default" : null, @@ -13175,6 +13213,7 @@ export function createAgentChatService(args: { promptText: text, displayText: "", attachments: [], + contextAttachments: [], resolvedAttachments: [], optimisticCursorTurnStart: true, }); @@ -14250,6 +14289,7 @@ export function createAgentChatService(args: { userText?: string; displayText: string; attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; resolvedAttachments: ResolvedAgentChatFileRef[]; laneDirectiveKey?: string | null; turnId?: string; @@ -14277,6 +14317,7 @@ export function createAgentChatService(args: { text: userText, displayText, attachments: args.attachments, + contextAttachments: args.contextAttachments, turnId, laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, @@ -14608,6 +14649,7 @@ export function createAgentChatService(args: { userText?: string; displayText: string; attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; resolvedAttachments: ResolvedAgentChatFileRef[]; laneDirectiveKey?: string | null; turnId?: string; @@ -14641,6 +14683,7 @@ export function createAgentChatService(args: { text: userText, displayText, attachments: args.attachments, + contextAttachments: args.contextAttachments, turnId, laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, @@ -14891,6 +14934,7 @@ export function createAgentChatService(args: { promptText: trimmedPrompt, displayText: trimmedPrompt, attachments: [], + contextAttachments: [], resolvedAttachments: [], }); const last = matched.runtime?.kind === "cursor" ? matched.runtime.activeCloudRunId : null; @@ -15338,6 +15382,7 @@ export function createAgentChatService(args: { userText?: string; displayText: string; attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; resolvedAttachments: ResolvedAgentChatFileRef[]; laneDirectiveKey?: string | null; turnId?: string; @@ -15383,6 +15428,7 @@ export function createAgentChatService(args: { text: userText, displayText, attachments: args.attachments, + contextAttachments: args.contextAttachments, turnId, laneDirectiveKey: args.laneDirectiveKey, onDispatched: args.onDispatched, @@ -15622,6 +15668,7 @@ export function createAgentChatService(args: { promptText, visibleText, attachments, + contextAttachments, resolvedAttachments, reasoningEffort, laneDirectiveKey, @@ -15662,6 +15709,7 @@ export function createAgentChatService(args: { userText: submittedText, displayText: visibleText, attachments, + contextAttachments, resolvedAttachments, laneDirectiveKey, providerSlashCommand, @@ -15688,6 +15736,7 @@ export function createAgentChatService(args: { userText: submittedText, displayText: visibleText, attachments, + contextAttachments, resolvedAttachments, laneDirectiveKey, turnId, @@ -15702,6 +15751,7 @@ export function createAgentChatService(args: { userText: submittedText, displayText: visibleText, attachments, + contextAttachments, resolvedAttachments, laneDirectiveKey, turnId, @@ -15723,6 +15773,7 @@ export function createAgentChatService(args: { userText: submittedText, displayText: visibleText, attachments, + contextAttachments, resolvedAttachments, laneDirectiveKey, turnId, @@ -15846,6 +15897,7 @@ export function createAgentChatService(args: { userText: submittedText, displayText: visibleText, attachments, + contextAttachments, resolvedAttachments, laneDirectiveKey, providerSlashCommand, @@ -15870,6 +15922,7 @@ export function createAgentChatService(args: { userText: submittedText, displayText: visibleText, attachments, + contextAttachments, resolvedAttachments, laneDirectiveKey, providerSlashCommand, @@ -15916,6 +15969,7 @@ export function createAgentChatService(args: { text: prepared.submittedText, ...(prepared.visibleText !== prepared.submittedText ? { displayText: prepared.visibleText } : {}), attachments: prepared.attachments, + ...(prepared.contextAttachments.length ? { contextAttachments: prepared.contextAttachments } : {}), turnId, }); emitChatEvent(prepared.managed, { type: "status", turnStatus: "started", turnId }); @@ -15939,6 +15993,7 @@ export function createAgentChatService(args: { text: prepared.submittedText, displayText: prepared.visibleText, attachments: prepared.attachments, + contextAttachments: prepared.contextAttachments, laneDirectiveKey: prepared.laneDirectiveKey, }); emitChatEvent(prepared.managed, { type: "status", turnStatus: "started" }); @@ -15973,7 +16028,7 @@ export function createAgentChatService(args: { } }; - const steer = async ({ sessionId, text, attachments = [] }: AgentChatSteerArgs): Promise => { + const steer = async ({ sessionId, text, attachments = [], contextAttachments = [] }: AgentChatSteerArgs): Promise => { const trimmed = text.trim(); const steerId = randomUUID(); if (!trimmed.length) { @@ -15994,6 +16049,7 @@ export function createAgentChatService(args: { text: trimmed, displayText: trimmed, attachments, + contextAttachments, }); if (!preparedSteer) { return { steerId, queued: false }; @@ -16005,6 +16061,7 @@ export function createAgentChatService(args: { steerId, preparedSteer.visibleText, preparedSteer.attachments, + preparedSteer.contextAttachments, preparedSteer.resolvedAttachments, ); return { steerId, queued: true }; @@ -16014,6 +16071,7 @@ export function createAgentChatService(args: { text: trimmed, displayText: trimmed, attachments, + contextAttachments, }); if (!preparedSteer) { return { steerId, queued: false }; @@ -16030,6 +16088,7 @@ export function createAgentChatService(args: { text: trimmed, displayText: trimmed, attachments, + contextAttachments, allowActiveSession: true, }); if (!preparedSteer) { @@ -16049,12 +16108,14 @@ export function createAgentChatService(args: { steerId, text: preparedSteer.visibleText, attachments: preparedSteer.attachments, + contextAttachments: preparedSteer.contextAttachments, resolvedAttachments: preparedSteer.resolvedAttachments, }); emitChatEvent(managed, { type: "user_message", text: preparedSteer.visibleText, ...(preparedSteer.attachments.length ? { attachments: preparedSteer.attachments } : {}), + ...(preparedSteer.contextAttachments.length ? { contextAttachments: preparedSteer.contextAttachments } : {}), steerId, turnId: rt.activeTurnId ?? undefined, deliveryState: "queued", @@ -16074,6 +16135,7 @@ export function createAgentChatService(args: { text: trimmed, displayText: trimmed, attachments, + contextAttachments, }); if (!preparedSteer) { return { steerId, queued: false }; @@ -16095,10 +16157,12 @@ export function createAgentChatService(args: { }); return { steerId, queued: false }; } - rt.pendingSteers.push({ steerId, text: trimmed, attachments: [], resolvedAttachments: [] }); + const normalizedContextAttachments = normalizeChatContextAttachments(contextAttachments); + rt.pendingSteers.push({ steerId, text: trimmed, attachments: [], contextAttachments: normalizedContextAttachments, resolvedAttachments: [] }); emitChatEvent(managed, { type: "user_message", text: trimmed, + ...(normalizedContextAttachments.length ? { contextAttachments: normalizedContextAttachments } : {}), steerId, turnId: rt.activeTurnId ?? undefined, deliveryState: "queued", @@ -16118,6 +16182,7 @@ export function createAgentChatService(args: { text: trimmed, displayText: trimmed, attachments: [], + contextAttachments, }); if (!preparedSteer) { return { steerId, queued: false }; @@ -16138,6 +16203,7 @@ export function createAgentChatService(args: { text: trimmed, displayText: trimmed, attachments, + contextAttachments, }); if (!preparedSteer) { return { steerId, queued: false }; @@ -16150,6 +16216,14 @@ export function createAgentChatService(args: { text_elements: [], }, ]; + const contextPrompt = buildChatContextAttachmentPrompt(preparedSteer.contextAttachments); + if (contextPrompt) { + input.unshift({ + type: "text", + text: contextPrompt, + text_elements: [], + }); + } for (const attachment of preparedSteer.resolvedAttachments) { const stagedPath = stageAttachmentForCodexInput(attachment); if (attachment.type === "image") { @@ -16169,6 +16243,7 @@ export function createAgentChatService(args: { type: "user_message", text: preparedSteer.visibleText, ...(preparedSteer.attachments.length ? { attachments: preparedSteer.attachments } : {}), + ...(preparedSteer.contextAttachments.length ? { contextAttachments: preparedSteer.contextAttachments } : {}), steerId, deliveryState: "delivered", turnId: runtime.activeTurnId, @@ -16182,6 +16257,7 @@ export function createAgentChatService(args: { text: trimmed, displayText: trimmed, attachments, + contextAttachments, }); if (!preparedSteer) { return { steerId, queued: false }; @@ -16194,6 +16270,7 @@ export function createAgentChatService(args: { steerId, preparedSteer.visibleText, preparedSteer.attachments, + preparedSteer.contextAttachments, preparedSteer.resolvedAttachments, ); return { steerId, queued: true }; @@ -16296,6 +16373,7 @@ export function createAgentChatService(args: { text: steer.text, displayText: steer.text, attachments: steer.attachments, + contextAttachments: steer.contextAttachments, }); if (!prepared) { logger.warn("agent_chat.dispatch_steer_inline_drop_skipped", { @@ -16315,7 +16393,9 @@ export function createAgentChatService(args: { // to the in-flight transcript and the model picks it up at the next // thinking step (verified in the V2 mid-turn spike, test C). const dispatchUuid = randomUUID(); - const sdkMsg = buildClaudeV2Message(steer.text, steer.resolvedAttachments, { + const contextPrompt = buildChatContextAttachmentPrompt(steer.contextAttachments); + const inlineSteerText = contextPrompt ? `${contextPrompt}\n\n${steer.text}` : steer.text; + const sdkMsg = buildClaudeV2Message(inlineSteerText, steer.resolvedAttachments, { baseDir: managed.laneWorktreePath, sessionId: runtime.sdkSessionId ?? null, forceUserMessage: true, @@ -16342,6 +16422,7 @@ export function createAgentChatService(args: { type: "user_message", text: steer.text, ...(steer.attachments.length ? { attachments: steer.attachments } : {}), + ...(steer.contextAttachments.length ? { contextAttachments: steer.contextAttachments } : {}), steerId, deliveryState: "inline", turnId: runtime.activeTurnId ?? undefined, diff --git a/apps/desktop/src/main/services/cto/issueTracker.ts b/apps/desktop/src/main/services/cto/issueTracker.ts index a1e235701..45bcb545c 100644 --- a/apps/desktop/src/main/services/cto/issueTracker.ts +++ b/apps/desktop/src/main/services/cto/issueTracker.ts @@ -1,5 +1,6 @@ import type { CtoLinearProject, + CtoLinearQuickView, LinearCatalogLabel, LinearCatalogState, LinearCatalogUser, @@ -11,6 +12,27 @@ export type IssueTrackerCandidateQuery = { stateTypes: string[]; }; +export type IssueTrackerIssueSearchQuery = { + projectId?: string | null; + projectSlug?: string | null; + teamKey?: string | null; + stateTypes?: string[]; + assigneeId?: string | null; + priority?: number | null; + query?: string | null; + first?: number; + after?: string | null; + includeArchived?: boolean; +}; + +export type IssueTrackerIssueSearchResult = { + issues: NormalizedLinearIssue[]; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; +}; + export type IssueTrackerWorkpadResult = { commentId: string; }; @@ -25,8 +47,10 @@ export type IssueTrackerWorkflowState = { export type IssueTracker = { listProjects(): Promise; + getQuickView(connection: CtoLinearQuickView["connection"]): Promise; listUsers(): Promise; listLabels(teamKey?: string | null): Promise; + searchIssues(query: IssueTrackerIssueSearchQuery): Promise; fetchCandidateIssues(query: IssueTrackerCandidateQuery): Promise; fetchIssueById(issueId: string): Promise; fetchIssuesByIds(issueIds: string[]): Promise>; diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index 6d7e3d446..f4b23b433 100644 --- a/apps/desktop/src/main/services/cto/linearAuth.test.ts +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -665,6 +665,59 @@ describe("linearClient", () => { ]); }); + it("searches issues with picker filters and pagination", async () => { + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? "{}")) as { query?: string; variables?: Record }; + if (!body.query?.includes("SearchIssues")) { + return new Response(JSON.stringify({ data: {} }), { status: 200, headers: { "content-type": "application/json" } }); + } + expect(body.query).toContain('project: { id: { eq: "project-1" } }'); + expect(body.query).toContain('state: { type: { in: ["unstarted", "started"] } }'); + expect(body.query).toContain('assignee: { id: { eq: "user-1" } }'); + expect(body.query).toContain("priority: { eq: 2 }"); + expect(body.query).toContain("containsIgnoreCase"); + expect(body.variables).toMatchObject({ + first: 25, + after: "cursor-1", + includeArchived: false, + }); + return new Response( + JSON.stringify({ + data: { + issues: { + pageInfo: { hasNextPage: true, endCursor: "cursor-2" }, + nodes: [makeIssueNode("7", "2026-03-05T00:07:00.000Z")], + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }); + + const client = createLinearClient({ + credentials: { + getTokenOrThrow: () => "lin_api_test", + getStatus: () => ({ authMode: "manual" }), + } as any, + fetchImpl: fetchImpl as any, + logger: null, + }); + + const result = await client.searchIssues({ + projectId: "project-1", + stateTypes: ["unstarted", "started"], + assigneeId: "user-1", + priority: 2, + query: "auth", + first: 25, + after: "cursor-1", + }); + + expect(result.pageInfo).toEqual({ hasNextPage: true, endCursor: "cursor-2" }); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]?.identifier).toBe("ABC-7"); + }); + it("strips a pasted bearer prefix from manual API keys", async () => { const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { expect(init?.headers).toMatchObject({ authorization: "lin_api_test" }); diff --git a/apps/desktop/src/main/services/cto/linearClient.ts b/apps/desktop/src/main/services/cto/linearClient.ts index e2e2adb3e..27975aea1 100644 --- a/apps/desktop/src/main/services/cto/linearClient.ts +++ b/apps/desktop/src/main/services/cto/linearClient.ts @@ -1,7 +1,11 @@ import fs from "node:fs"; import path from "node:path"; +import { LinearClient as LinearSdkClient } from "@linear/sdk"; import type { Logger } from "../logging/logger"; import type { + CtoLinearQuickView, + CtoLinearQuickViewProject, + CtoLinearQuickViewTeam, CtoLinearProject, LinearCatalogLabel, LinearCatalogState, @@ -10,6 +14,7 @@ import type { NormalizedLinearIssue, } from "../../../shared/types"; import type { LinearCredentialService } from "./linearCredentialService"; +import type { IssueTrackerIssueSearchQuery, IssueTrackerIssueSearchResult } from "./issueTracker"; import { isRecord, toOptionalString as asString, asArray, sleep, getErrorMessage } from "../shared/utils"; const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"; @@ -33,6 +38,28 @@ function toAuthorizationHeaderValue(token: string, authMode: "manual" | "oauth" return trimmed; } +function toSdkTokenValue(token: string): string { + return token.trim().replace(/^bearer\s+/i, ""); +} + +function gqlString(value: string): string { + return JSON.stringify(value); +} + +function gqlStringArray(values: string[]): string { + return `[${values.map((value) => gqlString(value)).join(", ")}]`; +} + +function priorityIsValid(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 4; +} + +function toIsoString(value: unknown): string | null { + if (value instanceof Date) return value.toISOString(); + if (typeof value === "string" && value.trim().length > 0) return value; + return null; +} + function toNormalizedIssue(node: Record): NormalizedLinearIssue | null { const id = asString(node.id); const identifier = asString(node.identifier); @@ -45,7 +72,7 @@ function toNormalizedIssue(node: Record): NormalizedLinearIssue if (!project || !team || !state) return null; const projectId = asString(project.id); - const projectSlug = asString(project.slug); + const projectSlug = asString(project.slug) ?? asString(project.slugId); const teamId = asString(team.id); const teamKey = asString(team.key); const stateId = asString(state.id); @@ -83,8 +110,10 @@ function toNormalizedIssue(node: Record): NormalizedLinearIssue url: asString(node.url), projectId, projectSlug, + projectName: asString(project.name), teamId, teamKey, + teamName: asString(team.name), stateId, stateName, stateType, @@ -101,6 +130,12 @@ function toNormalizedIssue(node: Record): NormalizedLinearIssue creatorName: owner ? (asString(owner.displayName) ?? asString(owner.name)) : null, blockerIssueIds, hasOpenBlockers, + dueDate: asString(node.dueDate), + estimate: typeof node.estimate === "number" && Number.isFinite(node.estimate) ? node.estimate : null, + archivedAt: asString(node.archivedAt), + completedAt: asString(node.completedAt), + canceledAt: asString(node.canceledAt), + startedAt: asString(node.startedAt), createdAt: asString(node.createdAt) ?? new Date().toISOString(), updatedAt: asString(node.updatedAt) ?? new Date().toISOString(), raw: node, @@ -125,6 +160,14 @@ type LinearWebhookSummary = { export function createLinearClient(args: LinearClientArgs) { const fetchImpl = args.fetchImpl ?? fetch; + const createSdkClient = () => { + const token = toSdkTokenValue(args.credentials.getTokenOrThrow()); + const authMode = args.credentials.getStatus().authMode; + if (authMode === "oauth") return new LinearSdkClient({ accessToken: token }); + if (authMode === "manual") return new LinearSdkClient({ apiKey: token }); + throw new Error("Linear credential auth mode is missing or unknown."); + }; + const request = async >(params: { query: string; variables?: Record; @@ -194,46 +237,71 @@ export function createLinearClient(args: LinearClientArgs) { }; const listProjects = async (): Promise => { - const data = await request<{ - projects?: { - nodes?: Array>; - }; - }>({ - query: ` - query Projects { - projects(first: 100) { - nodes { - id - name - slug - teams { - nodes { - name + const projects = new Map(); + let after: string | null = null; + for (let page = 0; page < 25; page += 1) { + const data = await request<{ + projects?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + nodes?: Array>; + }; + }>({ + query: ` + query Projects($after: String) { + projects(first: 100, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + id + name + slug: slugId + teams { + nodes { + key + name + } } } } } - } - `, - maxRetries: 2, - }); + `, + variables: { after }, + maxRetries: 2, + }); - return asArray(data.projects?.nodes) - .map((node) => { - if (!isRecord(node)) return null; - const id = asString(node.id); - const name = asString(node.name); - const slug = asString(node.slug); - if (!id || !name || !slug) return null; - const teamName = - (isRecord(node.teams) - ? asArray(node.teams.nodes) - .map((entry) => (isRecord(entry) ? asString(entry.name) : null)) - .find((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) - : null) ?? "Unassigned"; - return { id, name, slug, teamName }; - }) - .filter((entry): entry is CtoLinearProject => entry != null) + const pageProjects = asArray(data.projects?.nodes) + .map((node): CtoLinearProject | null => { + if (!isRecord(node)) return null; + const id = asString(node.id); + const name = asString(node.name); + const slug = asString(node.slug); + if (!id || !name || !slug) return null; + const teamName = + (isRecord(node.teams) + ? asArray(node.teams.nodes) + .map((entry) => (isRecord(entry) ? asString(entry.name) : null)) + .find((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + : null) ?? "Unassigned"; + const teamKey = + (isRecord(node.teams) + ? asArray(node.teams.nodes) + .map((entry) => (isRecord(entry) ? asString(entry.key) : null)) + .find((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + : null) ?? null; + return teamKey ? { id, name, slug, teamName, teamKey } : { id, name, slug, teamName }; + }) + .filter((entry): entry is CtoLinearProject => entry != null); + + for (const project of pageProjects) { + projects.set(project.id, project); + } + + if (!data.projects?.pageInfo?.hasNextPage) break; + const nextCursor = asString(data.projects.pageInfo.endCursor); + if (!nextCursor || nextCursor === after) break; + after = nextCursor; + } + + return [...projects.values()] .sort((left, right) => left.name.localeCompare(right.name)); }; @@ -334,8 +402,14 @@ export function createLinearClient(args: LinearClientArgs) { priority createdAt updatedAt - project { id slug } - team { id key } + dueDate + estimate + archivedAt + completedAt + canceledAt + startedAt + project { id name slug: slugId } + team { id key name } state { id name type } assignee { id name displayName } creator { id name displayName } @@ -362,7 +436,7 @@ export function createLinearClient(args: LinearClientArgs) { first: 50, after: $after, filter: { - project: { slug: { eq: $projectSlug } }, + project: { slugId: { eq: $projectSlug } }, state: { type: { in: $stateTypes } } } ) { @@ -413,6 +487,87 @@ export function createLinearClient(args: LinearClientArgs) { return results.flat(); }; + const buildIssueSearchFilter = (params: IssueTrackerIssueSearchQuery): string => { + const parts: string[] = []; + const projectId = params.projectId?.trim(); + const projectSlug = params.projectSlug?.trim(); + const teamKey = params.teamKey?.trim(); + const stateTypes = (params.stateTypes ?? []).map((entry) => entry.trim()).filter(Boolean); + const assigneeId = params.assigneeId?.trim(); + const query = params.query?.trim(); + + if (projectId) { + parts.push(`project: { id: { eq: ${gqlString(projectId)} } }`); + } else if (projectSlug) { + parts.push(`project: { slugId: { eq: ${gqlString(projectSlug)} } }`); + } + if (teamKey) { + parts.push(`team: { key: { eq: ${gqlString(teamKey)} } }`); + } + if (stateTypes.length > 0) { + parts.push(`state: { type: { in: ${gqlStringArray(stateTypes)} } }`); + } + if (assigneeId) { + parts.push(`assignee: { id: { eq: ${gqlString(assigneeId)} } }`); + } + if (priorityIsValid(params.priority)) { + parts.push(`priority: { eq: ${params.priority} }`); + } + if (query) { + parts.push(`or: [ + { title: { containsIgnoreCase: ${gqlString(query)} } }, + { description: { containsIgnoreCase: ${gqlString(query)} } }, + { identifier: { containsIgnoreCase: ${gqlString(query)} } } + ]`); + } + + return parts.length > 0 ? `{ ${parts.join(", ")} }` : "{}"; + }; + + const searchIssues = async (params: IssueTrackerIssueSearchQuery): Promise => { + const first = Math.min(100, Math.max(10, Math.floor(params.first ?? 50))); + const filter = buildIssueSearchFilter(params); + const data = await request<{ + issues?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + nodes?: Array>; + }; + }>({ + query: ` + query SearchIssues($first: Int!, $after: String, $includeArchived: Boolean!) { + issues( + first: $first, + after: $after, + includeArchived: $includeArchived, + orderBy: updatedAt, + filter: ${filter} + ) { + pageInfo { hasNextPage endCursor } + nodes { + ${ISSUE_FIELDS_FRAGMENT} + } + } + } + `, + variables: { + first, + after: params.after?.trim() || null, + includeArchived: params.includeArchived === true, + }, + maxRetries: 2, + }); + + return { + issues: asArray(data.issues?.nodes) + .map((entry) => (isRecord(entry) ? toNormalizedIssue(entry) : null)) + .filter((entry): entry is NormalizedLinearIssue => entry != null), + pageInfo: { + hasNextPage: Boolean(data.issues?.pageInfo?.hasNextPage), + endCursor: asString(data.issues?.pageInfo?.endCursor), + }, + }; + }; + const fetchIssueById = async (issueId: string): Promise => { const data = await request<{ issue?: Record }>({ query: ` @@ -428,6 +583,217 @@ export function createLinearClient(args: LinearClientArgs) { return data.issue && isRecord(data.issue) ? toNormalizedIssue(data.issue) : null; }; + const normalizeSdkIssue = async (issue: Record): Promise => { + const [project, team, state, assignee, creator, labelsConnection, childrenConnection] = await Promise.all([ + typeof issue.project === "object" ? issue.project : Promise.resolve(null), + typeof issue.team === "object" ? issue.team : Promise.resolve(null), + typeof issue.state === "object" ? issue.state : Promise.resolve(null), + typeof issue.assignee === "object" ? issue.assignee : Promise.resolve(null), + typeof issue.creator === "object" ? issue.creator : Promise.resolve(null), + typeof issue.labels === "function" + ? (issue.labels as (args: { first: number }) => Promise<{ nodes?: unknown[] }>)({ first: 8 }).catch(() => null) + : Promise.resolve(null), + typeof issue.children === "function" + ? (issue.children as (args: { first: number }) => Promise<{ nodes?: unknown[] }>)({ first: 20 }).catch(() => null) + : typeof issue.children === "object" + ? issue.children + : Promise.resolve(null), + ]); + const childNodes = await Promise.all( + asArray(isRecord(childrenConnection) ? childrenConnection.nodes : []) + .filter(isRecord) + .map(async (child) => { + const childState = typeof child.state === "object" + ? await Promise.resolve(child.state).catch(() => null) + : null; + return { + id: child.id, + state: isRecord(childState) ? { type: childState.type } : null, + }; + }), + ); + + const raw = { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description, + url: issue.url, + priority: issue.priority, + createdAt: toIsoString(issue.createdAt), + updatedAt: toIsoString(issue.updatedAt), + dueDate: issue.dueDate, + estimate: issue.estimate, + archivedAt: toIsoString(issue.archivedAt), + completedAt: toIsoString(issue.completedAt), + canceledAt: toIsoString(issue.canceledAt), + startedAt: toIsoString(issue.startedAt), + project: isRecord(project) ? { + id: project.id, + name: project.name, + slug: project.slugId ?? project.slug, + } : null, + team: isRecord(team) ? { + id: team.id, + key: team.key, + name: team.name, + } : null, + state: isRecord(state) ? { + id: state.id, + name: state.name, + type: state.type, + } : null, + assignee: isRecord(assignee) ? { + id: assignee.id, + name: assignee.name, + displayName: assignee.displayName, + } : null, + creator: isRecord(creator) ? { + id: creator.id, + name: creator.name, + displayName: creator.displayName, + } : null, + labels: { + nodes: asArray(isRecord(labelsConnection) ? labelsConnection.nodes : []) + .filter(isRecord) + .map((label) => ({ id: label.id, name: label.name })), + }, + children: { nodes: childNodes }, + metadata: {}, + }; + + return toNormalizedIssue(raw); + }; + + const getQuickView = async (connection: CtoLinearQuickView["connection"]): Promise => { + const sdk = createSdkClient(); + const [viewer, organization, projectsConnection, teamsConnection, recentIssuesConnection] = await Promise.all([ + sdk.viewer, + sdk.organization.catch(() => null), + sdk.projects({ first: 8, includeArchived: false } as never).catch(() => null), + sdk.teams({ first: 8, includeArchived: false } as never).catch(() => null), + sdk.issues({ + first: 12, + includeArchived: false, + orderBy: "updatedAt", + } as never).catch(() => null), + ]); + + const assignedIssuesConnection = await viewer + .assignedIssues({ + first: 12, + includeArchived: false, + orderBy: "updatedAt", + } as never) + .catch(() => null); + + const projects: CtoLinearQuickViewProject[] = await Promise.all( + asArray(projectsConnection?.nodes).filter(isRecord).map(async (project) => { + const [status, lead, teams] = await Promise.all([ + typeof project.status === "object" ? project.status : Promise.resolve(null), + typeof project.lead === "object" ? project.lead : Promise.resolve(null), + typeof project.teams === "function" + ? (project.teams as (args: { first: number }) => Promise<{ nodes?: unknown[] }>)({ first: 4 }).catch(() => null) + : Promise.resolve(null), + ]); + const teamNodes = asArray(isRecord(teams) ? teams.nodes : []).filter(isRecord); + const teamName = teamNodes + .map((team) => asString(team.name)) + .find((entry): entry is string => Boolean(entry?.trim())) ?? "Unassigned"; + const teamKey = teamNodes + .map((team) => asString(team.key)) + .find((entry): entry is string => Boolean(entry?.trim())) ?? null; + return { + id: String(project.id ?? ""), + name: String(project.name ?? "Untitled project"), + slug: String(project.slugId ?? project.slug ?? ""), + teamName, + ...(teamKey ? { teamKey } : {}), + url: asString(project.url), + color: asString(project.color), + icon: asString(project.icon), + description: asString(project.description), + statusName: isRecord(status) ? asString(status.name) : null, + statusType: isRecord(status) ? asString(status.type) : null, + health: asString(project.health), + progress: typeof project.progress === "number" ? project.progress : null, + scope: typeof project.scope === "number" ? project.scope : null, + priority: typeof project.priority === "number" ? project.priority : null, + priorityLabel: asString(project.priorityLabel), + issueCount: Array.isArray(project.issueCountHistory) ? Number(project.issueCountHistory.at(-1) ?? 0) : null, + completedIssueCount: Array.isArray(project.completedIssueCountHistory) + ? Number(project.completedIssueCountHistory.at(-1) ?? 0) + : null, + startDate: asString(project.startDate), + targetDate: asString(project.targetDate), + leadName: isRecord(lead) ? (asString(lead.displayName) ?? asString(lead.name)) : null, + teamKeys: teamNodes.map((team) => asString(team.key)).filter((entry): entry is string => Boolean(entry)), + }; + }) + ); + + const teams: CtoLinearQuickViewTeam[] = asArray(teamsConnection?.nodes) + .filter(isRecord) + .map((team) => ({ + id: String(team.id ?? ""), + key: String(team.key ?? ""), + name: String(team.name ?? "Team"), + displayName: String(team.displayName ?? team.name ?? "Team"), + color: asString(team.color), + issueCount: typeof team.issueCount === "number" ? team.issueCount : null, + cyclesEnabled: typeof team.cyclesEnabled === "boolean" ? team.cyclesEnabled : null, + private: typeof team.private === "boolean" ? team.private : null, + })); + + const [assignedIssues, recentIssues] = await Promise.all([ + Promise.all(asArray(assignedIssuesConnection?.nodes).filter(isRecord).map(normalizeSdkIssue)), + Promise.all(asArray(recentIssuesConnection?.nodes).filter(isRecord).map(normalizeSdkIssue)), + ]); + + return { + connection, + organization: isRecord(organization) ? { + id: String(organization.id ?? ""), + name: String(organization.name ?? "Linear"), + urlKey: asString(organization.urlKey), + logoUrl: asString(organization.logoUrl), + gitBranchFormat: asString(organization.gitBranchFormat), + createdIssueCount: typeof organization.createdIssueCount === "number" ? organization.createdIssueCount : null, + roadmapEnabled: typeof organization.roadmapEnabled === "boolean" ? organization.roadmapEnabled : null, + customersEnabled: typeof organization.customersEnabled === "boolean" ? organization.customersEnabled : null, + releasesEnabled: typeof organization.releasesEnabled === "boolean" ? organization.releasesEnabled : null, + } : null, + viewer: { + id: String(viewer.id ?? ""), + name: String(viewer.name ?? viewer.displayName ?? "Linear user"), + displayName: String(viewer.displayName ?? viewer.name ?? "Linear user"), + email: asString(viewer.email), + avatarUrl: asString(viewer.avatarUrl), + admin: typeof viewer.admin === "boolean" ? viewer.admin : null, + guest: typeof viewer.guest === "boolean" ? viewer.guest : null, + url: asString(viewer.url), + }, + projects: projects.filter((project) => project.id && project.slug), + teams: teams.filter((team) => team.id && team.key), + assignedIssues: assignedIssues.filter((issue): issue is NormalizedLinearIssue => issue != null), + recentIssues: recentIssues.filter((issue): issue is NormalizedLinearIssue => issue != null), + fetchedAt: new Date().toISOString(), + sdk: { + packageName: "@linear/sdk", + surfaces: [ + "viewer", + "organization", + "projects", + "teams", + "assignedIssues", + "issues", + "project.status", + "project.lead", + ], + }, + }; + }; + const fetchIssuesByIds = async (issueIds: string[]): Promise> => { const results = new Map(); if (!issueIds.length) return results; @@ -890,9 +1256,11 @@ export function createLinearClient(args: LinearClientArgs) { listLabels, listWebhooks, createWebhook, + searchIssues, fetchCandidateIssues, fetchIssueById, fetchIssuesByIds, + getQuickView, fetchWorkflowStates, listWorkflowStates, updateIssueState, diff --git a/apps/desktop/src/main/services/cto/linearIssueTracker.ts b/apps/desktop/src/main/services/cto/linearIssueTracker.ts index a46080133..d3c34132f 100644 --- a/apps/desktop/src/main/services/cto/linearIssueTracker.ts +++ b/apps/desktop/src/main/services/cto/linearIssueTracker.ts @@ -8,6 +8,10 @@ export function createLinearIssueTracker(args: { client: LinearClient }): IssueT return args.client.listProjects(); }, + getQuickView(connection) { + return args.client.getQuickView(connection); + }, + listUsers() { return args.client.listUsers(); }, @@ -16,6 +20,10 @@ export function createLinearIssueTracker(args: { client: LinearClient }): IssueT return args.client.listLabels(teamKey); }, + searchIssues(query) { + return args.client.searchIssues(query); + }, + fetchCandidateIssues(query) { return args.client.fetchCandidateIssues(query); }, diff --git a/apps/desktop/src/main/services/git/gitOperationsService.test.ts b/apps/desktop/src/main/services/git/gitOperationsService.test.ts index 9fd605bd4..48d1293c6 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.test.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.test.ts @@ -191,6 +191,79 @@ describe("gitOperationsService stash item commands", () => { }); }); +describe("gitOperationsService.commit", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("prefixes commits from linked Linear lanes with a non-closing reference", async () => { + mockGit.getHeadSha.mockResolvedValueOnce("before").mockResolvedValueOnce("after"); + mockGit.runGitOrThrow.mockResolvedValue(undefined); + const mockStart = vi.fn().mockReturnValue({ operationId: "op-1" }); + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef: "ade-123-linked-commit", + worktreePath: "/tmp/ade-lane", + laneType: "worktree", + linearIssue: { + id: "issue-1", + identifier: "ADE-123", + title: "Linked commit", + description: null, + url: null, + projectId: "project-1", + projectSlug: "ade", + teamId: "team-1", + teamKey: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 0, + priorityLabel: "none", + labels: [], + assigneeId: null, + assigneeName: null, + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + }, + }), + } as any, + operationService: { + start: mockStart, + finish: vi.fn(), + } as any, + projectConfigService: { + get: () => ({ effective: { ai: {} } }), + } as any, + aiIntegrationService: { + getFeatureFlag: () => false, + getStatus: vi.fn(async () => ({ availableModelIds: [] })), + generateCommitMessage: vi.fn(), + } as any, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any, + }); + + await service.commit({ laneId: "lane-1", message: "Update git service" }); + + expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( + ["commit", "-m", "Refs ADE-123: Update git service"], + { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, + ); + expect(mockStart).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ message: "Refs ADE-123: Update git service" }), + }), + ); + }); +}); + describe("gitOperationsService.generateCommitMessage", () => { beforeEach(() => { vi.clearAllMocks(); @@ -295,6 +368,98 @@ describe("gitOperationsService.generateCommitMessage", () => { ["diff", "--cached", "--no-color", "-U2", "--find-renames"], ]); }); + + it("prefixes generated commit messages with a Linear reference for linked lanes", async () => { + mockGit.runGit.mockImplementation(async (args: string[]) => { + if (args[0] === "diff") { + return { + exitCode: 0, + stdout: "M\tapps/desktop/src/main/foo.ts\n", + stderr: "", + }; + } + if (args[0] === "show") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { exitCode: 1, stdout: "", stderr: `unexpected git command: ${args.join(" ")}` }; + }); + + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: () => ({ + baseRef: "main", + branchRef: "ade-123-connect-linear-commits", + worktreePath: "/tmp/ade-lane", + laneType: "worktree", + linearIssue: { + id: "issue-1", + identifier: "ADE-123", + title: "Connect Linear commits", + description: null, + url: null, + projectId: "project-1", + projectSlug: "ade", + teamId: "team-1", + teamKey: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 0, + priorityLabel: "none", + labels: [], + assigneeId: null, + assigneeName: null, + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + }, + }), + } as any, + operationService: { + start: vi.fn(), + finish: vi.fn(), + } as any, + projectConfigService: { + get: () => ({ + effective: { + ai: { + featureModelOverrides: { + commit_messages: "anthropic/claude-haiku-4-5", + }, + }, + }, + }), + } as any, + aiIntegrationService: { + getFeatureFlag: () => true, + getStatus: vi.fn(async () => ({ + availableModelIds: ["anthropic/claude-haiku-4-5"], + })), + generateCommitMessage: vi.fn(async () => ({ + text: "Update git service.", + structuredOutput: null, + provider: "anthropic", + model: null, + sessionId: null, + inputTokens: null, + outputTokens: null, + durationMs: 5, + })), + } as any, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any, + }); + + const result = await service.generateCommitMessage({ laneId: "lane-1" }); + + expect(result).toEqual({ + message: "Refs ADE-123: Update git service", + model: "anthropic/claude-haiku-4-5", + }); + }); }); describe("gitOperationsService cached lane reads", () => { diff --git a/apps/desktop/src/main/services/git/gitOperationsService.ts b/apps/desktop/src/main/services/git/gitOperationsService.ts index 7adc46cfe..af4896615 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.ts @@ -27,8 +27,10 @@ import type { GitSyncArgs, GitSyncMode, GitUpstreamSyncStatus, + LaneLinearIssue, LaneType } from "../../../shared/types"; +import { ensureLinearCommitReference } from "../../../shared/linearMagicWords"; import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; import type { createOperationService } from "../history/operationService"; @@ -41,6 +43,7 @@ type LaneInfo = { branchRef: string; worktreePath: string; laneType: LaneType; + linearIssue?: LaneLinearIssue | null; }; type CommitMessagePromptContext = { @@ -512,7 +515,11 @@ export function createGitOperationsService({ }, async commit(args: GitCommitArgs): Promise { - const message = args.message.trim(); + const inputMessage = args.message.trim(); + const laneForMessage = laneService.getLaneBaseAndBranch(args.laneId); + const message = laneForMessage.linearIssue + ? ensureLinearCommitReference(inputMessage, laneForMessage.linearIssue) + : inputMessage; if (!message.length) { throw new Error("Commit message is required"); } @@ -547,8 +554,11 @@ export function createGitOperationsService({ prompt, model }); + const message = lane.linearIssue + ? ensureLinearCommitReference(normalizeCommitMessage(result.text), lane.linearIssue) + : normalizeCommitMessage(result.text); return { - message: normalizeCommitMessage(result.text), + message, model: result.model ?? model }; } catch (error) { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index f6102070c..bf7d8397b 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -526,6 +526,10 @@ import type { CtoClearAgentTaskSessionArgs, CtoGetLinearOAuthSessionArgs, CtoGetLinearOAuthSessionResult, + CtoGetLinearIssuePickerDataResult, + CtoLinearQuickView, + CtoSearchLinearIssuesArgs, + CtoSearchLinearIssuesResult, CtoRunProjectScanResult, CtoStartLinearOAuthResult, LinearConnectionStatus, @@ -5405,6 +5409,8 @@ export function registerIpc({ description: arg.description, parentLaneId: arg.parentLaneId, baseBranch: arg.baseBranch, + branchName: arg.branchName, + linearIssue: arg.linearIssue ?? null, }); await ensureLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); @@ -9571,6 +9577,49 @@ export function registerIpc({ } }); + ipcMain.handle(IPC.ctoGetLinearQuickView, async (): Promise => { + const ctx = getCtx(); + const tokenStored = Boolean(ctx.linearCredentialService?.getStatus().tokenStored); + const connection = await buildLinearConnectionStatus(ctx, tokenStored); + if (!connection.connected || !ctx.linearIssueTracker) { + return { + connection, + organization: null, + viewer: null, + projects: [], + teams: [], + assignedIssues: [], + recentIssues: [], + fetchedAt: nowIso(), + sdk: { + packageName: "@linear/sdk", + surfaces: [], + }, + }; + } + return ctx.linearIssueTracker.getQuickView(connection); + }); + + ipcMain.handle(IPC.ctoGetLinearIssuePickerData, async (): Promise => { + const ctx = getCtx(); + if (!ctx.linearIssueTracker) throw new Error("Linear issue tracker is not available."); + const [projects, users, states] = await Promise.all([ + ctx.linearIssueTracker.listProjects().catch(() => []), + ctx.linearIssueTracker.listUsers().catch(() => []), + ctx.linearIssueTracker.listWorkflowStates().catch(() => []), + ]); + return { projects, users, states }; + }); + + ipcMain.handle( + IPC.ctoSearchLinearIssues, + async (_event, arg: CtoSearchLinearIssuesArgs = {}): Promise => { + const ctx = getCtx(); + if (!ctx.linearIssueTracker) throw new Error("Linear issue tracker is not available."); + return ctx.linearIssueTracker.searchIssues(arg); + } + ); + ipcMain.handle(IPC.ctoRunProjectScan, async (): Promise => { const ctx = getCtx(); const detection = await ctx.onboardingService.detectDefaults().catch(() => null); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index b88a0a3e8..54707ee4f 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -7,6 +7,7 @@ import { isWithinDir, normalizeBranchName } from "../shared/utils"; import { fetchRemoteTrackingBranch, resolveQueueRebaseOverride, type QueueRebaseOverride } from "../shared/queueRebase"; import { detectConflictKind } from "../git/gitConflictState"; import { shouldLaneTrackParent } from "../../../shared/laneBaseResolution"; +import { linearIssueBranchName, sanitizeLinearIssueBranchName } from "../../../shared/linearIssueBranch"; import type { createOperationService } from "../history/operationService"; import type { Logger } from "../logging/logger"; import type { @@ -27,6 +28,7 @@ import type { LaneBranchSwitchArgs, LaneBranchSwitchPreview, LaneBranchSwitchResult, + LaneLinearIssue, MissionLaneRole, LaneStateSnapshotSummary, LaneStatus, @@ -96,6 +98,16 @@ type LaneBranchProfileRow = { last_checked_out_at: string | null; }; +type LaneLinearIssueRow = { + id: string; + project_id: string; + lane_id: string; + issue_id: string; + issue_json: string; + created_at: string; + updated_at: string; +}; + const DEFAULT_LANE_STATUS: LaneStatus = { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }; const LANE_LIST_CACHE_TTL_MS = 10_000; @@ -115,7 +127,8 @@ function cloneLaneSummary(summary: LaneSummary): LaneSummary { status: cloneLaneStatus(summary.status), parentStatus: summary.parentStatus ? cloneLaneStatus(summary.parentStatus) : null, tags: [...summary.tags], - activeBranchProfile: summary.activeBranchProfile ? { ...summary.activeBranchProfile } : null + activeBranchProfile: summary.activeBranchProfile ? { ...summary.activeBranchProfile } : null, + linearIssue: summary.linearIssue ? { ...summary.linearIssue, labels: [...summary.linearIssue.labels] } : null }; } @@ -167,6 +180,64 @@ function parseSummaryRecord(raw: string | null): Record | null } } +function parseLaneLinearIssue(raw: string | null): LaneLinearIssue | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const record = parsed as Record; + const id = typeof record.id === "string" ? record.id : ""; + const identifier = typeof record.identifier === "string" ? record.identifier : ""; + const title = typeof record.title === "string" ? record.title : ""; + const projectId = typeof record.projectId === "string" ? record.projectId : ""; + const projectSlug = typeof record.projectSlug === "string" ? record.projectSlug : ""; + const teamId = typeof record.teamId === "string" ? record.teamId : ""; + const teamKey = typeof record.teamKey === "string" ? record.teamKey : ""; + const stateId = typeof record.stateId === "string" ? record.stateId : ""; + const stateName = typeof record.stateName === "string" ? record.stateName : ""; + const stateType = typeof record.stateType === "string" ? record.stateType : ""; + const createdAt = typeof record.createdAt === "string" ? record.createdAt : ""; + const updatedAt = typeof record.updatedAt === "string" ? record.updatedAt : ""; + if (!id || !identifier || !title || !projectId || !projectSlug || !teamId || !teamKey || !stateId || !stateName || !stateType || !createdAt || !updatedAt) { + return null; + } + const priority = typeof record.priority === "number" && Number.isFinite(record.priority) ? record.priority : 0; + const priorityLabel = record.priorityLabel === "urgent" || record.priorityLabel === "high" || record.priorityLabel === "normal" || record.priorityLabel === "low" + ? record.priorityLabel + : "none"; + return { + id, + identifier, + title, + description: typeof record.description === "string" ? record.description : null, + url: typeof record.url === "string" ? record.url : null, + projectId, + projectSlug, + projectName: typeof record.projectName === "string" ? record.projectName : null, + teamId, + teamKey, + teamName: typeof record.teamName === "string" ? record.teamName : null, + stateId, + stateName, + stateType, + priority, + priorityLabel, + labels: Array.isArray(record.labels) ? record.labels.filter((entry): entry is string => typeof entry === "string") : [], + assigneeId: typeof record.assigneeId === "string" ? record.assigneeId : null, + assigneeName: typeof record.assigneeName === "string" ? record.assigneeName : null, + creatorId: typeof record.creatorId === "string" ? record.creatorId : null, + creatorName: typeof record.creatorName === "string" ? record.creatorName : null, + dueDate: typeof record.dueDate === "string" ? record.dueDate : null, + estimate: typeof record.estimate === "number" && Number.isFinite(record.estimate) ? record.estimate : null, + branchName: typeof record.branchName === "string" ? record.branchName : null, + createdAt, + updatedAt, + }; + } catch { + return null; + } +} + function toLaneSummary(args: { row: LaneRow; status: LaneStatus; @@ -174,8 +245,9 @@ function toLaneSummary(args: { childCount: number; stackDepth: number; activeBranchProfile?: LaneBranchProfile | null; + linearIssue?: LaneLinearIssue | null; }): LaneSummary { - const { row, status, parentStatus, childCount, stackDepth, activeBranchProfile } = args; + const { row, status, parentStatus, childCount, stackDepth, activeBranchProfile, linearIssue } = args; return { id: row.id, name: row.name, @@ -199,7 +271,8 @@ function toLaneSummary(args: { laneRole: row.lane_role, createdAt: row.created_at, archivedAt: row.archived_at, - activeBranchProfile: activeBranchProfile ?? null + activeBranchProfile: activeBranchProfile ?? null, + linearIssue: linearIssue ?? null }; } @@ -661,6 +734,25 @@ export function createLaneService({ const getLaneRow = (laneId: string) => db.get("select * from lanes where id = ? and project_id = ? limit 1", [laneId, projectId]); + const getLaneLinearIssue = (laneId: string): LaneLinearIssue | null => { + try { + const row = db.get( + ` + select * + from lane_linear_issues + where project_id = ? + and lane_id = ? + order by updated_at desc + limit 1 + `, + [projectId, laneId], + ); + return parseLaneLinearIssue(row?.issue_json ?? null); + } catch { + return null; + } + }; + const getAllLaneRows = (includeArchived = false) => db.all( includeArchived @@ -793,6 +885,125 @@ export function createLaneService({ return toLaneBranchProfile(profile); }; + const normalizeLaneLinearIssue = (issue: LaneLinearIssue, branchName: string): LaneLinearIssue => ({ + ...issue, + id: issue.id.trim(), + identifier: issue.identifier.trim(), + title: issue.title.trim(), + description: issue.description ?? null, + url: issue.url ?? null, + projectId: issue.projectId.trim(), + projectSlug: issue.projectSlug.trim(), + projectName: issue.projectName ?? null, + teamId: issue.teamId.trim(), + teamKey: issue.teamKey.trim(), + teamName: issue.teamName ?? null, + stateId: issue.stateId.trim(), + stateName: issue.stateName.trim(), + stateType: issue.stateType.trim(), + labels: issue.labels.map((entry) => entry.trim()).filter(Boolean).slice(0, 24), + assigneeId: issue.assigneeId ?? null, + assigneeName: issue.assigneeName ?? null, + creatorId: issue.creatorId ?? null, + creatorName: issue.creatorName ?? null, + dueDate: issue.dueDate ?? null, + estimate: issue.estimate ?? null, + branchName, + }); + + const upsertLaneLinearIssue = (laneId: string, issue: LaneLinearIssue, branchName: string): LaneLinearIssue => { + const normalized = normalizeLaneLinearIssue(issue, branchName); + if (!normalized.id || !normalized.identifier || !normalized.title) { + throw new Error("Linear issue attachment is missing required issue details."); + } + const now = new Date().toISOString(); + db.run("begin"); + try { + db.run( + ` + delete from lane_linear_issues + where project_id = ? + and lane_id = ? + `, + [projectId, laneId], + ); + db.run( + ` + insert into lane_linear_issues( + id, project_id, lane_id, issue_id, issue_json, created_at, updated_at + ) + values(?, ?, ?, ?, ?, ?, ?) + `, + [ + randomUUID(), + projectId, + laneId, + normalized.id, + JSON.stringify(normalized), + now, + now, + ], + ); + db.run("commit"); + } catch (err) { + try { db.run("rollback"); } catch { /* keep the original upsert error */ } + throw err; + } + return normalized; + }; + + const resolveCreateBranchRef = async (args: { + name: string; + laneId: string; + branchName?: string | null; + linearIssue?: LaneLinearIssue | null; + }): Promise => { + const suggested = args.branchName?.trim() + || (args.linearIssue ? linearIssueBranchName(args.linearIssue) : ""); + const isCustomBranch = suggested.length > 0; + const slug = slugify(args.name); + const fallback = `ade/${slug}-${args.laneId.slice(0, 8)}`; + const branchRef = suggested + ? sanitizeLinearIssueBranchName(suggested) + : fallback; + + const check = await runGit(["check-ref-format", "--branch", branchRef], { + cwd: projectRoot, + timeoutMs: 8_000, + }); + if (check.exitCode !== 0) { + throw new Error(`Generated branch name "${branchRef}" is not valid.`); + } + + const localExists = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branchRef}`], { + cwd: projectRoot, + timeoutMs: 8_000, + }).then((res) => res.exitCode === 0); + if (localExists) { + throw new Error(`Branch "${branchRef}" already exists locally.`); + } + + if (isCustomBranch) { + const remoteTrackingExists = await runGit(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branchRef}`], { + cwd: projectRoot, + timeoutMs: 8_000, + }).then((res) => res.exitCode === 0); + if (remoteTrackingExists) { + throw new Error(`Branch "origin/${branchRef}" already exists. Detach the Linear issue or choose a different issue.`); + } + + const remoteExists = await runGit(["ls-remote", "--heads", "origin", branchRef], { + cwd: projectRoot, + timeoutMs: 15_000, + }).then((res) => res.exitCode === 0 && res.stdout.trim().length > 0); + if (remoteExists) { + throw new Error(`Branch "origin/${branchRef}" already exists. Detach the Linear issue or choose a different issue.`); + } + } + + return branchRef; + }; + const ensureBranchProfileForRow = (row: LaneRow): LaneBranchProfile => upsertBranchProfileForRow(row); @@ -1280,7 +1491,8 @@ export function createLaneService({ parentStatus, childCount: childCountMap.get(row.id) ?? 0, stackDepth, - activeBranchProfile: ensureBranchProfileForRow(row) + activeBranchProfile: ensureBranchProfileForRow(row), + linearIssue: getLaneLinearIssue(row.id), }) ); if (includeStatus) { @@ -1312,12 +1524,19 @@ export function createLaneService({ folder?: string; missionId?: string | null; laneRole?: MissionLaneRole | null; + branchName?: string | null; + linearIssue?: LaneLinearIssue | null; }): Promise => { const laneId = randomUUID(); const now = new Date().toISOString(); const slug = slugify(args.name); const suffix = laneId.slice(0, 8); - const branchRef = `ade/${slug}-${suffix}`; + const branchRef = await resolveCreateBranchRef({ + name: args.name, + laneId, + branchName: args.branchName, + linearIssue: args.linearIssue, + }); const worktreePath = path.join(worktreesDir, `${slug}-${suffix}`); await runGitOrThrow(["worktree", "add", "-b", branchRef, worktreePath, args.startPoint], { @@ -1349,6 +1568,9 @@ export function createLaneService({ now ] ); + const linearIssue = args.linearIssue + ? upsertLaneLinearIssue(laneId, args.linearIssue, branchRef) + : null; invalidateLaneListCache(); // Best-effort initial push to establish upstream tracking @@ -1379,7 +1601,8 @@ export function createLaneService({ parentStatus, childCount: 0, stackDepth: computeStackDepth({ laneId: laneId, rowsById, memo: new Map() }), - activeBranchProfile: ensureBranchProfileForRow(row) + activeBranchProfile: ensureBranchProfileForRow(row), + linearIssue, }); }; @@ -1568,7 +1791,7 @@ export function createLaneService({ invalidateLaneListCache(); }, - async create({ name, description, parentLaneId, baseBranch }: CreateLaneArgs): Promise { + async create({ name, description, parentLaneId, baseBranch, branchName, linearIssue }: CreateLaneArgs): Promise { if (parentLaneId) { const parent = getLaneRow(parentLaneId); if (!parent) throw new Error(`Parent lane not found: ${parentLaneId}`); @@ -1615,7 +1838,9 @@ export function createLaneService({ description, baseRef: requestedBaseRef, startPoint: parentHeadSha, - parentLaneId: parent.lane_type === "primary" ? null : parent.id + parentLaneId: parent.lane_type === "primary" ? null : parent.id, + branchName, + linearIssue, }); } @@ -1632,7 +1857,9 @@ export function createLaneService({ description, baseRef: requestedBaseRef, startPoint, - parentLaneId: null + parentLaneId: null, + branchName, + linearIssue, }); }, @@ -1680,6 +1907,8 @@ export function createLaneService({ folder: args.folder, missionId: args.missionId ?? null, laneRole: args.laneRole ?? null, + branchName: args.branchName, + linearIssue: args.linearIssue ?? null, }); } @@ -1699,6 +1928,8 @@ export function createLaneService({ folder: args.folder, missionId: args.missionId ?? null, laneRole: args.laneRole ?? null, + branchName: args.branchName, + linearIssue: args.linearIssue ?? null, }); } @@ -1712,7 +1943,9 @@ export function createLaneService({ parentLaneId: parent.id, folder: args.folder, missionId: args.missionId ?? null, - laneRole: args.laneRole ?? null + laneRole: args.laneRole ?? null, + branchName: args.branchName, + linearIssue: args.linearIssue ?? null, }); }, @@ -3399,10 +3632,16 @@ export function createLaneService({ return row.worktree_path; }, - getLaneBaseAndBranch(laneId: string): { baseRef: string; branchRef: string; worktreePath: string; laneType: LaneType } { + getLaneBaseAndBranch(laneId: string): { baseRef: string; branchRef: string; worktreePath: string; laneType: LaneType; linearIssue: LaneLinearIssue | null } { const row = getLaneRow(laneId); if (!row) throw new Error(`Lane not found: ${laneId}`); - return { baseRef: row.base_ref, branchRef: row.branch_ref, worktreePath: row.worktree_path, laneType: row.lane_type }; + return { + baseRef: row.base_ref, + branchRef: row.branch_ref, + worktreePath: row.worktree_path, + laneType: row.lane_type, + linearIssue: getLaneLinearIssue(laneId), + }; }, updateBranchRef(laneId: string, branchRef: string): void { diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index e112dfa4e..942884656 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -793,6 +793,59 @@ describe("prService.createFromLane", () => { ); }); + it("adds a non-closing Linear reference when creating a PR from a linked lane", async () => { + const ghService = makeGithubService({ + apiRequest: vi.fn().mockRejectedValue(new Error("stop after payload capture")), + }); + const laneService = makeLaneService([ + makeFakeLane({ + linearIssue: { + id: "issue-1", + identifier: "ADE-123", + title: "Connect Linear PR linking", + description: null, + url: "https://linear.app/ade/issue/ADE-123/connect-linear-pr-linking", + projectId: "project-1", + projectSlug: "ade", + teamId: "team-1", + teamKey: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 0, + priorityLabel: "none", + labels: [], + assigneeId: null, + assigneeName: null, + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + }, + }), + ]); + + const { service } = buildService({ githubService: ghService, laneService }); + + await expect( + service.createFromLane({ + laneId: LANE_ID, + title: "My PR", + body: "description", + draft: false, + allowDirtyWorktree: true, + }), + ).rejects.toThrow('Failed to create pull request for "my-feature" → "main": stop after payload capture'); + + expect(ghService.apiRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + body: expect.objectContaining({ + title: "My PR", + body: "Refs ADE-123\n\ndescription", + }), + }), + ); + }); + it("blocks PR creation when the remote branch has newer commits", async () => { const ghService = makeGithubService({ apiRequest: vi.fn().mockRejectedValue(new Error("should not create")), diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index f0e9d9072..8f10b80ce 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -137,6 +137,10 @@ import { fetchRemoteTrackingBranch } from "../shared/queueRebase"; import { asNumber, asString, getErrorMessage, normalizeBranchName, nowIso, resolvePathWithinRoot } from "../shared/utils"; import { branchNameFromLaneRef, resolveStableLaneBaseBranch } from "../../../shared/laneBaseResolution"; import { normalizePrCreationStrategy, resolvePrRebaseMode } from "../../../shared/prStrategy"; +import { + buildLinearPrTitle, + ensureLinearPrReference, +} from "../../../shared/linearMagicWords"; type CreatePrFromLaneInternalArgs = CreatePrFromLaneArgs & { skipBranchPush?: boolean; @@ -2790,19 +2794,32 @@ export function createPrService({ branchRef: lane.branchRef, baseRef: baseRefForDiff, parentLaneId: lane.parentLaneId, + linearIssue: lane.linearIssue, commits, packBody, prTemplate: template }; const providerMode = projectConfigService.get().effective.providerMode ?? "guest"; - const defaultTitle = lane.name.replace(/[-_/]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim() || lane.name; + const defaultTitle = lane.linearIssue + ? buildLinearPrTitle(lane.linearIssue) + : lane.name.replace(/[-_/]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim() || lane.name; + const finalizeDraft = (draft: { title: string; body: string }): { title: string; body: string } => { + if (!lane.linearIssue) return draft; + const escapedIdentifier = lane.linearIssue.identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const hasExactIdentifier = new RegExp(`(^|[^A-Za-z0-9])${escapedIdentifier}([^A-Za-z0-9]|$)`, "i").test(draft.title); + return { + title: hasExactIdentifier ? draft.title : defaultTitle, + body: ensureLinearPrReference(draft.body, lane.linearIssue, args.closeLinearIssueOnMerge === true), + }; + }; if (providerMode !== "guest" && aiIntegrationService) { const prompt = [ "You are ADE's PR drafting assistant. Keep content factual and concise.", "Return JSON only with shape: {\"title\": string, \"body\": string}.", "The body must be GitHub-flavored markdown with sections: Summary, What Changed, Validation, Risks.", + "If Linear issue context is present, include the exact Linear issue identifier in the title and include a non-closing Linear reference in the body.", "", "PR Context JSON:", JSON.stringify(context, null, 2) @@ -2817,13 +2834,13 @@ export function createPrService({ ...(reasoningEffort ? { reasoningEffort } : {}) }); const parsed = parsePrDraftJson(draft.text); - if (parsed) return parsed; + if (parsed) return finalizeDraft(parsed); if (draft.text.trim().length) { - return { + return finalizeDraft({ title: defaultTitle, body: `${draft.text.trim()}\n` - }; + }); } } catch (error) { logger.warn("prs.draft.ai_failed", { @@ -2856,10 +2873,10 @@ export function createPrService({ lines.push(""); lines.push(template); } - return { + return finalizeDraft({ title: defaultTitle || lane.name, body: `${lines.join("\n")}\n` - }; + }); }; const createFromLane = async (args: CreatePrFromLaneInternalArgs): Promise => { @@ -2890,6 +2907,9 @@ export function createPrService({ if (!baseBranch) { throw new Error("Choose a target branch before creating the PR."); } + const prBody = lane.linearIssue + ? ensureLinearPrReference(args.body, lane.linearIssue, args.closeLinearIssueOnMerge === true) + : args.body; if (!args.skipBranchPush) { await pushLaneBranchForPr(lane, headBranch); @@ -2906,7 +2926,7 @@ export function createPrService({ title: args.title, head: headBranch, base: baseBranch, - body: args.body, + body: prBody, draft: Boolean(args.draft) } }); diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 2a054bd10..7e41548aa 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -781,6 +781,22 @@ function migrate(db: MigrationDb) { db.run("create index if not exists idx_lanes_project_mission on lanes(project_id, mission_id)"); db.run("create index if not exists idx_lanes_project_role on lanes(project_id, lane_role)"); + db.run(` + create table if not exists lane_linear_issues ( + id text primary key, + project_id text not null, + lane_id text not null, + issue_id text not null, + issue_json text not null, + created_at text not null, + updated_at text not null, + foreign key(project_id) references projects(id) on delete cascade, + foreign key(lane_id) references lanes(id) on delete cascade + ) + `); + db.run("create index if not exists idx_lane_linear_issues_lane on lane_linear_issues(project_id, lane_id)"); + db.run("create index if not exists idx_lane_linear_issues_issue on lane_linear_issues(project_id, issue_id)"); + db.run(` create table if not exists lane_branch_profiles ( id text primary key, diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 43424bca8..088a1a387 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -812,12 +812,14 @@ describe("createSyncRemoteCommandService", () => { title: "My PR", body: "Description", draft: true, + closeLinearIssueOnMerge: true, })); expect(prService.createFromLane).toHaveBeenCalledWith({ laneId: "lane-1", title: "My PR", body: "Description", draft: true, + closeLinearIssueOnMerge: true, }); }); diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index 004cc3854..c11e0f972 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -964,6 +964,7 @@ function parseCreatePrArgs(value: Record): CreatePrFromLaneArgs ...(asStringArray(value.labels).length ? { labels: asStringArray(value.labels) } : {}), ...(asStringArray(value.reviewers).length ? { reviewers: asStringArray(value.reviewers) } : {}), ...(typeof value.allowDirtyWorktree === "boolean" ? { allowDirtyWorktree: value.allowDirtyWorktree } : {}), + ...(typeof value.closeLinearIssueOnMerge === "boolean" ? { closeLinearIssueOnMerge: value.closeLinearIssueOnMerge } : {}), ...(strategy ? { strategy } : {}), }; } @@ -983,6 +984,7 @@ function parseDraftPrDescriptionArgs(value: Record): DraftPrDes ? { reasoningEffort: value.reasoningEffort == null ? null : asTrimmedString(value.reasoningEffort) ?? null } : {}), ...(asTrimmedString(value.baseBranch) ? { baseBranch: asTrimmedString(value.baseBranch)! } : {}), + ...(typeof value.closeLinearIssueOnMerge === "boolean" ? { closeLinearIssueOnMerge: value.closeLinearIssueOnMerge } : {}), }; } diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index fa19f986d..5ac55697d 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -208,6 +208,10 @@ import type { CtoOnboardingState, CtoSystemPromptPreview, CtoLinearProject, + CtoLinearQuickView, + CtoGetLinearIssuePickerDataResult, + CtoSearchLinearIssuesArgs, + CtoSearchLinearIssuesResult, CtoSetLinearOAuthClientArgs, CtoStartLinearOAuthResult, CtoGetLinearOAuthSessionArgs, @@ -2080,6 +2084,11 @@ declare global { identityOverride?: Record; }) => Promise; getLinearProjects: () => Promise; + getLinearQuickView: () => Promise; + getLinearIssuePickerData: () => Promise; + searchLinearIssues: ( + args?: CtoSearchLinearIssuesArgs, + ) => Promise; setLinearOAuthClient: ( args: CtoSetLinearOAuthClientArgs, ) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index cf1ff2976..e0bedaedc 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -110,6 +110,10 @@ import type { CtoOnboardingState, CtoSystemPromptPreview, CtoLinearProject, + CtoLinearQuickView, + CtoGetLinearIssuePickerDataResult, + CtoSearchLinearIssuesArgs, + CtoSearchLinearIssuesResult, CtoStartLinearOAuthResult, CtoGetLinearOAuthSessionArgs, CtoGetLinearOAuthSessionResult, @@ -3670,6 +3674,14 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.ctoPreviewSystemPrompt, args), getLinearProjects: async (): Promise => ipcRenderer.invoke(IPC.ctoGetLinearProjects), + getLinearQuickView: async (): Promise => + ipcRenderer.invoke(IPC.ctoGetLinearQuickView), + getLinearIssuePickerData: async (): Promise => + ipcRenderer.invoke(IPC.ctoGetLinearIssuePickerData), + searchLinearIssues: async ( + args: CtoSearchLinearIssuesArgs = {}, + ): Promise => + ipcRenderer.invoke(IPC.ctoSearchLinearIssues, args), setLinearOAuthClient: async ( args: CtoSetLinearOAuthClientArgs, ): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index c59751dfb..df1fffbfe 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4261,6 +4261,94 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }), onLinearWorkflowEvent: noop, getLinearProjects: resolvedArg([]), + getLinearQuickView: resolvedArg({ + connection: { + tokenStored: true, + connected: true, + viewerId: "mock-linear-user", + viewerName: "Mock Linear User", + checkedAt: now, + authMode: "manual", + oauthAvailable: true, + tokenExpiresAt: null, + message: null, + }, + organization: { + id: "mock-linear-org", + name: "ADE", + urlKey: "ade", + logoUrl: null, + gitBranchFormat: null, + createdIssueCount: 128, + roadmapEnabled: true, + customersEnabled: false, + releasesEnabled: true, + }, + viewer: { + id: "mock-linear-user", + name: "Mock Linear User", + displayName: "Mock Linear User", + email: "mock@example.com", + avatarUrl: null, + admin: true, + guest: false, + url: null, + }, + projects: [ + { + id: "mock-linear-project", + name: "Desktop polish", + slug: "desktop-polish", + teamName: "ADE", + teamKey: "ADE", + url: "https://linear.app/ade/project/desktop-polish", + color: "#5E6AD2", + icon: null, + description: "Mock Linear project", + statusName: "Started", + statusType: "started", + health: "onTrack", + progress: 0.42, + scope: 21, + priority: 2, + priorityLabel: "High", + issueCount: 9, + completedIssueCount: 4, + startDate: null, + targetDate: null, + leadName: "Mock Linear User", + teamKeys: ["ADE"], + }, + ], + teams: [ + { + id: "mock-linear-team", + key: "ADE", + name: "ADE", + displayName: "ADE", + color: "#5E6AD2", + issueCount: 32, + cyclesEnabled: true, + private: false, + }, + ], + assignedIssues: [], + recentIssues: [], + fetchedAt: now, + sdk: { + packageName: "@linear/sdk", + surfaces: ["viewer", "organization", "projects", "teams", "assignedIssues", "issues"], + }, + }), + getLinearIssuePickerData: resolvedArg({ + projects: [], + users: [], + states: [], + }), + searchLinearIssues: resolvedArg({ + issues: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }), getLinearConnectionStatus: resolvedArg({ tokenStored: false, connected: false, diff --git a/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx new file mode 100644 index 000000000..c79918ce4 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/LinearQuickViewButton.tsx @@ -0,0 +1,853 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + ArrowSquareOut, + CaretDown, + CircleNotch, + GitBranch, + MagnifyingGlass, + Plus, + Warning, + X, +} from "@phosphor-icons/react"; + +import type { + CtoGetLinearIssuePickerDataResult, + CtoLinearProject, + CtoLinearQuickView, + CtoLinearQuickViewProject, + LaneLinearIssue, + NormalizedLinearIssue, +} from "../../../shared/types"; +import { linearIssueBranchName, linearIssueLaneName } from "../../../shared/linearIssueBranch"; +import { useAppStore } from "../../state/appStore"; +import { cn } from "../ui/cn"; +import { Button } from "../ui/Button"; +import { + issueProjectLabel, + issueUpdatedLabel, + LinearIssueRow, + linearPriorityLabel, + toLaneLinearIssue, +} from "../lanes/LinearIssuePicker"; +import { LinearMark, LinearPriorityIcon, LinearStateIcon, LINEAR_BRAND } from "../lanes/linearBrand"; + +type PopoverPosition = { top: number; right: number } | null; +type IssueSort = "updated_desc" | "created_desc" | "priority" | "due_soon" | "identifier_asc"; + +type LinearQuickFilters = { + projectId: string; + statePreset: "active" | "all" | string; + assigneeId: string; + priority: string; + query: string; + sort: IssueSort; +}; + +const ACTIVE_LINEAR_STATE_TYPES = ["backlog", "unstarted", "started"]; +const FILTER_STORAGE_PREFIX = "ade.linear.quickView.filters.v1:"; + +const DEFAULT_FILTERS: LinearQuickFilters = { + projectId: "", + statePreset: "all", + assigneeId: "", + priority: "", + query: "", + sort: "updated_desc", +}; + +const STATE_LABELS: Record = { + active: "Active", + all: "All states", + backlog: "Backlog", + unstarted: "Todo", + started: "In progress", + completed: "Done", + canceled: "Canceled", + triage: "Triage", +}; + +const PRIORITY_OPTIONS = [ + { value: "", label: "Any priority" }, + { value: "1", label: "Urgent" }, + { value: "2", label: "High" }, + { value: "3", label: "Medium" }, + { value: "4", label: "Low" }, + { value: "0", label: "No priority" }, +] as const; + +const SORT_OPTIONS: ReadonlyArray<{ value: IssueSort; label: string }> = [ + { value: "updated_desc", label: "Recently updated" }, + { value: "created_desc", label: "Recently created" }, + { value: "priority", label: "Priority" }, + { value: "due_soon", label: "Due soon" }, + { value: "identifier_asc", label: "Issue key" }, +]; + +function storageKey(projectRoot: string | null | undefined): string | null { + const root = projectRoot?.trim(); + return root ? `${FILTER_STORAGE_PREFIX}${root}` : null; +} + +function safeLoadFilters(projectRoot: string | null | undefined): LinearQuickFilters { + const key = storageKey(projectRoot); + if (!key || typeof window === "undefined") return DEFAULT_FILTERS; + try { + const parsed = JSON.parse(window.localStorage.getItem(key) ?? "null") as Partial | null; + if (!parsed || typeof parsed !== "object") return DEFAULT_FILTERS; + return { + ...DEFAULT_FILTERS, + projectId: typeof parsed.projectId === "string" ? parsed.projectId : "", + statePreset: typeof parsed.statePreset === "string" ? parsed.statePreset : DEFAULT_FILTERS.statePreset, + assigneeId: typeof parsed.assigneeId === "string" ? parsed.assigneeId : "", + priority: typeof parsed.priority === "string" ? parsed.priority : "", + query: typeof parsed.query === "string" ? parsed.query : "", + sort: SORT_OPTIONS.some((option) => option.value === parsed.sort) ? parsed.sort as IssueSort : DEFAULT_FILTERS.sort, + }; + } catch { + return DEFAULT_FILTERS; + } +} + +function safeSaveFilters(projectRoot: string | null | undefined, filters: LinearQuickFilters): void { + const key = storageKey(projectRoot); + if (!key || typeof window === "undefined") return; + try { + if ( + filters.projectId === DEFAULT_FILTERS.projectId && + filters.statePreset === DEFAULT_FILTERS.statePreset && + filters.assigneeId === DEFAULT_FILTERS.assigneeId && + filters.priority === DEFAULT_FILTERS.priority && + filters.query === DEFAULT_FILTERS.query && + filters.sort === DEFAULT_FILTERS.sort + ) { + window.localStorage.removeItem(key); + return; + } + window.localStorage.setItem(key, JSON.stringify(filters)); + } catch { + // Best effort only; losing this preference should never break the picker. + } +} + +function issueListKey(issue: NormalizedLinearIssue): string { + return `${issue.id}:${issue.updatedAt}`; +} + +function mergeIssuePages(current: NormalizedLinearIssue[], next: NormalizedLinearIssue[]): NormalizedLinearIssue[] { + const map = new Map(); + for (const issue of [...current, ...next]) map.set(issue.id, issue); + return [...map.values()]; +} + +function toTimestamp(value: string | null | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function sortedIssues(issues: NormalizedLinearIssue[], sort: IssueSort): NormalizedLinearIssue[] { + const out = [...issues]; + out.sort((left, right) => { + if (sort === "created_desc") return toTimestamp(right.createdAt) - toTimestamp(left.createdAt); + if (sort === "priority") { + const leftRank = left.priority === 0 ? 99 : left.priority; + const rightRank = right.priority === 0 ? 99 : right.priority; + return leftRank - rightRank || toTimestamp(right.updatedAt) - toTimestamp(left.updatedAt); + } + if (sort === "due_soon") { + const leftDue = left.dueDate ? toTimestamp(left.dueDate) : Number.POSITIVE_INFINITY; + const rightDue = right.dueDate ? toTimestamp(right.dueDate) : Number.POSITIVE_INFINITY; + return leftDue - rightDue || toTimestamp(right.updatedAt) - toTimestamp(left.updatedAt); + } + if (sort === "identifier_asc") return left.identifier.localeCompare(right.identifier, undefined, { numeric: true }); + return toTimestamp(right.updatedAt) - toTimestamp(left.updatedAt); + }); + return out; +} + +function formatDate(value: string | null | undefined): string { + if (!value) return "n/a"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }).format(date); +} + +function stateTypesForPreset(preset: string): string[] { + if (preset === "all") return []; + if (preset === "active") return ACTIVE_LINEAR_STATE_TYPES; + return preset ? [preset] : []; +} + +function openLinearUrl(url: string | null | undefined): void { + if (!url) return; + void window.ade.app.openExternal(url); +} + +function toLaneIssue(issue: NormalizedLinearIssue | LaneLinearIssue): LaneLinearIssue { + return "raw" in issue ? toLaneLinearIssue(issue) : issue; +} + +export function LinearQuickViewButton() { + const project = useAppStore((s) => s.project); + const refreshLanes = useAppStore((s) => s.refreshLanes); + const selectLane = useAppStore((s) => s.selectLane); + const [visible, setVisible] = useState(false); + const [open, setOpen] = useState(false); + const [position, setPosition] = useState(null); + const [quickView, setQuickView] = useState(null); + const [catalog, setCatalog] = useState({ projects: [], users: [], states: [] }); + const [filters, setFilters] = useState(() => safeLoadFilters(project?.rootPath)); + const [issues, setIssues] = useState([]); + const [pageInfo, setPageInfo] = useState<{ hasNextPage: boolean; endCursor: string | null }>({ hasNextPage: false, endCursor: null }); + const pageInfoRef = useRef(pageInfo); + const [loading, setLoading] = useState(false); + const [loadingCatalog, setLoadingCatalog] = useState(false); + const [loadingIssues, setLoadingIssues] = useState(false); + const [creatingIssueId, setCreatingIssueId] = useState(null); + const [selectedIssueId, setSelectedIssueId] = useState(null); + const [error, setError] = useState(null); + const [createdLaneName, setCreatedLaneName] = useState(null); + const buttonRef = useRef(null); + const popoverRef = useRef(null); + const requestIdRef = useRef(0); + const searchRequestIdRef = useRef(0); + + useEffect(() => { + pageInfoRef.current = pageInfo; + }, [pageInfo]); + + useEffect(() => { + setFilters(safeLoadFilters(project?.rootPath)); + setIssues([]); + setPageInfo({ hasNextPage: false, endCursor: null }); + }, [project?.rootPath]); + + useEffect(() => { + let cancelled = false; + setVisible(false); + setQuickView(null); + setOpen(false); + if (!project?.rootPath || !window.ade.cto?.getLinearConnectionStatus) return; + void window.ade.cto.getLinearConnectionStatus() + .then((status) => { + if (!cancelled) setVisible(status.connected === true); + }) + .catch(() => { + if (!cancelled) setVisible(false); + }); + return () => { + cancelled = true; + }; + }, [project?.rootPath]); + + const loadQuickView = useCallback((force = false) => { + if (!window.ade.cto?.getLinearQuickView) return; + if (!force && quickView) return; + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + setLoading(true); + setError(null); + void window.ade.cto.getLinearQuickView() + .then((data) => { + if (requestIdRef.current !== requestId) return; + setQuickView(data); + setVisible(data.connection.connected === true); + }) + .catch((err) => { + if (requestIdRef.current !== requestId) return; + setError(err instanceof Error ? err.message : "Unable to load Linear."); + }) + .finally(() => { + if (requestIdRef.current === requestId) setLoading(false); + }); + }, [quickView]); + + const loadCatalog = useCallback(() => { + const cto = window.ade.cto; + if (!cto?.getLinearIssuePickerData) return; + setLoadingCatalog(true); + void cto.getLinearIssuePickerData() + .then((data) => setCatalog(data)) + .catch((err) => setError(err instanceof Error ? err.message : "Unable to load Linear filters.")) + .finally(() => setLoadingCatalog(false)); + }, []); + + const searchIssues = useCallback((append: boolean) => { + const cto = window.ade.cto; + if (!cto?.searchLinearIssues) return; + const requestId = searchRequestIdRef.current + 1; + searchRequestIdRef.current = requestId; + setLoadingIssues(true); + setError(null); + void cto.searchLinearIssues({ + projectId: filters.projectId || null, + stateTypes: stateTypesForPreset(filters.statePreset), + assigneeId: filters.assigneeId || null, + priority: filters.priority ? Number(filters.priority) : null, + query: filters.query.trim() || null, + first: 50, + after: append ? pageInfoRef.current.endCursor : null, + includeArchived: false, + }) + .then((result) => { + if (searchRequestIdRef.current !== requestId) return; + setIssues((current) => append ? mergeIssuePages(current, result.issues) : result.issues); + setPageInfo(result.pageInfo); + }) + .catch((err) => { + if (searchRequestIdRef.current !== requestId) return; + setError(err instanceof Error ? err.message : "Unable to search Linear issues."); + }) + .finally(() => { + if (searchRequestIdRef.current === requestId) setLoadingIssues(false); + }); + }, [filters]); + + useEffect(() => { + if (!open) return; + const timer = window.setTimeout(() => searchIssues(false), 220); + return () => window.clearTimeout(timer); + }, [filters, open, searchIssues]); + + const updateFilters = useCallback((patch: Partial) => { + const next = { ...filters, ...patch }; + setFilters(next); + safeSaveFilters(project?.rootPath, next); + }, [filters, project?.rootPath]); + + const resetFilters = useCallback(() => { + setFilters(DEFAULT_FILTERS); + safeSaveFilters(project?.rootPath, DEFAULT_FILTERS); + setIssues([]); + setPageInfo({ hasNextPage: false, endCursor: null }); + }, [project?.rootPath]); + + const openAtButton = useCallback(() => { + const el = buttonRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setPosition({ top: rect.bottom + 7, right: Math.max(8, window.innerWidth - rect.right) }); + setOpen(true); + loadQuickView(false); + loadCatalog(); + }, [loadCatalog, loadQuickView]); + + const close = useCallback(() => { + setOpen(false); + setPosition(null); + }, []); + + useEffect(() => { + if (!open) return; + const onDown = (event: MouseEvent) => { + const target = event.target as Node | null; + if (!target) return; + if (popoverRef.current?.contains(target)) return; + if (buttonRef.current?.contains(target)) return; + close(); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + close(); + } + }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey); + }; + }, [close, open]); + + const displayIssues = useMemo( + () => sortedIssues(issues, filters.sort), + [filters.sort, issues], + ); + + useEffect(() => { + if (selectedIssueId && displayIssues.some((issue) => issue.id === selectedIssueId)) return; + setSelectedIssueId(displayIssues[0]?.id ?? null); + }, [displayIssues, selectedIssueId]); + + const selectedIssue = displayIssues.find((issue) => issue.id === selectedIssueId) ?? displayIssues[0] ?? null; + + const stateOptions = useMemo(() => { + const seen = new Set(); + const dynamic = catalog.states + .filter((state) => { + if (seen.has(state.type)) return false; + seen.add(state.type); + return true; + }) + .sort((left, right) => left.type.localeCompare(right.type)) + .map((state) => ({ value: state.type, label: STATE_LABELS[state.type] ?? state.type })); + const options = [ + { value: "all", label: "All states" }, + { value: "active", label: "Active" }, + ...dynamic, + ]; + if (filters.statePreset && !options.some((option) => option.value === filters.statePreset)) { + options.push({ value: filters.statePreset, label: STATE_LABELS[filters.statePreset] ?? filters.statePreset }); + } + return options; + }, [catalog.states, filters.statePreset]); + + const assigneeOptions = useMemo( + () => [ + { value: "", label: "Anyone" }, + ...catalog.users.map((user) => ({ value: user.id, label: user.displayName ?? user.name })), + ], + [catalog.users], + ); + + const projectFilters = useMemo(() => { + const quickProjects = new Map(); + for (const projectEntry of quickView?.projects ?? []) quickProjects.set(projectEntry.id, projectEntry); + return catalog.projects.map((projectEntry) => ({ + ...projectEntry, + quick: quickProjects.get(projectEntry.id) ?? null, + })); + }, [catalog.projects, quickView?.projects]); + + const createLaneForIssue = useCallback(async (issue: NormalizedLinearIssue | LaneLinearIssue) => { + const laneIssue = toLaneIssue(issue); + const name = linearIssueLaneName(laneIssue); + const branchName = linearIssueBranchName(laneIssue); + setCreatingIssueId(laneIssue.id); + setError(null); + setCreatedLaneName(null); + try { + const lane = await window.ade.lanes.create({ + name, + branchName, + linearIssue: { ...laneIssue, branchName }, + }); + await refreshLanes({ includeStatus: false }).catch(() => undefined); + selectLane(lane.id); + setCreatedLaneName(lane.name); + close(); + window.location.hash = `#/lanes?laneId=${encodeURIComponent(lane.id)}&focus=single`; + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to create lane for Linear issue."); + } finally { + setCreatingIssueId(null); + } + }, [close, refreshLanes, selectLane]); + + if (!visible) return null; + + return ( + <> + + + {open && position ? createPortal( +
+
+
+ + + +
+
+ {quickView?.organization?.name ?? "Linear"} +
+
+ {quickView?.viewer?.displayName ?? quickView?.connection.viewerName ?? "Connected"} + {quickView?.organization?.urlKey ? ` · ${quickView.organization.urlKey}` : ""} +
+
+
+
+ + +
+
+ + {error ? ( +
+ + {error} +
+ ) : null} + + {createdLaneName ? ( +
+ Created lane {createdLaneName}. +
+ ) : null} + +
+ + +
+
+
+ + updateFilters({ query: event.target.value })} + placeholder="Search all Linear issues" + className="h-9 w-full rounded-lg border border-white/[0.07] bg-black/20 pl-8 pr-3 text-[12px] text-fg outline-none transition-colors placeholder:text-muted-fg/40 focus:border-white/18" + /> +
+
+ updateFilters({ statePreset: value })} + /> + updateFilters({ assigneeId: value })} + /> + updateFilters({ priority: value })} + /> + updateFilters({ sort: value as IssueSort })} + /> +
+
+ +
+
+ Issues +
+
+ {filters.projectId + ? projectFilters.find((projectEntry) => projectEntry.id === filters.projectId)?.name ?? "Project issues" + : "All issues"} + {loadingIssues ? : null} +
+
+ +
+ {loading && !quickView ? ( +
+ +
+ ) : displayIssues.length > 0 ? ( + <> + {displayIssues.map((issue) => ( + setSelectedIssueId(issue.id)} + /> + ))} + {pageInfo.hasNextPage ? ( + + ) : null} + + ) : ( +
+ No issues match these filters. +
+ )} +
+
+ + +
+
, + document.body, + ) : null} + + ); +} + +function ProjectFilterButton({ + project, + active, + onClick, +}: { + project: CtoLinearProject & { quick: CtoLinearQuickViewProject | null }; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function FilterSelect({ + label, + value, + options, + onChange, +}: { + label: string; + value: string; + options: ReadonlyArray<{ value: string; label: string }>; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function IssueDetails({ + issue, + creating, + onCreateLane, +}: { + issue: NormalizedLinearIssue | null; + creating: boolean; + onCreateLane: (issue: NormalizedLinearIssue) => void | Promise; +}) { + if (!issue) { + return ( + + ); + } + + const branchName = linearIssueBranchName(issue); + return ( + + ); +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 2cf12252f..3f841ba3c 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -263,6 +263,123 @@ describe("TopBar", () => { expect(getStatus).toHaveBeenCalledTimes(2); }); + it("opens Linear quick view and creates a linked lane from an issue", async () => { + const issue = { + id: "issue-1", + identifier: "ADE-123", + title: "Add Linear quick view", + description: "Show Linear in the app chrome.", + url: "https://linear.app/ade/issue/ADE-123/add-linear-quick-view", + projectId: "project-1", + projectSlug: "desktop", + projectName: "Desktop", + teamId: "team-1", + teamKey: "ADE", + teamName: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 2, + priorityLabel: "high", + labels: [], + metadataTags: [], + assigneeId: "user-1", + assigneeName: "Arul", + creatorId: "user-1", + creatorName: "Arul", + blockerIssueIds: [], + hasOpenBlockers: false, + dueDate: null, + estimate: 3, + archivedAt: null, + completedAt: null, + canceledAt: null, + startedAt: null, + createdAt: "2026-04-22T00:00:00.000Z", + updatedAt: "2026-04-22T01:00:00.000Z", + raw: {}, + }; + const createLane = vi.fn(async () => ({ + id: "lane-linear", + name: "ADE-123 Add Linear quick view", + })); + globalThis.window.ade.cto = { + getLinearConnectionStatus: vi.fn(async () => ({ + tokenStored: true, + connected: true, + viewerId: "user-1", + viewerName: "Arul", + checkedAt: "2026-04-22T01:00:00.000Z", + authMode: "manual", + oauthAvailable: true, + tokenExpiresAt: null, + message: null, + })), + getLinearQuickView: vi.fn(async () => ({ + connection: { + tokenStored: true, + connected: true, + viewerId: "user-1", + viewerName: "Arul", + checkedAt: "2026-04-22T01:00:00.000Z", + authMode: "manual", + oauthAvailable: true, + tokenExpiresAt: null, + message: null, + }, + organization: { + id: "org-1", + name: "ADE", + urlKey: "ade", + logoUrl: null, + gitBranchFormat: null, + createdIssueCount: 40, + roadmapEnabled: true, + customersEnabled: false, + releasesEnabled: true, + }, + viewer: null, + projects: [], + teams: [], + assignedIssues: [issue], + recentIssues: [], + fetchedAt: "2026-04-22T01:00:00.000Z", + sdk: { packageName: "@linear/sdk", surfaces: ["viewer", "issues"] }, + })), + getLinearIssuePickerData: vi.fn(async () => ({ + projects: [{ id: "project-1", name: "Desktop", slug: "desktop", teamName: "ADE", teamKey: "ADE" }], + users: [{ id: "user-1", name: "arul", displayName: "Arul", email: null, active: true }], + states: [{ id: "state-1", name: "In Progress", type: "started", teamId: "team-1", teamKey: "ADE" }], + })), + searchLinearIssues: vi.fn(async () => ({ + issues: [issue], + pageInfo: { hasNextPage: false, endCursor: null }, + })), + } as any; + globalThis.window.ade.lanes.create = createLane as any; + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /linear quick view/i })); + + await waitFor(() => { + expect(screen.getAllByText("Add Linear quick view").length).toBeGreaterThan(0); + }); + + fireEvent.click(screen.getByRole("button", { name: /create lane/i })); + + await waitFor(() => { + expect(createLane).toHaveBeenCalledWith(expect.objectContaining({ + name: "ADE-123 Add Linear quick view", + branchName: "ade-123-add-linear-quick-view", + linearIssue: expect.objectContaining({ + identifier: "ADE-123", + branchName: "ade-123-add-linear-quick-view", + }), + })); + }); + }); + it("shows project icon replacement errors", async () => { globalThis.window.ade.project.chooseIcon = vi.fn(async () => { throw new Error("Failed to set project icon: Project icon must be 10 MB or smaller."); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 0bcc41378..fb1578ce4 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -18,6 +18,7 @@ import type { ProcessRuntime, ProjectIcon, RecentProjectSummary, SyncRoleSnapsho import { AutoUpdateControl } from "./AutoUpdateControl"; import { FeedbackReporterModal } from "./FeedbackReporterModal"; import { HelpMenu } from "../onboarding/HelpMenu"; +import { LinearQuickViewButton } from "./LinearQuickViewButton"; import { PublishToGitHubDialog } from "../projects/PublishToGitHubDialog"; import { SyncDevicesSection } from "../settings/SyncDevicesSection"; @@ -1083,6 +1084,8 @@ export function TopBar() { ) : null} + + {syncSnapshot && syncLabel ? ( + + + + ) : null} {attachmentPickerOpen ? (
@@ -2952,6 +3082,38 @@ export function AgentChatComposer({ + + +
); } @@ -1987,8 +2002,13 @@ function renderEvent(
); })()} - {event.attachments?.length ? ( - + {event.attachments?.length || event.contextAttachments?.length ? ( + ) : null} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts index 61b4747a5..2501336bb 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.ts @@ -138,6 +138,17 @@ describe("parallel launch helpers", () => { expect(result.sendText).toBe("Please review the attached files."); }); + it("uses an issue-context prompt for context-only parallel launches", () => { + const result = buildParallelLaunchPrompt({ + text: "", + attachmentCount: 0, + contextAttachmentCount: 1, + }); + + expect(result.displayText).toBe("Use the attached issue context."); + expect(result.sendText).toBe("Use the attached issue context."); + }); + it("force-cleans transient lanes and refreshes lane state after rollback", async () => { const deleteLane = vi.fn() .mockResolvedValueOnce(undefined) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index ec45730fd..27a55180e 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -14,6 +14,7 @@ import { type AgentChatDroidPermissionMode, type AgentChatExecutionMode, type AgentChatEventEnvelope, + type AgentChatContextAttachment, type AgentChatFileRef, type AgentChatInteractionMode, type AiProviderConnectionStatus, @@ -32,12 +33,18 @@ import { type AppControlContextItem, type IosElementContextItem, type IosSimulatorDrawerMode, + type LaneLinearIssue, type AiSettingsStatus, type MacosVmContextItem, type MacosVmStatus, type TerminalSessionDetail, type TerminalToolType, } from "../../../shared/types"; +import { + makeLinearIssueContextAttachment, + mergeChatContextAttachments, + removeChatContextAttachment, +} from "../../../shared/chatContextAttachments"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; import { @@ -1373,6 +1380,7 @@ export function parallelLaneModelSuffix(descriptor: ModelDescriptor | null | und export function buildParallelLaunchPrompt(args: { text: string; attachmentCount: number; + contextAttachmentCount?: number; }): { sendText: string; displayText: string } { const trimmed = args.text.trim(); let displayText = ""; @@ -1380,6 +1388,8 @@ export function buildParallelLaunchPrompt(args: { displayText = trimmed; } else if (args.attachmentCount > 0) { displayText = DEFAULT_PARALLEL_ATTACHMENT_REQUEST; + } else if ((args.contextAttachmentCount ?? 0) > 0) { + displayText = "Use the attached issue context."; } if (!displayText.length) { return { sendText: "", displayText: "" }; @@ -1609,6 +1619,8 @@ export function AgentChatPane({ isTileActive = true, isTileVisible = isTileActive, shouldAutofocusComposer = false, + initialLinearIssueContext = null, + onInitialLinearIssueContextConsumed, onSessionCreated, availableLanes, onLaneChange, @@ -1636,6 +1648,8 @@ export function AgentChatPane({ /** Visible grid tiles hydrate transcripts even when they are not the focused tile. */ isTileVisible?: boolean; shouldAutofocusComposer?: boolean; + initialLinearIssueContext?: LaneLinearIssue | null; + onInitialLinearIssueContextConsumed?: () => void; onSessionCreated?: (session: AgentChatSession) => void | Promise; /** Available lanes for the lane selector in empty state (full `LaneSummary` includes `branchRef` for branch sublines in the menu). */ availableLanes?: Array<{ id: string; name: string; color?: string | null; branchRef?: string | null }>; @@ -1666,6 +1680,10 @@ export function AgentChatPane({ if (!laneId) return null; return s.lanes.find((l) => l.id === laneId)?.color ?? null; }); + const pinnedLinearIssue = useAppStore((s) => { + if (!laneId) return null; + return s.lanes.find((l) => l.id === laneId)?.linearIssue ?? null; + }); const lockedSingleSessionMode = Boolean(lockSessionId && hideSessionTabs); const forceDraft = forceDraftMode || forceNewSession; const preferDraftStart = !lockSessionId && !initialSessionId && !forceNewSession; @@ -1740,6 +1758,7 @@ export function AgentChatPane({ : null, ); const [attachments, setAttachments] = useState([]); + const [contextAttachments, setContextAttachments] = useState([]); const [sdkSlashCommands, setSdkSlashCommands] = useState([]); const [sendOnEnter, setSendOnEnter] = useState(true); const [draft, setDraft] = useState(""); @@ -3471,6 +3490,7 @@ export function AgentChatPane({ useEffect(() => { setAttachments([]); + setContextAttachments([]); setPromptSuggestion(null); setHandoffOpen(false); setHandoffBusy(false); @@ -3959,6 +3979,29 @@ export function AgentChatPane({ setBuiltInBrowserContextItems((prev) => prev.filter((entry) => getBuiltInBrowserContextAttachmentPath(entry) !== attachmentPath)); }, []); + const addContextAttachment = useCallback((attachment: AgentChatContextAttachment) => { + setContextAttachments((prev) => mergeChatContextAttachments(prev, [attachment])); + }, []); + + const removeContextAttachment = useCallback((key: string) => { + setContextAttachments((prev) => removeChatContextAttachment(prev, key)); + }, []); + + const consumedInitialLinearIssueContextRef = useRef(null); + useEffect(() => { + if (!initialLinearIssueContext) { + consumedInitialLinearIssueContextRef.current = null; + return; + } + const key = initialLinearIssueContext.id; + if (consumedInitialLinearIssueContextRef.current === key) return; + consumedInitialLinearIssueContextRef.current = key; + setContextAttachments((prev) => mergeChatContextAttachments(prev, [ + makeLinearIssueContextAttachment(initialLinearIssueContext, "lane_link"), + ])); + onInitialLinearIssueContextConsumed?.(); + }, [initialLinearIssueContext, onInitialLinearIssueContextConsumed]); + const currentNativeControls = useMemo(() => ({ interactionMode, claudePermissionMode, @@ -4309,7 +4352,7 @@ export function AgentChatPane({ if (isParallelLaunch) { const text = draft.trim(); - if ((!text.length && attachments.length === 0) || !laneId || !projectRoot) return; + if ((!text.length && attachments.length === 0 && contextAttachments.length === 0) || !laneId || !projectRoot) return; if (parallelModelSlots.length < 2) { setError("Add at least two models for a parallel launch."); return; @@ -4331,6 +4374,7 @@ export function AgentChatPane({ const draftSnapshot = draft; const attachmentsSnapshot = [...attachments]; + const contextAttachmentsSnapshot = [...contextAttachments]; submitInFlightRef.current = true; setParallelLaunchBusy(true); setParallelLaunchStatus("Naming lanes…"); @@ -4340,13 +4384,15 @@ export function AgentChatPane({ const sessionByLane = new Map(); try { let namingSeed = text; - if (!text.length && attachmentsSnapshot.length) { + if (!text.length && (attachmentsSnapshot.length || contextAttachmentsSnapshot.length)) { const imageCount = attachmentsSnapshot.filter((a) => a.type === "image").length; const fileCount = attachmentsSnapshot.filter((a) => a.type === "file").length; + const issueCount = contextAttachmentsSnapshot.filter((a) => a.type === "linear_issue").length; namingSeed = [ "Parallel attachment task", imageCount ? `${imageCount} image${imageCount === 1 ? "" : "s"}` : null, fileCount ? `${fileCount} file${fileCount === 1 ? "" : "s"}` : null, + issueCount ? `${issueCount} issue${issueCount === 1 ? "" : "s"}` : null, ].filter(Boolean).join(" · "); } const baseName = await window.ade.agentChat.suggestLaneName({ @@ -4388,6 +4434,7 @@ export function AgentChatPane({ const { sendText, displayText: displayForSend } = buildParallelLaunchPrompt({ text, attachmentCount: attachmentsSnapshot.length, + contextAttachmentCount: contextAttachmentsSnapshot.length, }); setParallelLaunchStatus("Sending prompt to each lane…"); @@ -4410,6 +4457,7 @@ export function AgentChatPane({ text: sendText, displayText: displayForSend, attachments: attachmentsSnapshot, + contextAttachments: contextAttachmentsSnapshot, reasoningEffort: slot.reasoningEffort, executionMode: slot.executionMode, interactionMode: provider === "claude" ? slot.interactionMode : null, @@ -4422,6 +4470,7 @@ export function AgentChatPane({ sessionId, text: sendText, ...(attachmentsSnapshot.length ? { attachments: attachmentsSnapshot } : {}), + ...(contextAttachmentsSnapshot.length ? { contextAttachments: contextAttachmentsSnapshot } : {}), }); } else { throw sendError; @@ -4457,6 +4506,7 @@ export function AgentChatPane({ setDraft(""); setAttachments([]); + setContextAttachments([]); setParallelChatMode(false); setParallelModelSlots([]); setParallelConfiguringIndex(null); @@ -4488,6 +4538,7 @@ export function AgentChatPane({ } setDraft((current) => (current.trim().length ? current : draftSnapshot)); setAttachments((current) => (current.length ? current : attachmentsSnapshot)); + setContextAttachments((current) => (current.length ? current : contextAttachmentsSnapshot)); setError(formatParallelLaunchFailureMessage({ launchError: message, cleanupIssues, @@ -4506,6 +4557,7 @@ export function AgentChatPane({ const appControlContextSnapshot = [...appControlContextItems]; const builtInBrowserContextSnapshot = [...builtInBrowserContextItems]; const macosVmContextSnapshot = [...macosVmContextItems]; + const contextAttachmentsSnapshot = [...contextAttachments]; const iosContextPrefix = formatIosElementContextForPrompt(iosContextSnapshot); const appControlContextPrefix = formatAppControlContextForPrompt(appControlContextSnapshot); const builtInBrowserContextPrefix = formatBuiltInBrowserContextForPrompt(builtInBrowserContextSnapshot); @@ -4516,7 +4568,7 @@ export function AgentChatPane({ const macosVmContextDisplayChips = formatMacosVmContextChipsForDisplay(macosVmContextSnapshot); const visualContextPrefix = [iosContextPrefix, appControlContextPrefix, builtInBrowserContextPrefix, macosVmContextPrefix].filter(Boolean).join("\n"); const visualContextDisplayChips = [iosContextDisplayChips, appControlContextDisplayChips, builtInBrowserContextDisplayChips, macosVmContextDisplayChips].filter(Boolean).join(" "); - if ((!text.length && !visualContextPrefix.length) || !laneId) return; + if ((!text.length && !visualContextPrefix.length && !contextAttachmentsSnapshot.length) || !laneId) return; const pendingNativeControlUpdate = pendingNativeControlUpdateRef.current; if (selectedSessionId && pendingNativeControlUpdate?.sessionId === selectedSessionId) { try { @@ -4543,16 +4595,24 @@ export function AgentChatPane({ setDraft(""); draftsPerSessionRef.current.delete(selectedSessionId); setAttachments([]); + setContextAttachments([]); try { const automaticMacosVmContextPrefix = await buildAutomaticMacosVmContextForPrompt(laneId); let justCreatedSession = false; const finalTextPrefix = [automaticMacosVmContextPrefix, visualContextPrefix].filter(Boolean).join("\n"); - const finalText = finalTextPrefix ? `${finalTextPrefix}${text}` : text; + let finalText = finalTextPrefix ? `${finalTextPrefix}${text}` : text; + if (!finalText.trim().length && contextAttachmentsSnapshot.length) { + finalText = "Use the attached issue context."; + } const finalDisplayText = visualContextDisplayChips ? text.length ? `${visualContextDisplayChips} ${text}` : visualContextDisplayChips - : text; + : text.length + ? text + : contextAttachmentsSnapshot.length + ? "Attached issue context" + : text; let sessionId = selectedSessionId; const shouldPromoteLightSession = shouldPromoteSessionForComputerUse(selectedSession); @@ -4565,6 +4625,7 @@ export function AgentChatPane({ && selectedSession?.provider === "codex" && (selectedSession.codexFastMode === true) !== codexFastMode; const selectedAttachments = isLiteralSlashCommand ? [] : attachmentsSnapshot; + const selectedContextAttachments = isLiteralSlashCommand ? [] : contextAttachmentsSnapshot; const optimisticEnvelope = (nextSessionId: string): AgentChatEventEnvelope => ({ sessionId: nextSessionId, timestamp: new Date().toISOString(), @@ -4572,6 +4633,7 @@ export function AgentChatPane({ type: "user_message", text: finalDisplayText || finalText, ...(selectedAttachments.length ? { attachments: selectedAttachments } : {}), + ...(selectedContextAttachments.length ? { contextAttachments: selectedContextAttachments } : {}), deliveryState: "queued", }, }); @@ -4620,6 +4682,7 @@ export function AgentChatPane({ sessionId, text: finalText, ...(selectedAttachments.length ? { attachments: selectedAttachments } : {}), + ...(selectedContextAttachments.length ? { contextAttachments: selectedContextAttachments } : {}), }); } else { try { @@ -4629,6 +4692,7 @@ export function AgentChatPane({ text: finalText, displayText: finalDisplayText || "Selected visual app context", attachments: selectedAttachments, + contextAttachments: selectedContextAttachments, reasoningEffort, executionMode: launchModeEditable ? executionMode : null, interactionMode: sessionProvider === "claude" ? interactionMode : null, @@ -4645,6 +4709,7 @@ export function AgentChatPane({ sessionId, text: finalText, ...(selectedAttachments.length ? { attachments: selectedAttachments } : {}), + ...(selectedContextAttachments.length ? { contextAttachments: selectedContextAttachments } : {}), }); } else { throw sendError; @@ -4664,6 +4729,7 @@ export function AgentChatPane({ const message = submitError instanceof Error ? submitError.message : String(submitError); setDraft((current) => (current.trim().length ? current : draftSnapshot)); setAttachments((current) => (current.length ? current : attachmentsSnapshot)); + setContextAttachments((current) => (current.length ? current : contextAttachmentsSnapshot)); setIosElementContextItems((current) => (current.length ? current : iosContextSnapshot)); setAppControlContextItems((current) => (current.length ? current : appControlContextSnapshot)); setBuiltInBrowserContextItems((current) => (current.length ? current : builtInBrowserContextSnapshot)); @@ -4687,6 +4753,7 @@ export function AgentChatPane({ busy, codexFastMode, createSession, + contextAttachments, draft, executionMode, hasComputerUseSelectionChanged, @@ -5544,6 +5611,7 @@ export function AgentChatPane({ setSelectedSessionId(null); setDraft(""); setAttachments([]); + setContextAttachments([]); }} > @@ -5590,6 +5658,8 @@ export function AgentChatPane({ codexFastMode={codexFastMode} draft={draft} attachments={attachments} + contextAttachments={contextAttachments} + pinnedLinearIssue={pinnedLinearIssue} pendingInput={pendingInput?.request ?? null} approvalResponding={pendingInput ? respondingApprovalIds.has(pendingInput.itemId) : false} turnActive={turnActive} @@ -5736,6 +5806,8 @@ export function AgentChatPane({ }} onAddAttachment={addAttachment} onRemoveAttachment={removeAttachment} + onAddContextAttachment={addContextAttachment} + onRemoveContextAttachment={removeContextAttachment} onSearchAttachments={searchAttachments} onClearEvents={() => { if (selectedSessionId) { diff --git a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx index d29a5b371..26fc5982f 100644 --- a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx @@ -2,8 +2,42 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { makeLinearIssueContextAttachment } from "../../../shared/chatContextAttachments"; +import type { LaneLinearIssue } from "../../../shared/types"; import { ChatAttachmentTray } from "./ChatAttachmentTray"; +function makeIssue(overrides: Partial = {}): LaneLinearIssue { + return { + id: "issue-1", + identifier: "ADE-123", + title: "Connect chat context to Linear", + description: null, + url: "https://linear.app/ade/issue/ADE-123/connect-chat-context-to-linear", + projectId: "project-1", + projectSlug: "ade", + projectName: "ADE", + teamId: "team-1", + teamKey: "ADE", + teamName: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 2, + priorityLabel: "high", + labels: ["desktop"], + assigneeId: "user-1", + assigneeName: "Arul", + creatorId: null, + creatorName: null, + dueDate: null, + estimate: null, + branchName: "ade-123-connect-chat-context-to-linear", + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + ...overrides, + }; +} + describe("ChatAttachmentTray", () => { const getImageDataUrl = vi.fn(); const writeClipboardImage = vi.fn(); @@ -74,4 +108,26 @@ describe("ChatAttachmentTray", () => { expect(screen.getByText("context.txt")).toBeTruthy(); expect(screen.queryByRole("button", { name: "Open context.txt" })).toBeNull(); }); + + it("renders removable Linear issue context chips", () => { + const onRemoveContext = vi.fn(); + const contextAttachment = makeLinearIssueContextAttachment(makeIssue(), "manual"); + + render( + , + ); + + expect(screen.getByTestId("linear-issue-context-chip")).toBeTruthy(); + expect(screen.getByText("ADE-123")).toBeTruthy(); + expect(screen.getByText("Connect chat context to Linear")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Remove ADE-123" })); + + expect(onRemoveContext).toHaveBeenCalledWith("linear:issue-1"); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx index b963007dc..2e87e50b4 100644 --- a/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatAttachmentTray.tsx @@ -1,8 +1,10 @@ import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; import { createPortal } from "react-dom"; import { Copy, File, Image, X } from "@phosphor-icons/react"; -import type { AgentChatFileRef, ChatSurfaceMode } from "../../../shared/types"; +import type { AgentChatContextAttachment, AgentChatFileRef, ChatSurfaceMode } from "../../../shared/types"; +import { chatContextAttachmentKey } from "../../../shared/chatContextAttachments"; import { cn } from "../ui/cn"; +import { LinearMark, LINEAR_BRAND } from "../lanes/linearBrand"; function attachmentName(path: string): string { // Split on both POSIX and Windows separators so a Windows path @@ -12,6 +14,78 @@ function attachmentName(path: string): string { return segments.pop() || path; } +function contextAttachmentProjectLabel(attachment: AgentChatContextAttachment): string | null { + if (attachment.type !== "linear_issue") return null; + const issue = attachment.issue; + return issue.projectName?.trim() || issue.projectSlug || issue.teamKey || null; +} + +function LinearIssueContextChip({ + attachment, + onRemove, +}: { + attachment: Extract; + onRemove?: (key: string) => void; +}) { + const projectLabel = contextAttachmentProjectLabel(attachment); + const title = [ + attachment.issue.identifier, + attachment.issue.title, + projectLabel, + attachment.issue.stateName, + ].filter(Boolean).join(" - "); + + return ( + + + + + + {attachment.issue.identifier} + + + {attachment.issue.title} + + {projectLabel ? ( + + {projectLabel} + + ) : null} + {onRemove ? ( + + ) : null} + + ); +} + function ImageAttachmentPreview({ attachment, toneClassName, @@ -244,16 +318,20 @@ function ImageLightbox({ export function ChatAttachmentTray({ attachments, + contextAttachments = [], mode, onRemove, + onRemoveContext, className, }: { attachments: AgentChatFileRef[]; + contextAttachments?: AgentChatContextAttachment[]; mode: ChatSurfaceMode; onRemove?: (path: string) => void; + onRemoveContext?: (key: string) => void; className?: string; }) { - if (!attachments.length) return null; + if (!attachments.length && !contextAttachments.length) return null; let chipTone: string; switch (mode) { @@ -273,6 +351,18 @@ export function ChatAttachmentTray({ return (
+ {contextAttachments.map((attachment) => { + if (attachment.type === "linear_issue") { + return ( + + ); + } + return null; + })} {attachments.map((attachment) => { if (attachment.type === "image") { return ( diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx index cdbe4731a..28c965048 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx @@ -1,7 +1,13 @@ import React from "react"; import { CaretDown, CaretRight, GitBranch, GitFork, Plus, StackSimple, Tag } from "@phosphor-icons/react"; import { Button } from "../ui/Button"; -import type { BranchPullRequest, LaneSummary, LaneEnvInitProgress, LaneTemplate } from "../../../shared/types"; +import type { + BranchPullRequest, + LaneLinearIssue, + LaneSummary, + LaneEnvInitProgress, + LaneTemplate, +} from "../../../shared/types"; import type { LaneBranchOption } from "./laneUtils"; import { LaneEnvInitProgressPanel } from "./LaneEnvInitProgress"; import { LaneDialogShell } from "./LaneDialogShell"; @@ -9,6 +15,13 @@ import { LaneColorPicker } from "./LaneColorPicker"; import { colorsInUse, nextAvailableColor } from "./laneColorPalette"; import { BranchPickerView } from "./BranchPickerView"; import { formatRelativeTime } from "./branchPickerSearch"; +import { linearIssueBranchName } from "../../../shared/linearIssueBranch"; +import { + LinearIssuePickerView, + LinearIssueSummaryCard, + branchExistsForLinearIssue, +} from "./LinearIssuePicker"; +import { LinearMark, LINEAR_BRAND } from "./linearBrand"; import { SECTION_CLASS_NAME, LABEL_CLASS_NAME, @@ -90,6 +103,8 @@ export function CreateLaneDialog({ importBranchWarning, selectedColor, setSelectedColor, + selectedLinearIssue, + setSelectedLinearIssue, branchPullRequests, currentGitUserName, loadingBranches, @@ -125,6 +140,8 @@ export function CreateLaneDialog({ importBranchWarning?: string | null; selectedColor: string | null; setSelectedColor: (c: string | null) => void; + selectedLinearIssue: LaneLinearIssue | null; + setSelectedLinearIssue: (issue: LaneLinearIssue | null) => void; /** Open PRs in the project's GitHub repo, keyed by head branch. */ branchPullRequests?: BranchPullRequest[]; /** Local git user.name — used by the picker to resolve `mine` / `author:me`. */ @@ -138,15 +155,26 @@ export function CreateLaneDialog({ const usedColors = React.useMemo(() => colorsInUse(lanes), [lanes]); const [pickerOpen, setPickerOpen] = React.useState(false); + const [issuePickerOpen, setIssuePickerOpen] = React.useState(false); React.useEffect(() => { - if (!open) setPickerOpen(false); + if (!open) { + setPickerOpen(false); + setIssuePickerOpen(false); + } }, [open]); React.useEffect(() => { if (createMode !== "existing") setPickerOpen(false); }, [createMode]); + React.useEffect(() => { + if (selectedLinearIssue && createMode === "existing") { + setCreateMode("primary"); + setCreateImportBranch(""); + } + }, [createMode, selectedLinearIssue, setCreateImportBranch, setCreateMode]); + const prByBranch = React.useMemo(() => { const map = new Map(); for (const pr of branchPullRequests ?? []) map.set(pr.branch, pr); @@ -169,6 +197,11 @@ export function CreateLaneDialog({ else if (allBranches.length === 0) branchPickerPlaceholder = "No branches found"; else branchPickerPlaceholder = "Pick a branch…"; + const selectedLinearBranchName = selectedLinearIssue ? linearIssueBranchName(selectedLinearIssue) : ""; + const selectedLinearBranchConflict = selectedLinearIssue + ? branchExistsForLinearIssue(selectedLinearBranchName, createBranches) + : false; + React.useEffect(() => { if (open && selectedColor === null) { const next = nextAvailableColor(lanes); @@ -182,20 +215,23 @@ export function CreateLaneDialog({ || !createLaneName.trim() || (createMode === "child" && !createParentLaneId) || (createMode === "primary" && !createBaseBranch) - || (createMode === "existing" && !createImportBranch)); + || (createMode === "existing" && !createImportBranch) + || selectedLinearBranchConflict); - const hasAdvanced = templates.length > 0 || !!onNavigateToTemplates; + const hasAdvanced = true; return ( { event.preventDefault(); @@ -205,7 +241,14 @@ export function CreateLaneDialog({ target?.focus?.(); }} > - {pickerOpen ? ( + {issuePickerOpen ? ( + setIssuePickerOpen(false)} + busy={busy || laneCreated} + /> + ) : pickerOpen ? (
- {meta.description} + {disabledByLinearIssue ? "Unavailable while a Linear issue is connected" : meta.description}
); @@ -443,9 +489,9 @@ export function CreateLaneDialog({ - {/* Advanced — template (collapsed by default) */} + {/* Advanced — Linear issue + template */} {hasAdvanced ? ( -
+
@@ -468,6 +514,60 @@ export function CreateLaneDialog({ ) : null}
+
+ Linear issue + {selectedLinearIssue ? ( + <> + setSelectedLinearIssue(null)} + /> +
+ +
+ + ) : ( + + )} +
Template {templates.length > 0 ? ( @@ -519,6 +619,7 @@ export function CreateLaneDialog({ setCreateImportBranch(""); setCreateChildBaseBranch(""); setSelectedColor(null); + setSelectedLinearIssue(null); }} > Cancel diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 08f26d318..487bd9e26 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -9,6 +9,7 @@ import { SmartTooltip, type SmartTooltipContent } from "../ui/SmartTooltip"; import { COLORS, LABEL_STYLE, MONO_FONT, inlineBadge, outlineButton, primaryButton, dangerButton } from "./laneDesignTokens"; import { CommitTimeline } from "./CommitTimeline"; import { LaneDiffPane } from "./LaneDiffPane"; +import { LinearIssueBadge } from "./LinearIssueBadge"; import type { DiffChanges, FileChange, @@ -1575,6 +1576,9 @@ export function LaneGitActionsPane({ > {lane?.name ?? "NO LANE"} + {lane?.linearIssue ? ( + + ) : null} {lane ? ( <> void; runtimeByLaneId: LaneRuntimeMap; integrationSourcesByLaneId: Map; + onStartChatWithLinearIssue?: (laneId: string, issue: LaneLinearIssue) => void; }) { const layout = React.useMemo(() => { const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); @@ -258,6 +261,13 @@ function StackGraph({ > {lane.color ? : null} + {lane.linearIssue ? ( + onStartChatWithLinearIssue?.(lane.id, lane.linearIssue!)} + /> + ) : null} 0 ? 120 : 160, lineHeight: 1.05, @@ -307,12 +317,14 @@ export function LaneStackPane({ onSelect, runtimeByLaneId, integrationSourcesByLaneId, + onStartChatWithLinearIssue, }: { lanes: LaneSummary[]; selectedLaneId: string | null; onSelect: (id: string) => void; runtimeByLaneId: LaneRuntimeMap; integrationSourcesByLaneId?: Map; + onStartChatWithLinearIssue?: (laneId: string, issue: LaneLinearIssue) => void; }) { const navigate = useNavigate(); React.useEffect(() => { @@ -393,6 +405,7 @@ export function LaneStackPane({ onSelect={onSelect} runtimeByLaneId={runtimeByLaneId} integrationSourcesByLaneId={effectiveIntegrationSourcesByLaneId} + onStartChatWithLinearIssue={onStartChatWithLinearIssue} />
); diff --git a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx index 0f9dfdf0f..eafab2bed 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo } from "react"; import { ChatCircleText, Command, Terminal } from "@phosphor-icons/react"; +import type { LaneLinearIssue } from "../../../shared/types"; import type { WorkDraftKind } from "../../state/appStore"; import { EmptyState } from "../ui/EmptyState"; import { SmartTooltip } from "../ui/SmartTooltip"; @@ -50,8 +51,12 @@ const ENTRY_OPTIONS: Array<{ export function LaneWorkPane({ laneId, + initialLinearIssueContext = null, + onInitialLinearIssueContextConsumed, }: { laneId: string | null; + initialLinearIssueContext?: LaneLinearIssue | null; + onInitialLinearIssueContextConsumed?: () => void; }) { const work = useLaneWorkSessions(laneId); const laneList = work.lane ? [work.lane] : []; @@ -161,6 +166,8 @@ export function LaneWorkPane({ onLaunchPtySession={work.launchPtySession} onShowDraftKind={work.showDraftKind} closingPtyIds={work.closingPtyIds} + initialLinearIssueContext={initialLinearIssueContext} + onInitialLinearIssueContextConsumed={onInitialLinearIssueContextConsumed} />
diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 2a7cafe24..021292237 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -22,6 +22,7 @@ import { ManageLaneDialog } from "./ManageLaneDialog"; import { LaneContextMenu } from "./LaneContextMenu"; import { getLaneAccent } from "./laneColorPalette"; import { LaneRebaseBanner } from "./LaneRebaseBanner"; +import { LinearIssueBadge } from "./LinearIssueBadge"; import { HelpChip } from "../onboarding/HelpChip"; import { useOnboardingStore } from "../../state/onboardingStore"; import { useDialogBus } from "../../lib/useDialogBus"; @@ -50,6 +51,7 @@ import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; import { getProjectConfigCached } from "../../lib/projectConfigCache"; import { logRendererDebugEvent } from "../../lib/debugLog"; import { branchNameFromLaneRef } from "../../../shared/laneBaseResolution"; +import { linearIssueBranchName, linearIssueLaneName } from "../../../shared/linearIssueBranch"; import type { BranchPullRequest, ConflictChip, @@ -59,6 +61,7 @@ import type { LaneEnvInitProgress, LaneBranchActiveWorkItem, LaneListSnapshot, + LaneLinearIssue, LaneSummary, PrSummary, RebaseRun, @@ -324,6 +327,7 @@ export function LanesPage() { const refreshLanes = useAppStore((s) => s.refreshLanes); const setLaneInspectorTab = useAppStore((s) => s.setLaneInspectorTab); const clearLaneInspectorTab = useAppStore((s) => s.clearLaneInspectorTab); + const setLaneWorkViewState = useAppStore((s) => s.setLaneWorkViewState); const keybindings = useAppStore((s) => s.keybindings); const project = useAppStore((s) => s.project); const activeTourId = useOnboardingStore((s) => s.activeTourId); @@ -356,6 +360,8 @@ export function LanesPage() { const [templates, setTemplates] = useState([]); const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [createSelectedColor, setCreateSelectedColor] = useState(null); + const [createSelectedLinearIssue, setCreateSelectedLinearIssue] = useState(null); + const createLinearIssueAutoNameRef = useRef(null); const [multiAttachOpen, setMultiAttachOpen] = useState(false); const [attachOpen, setAttachOpen] = useState(false); const [attachName, setAttachName] = useState(""); @@ -439,6 +445,11 @@ export function LanesPage() { const [expandedGitActionsLaneId, setExpandedGitActionsLaneId] = useState(null); const [integrationProposals, setIntegrationProposals] = useState([]); const [lanePrTags, setLanePrTags] = useState([]); + const [linearIssueChatContextRequest, setLinearIssueChatContextRequest] = useState<{ + laneId: string; + issue: LaneLinearIssue; + requestedAt: number; + } | null>(null); const laneSnapshots = useAppStore((s) => s.laneSnapshots); const consumedLaneIdsDeepLinkSignatureRef = useRef(null); @@ -1526,6 +1537,27 @@ export function LanesPage() { selectLane(laneId); }, [deletingLaneIds, lanesById, pinnedLaneIds, activeWithPins, selectLane]); + const handleStartChatWithLinearIssue = useCallback((laneId: string, issue: LaneLinearIssue) => { + if (deletingLaneIds.has(laneId) || !lanesById.has(laneId)) return; + const pinned = Array.from(pinnedLaneIds).filter((id) => id !== laneId && lanesById.has(id) && !deletingLaneIds.has(id)); + setActiveLaneIds(mergeUnique([laneId], pinned)); + selectLane(laneId); + setStackGraphHeaderOpen(false); + setLaneWorkViewState(project?.rootPath ?? null, laneId, (prev) => ({ + ...prev, + draftKind: "chat", + viewMode: "tabs", + activeItemId: null, + selectedItemId: null, + })); + setLinearIssueChatContextRequest({ + laneId, + issue, + requestedAt: Date.now(), + }); + navigate(`/lanes?laneId=${encodeURIComponent(laneId)}`); + }, [deletingLaneIds, lanesById, navigate, pinnedLaneIds, project?.rootPath, selectLane, setLaneWorkViewState]); + const removeSplitLane = useCallback((laneId: string) => { if (pinnedLaneIds.has(laneId)) return; const pinned = Array.from(pinnedLaneIds).filter((id) => lanesById.has(id) && !deletingLaneIds.has(id)); @@ -1815,6 +1847,8 @@ export function LanesPage() { setCreateEnvInitProgress(null); setSelectedTemplateId(""); setCreateSelectedColor(null); + setCreateSelectedLinearIssue(null); + createLinearIssueAutoNameRef.current = null; }, []); const prepareCreateDialog = useCallback(() => { @@ -1827,6 +1861,8 @@ export function LanesPage() { setCreateBranches([]); setCreateBranchPullRequests([]); setCreateGitUserName(""); + setCreateSelectedLinearIssue(null); + createLinearIssueAutoNameRef.current = null; setCreateBranchesLoading(false); setCreateBranchPullRequestsLoading(false); setLaneCreated(false); @@ -1975,6 +2011,25 @@ export function LanesPage() { setCreateBaseBranch(v); }, []); + const handleSetCreateLinearIssue = useCallback((issue: LaneLinearIssue | null) => { + setCreateSelectedLinearIssue(issue); + if (!issue) return; + + const nextName = linearIssueLaneName(issue); + setCreateLaneName((current) => { + const trimmed = current.trim(); + const previousAutoName = createLinearIssueAutoNameRef.current; + if (!trimmed || (previousAutoName && trimmed === previousAutoName)) { + createLinearIssueAutoNameRef.current = nextName; + return nextName; + } + createLinearIssueAutoNameRef.current = nextName; + return current; + }); + setCreateImportBranch(""); + setCreateMode((mode) => mode === "existing" ? "primary" : mode); + }, []); + /** Run only the environment-setup phase (applyTemplate / initEnv) for a lane * that has already been created. Used as the retry path when env setup fails. */ const runEnvSetupForCreatedLane = useCallback(async (laneId: string) => { @@ -2015,6 +2070,10 @@ export function LanesPage() { if (createMode === "child" && !createParentLaneId) return; if (createMode === "primary" && !createBaseBranch) return; if (createMode === "existing" && !createImportBranch) return; + if (createSelectedLinearIssue && createMode === "existing") { + setCreateError("Detach the Linear issue before importing an existing branch."); + return; + } if (selectedTemplateId && !templates.some((template) => template.id === selectedTemplateId)) { setCreateError("The selected lane template no longer exists. Refresh templates or choose a different option."); return; @@ -2032,6 +2091,15 @@ export function LanesPage() { createBaseBranch, createImportBranch, }); + const linearIssueArgs = createSelectedLinearIssue + ? { + linearIssue: { + ...createSelectedLinearIssue, + branchName: linearIssueBranchName(createSelectedLinearIssue), + }, + branchName: linearIssueBranchName(createSelectedLinearIssue), + } + : {}; let lane: LaneSummary; if (request.kind === "import") { lane = await window.ade.lanes.importBranch(request.args); @@ -2044,11 +2112,11 @@ export function LanesPage() { return; } const childArgs = trimmedBase && trimmedBase !== parentLane.branchRef - ? { ...request.args, baseBranchRef: trimmedBase } - : request.args; + ? { ...request.args, baseBranchRef: trimmedBase, ...linearIssueArgs } + : { ...request.args, ...linearIssueArgs }; lane = await window.ade.lanes.createChild(childArgs); } else { - lane = await window.ade.lanes.create(request.args); + lane = await window.ade.lanes.create({ ...request.args, ...linearIssueArgs }); } // Lane created successfully — record its id so retries skip creation. @@ -2072,7 +2140,7 @@ export function LanesPage() { setCreateError(err instanceof Error ? err.message : String(err)); setCreateBusy(false); } - }, [createLaneName, createMode, createParentLaneId, createBaseBranch, createImportBranch, createChildBaseBranch, lanes, createBusy, navigate, refreshLanes, runEnvSetupForCreatedLane, selectedTemplateId, templates, createSelectedColor]); + }, [createLaneName, createMode, createParentLaneId, createBaseBranch, createImportBranch, createChildBaseBranch, lanes, createBusy, navigate, refreshLanes, runEnvSetupForCreatedLane, selectedTemplateId, templates, createSelectedColor, createSelectedLinearIssue]); const handleAttachSubmit = useCallback(async () => { const name = attachName.trim(); @@ -2148,6 +2216,10 @@ export function LanesPage() { const getPaneConfigs = useCallback((laneId: string | null) => { const laneDetail = laneId ? lanePaneDetails[laneId] ?? EMPTY_LANE_PANE_DETAIL : EMPTY_LANE_PANE_DETAIL; const laneSnapshot = laneId ? laneSnapshotByLaneId.get(laneId) ?? null : null; + const pendingLinearIssueContext = + laneId && linearIssueChatContextRequest?.laneId === laneId + ? linearIssueChatContextRequest + : null; return { "git-actions": { title: "Git Actions", @@ -2206,7 +2278,22 @@ export function LanesPage() { hideHeaderWhenExpanded: true, children: ( - + { + setLinearIssueChatContextRequest((current) => ( + current?.laneId === pendingLinearIssueContext.laneId + && current.requestedAt === pendingLinearIssueContext.requestedAt + ? null + : current + )); + } + : undefined + } + /> ) }, @@ -2214,6 +2301,7 @@ export function LanesPage() { }, [ lanePaneDetails, laneSnapshotByLaneId, + linearIssueChatContextRequest, expandedGitActionsLaneId, autoRebaseEnabled, openAutoRebaseSettings, @@ -2755,6 +2843,7 @@ export function LanesPage() { }} runtimeByLaneId={laneRuntimeById} integrationSourcesByLaneId={integrationSourcesByLaneId} + onStartChatWithLinearIssue={handleStartChatWithLinearIssue} /> ) : null} @@ -2970,6 +3059,13 @@ export function LanesPage() { fontWeight: isSelected ? 600 : 500, color: isSelected ? COLORS.textPrimary : COLORS.textMuted, }}>{lane.name} + {!isDeleting && lane.linearIssue ? ( + handleStartChatWithLinearIssue(lane.id, lane.linearIssue!)} + /> + ) : null} {!isDeleting && lanePr ? ( + ) : null} + {issue.url ? ( + <> + + + + ) : null} + + ) : null} + + + ); +} diff --git a/apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx b/apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx new file mode 100644 index 000000000..461711fb7 --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx @@ -0,0 +1,695 @@ +import React from "react"; +import { ArrowSquareOut, CaretDown, Check, CircleNotch, MagnifyingGlass, X } from "@phosphor-icons/react"; +import { Button } from "../ui/Button"; +import type { + CtoGetLinearIssuePickerDataResult, + LaneLinearIssue, + NormalizedLinearIssue, +} from "../../../shared/types"; +import { linearIssueBranchName } from "../../../shared/linearIssueBranch"; +import { formatRelativeTime } from "./branchPickerSearch"; +import type { LaneBranchOption } from "./laneUtils"; +import { LABEL_CLASS_NAME } from "./laneDialogTokens"; +import { LinearMark, LinearPriorityIcon, LinearStateIcon, LINEAR_BRAND } from "./linearBrand"; + +const ACTIVE_LINEAR_STATE_TYPES = ["backlog", "unstarted", "started"] as const; + +// Re-exported so existing callers (ChatAttachmentTray, AgentChatComposer) +// keep working without updating their imports. +export { LinearMark } from "./linearBrand"; + +const PRIORITY_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [ + { value: "", label: "Any priority" }, + { value: "1", label: "Urgent" }, + { value: "2", label: "High" }, + { value: "3", label: "Medium" }, + { value: "4", label: "Low" }, + { value: "0", label: "No priority" }, +]; + +const STATE_PRESET_LABELS: Record = { + active: "Active", + all: "All states", + backlog: "Backlog", + unstarted: "Todo", + started: "In progress", + completed: "Done", + canceled: "Canceled", + triage: "Triage", +}; + +export function linearPriorityLabel(issue: Pick): string { + if (issue.priorityLabel === "none" || !issue.priorityLabel) return "No priority"; + return issue.priorityLabel[0]!.toUpperCase() + issue.priorityLabel.slice(1); +} + +export function issueProjectLabel(issue: Pick): string { + return issue.projectName?.trim() || issue.projectSlug || issue.teamKey; +} + +export function issueUpdatedLabel(issue: Pick): string { + return formatRelativeTime(issue.updatedAt) || "Updated recently"; +} + +export function toLaneLinearIssue(issue: NormalizedLinearIssue): LaneLinearIssue { + const branchName = linearIssueBranchName(issue); + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description, + url: issue.url, + projectId: issue.projectId, + projectSlug: issue.projectSlug, + projectName: issue.projectName ?? null, + teamId: issue.teamId, + teamKey: issue.teamKey, + teamName: issue.teamName ?? null, + stateId: issue.stateId, + stateName: issue.stateName, + stateType: issue.stateType, + priority: issue.priority, + priorityLabel: issue.priorityLabel, + labels: issue.labels, + assigneeId: issue.assigneeId, + assigneeName: issue.assigneeName, + creatorId: issue.creatorId ?? null, + creatorName: issue.creatorName ?? null, + dueDate: issue.dueDate ?? null, + estimate: issue.estimate ?? null, + branchName, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + }; +} + +function isLaneLinearIssue(issue: NormalizedLinearIssue | LaneLinearIssue): issue is LaneLinearIssue { + return !("raw" in issue); +} + +export function branchExistsForLinearIssue(branchName: string, branches: LaneBranchOption[]): boolean { + const normalized = branchName.trim().toLowerCase(); + if (!normalized) return false; + return branches.some((branch) => { + const candidate = branch.name.trim().toLowerCase(); + const withoutRemote = candidate.replace(/^[^/]+\//, ""); + return candidate === normalized || withoutRemote === normalized; + }); +} + +function LinearIdentifierTag({ identifier, size = "sm" }: { identifier: string; size?: "xs" | "sm" }) { + const fontSize = size === "xs" ? 9.5 : 10.5; + return ( + + {identifier} + + ); +} + +export function LinearIssueSummaryCard({ + issue, + branchName, + branchConflict, + onClear, +}: { + issue: LaneLinearIssue; + branchName: string; + branchConflict: boolean; + onClear: () => void; +}) { + return ( +
+
+ + + +
+
+ + + + {issue.title} +
+
+ {issueProjectLabel(issue)} + · + {issue.stateName} + {issue.assigneeName ? ( + <> + · + {issue.assigneeName} + + ) : null} +
+
+ branch + {branchName} + {branchConflict ? already exists : null} +
+
+ +
+
+ ); +} + +export function LinearIssueRow({ + issue, + active, + eyebrow, + busy, + onClick, +}: { + issue: NormalizedLinearIssue | LaneLinearIssue; + active: boolean; + eyebrow?: string; + busy?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function FilterPill({ + label, + value, + options, + onChange, + busy, + width = "8.5rem", +}: { + label: string; + value: string; + options: ReadonlyArray<{ value: string; label: string }>; + onChange: (value: string) => void; + busy?: boolean; + width?: string; +}) { + const selected = options.find((option) => option.value === value); + return ( + + + + {/* Visually-hidden label for screen readers */} + + {label}: {selected?.label ?? value} + + + ); +} + +export function LinearIssuePickerView({ + selectedIssue, + pinnedIssue, + pinnedIssueLabel = "Linked to this lane", + onSelect, + onBack, + busy, + selectOnIssueClick = false, + submitLabel = "Connect issue", +}: { + selectedIssue: LaneLinearIssue | null; + pinnedIssue?: LaneLinearIssue | null; + pinnedIssueLabel?: string; + onSelect: (issue: LaneLinearIssue) => void; + onBack: () => void; + busy?: boolean; + selectOnIssueClick?: boolean; + submitLabel?: string; +}) { + const [catalog, setCatalog] = React.useState({ + projects: [], + users: [], + states: [], + }); + const [projectId, setProjectId] = React.useState(""); + const [statePreset, setStatePreset] = React.useState<"active" | "all" | string>("active"); + const [assigneeId, setAssigneeId] = React.useState(""); + const [priority, setPriority] = React.useState(""); + const [query, setQuery] = React.useState(""); + const [issues, setIssues] = React.useState([]); + const [pendingIssue, setPendingIssue] = React.useState(pinnedIssue ?? null); + const [pageInfo, setPageInfo] = React.useState<{ hasNextPage: boolean; endCursor: string | null }>({ + hasNextPage: false, + endCursor: null, + }); + const pageInfoRef = React.useRef(pageInfo); + const [loadingCatalog, setLoadingCatalog] = React.useState(false); + const [loadingIssues, setLoadingIssues] = React.useState(false); + const [error, setError] = React.useState(null); + const requestIdRef = React.useRef(0); + + React.useEffect(() => { + pageInfoRef.current = pageInfo; + }, [pageInfo]); + + React.useEffect(() => { + setPendingIssue((current) => current ?? pinnedIssue ?? null); + }, [pinnedIssue]); + + React.useEffect(() => { + let cancelled = false; + const cto = window.ade.cto; + if (!cto) { + setError("Linear controls are not available in this ADE surface."); + return () => { + cancelled = true; + }; + } + setLoadingCatalog(true); + setError(null); + cto.getLinearIssuePickerData() + .then((data) => { + if (cancelled) return; + setCatalog(data); + if (data.projects.length === 1) setProjectId(data.projects[0].id); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : "Unable to load Linear issue filters."); + }) + .finally(() => { + if (!cancelled) setLoadingCatalog(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const stateTypes = React.useMemo(() => { + if (statePreset === "all") return []; + if (statePreset === "active") return [...ACTIVE_LINEAR_STATE_TYPES]; + return [statePreset]; + }, [statePreset]); + + const searchIssues = React.useCallback(async (append: boolean) => { + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + setLoadingIssues(true); + setError(null); + try { + const cto = window.ade.cto; + if (!cto) throw new Error("Linear controls are not available in this ADE surface."); + const result = await cto.searchLinearIssues({ + projectId: projectId || null, + stateTypes, + assigneeId: assigneeId || null, + priority: priority ? Number(priority) : null, + query: query.trim() || null, + first: 50, + after: append ? pageInfoRef.current.endCursor : null, + }); + if (requestIdRef.current !== requestId) return; + setIssues((current) => append ? [...current, ...result.issues] : result.issues); + setPageInfo(result.pageInfo); + setPendingIssue((current) => { + if (append && current) { + return current; + } + if (current && result.issues.some((issue) => issue.id === current.id)) { + return current; + } + const selectedMatch = selectedIssue + ? result.issues.find((issue) => issue.id === selectedIssue.id) ?? null + : null; + return pinnedIssue ?? selectedMatch ?? result.issues[0] ?? null; + }); + } catch (err) { + if (requestIdRef.current === requestId) { + setError(err instanceof Error ? err.message : "Unable to search Linear issues."); + } + } finally { + if (requestIdRef.current === requestId) setLoadingIssues(false); + } + }, [assigneeId, priority, projectId, query, selectedIssue, stateTypes, pinnedIssue]); + + React.useEffect(() => { + const timer = window.setTimeout(() => { + void searchIssues(false); + }, 180); + return () => window.clearTimeout(timer); + }, [searchIssues]); + + const selectedProject = catalog.projects.find((project) => project.id === projectId) ?? null; + const issueForDetails = pendingIssue ?? selectedIssue; + + const projectOptions = React.useMemo( + () => [ + { value: "", label: "All projects" }, + ...catalog.projects.map((project) => ({ + value: project.id, + label: project.teamName ? `${project.name} · ${project.teamName}` : project.name, + })), + ], + [catalog.projects], + ); + + const assigneeOptions = React.useMemo( + () => [ + { value: "", label: "Anyone" }, + ...catalog.users.map((user) => ({ + value: user.id, + label: user.displayName ?? user.name, + })), + ], + [catalog.users], + ); + + const stateOptions = React.useMemo(() => { + const seen = new Set(); + const presetEntries = [ + { value: "active", label: STATE_PRESET_LABELS.active! }, + { value: "all", label: STATE_PRESET_LABELS.all! }, + ]; + const dynamic = catalog.states + .filter((state) => { + if (seen.has(state.type)) return false; + seen.add(state.type); + return true; + }) + .sort((left, right) => left.type.localeCompare(right.type)) + .map((state) => ({ + value: state.type, + label: STATE_PRESET_LABELS[state.type] ?? state.type, + })); + return [...presetEntries, ...dynamic]; + }, [catalog.states]); + + const handleChooseIssue = React.useCallback((issue: NormalizedLinearIssue | LaneLinearIssue) => { + if (selectOnIssueClick) { + onSelect(isLaneLinearIssue(issue) ? issue : toLaneLinearIssue(issue)); + onBack(); + return; + } + setPendingIssue(issue); + }, [onBack, onSelect, selectOnIssueClick]); + + return ( +
+ {/* Linear-branded header banner — establishes context */} +
+ + + + + Linear + + Connect this lane to an issue and we'll auto-name the branch. +
+ + {pinnedIssue ? ( +
+ handleChooseIssue(pinnedIssue)} + /> +
+ ) : null} + + {/* Search row — single dominant input, loader inline */} +
+ + setQuery(event.target.value)} + className="h-10 w-full rounded-lg border border-white/[0.06] bg-white/[0.03] pl-9 pr-9 text-sm text-fg outline-none transition-colors placeholder:text-muted-fg/55 focus:border-white/15" + placeholder="Search issues by title, description, or identifier" + disabled={busy} + /> + {loadingCatalog || loadingIssues ? ( + + ) : null} +
+ + {/* Linear-style filter pill row */} +
+ + + + +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {/* List + detail */} +
+
+ {issues.length === 0 && !loadingIssues ? ( +
+ No Linear issues match these filters. +
+ ) : null} + {issues.map((issue) => { + const active = pendingIssue?.id === issue.id || (!pendingIssue && selectedIssue?.id === issue.id); + return ( + handleChooseIssue(issue)} + /> + ); + })} + {pageInfo.hasNextPage ? ( +
+ +
+ ) : null} +
+ + +
+ +
+ + +
+
+ ); +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/lanes/linearBrand.tsx b/apps/desktop/src/renderer/components/lanes/linearBrand.tsx new file mode 100644 index 000000000..f3e015f61 --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/linearBrand.tsx @@ -0,0 +1,195 @@ +import React from "react"; + +/** + * Linear brand identity. Linear's primary purple sits between blue and ADE's + * violet accent, so Linear-specific surfaces stay distinguishable from the + * rest of the app even though both palettes are purple-leaning. + */ +export const LINEAR_BRAND = { + primary: "#5E6AD2", + primaryBright: "#7B8AF0", + primaryDeep: "#4752B5", + surface: "rgba(94, 106, 210, 0.10)", + surfaceHover: "rgba(94, 106, 210, 0.16)", + border: "rgba(94, 106, 210, 0.32)", + borderSubtle: "rgba(94, 106, 210, 0.20)", + text: "#C7CDF5", + textMuted: "rgba(199, 205, 245, 0.65)", +} as const; + +/** Linear's official simple-icons mark. */ +export function LinearMark({ size = 14, className }: { size?: number | string; className?: string }) { + return ( + + ); +} + +/** + * Linear's signature status circles. Each Linear workflow state belongs to one + * of five state types, and Linear renders them with a recognizable iconography: + * dashed for backlog, hollow for todo, partially-filled for in-progress, solid + * with a checkmark for done, and a slashed circle for canceled. + */ +const STATE_COLORS = { + backlog: "#94A3B8", + unstarted: "#94A3B8", + started: "#F2C94C", + completed: "#5E6AD2", + canceled: "#94A3B8", + triage: "#F2994A", +} as const; + +type StateType = keyof typeof STATE_COLORS; + +export function LinearStateIcon({ + stateType, + size = 14, +}: { + stateType: string; + size?: number; +}) { + const type = (Object.prototype.hasOwnProperty.call(STATE_COLORS, stateType) ? stateType : "unstarted") as StateType; + const color = STATE_COLORS[type]; + const stroke = Math.max(1.4, size * 0.12); + const inset = stroke / 2; + const r = size / 2 - inset; + const center = size / 2; + const dashArray = `${stroke * 1.7} ${stroke * 1.4}`; + + if (type === "backlog") { + return ( + + ); + } + + if (type === "unstarted" || type === "triage") { + return ( + + ); + } + + if (type === "started") { + const innerR = r - stroke * 0.6; + const sweep = `M ${center} ${center} L ${center} ${center - innerR} A ${innerR} ${innerR} 0 0 1 ${center + innerR} ${center} Z`; + return ( + + ); + } + + if (type === "completed") { + return ( + + ); + } + + // canceled + return ( + + ); +} + +/** + * Linear's signal-bar priority glyph. Three bars filled left-to-right by + * urgency, with a dedicated treatment for Urgent (warning square) and No + * Priority (a single dim dash). + */ +export function LinearPriorityIcon({ + priority, + size = 14, +}: { + /** Linear priority: 0=none, 1=urgent, 2=high, 3=medium, 4=low. */ + priority: number; + size?: number; +}) { + const dim = "rgba(148, 163, 184, 0.40)"; + const strong = "#C7CDF5"; + + if (priority === 1) { + return ( + + ); + } + + if (priority === 0) { + return ( + + ); + } + + // 2 = high (3 bars), 3 = medium (2 bars), 4 = low (1 bar) + const filledBars = priority === 2 ? 3 : priority === 3 ? 2 : 1; + const barWidth = size * 0.18; + const barGap = size * 0.10; + const totalWidth = barWidth * 3 + barGap * 2; + const startX = (size - totalWidth) / 2; + const heights = [size * 0.32, size * 0.55, size * 0.78]; + return ( + + ); +} diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx index 53d0bd6de..938e034b8 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx @@ -5,7 +5,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; -import type { LaneSummary } from "../../../shared/types"; +import type { LaneLinearIssue, LaneSummary } from "../../../shared/types"; function renderWithRouter(ui: React.ReactElement) { return render({ui}); @@ -33,6 +33,38 @@ function makeLane(overrides: Partial = {}): LaneSummary { }; } +function makeLinearIssue(overrides: Partial = {}): LaneLinearIssue { + return { + id: "issue-1", + identifier: "ADE-123", + title: "Connect Linear issue dropdown", + description: "Let PRs link back to Linear.", + url: "https://linear.app/ade/issue/ADE-123/connect-linear-issue-dropdown", + projectId: "project-1", + projectSlug: "ade", + projectName: "ADE", + teamId: "team-1", + teamKey: "ADE", + teamName: "ADE", + stateId: "state-1", + stateName: "In Progress", + stateType: "started", + priority: 2, + priorityLabel: "high", + labels: ["desktop"], + assigneeId: "user-1", + assigneeName: "Arul", + creatorId: "user-2", + creatorName: "Annie", + dueDate: null, + estimate: null, + branchName: "ade-123-connect-linear-issue-dropdown", + createdAt: "2026-05-08T00:00:00.000Z", + updatedAt: "2026-05-08T00:00:00.000Z", + ...overrides, + }; +} + const mockLanes: LaneSummary[] = [ makeLane({ id: "lane-primary", @@ -62,6 +94,17 @@ const mockLanes: LaneSummary[] = [ status: { dirty: false, ahead: 2, behind: 0, remoteBehind: 0, rebaseInProgress: false }, createdAt: "2026-03-23T12:02:00.000Z", }), + makeLane({ + id: "lane-linear", + name: "Linear linked lane", + branchRef: "ade-123-connect-linear-issue-dropdown", + worktreePath: "/tmp/lane-linear", + parentLaneId: "lane-primary", + stackDepth: 1, + status: { dirty: false, ahead: 3, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + linearIssue: makeLinearIssue(), + createdAt: "2026-05-08T12:02:00.000Z", + }), ]; vi.mock("../../state/appStore", () => ({ @@ -195,6 +238,56 @@ describe("CreatePrModal queue workflow", () => { ); }); + it("defaults single-PR title and body from a linked Linear issue", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + const comboboxes = screen.getAllByRole("combobox"); + await user.selectOptions(comboboxes[0]!, "lane-linear"); + + await user.click(screen.getByRole("button", { name: /next step/i })); + + expect(screen.getByDisplayValue("ADE-123: Connect Linear issue dropdown")).toBeTruthy(); + expect(screen.getByDisplayValue(/Refs ADE-123/)).toBeTruthy(); + expect(screen.getByText(/PR body will include Refs ADE-123/i)).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: /create pr/i })); + + await waitFor(() => expect(createFromLane).toHaveBeenCalledTimes(1)); + expect(createFromLane).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-linear", + title: "ADE-123: Connect Linear issue dropdown", + body: expect.stringContaining("Refs ADE-123"), + closeLinearIssueOnMerge: false, + }), + ); + }); + + it("uses a closing Linear magic word when close-on-merge is enabled", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + const comboboxes = screen.getAllByRole("combobox"); + await user.selectOptions(comboboxes[0]!, "lane-linear"); + await user.click(screen.getByRole("button", { name: /next step/i })); + await user.click(screen.getByRole("checkbox", { name: /close linear issue/i })); + + expect(screen.getByDisplayValue(/Fixes ADE-123/)).toBeTruthy(); + expect(screen.getByText(/PR body will include Fixes ADE-123/i)).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: /create pr/i })); + + await waitFor(() => expect(createFromLane).toHaveBeenCalledTimes(1)); + expect(createFromLane).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-linear", + body: expect.stringContaining("Fixes ADE-123"), + closeLinearIssueOnMerge: true, + }), + ); + }); + it("warns when the PR target branch differs from the lane base branch", async () => { const user = userEvent.setup(); renderWithRouter(); diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx index 7506d6f73..b219d9c05 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx @@ -13,6 +13,11 @@ import type { GitBranchSummary, LaneSummary, } from "../../../shared/types"; +import { + buildLinearPrReference, + buildLinearPrTitle, + ensureLinearPrReference, +} from "../../../shared/linearMagicWords"; import { COLORS, MONO_FONT, LABEL_STYLE } from "../lanes/laneDesignTokens"; import { isDirtyWorktreeErrorMessage, stripDirtyWorktreePrefix } from "./shared/dirtyWorktree"; import { branchNameFromRef, describePrTargetDiff, resolveLaneBaseBranch } from "./shared/laneBranchTargets"; @@ -521,7 +526,10 @@ export function CreatePrModal({ const [normalTitle, setNormalTitle] = React.useState(""); const [normalDraft, setNormalDraft] = React.useState(false); const [normalBaseBranch, setNormalBaseBranch] = React.useState(""); + const [normalCloseLinearIssueOnMerge, setNormalCloseLinearIssueOnMerge] = React.useState(false); const normalBaseBranchDefaultRef = React.useRef(""); + const normalLinearTitleDefaultRef = React.useRef(""); + const normalLinearBodyDefaultRef = React.useRef(""); // Queue PRs const [queueLaneIds, setQueueLaneIds] = React.useState([]); @@ -611,8 +619,10 @@ export function CreatePrModal({ try { const result = await window.ade.prs.draftDescription({ laneId }); if (mode === "normal") { - setNormalTitle(result.title); - setNormalBody(result.body); + const lane = lanes.find((entry) => entry.id === laneId) ?? null; + const issue = lane?.linearIssue ?? null; + setNormalTitle(issue && !result.title.includes(issue.identifier) ? buildLinearPrTitle(issue) : result.title); + setNormalBody(issue ? ensureLinearPrReference(result.body, issue, normalCloseLinearIssueOnMerge, { preserveExisting: false }) : result.body); } } catch (err: unknown) { setDraftError(err instanceof Error ? err.message : String(err)); @@ -648,6 +658,9 @@ export function CreatePrModal({ normalBaseBranchDefaultRef.current = ""; setNormalTitle(""); setNormalDraft(false); + setNormalCloseLinearIssueOnMerge(false); + normalLinearTitleDefaultRef.current = ""; + normalLinearBodyDefaultRef.current = ""; setQueueLaneIds([]); setQueueDraft(false); setQueueDragLaneId(null); @@ -732,6 +745,38 @@ export function CreatePrModal({ () => lanes.find((lane) => lane.id === normalLaneId) ?? null, [lanes, normalLaneId], ); + const selectedNormalLinearIssue = selectedNormalLane?.linearIssue ?? null; + + React.useEffect(() => { + if (!open) return; + if (!selectedNormalLinearIssue) { + normalLinearTitleDefaultRef.current = ""; + normalLinearBodyDefaultRef.current = ""; + return; + } + + const nextTitle = buildLinearPrTitle(selectedNormalLinearIssue); + setNormalTitle((current) => { + const previousAutoTitle = normalLinearTitleDefaultRef.current; + if (!current.trim() || (previousAutoTitle && current === previousAutoTitle)) { + normalLinearTitleDefaultRef.current = nextTitle; + return nextTitle; + } + normalLinearTitleDefaultRef.current = nextTitle; + return current; + }); + + const nextBody = `${buildLinearPrReference(selectedNormalLinearIssue, normalCloseLinearIssueOnMerge)}\n`; + setNormalBody((current) => { + const previousAutoBody = normalLinearBodyDefaultRef.current; + if (!current.trim() || (previousAutoBody && current === previousAutoBody)) { + normalLinearBodyDefaultRef.current = nextBody; + return nextBody; + } + normalLinearBodyDefaultRef.current = nextBody; + return ensureLinearPrReference(current, selectedNormalLinearIssue, normalCloseLinearIssueOnMerge, { preserveExisting: false }); + }); + }, [open, normalCloseLinearIssueOnMerge, selectedNormalLinearIssue]); React.useEffect(() => { if (!open) return; @@ -803,13 +848,21 @@ export function CreatePrModal({ try { if (mode === "normal") { const lane = lanes.find((l) => l.id === normalLaneId); + const linearIssue = lane?.linearIssue ?? null; + const title = linearIssue && !normalTitle.trim() + ? buildLinearPrTitle(linearIssue) + : normalTitle || lane?.name || "PR"; + const body = linearIssue + ? ensureLinearPrReference(normalBody, linearIssue, normalCloseLinearIssueOnMerge, { preserveExisting: false }) + : normalBody; const pr = await runWithDirtyWorktreeConfirmation({ confirmMessage: "Continue and create the PR anyway?", run: async (allowDirtyWorktree) => await window.ade.prs.createFromLane({ laneId: normalLaneId, - title: normalTitle || lane?.name || "PR", - body: normalBody, + title, + body, draft: normalDraft, + ...(linearIssue ? { closeLinearIssueOnMerge: normalCloseLinearIssueOnMerge } : {}), ...(normalBaseBranch.trim() ? { baseBranch: normalBaseBranch.trim() } : {}), ...(allowDirtyWorktree ? { allowDirtyWorktree: true } : {}) }) @@ -1933,6 +1986,73 @@ export function CreatePrModal({ /> + {selectedNormalLinearIssue ? ( +
+
+ + {selectedNormalLinearIssue.identifier} + + + {selectedNormalLinearIssue.title} + +
+ +
+ PR body will include {buildLinearPrReference(selectedNormalLinearIssue, normalCloseLinearIssueOnMerge)} so Linear links the PR. +
+
+ ) : null} +
- ) : null} {error ? (
diff --git a/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx b/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx index af206c215..7905f3ecf 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx @@ -27,13 +27,16 @@ export function LaneDialogShell({ return ( { if (!busy || next) onOpenChange(next); }}> - + -
+
diff --git a/apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx b/apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx index 461711fb7..02fdf3ccd 100644 --- a/apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx +++ b/apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx @@ -14,10 +14,6 @@ import { LinearMark, LinearPriorityIcon, LinearStateIcon, LINEAR_BRAND } from ". const ACTIVE_LINEAR_STATE_TYPES = ["backlog", "unstarted", "started"] as const; -// Re-exported so existing callers (ChatAttachmentTray, AgentChatComposer) -// keep working without updating their imports. -export { LinearMark } from "./linearBrand"; - const PRIORITY_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [ { value: "", label: "Any priority" }, { value: "1", label: "Urgent" }, @@ -295,6 +291,7 @@ export function LinearIssuePickerView({ pinnedIssueLabel = "Linked to this lane", onSelect, onBack, + onOpenLinearSettings, busy, selectOnIssueClick = false, submitLabel = "Connect issue", @@ -304,6 +301,7 @@ export function LinearIssuePickerView({ pinnedIssueLabel?: string; onSelect: (issue: LaneLinearIssue) => void; onBack: () => void; + onOpenLinearSettings?: () => void; busy?: boolean; selectOnIssueClick?: boolean; submitLabel?: string; @@ -474,6 +472,20 @@ export function LinearIssuePickerView({ setPendingIssue(issue); }, [onBack, onSelect, selectOnIssueClick]); + const openLinearSettings = React.useCallback(() => { + onBack(); + if (onOpenLinearSettings) { + onOpenLinearSettings(); + return; + } + const target = "/settings?tab=integrations&integration=linear"; + if (window.location.protocol === "http:" || window.location.protocol === "https:") { + window.location.assign(target); + return; + } + window.location.hash = target; + }, [onBack, onOpenLinearSettings]); + return (
{/* Linear-branded header banner — establishes context */} @@ -514,7 +526,8 @@ export function LinearIssuePickerView({ setQuery(event.target.value)} - className="h-10 w-full rounded-lg border border-white/[0.06] bg-white/[0.03] pl-9 pr-9 text-sm text-fg outline-none transition-colors placeholder:text-muted-fg/55 focus:border-white/15" + className="h-10 w-full rounded-lg border border-white/[0.06] pl-9 pr-9 text-sm text-fg outline-none transition-colors placeholder:text-muted-fg/55 focus:border-white/15" + style={{ backgroundColor: "var(--color-composer-bg, #14121F)" }} placeholder="Search issues by title, description, or identifier" disabled={busy} /> @@ -560,14 +573,28 @@ export function LinearIssuePickerView({
{error ? ( -
- {error} +
+ {error} +
) : null} {/* List + detail */}
-
+
{issues.length === 0 && !loadingIssues ? (
No Linear issues match these filters. @@ -598,7 +625,10 @@ export function LinearIssuePickerView({ ) : null}
-
+ {workspaceLabel ? ( +
+
+
+ Workspace +
+
+ {workspaceLabel} +
+
+ {connection?.organizationUrlKey ? ( +
+ {connection.organizationUrlKey} +
+ ) : null} +
+ To connect a different workspace, switch workspaces in Linear first, then reconnect here. +
+
+ ) : null} + {/* Project list */} {projects.length > 0 ? (
@@ -369,7 +417,7 @@ export function LinearSection() { Sign in with Linear
- Opens Linear in your browser for a secure OAuth flow. No keys to manage. + Connects the workspace currently selected in Linear.