From 6ed5e12d6ed55f93cfc5035b5dca950e04d36067 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:23:41 -0400 Subject: [PATCH 1/2] Fix Linear lane launch prompt delivery --- apps/ade-cli/src/adeRpcServer.test.ts | 24 +- apps/ade-cli/src/cli.test.ts | 3 +- .../services/chat/agentChatCliLaunch.test.ts | 10 + .../main/services/chat/agentChatCliLaunch.ts | 8 + .../src/main/services/pty/ptyService.ts | 19 +- .../components/app/LinearIssueBrowser.tsx | 4 +- .../components/app/LinearIssueSelectModal.tsx | 77 ++ .../components/chat/AgentChatComposer.tsx | 82 +- .../lanes/CreateLaneDialog.test.tsx | 47 +- .../components/lanes/CreateLaneDialog.tsx | 121 ++- .../renderer/components/lanes/LanesPage.tsx | 2 + .../components/lanes/LinearIssuePicker.tsx | 725 ------------------ .../components/lanes/linearIssueDisplay.ts | 59 ++ apps/desktop/src/shared/types/sessions.ts | 1 + 14 files changed, 356 insertions(+), 826 deletions(-) create mode 100644 apps/desktop/src/renderer/components/app/LinearIssueSelectModal.tsx delete mode 100644 apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx create mode 100644 apps/desktop/src/renderer/components/lanes/linearIssueDisplay.ts diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index f3307a4b6..104f5c5c8 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createAdeRpcRequestHandler, _resetGlobalAskUserRateLimit, @@ -11,6 +11,17 @@ import { JsonRpcError, JsonRpcErrorCode } from "./jsonrpc"; type RuntimeFixture = ReturnType; const originalPlatform = process.platform; +const ADE_ENV_KEYS = [ + "ADE_DEFAULT_ROLE", + "ADE_CHAT_SESSION_ID", + "ADE_RUN_ID", + "ADE_STEP_ID", + "ADE_ATTEMPT_ID", + "ADE_OWNER_ID", +] as const; +const originalAdeEnv = new Map( + ADE_ENV_KEYS.map((key) => [key, process.env[key]]), +); function setPlatform(value: NodeJS.Platform): void { Object.defineProperty(process, "platform", { @@ -19,8 +30,19 @@ function setPlatform(value: NodeJS.Platform): void { }); } +beforeEach(() => { + for (const key of ADE_ENV_KEYS) { + delete process.env[key]; + } +}); + afterEach(() => { setPlatform(originalPlatform); + for (const key of ADE_ENV_KEYS) { + const value = originalAdeEnv.get(key); + if (value == null) delete process.env[key]; + else process.env[key] = value; + } }); function createRuntime() { diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 23a103bd3..cf5e85c9f 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -207,8 +207,9 @@ describe("ADE CLI", () => { }); it("allows explicit runtime socket overrides across build hashes", () => { + const currentVersion = process.env.ADE_CLI_VERSION?.trim() || "0.0.0"; const runtimeInfo = { - version: "0.0.0", + version: currentVersion, buildHash: "other-build", defaultRole: "agent", packageChannel: null, diff --git a/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts b/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts index 9b4f9d2e1..54fec1498 100644 --- a/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts @@ -126,6 +126,16 @@ describe("launchAgentChatCli worktree-path resolution", () => { }); describe("launchAgentChatCli attached issue ids", () => { + it("awaits delayed kickoff input so slow CLI readiness cannot silently drop the prompt", async () => { + const deps = makeDeps(); + await launchAgentChatCli(makeArgs(), deps); + + const createArg = deps.create.mock.calls[0]?.[0] as PtyCreateArgs; + expect(createArg.initialInput).toContain("Resolve the attached issue"); + expect(createArg.awaitInitialInput).toBe(true); + expect(createArg.initialInputReadyTimeoutMs).toBe(120_000); + }); + it("returns only well-formed attached issue ids and drops malformed entries", async () => { const deps = makeDeps(); const result = await launchAgentChatCli( diff --git a/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts b/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts index 9a4ce5fb1..733c0ec84 100644 --- a/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts +++ b/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts @@ -23,6 +23,8 @@ type LoggerForCliLaunch = { info: (message: string, meta?: Record) => void; }; +const AGENT_CHAT_CLI_KICKOFF_READY_TIMEOUT_MS = 120_000; + export type AgentChatCliLaunchDeps = { laneService: LaneServiceForCliLaunch; ptyService: PtyServiceForCliLaunch; @@ -118,6 +120,12 @@ export async function launchAgentChatCli( ...(launch.initialInputDelayMs !== undefined ? { initialInputDelayMs: launch.initialInputDelayMs } : {}), + ...(launch.initialInput !== undefined + ? { + awaitInitialInput: true, + initialInputReadyTimeoutMs: AGENT_CHAT_CLI_KICKOFF_READY_TIMEOUT_MS, + } + : {}), ...(launch.env ? { env: launch.env } : {}), }); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index eb9039e99..50cfa305c 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -2964,8 +2964,12 @@ export function createPtyService({ return false; }; - const waitForAgentCliInputReady = async (sessionId: string, provider: TerminalResumeProvider): Promise => { - const deadline = Date.now() + AGENT_CLI_READY_TIMEOUT_MS; + const waitForAgentCliInputReady = async ( + sessionId: string, + provider: TerminalResumeProvider, + timeoutMs = AGENT_CLI_READY_TIMEOUT_MS, + ): Promise => { + const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const live = liveEntryBySessionId(sessionId); if (!live) return false; @@ -2985,7 +2989,7 @@ export function createPtyService({ } await delay(AGENT_CLI_READY_POLL_MS); } - logger.warn("pty.agent_cli_ready_wait_timeout", { sessionId, provider }); + logger.warn("pty.agent_cli_ready_wait_timeout", { sessionId, provider, timeoutMs }); return false; }; @@ -3751,13 +3755,20 @@ export function createPtyService({ if (requestedInitialInput.length > 0) { const normalizedInitialInput = requestedInitialInput.replace(/\r\n?/g, "\n"); + const initialInputReadyTimeoutMs = Math.max( + AGENT_CLI_READY_TIMEOUT_MS, + Math.min( + 300_000, + Math.floor(Number(args.initialInputReadyTimeoutMs ?? AGENT_CLI_READY_TIMEOUT_MS) || 0), + ), + ); const writeInitialInput = async (): Promise => { entry.initialInputTimer = null; if (entry.disposed) throw new Error("Terminal session closed before initial input could be sent."); const provider = providerFromTool(toolTypeHint); try { if (provider) { - const ready = await waitForAgentCliInputReady(sessionId, provider); + const ready = await waitForAgentCliInputReady(sessionId, provider, initialInputReadyTimeoutMs); if (!ready) { logger.warn("pty.initial_input_skipped_not_ready", { ptyId, diff --git a/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx b/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx index 7548728a1..6c937149b 100644 --- a/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx +++ b/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx @@ -34,13 +34,13 @@ import { issueUpdatedLabel, linearPriorityLabel, toLaneLinearIssue, -} from "../lanes/LinearIssuePicker"; +} from "../lanes/linearIssueDisplay"; import { LinearPriorityIcon, LinearStateIcon } from "../lanes/linearBrand"; import { LinearProjectIcon } from "../lanes/linearProjectIcon"; import { LinearIssueOpenLink } from "./LinearIssueResolveModals"; import type { IssueConflict } from "../../lib/linearBatchLaunch"; -type BrowserIssue = NormalizedLinearIssue | LaneLinearIssue; +export type BrowserIssue = NormalizedLinearIssue | LaneLinearIssue; type IssueSort = "updated_desc" | "created_desc" | "priority" | "due_soon" | "identifier_asc"; /** diff --git a/apps/desktop/src/renderer/components/app/LinearIssueSelectModal.tsx b/apps/desktop/src/renderer/components/app/LinearIssueSelectModal.tsx new file mode 100644 index 000000000..ce84fc0b6 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/LinearIssueSelectModal.tsx @@ -0,0 +1,77 @@ +import React, { useCallback, useState } from "react"; +import { Check } from "@phosphor-icons/react"; + +import type { CtoLinearQuickView, LaneLinearIssue } from "../../../shared/types"; +import { LinearIssueBrowser, linearBrowserIssueToLaneIssue } from "./LinearIssueBrowser"; +import { LinearPaneModal } from "./LinearPaneModal"; + +export function LinearIssueSelectModal({ + open, + ariaLabel = "Select Linear issue", + projectRoot, + selectedIssue, + pinnedIssue, + pinnedIssueLabel, + actionLabel = "Connect issue", + actionBusyLabel, + actionDisabled = false, + showBranchPreview = true, + onOpenChange, + onSelectIssue, + onOpenLinearSettings, +}: { + open: boolean; + ariaLabel?: string; + projectRoot?: string | null; + selectedIssue: LaneLinearIssue | null; + pinnedIssue?: LaneLinearIssue | null; + pinnedIssueLabel?: string; + actionLabel?: string; + actionBusyLabel?: string; + actionDisabled?: boolean; + showBranchPreview?: boolean; + onOpenChange: (open: boolean) => void; + onSelectIssue: (issue: LaneLinearIssue) => void; + onOpenLinearSettings?: () => void; +}) { + const featuredIssue = pinnedIssue ?? selectedIssue; + const [quickView, setQuickView] = useState(null); + const [browserLoading, setBrowserLoading] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + + const close = useCallback(() => onOpenChange(false), [onOpenChange]); + const openLinearSettings = useCallback(() => { + onOpenChange(false); + onOpenLinearSettings?.(); + }, [onOpenChange, onOpenLinearSettings]); + + return ( + setRefreshKey((key) => key + 1)} + onClose={close} + > + } + actionDisabled={actionDisabled} + showBranchPreview={showBranchPreview} + refreshKey={refreshKey} + onOpenLinearSettings={openLinearSettings} + onQuickViewChange={setQuickView} + onLoadingChange={setBrowserLoading} + onIssueAction={(issue) => { + onSelectIssue(linearBrowserIssueToLaneIssue(issue)); + onOpenChange(false); + }} + /> + + ); +} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index b014c9da4..19dbc9980 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -21,7 +21,6 @@ import { type AgentChatSlashCommand, type CodexThreadTokenUsage, type ComputerUseOwnerSnapshot, - type CtoLinearQuickView, type ChatSurfaceMode, type AppControlContextItem, type BuiltInBrowserContextItem, @@ -54,8 +53,7 @@ import { type ChatAttachmentPendingImage, } from "./ChatAttachmentTray"; import { ChatComposerShell } from "./ChatComposerShell"; -import { LinearIssueBrowser, linearBrowserIssueToLaneIssue } from "../app/LinearIssueBrowser"; -import { LinearPaneModal } from "../app/LinearPaneModal"; +import { LinearIssueSelectModal } from "../app/LinearIssueSelectModal"; import { LinearMark, LINEAR_BRAND } from "../lanes/linearBrand"; import { getPendingInputQuestionCount, hasPendingInputOptions } from "./pendingInput"; import { CURSOR_MODE_LABELS } from "../../../shared/cursorModes"; @@ -729,68 +727,6 @@ function PendingSteerItem({ ); } -function LinearIssueContextDialog({ - open, - selectedIssue, - pinnedIssue, - busy, - onOpenChange, - onAttach, - onOpenLinearSettings, -}: { - open: boolean; - selectedIssue: LaneLinearIssue | null; - pinnedIssue?: LaneLinearIssue | null; - busy?: boolean; - onOpenChange: (open: boolean) => void; - onAttach: (attachment: AgentChatContextAttachment) => void; - onOpenLinearSettings?: () => void; -}) { - const featuredIssue = pinnedIssue ?? selectedIssue; - const [quickView, setQuickView] = useState(null); - const [browserLoading, setBrowserLoading] = useState(false); - const [refreshKey, setRefreshKey] = useState(0); - - const close = useCallback(() => onOpenChange(false), [onOpenChange]); - const openLinearSettings = useCallback(() => { - onOpenChange(false); - onOpenLinearSettings?.(); - }, [onOpenChange, onOpenLinearSettings]); - - return ( - setRefreshKey((key) => key + 1)} - onClose={close} - > - } - actionDisabled={busy} - showBranchPreview={false} - refreshKey={refreshKey} - onOpenLinearSettings={openLinearSettings} - onQuickViewChange={setQuickView} - onLoadingChange={setBrowserLoading} - onIssueAction={(issue) => { - const laneIssue = linearBrowserIssueToLaneIssue(issue); - onAttach(makeLinearIssueContextAttachment( - laneIssue, - pinnedIssue?.id === laneIssue.id ? "lane_link" : "manual", - )); - onOpenChange(false); - }} - /> - - ); -} - export function AgentChatComposer({ surfaceMode = "standard", layoutVariant = "standard", @@ -2940,18 +2876,26 @@ export function AgentChatComposer({ return ( <> {issueContextMenu} - { - onAddContextAttachment?.(attachment); + onSelectIssue={(laneIssue) => { + onAddContextAttachment?.(makeLinearIssueContextAttachment( + laneIssue, + pinnedLinearIssue?.id === laneIssue.id ? "lane_link" : "manual", + )); setLinearIssuePickerOpen(false); }} onOpenLinearSettings={onOpenLinearSettings} diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx index 589205d02..b43178c06 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx @@ -1,10 +1,26 @@ /* @vitest-environment jsdom */ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { LaneRuntimePlacement, LaneSummary } from "../../../shared/types"; import { CreateLaneDialog } from "./CreateLaneDialog"; +beforeEach(() => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + afterEach(cleanup); type DialogProps = Parameters[0]; @@ -152,4 +168,33 @@ describe("CreateLaneDialog VM-lane gate", () => { expect(vmRadio.disabled).toBe(false); expect(screen.queryByTestId("create-lane-vm-gate")).toBeNull(); }); + + it("opens the shared Linear issue browser as its own modal", async () => { + Object.defineProperty(window, "ade", { + configurable: true, + value: { + cto: { + getLinearIssuePickerData: vi.fn(async () => ({ + projects: [], + users: [], + states: [], + })), + searchLinearIssues: vi.fn(async () => ({ + issues: [], + pageInfo: { hasNextPage: false, endCursor: null }, + })), + }, + }, + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /connect a linear issue/i })); + + expect(await screen.findByRole("dialog", { name: "Connect Linear issue" })).toBeTruthy(); + expect(screen.queryByText("Lane name")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Close Connect Linear issue backdrop" })); + expect(await screen.findByText("Lane name")).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx index 133336388..5336eec94 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { ArrowSquareOut, CaretDown, CaretRight, CheckCircle, Circle, DesktopTower, GitBranch, GitFork, Plus, SpinnerGap, StackSimple, Tag } from "@phosphor-icons/react"; +import { ArrowSquareOut, CaretDown, CaretRight, CheckCircle, Circle, DesktopTower, GitBranch, GitFork, Plus, SpinnerGap, StackSimple, Tag, X } from "@phosphor-icons/react"; import { Button } from "../ui/Button"; import type { BranchPullRequest, @@ -17,12 +17,9 @@ 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 { branchExistsForLinearIssue, issueProjectLabel } from "./linearIssueDisplay"; +import { LinearMark, LinearPriorityIcon, LinearStateIcon, LINEAR_BRAND } from "./linearBrand"; +import { LinearIssueSelectModal } from "../app/LinearIssueSelectModal"; import { SECTION_CLASS_NAME, LABEL_CLASS_NAME, @@ -110,6 +107,7 @@ export function CreateLaneDialog({ existingVmLane = null, onOpenVmTab, onOpenVmLaneInWork, + projectRoot, createBranches, lanes, onSubmit, @@ -123,6 +121,7 @@ export function CreateLaneDialog({ selectedTemplateId, setSelectedTemplateId, onNavigateToTemplates, + onOpenLinearSettings, importBranchWarning, selectedColor, setSelectedColor, @@ -159,6 +158,8 @@ export function CreateLaneDialog({ onOpenVmTab?: () => void; /** Open the Work tab on the existing VM lane. Used when a VM lane already exists. */ onOpenVmLaneInWork?: (laneId: string) => void; + /** Project scope for shared Linear issue browser cache/filter persistence. */ + projectRoot?: string | null; createBranches: LaneBranchOption[]; lanes: LaneSummary[]; onSubmit: () => void; @@ -173,6 +174,7 @@ export function CreateLaneDialog({ selectedTemplateId: string; setSelectedTemplateId: (id: string) => void; onNavigateToTemplates?: () => void; + onOpenLinearSettings?: () => void; /** Warning shown below the import branch selector (e.g. uncommitted changes). */ importBranchWarning?: string | null; selectedColor: string | null; @@ -275,17 +277,16 @@ export function CreateLaneDialog({ || vmRuntimeBlocked); return ( + <> { event.preventDefault(); @@ -295,14 +296,7 @@ export function CreateLaneDialog({ target?.focus?.(); }} > - {issuePickerOpen ? ( - setIssuePickerOpen(false)} - busy={busy || laneCreated} - /> - ) : pickerOpen ? ( + {pickerOpen ? ( Linear issue {selectedLinearIssue ? ( <> - )} + + + ); +} + +function SelectedLinearIssueCard({ + issue, + branchName, + branchConflict, + onClear, +}: { + issue: LaneLinearIssue; + branchName: string; + branchConflict: boolean; + onClear: () => void; +}) { + return ( +
+
+ + + +
+
+ + + + {issue.identifier} + + {issue.title} +
+
+ {issueProjectLabel(issue)} + · + {issue.stateName} + {issue.assigneeName ? ( + <> + · + {issue.assigneeName} + + ) : null} +
+
+ branch + {branchName} + {branchConflict ? already exists : null} +
+
+ +
+
); } diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 857c7da57..49703843c 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -4292,6 +4292,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { // longer the canonical destination for opening a lane. navigate("/project"); }} + projectRoot={project?.rootPath ?? null} createBranches={createBranches} lanes={lanes} onSubmit={handleCreateSubmit} @@ -4312,6 +4313,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { currentGitUserName={createGitUserName} loadingBranches={createBranchesLoading} loadingBranchPullRequests={createBranchPullRequestsLoading} + onOpenLinearSettings={() => navigate("/settings?tab=integrations&integration=linear")} onNavigateToTemplates={() => navigate("/settings?tab=lane-templates")} importBranchWarning={ createMode === "existing" && createImportBranch && primaryLane?.status.dirty diff --git a/apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx b/apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx deleted file mode 100644 index d280b89f4..000000000 --- a/apps/desktop/src/renderer/components/lanes/LinearIssuePicker.tsx +++ /dev/null @@ -1,725 +0,0 @@ -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; - -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, - onOpenLinearSettings, - busy, - selectOnIssueClick = false, - submitLabel = "Connect issue", -}: { - selectedIssue: LaneLinearIssue | null; - pinnedIssue?: LaneLinearIssue | null; - pinnedIssueLabel?: string; - onSelect: (issue: LaneLinearIssue) => void; - onBack: () => void; - onOpenLinearSettings?: () => 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 ?? selectedIssue ?? 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 ?? selectedIssue ?? null); - }, [pinnedIssue, selectedIssue]); - - 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]); - - 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 */} -
- - - - - 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] 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} - /> - {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/linearIssueDisplay.ts b/apps/desktop/src/renderer/components/lanes/linearIssueDisplay.ts new file mode 100644 index 000000000..69bd7c941 --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/linearIssueDisplay.ts @@ -0,0 +1,59 @@ +import type { LaneLinearIssue, NormalizedLinearIssue } from "../../../shared/types"; +import { linearIssueBranchName } from "../../../shared/linearIssueBranch"; +import { formatRelativeTime } from "./branchPickerSearch"; +import type { LaneBranchOption } from "./laneUtils"; + +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, + }; +} + +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; + }); +} diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index a35c84286..41f58031e 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -138,6 +138,7 @@ export type PtyCreateArgs = { /** Optional input to send to the PTY after the process starts. */ initialInput?: string; initialInputDelayMs?: number; + initialInputReadyTimeoutMs?: number; /** When true, create rejects if initialInput cannot be delivered. */ awaitInitialInput?: boolean; command?: string; From 526b5a02fb1f034c6dbd6e463b3b1827d12d729e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:56:38 -0400 Subject: [PATCH 2/2] Address PR review feedback --- .../src/main/services/pty/ptyService.ts | 19 ++++++- .../components/app/LinearIssueBrowser.tsx | 57 +++++++++++-------- .../components/app/LinearIssueSelectModal.tsx | 1 + .../components/chat/AgentChatComposer.tsx | 13 +++-- 4 files changed, 59 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 50cfa305c..e0baac7d0 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -3755,13 +3755,30 @@ export function createPtyService({ if (requestedInitialInput.length > 0) { const normalizedInitialInput = requestedInitialInput.replace(/\r\n?/g, "\n"); + const requestedInitialInputReadyTimeoutMs = args.initialInputReadyTimeoutMs; + const parsedInitialInputReadyTimeoutMs = Math.floor( + Number(requestedInitialInputReadyTimeoutMs ?? AGENT_CLI_READY_TIMEOUT_MS) || 0, + ); const initialInputReadyTimeoutMs = Math.max( AGENT_CLI_READY_TIMEOUT_MS, Math.min( 300_000, - Math.floor(Number(args.initialInputReadyTimeoutMs ?? AGENT_CLI_READY_TIMEOUT_MS) || 0), + parsedInitialInputReadyTimeoutMs, ), ); + if ( + requestedInitialInputReadyTimeoutMs != null + && parsedInitialInputReadyTimeoutMs !== initialInputReadyTimeoutMs + ) { + logger.warn("pty.initial_input_ready_timeout_clamped", { + ptyId, + sessionId, + requestedTimeoutMs: requestedInitialInputReadyTimeoutMs, + effectiveTimeoutMs: initialInputReadyTimeoutMs, + minTimeoutMs: AGENT_CLI_READY_TIMEOUT_MS, + maxTimeoutMs: 300_000, + }); + } const writeInitialInput = async (): Promise => { entry.initialInputTimer = null; if (entry.disposed) throw new Error("Terminal session closed before initial input could be sent."); diff --git a/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx b/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx index 6c937149b..8dc777e30 100644 --- a/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx +++ b/apps/desktop/src/renderer/components/app/LinearIssueBrowser.tsx @@ -417,6 +417,7 @@ export function LinearIssueBrowser({ actionBusyIssueId, actionDisabled = false, showBranchPreview = true, + singleSelect = false, refreshKey = 0, requestedIssueIdentifier, requestedIssueRequestKey, @@ -436,6 +437,7 @@ export function LinearIssueBrowser({ actionBusyIssueId?: string | null; actionDisabled?: boolean; showBranchPreview?: boolean; + singleSelect?: boolean; refreshKey?: number; requestedIssueIdentifier?: string | null; requestedIssueRequestKey?: string | number | null; @@ -474,7 +476,8 @@ export function LinearIssueBrowser({ const [selectedIssueId, setSelectedIssueId] = useState(featuredIssue?.id ?? null); const [selectedIssueIds, setSelectedIssueIds] = useState>(() => safeLoadSelection(projectRoot)); const [lastCheckedId, setLastCheckedId] = useState(null); - const anyChecked = selectedIssueIds.size > 0; + const multiSelectEnabled = !singleSelect; + const anyChecked = multiSelectEnabled && selectedIssueIds.size > 0; const [collapsedGroups, setCollapsedGroups] = useState>({}); const [error, setError] = useState(null); const quickViewRequestIdRef = useRef(0); @@ -502,8 +505,9 @@ export function LinearIssueBrowser({ // the launch modal → back). Cleared automatically when the selection empties // (safeSaveSelection removes the key) and when filters change (effect below). useEffect(() => { + if (!multiSelectEnabled) return; safeSaveSelection(projectRoot, selectedIssueIds); - }, [projectRoot, selectedIssueIds]); + }, [multiSelectEnabled, projectRoot, selectedIssueIds]); useEffect(() => { const nextFilters = safeLoadFilters(projectRoot); @@ -983,7 +987,7 @@ export function LinearIssueBrowser({ - {displayIssues.length > 0 && ( + {multiSelectEnabled && displayIssues.length > 0 && (
handleToggleCheck(issue.id, e)} onClick={() => setSelectedIssueId(issue.id)} @@ -1080,7 +1085,7 @@ export function LinearIssueBrowser({
- {selectedIssueIds.size > 1 && onBatchLaunch ? ( + {multiSelectEnabled && selectedIssueIds.size > 1 && onBatchLaunch ? ( setSelectedIssueIds(new Set())} @@ -1188,6 +1193,7 @@ function LinearBrowserIssueRow({ busy, checked, anyChecked: anyRowChecked, + showCheckbox, conflict, onToggleCheck, onClick, @@ -1198,6 +1204,7 @@ function LinearBrowserIssueRow({ busy?: boolean; checked: boolean; anyChecked: boolean; + showCheckbox: boolean; conflict?: IssueConflict | null; onToggleCheck: (event: React.MouseEvent) => void; onClick: () => void; @@ -1236,27 +1243,29 @@ function LinearBrowserIssueRow({ "bounce" when the row toggles. stopPropagation keeps the toggle from also triggering the row's preview-select. */} - + + {checked ? : null} + + + ) : null} {issue.identifier} diff --git a/apps/desktop/src/renderer/components/app/LinearIssueSelectModal.tsx b/apps/desktop/src/renderer/components/app/LinearIssueSelectModal.tsx index ce84fc0b6..68dbaa87f 100644 --- a/apps/desktop/src/renderer/components/app/LinearIssueSelectModal.tsx +++ b/apps/desktop/src/renderer/components/app/LinearIssueSelectModal.tsx @@ -63,6 +63,7 @@ export function LinearIssueSelectModal({ actionIcon={} actionDisabled={actionDisabled} showBranchPreview={showBranchPreview} + singleSelect refreshKey={refreshKey} onOpenLinearSettings={openLinearSettings} onQuickViewChange={setQuickView} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 19dbc9980..cc711e824 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -2873,17 +2873,19 @@ export function AgentChatComposer({ document.body, ) : null; + const selectedLinearContextIssue = contextAttachments.find( + (attachment): attachment is Extract => ( + attachment.type === "linear_issue" + ), + )?.issue ?? null; + return ( <> {issueContextMenu}