From 8d35be285d32c1640308f0d1f9a6c8cc6f7d4e77 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:12:28 -0400 Subject: [PATCH 1/4] feat: add Work tab lane creation flow --- apps/ade-cli/src/bootstrap.ts | 1 + .../src/tuiClient/__tests__/adeApi.test.ts | 54 +- apps/ade-cli/src/tuiClient/adeApi.ts | 30 +- apps/ade-cli/src/tuiClient/app.tsx | 21 +- apps/desktop/src/main/main.ts | 1 + .../src/main/services/lanes/laneService.ts | 46 +- apps/desktop/src/preload/global.d.ts | 2 + apps/desktop/src/preload/preload.ts | 42 ++ apps/desktop/src/renderer/browserMock.ts | 2 + .../components/app/AppShell.aiStatus.test.tsx | 2 + .../src/renderer/components/app/AppShell.tsx | 8 +- .../components/app/toast/ToastStack.tsx | 105 +++ .../components/app/toast/toastStore.test.ts | 322 +++++++++ .../components/app/toast/toastStore.ts | 204 ++++++ .../app/toast/useLaneEventToasts.ts | 83 +++ .../components/graph/WorkspaceGraphPage.tsx | 10 + .../components/history/historyLaneActions.ts | 22 +- .../components/lanes/CreateLaneDialog.tsx | 54 +- .../components/lanes/CreateLaneDialogHost.tsx | 677 ++++++++++++++++++ .../components/lanes/LaneDialogShell.tsx | 10 + .../components/lanes/LaneGitActionsPane.tsx | 14 + .../renderer/components/lanes/LanesPage.tsx | 535 +------------- .../components/terminals/SessionListPane.tsx | 17 +- apps/desktop/src/renderer/index.css | 14 + apps/desktop/src/shared/ipc.ts | 1 + apps/desktop/src/shared/types/lanes.ts | 16 + apps/ios/ADE/Views/Work/WorkPreviews.swift | 3 +- .../ADE/Views/Work/WorkRootComponents.swift | 63 +- apps/ios/ADE/Views/Work/WorkRootScreen.swift | 20 +- docs/ARCHITECTURE.md | 8 +- docs/features/lanes/README.md | 20 +- docs/features/lanes/runtime.md | 8 +- .../features/terminals-and-sessions/README.md | 6 +- .../terminals-and-sessions/ui-surfaces.md | 5 + docs/perf/work-tab-action-inventory.md | 2 +- 35 files changed, 1854 insertions(+), 574 deletions(-) create mode 100644 apps/desktop/src/renderer/components/app/toast/ToastStack.tsx create mode 100644 apps/desktop/src/renderer/components/app/toast/toastStore.test.ts create mode 100644 apps/desktop/src/renderer/components/app/toast/toastStore.ts create mode 100644 apps/desktop/src/renderer/components/app/toast/useLaneEventToasts.ts create mode 100644 apps/desktop/src/renderer/components/lanes/CreateLaneDialogHost.tsx diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 87e493bcb..fd56dcf1b 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -496,6 +496,7 @@ export async function createAdeRuntime(args: { } }, onDeleteEvent: (event) => pushEvent("runtime", { type: "lane_delete_event", event }), + onLifecycleEvent: (event) => pushEvent("runtime", { type: "lane_lifecycle_event", event }), onLinearIssueLinked: ({ lane, issue, linkedAt }) => { const tracker = linearIssueTrackerRef; if (!tracker) return; diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 2487f0284..efd02b60b 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { archiveChatSession, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, getChatHistoryPage, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, resumeTerminalSession, sendChatMessage, signalTerminal, startClaudeTerminalSession, steerChatMessage, unarchiveChatSession } from "../adeApi"; +import { archiveChatSession, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, getChatHistoryPage, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, signalTerminal, startClaudeTerminalSession, steerChatMessage, unarchiveChatSession } from "../adeApi"; import type { AdeCodeConnection } from "../types"; const tmpPaths: string[] = []; @@ -56,6 +56,58 @@ describe("listLaneDiffStats", () => { }); }); +describe("runDefaultLaneSetup", () => { + it("applies the configured default template when it still exists", async () => { + const calls: Array<{ domain: string; action: string; args: Record | undefined }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); + if (action === "listTemplates") return [{ id: "tpl-1", name: "Default" }]; + if (action === "getDefaultTemplate") return "tpl-1"; + if (action === "applyTemplate") { + return { laneId: "lane-1", steps: [], startedAt: "2026-01-01T00:00:00.000Z", overallStatus: "completed" }; + } + throw new Error(`unexpected action ${action}`); + }, + } as unknown as AdeCodeConnection; + + const result = await runDefaultLaneSetup(connection, "lane-1"); + + expect(result.templateId).toBe("tpl-1"); + expect(result.progress.overallStatus).toBe("completed"); + expect(calls).toEqual([ + { domain: "lane", action: "listTemplates", args: undefined }, + { domain: "lane", action: "getDefaultTemplate", args: undefined }, + { domain: "lane", action: "applyTemplate", args: { laneId: "lane-1", templateId: "tpl-1" } }, + ]); + }); + + it("falls back to lane init when the saved default template is gone", async () => { + const calls: Array<{ domain: string; action: string; args: Record | undefined }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); + if (action === "listTemplates") return [{ id: "other", name: "Other" }]; + if (action === "getDefaultTemplate") return "missing"; + if (action === "initEnv") { + return { laneId: "lane-1", steps: [], startedAt: "2026-01-01T00:00:00.000Z", overallStatus: "completed" }; + } + throw new Error(`unexpected action ${action}`); + }, + } as unknown as AdeCodeConnection; + + const result = await runDefaultLaneSetup(connection, "lane-1"); + + expect(result.templateId).toBeNull(); + expect(result.progress.overallStatus).toBe("completed"); + expect(calls).toEqual([ + { domain: "lane", action: "listTemplates", args: undefined }, + { domain: "lane", action: "getDefaultTemplate", args: undefined }, + { domain: "lane", action: "initEnv", args: { laneId: "lane-1" } }, + ]); + }); +}); + describe("getChatHistoryPage", () => { it("calls the positional chat history page action and passes maxBytes only when set", async () => { const calls: Array<{ domain: string; action: string; argsList: unknown[] }> = []; diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 073e6f453..6184e547a 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -40,7 +40,12 @@ import type { AgentChatSubagentTranscriptMessage, CodexThreadGoal, } from "../../../desktop/src/shared/types/chat"; -import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; +import type { + AiSettingsStatus, + LaneEnvInitProgress, + LaneTemplate, + OpenCodeRuntimeSnapshot, +} from "../../../desktop/src/shared/types/config"; import type { DiffLineStats, GitBranchSummary } from "../../../desktop/src/shared/types/git"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { PrLaneSummary } from "../../../desktop/src/shared/types/prs"; @@ -66,6 +71,29 @@ export async function listLanes( }); } +export type DefaultLaneSetupResult = { + progress: LaneEnvInitProgress; + templateId: string | null; +}; + +export async function runDefaultLaneSetup( + connection: AdeCodeConnection, + laneId: string, +): Promise { + const [templates, defaultTemplateId] = await Promise.all([ + connection.action("lane", "listTemplates").catch(() => []), + connection.action("lane", "getDefaultTemplate").catch(() => null), + ]); + const trimmedTemplateId = typeof defaultTemplateId === "string" ? defaultTemplateId.trim() : ""; + const templateId = trimmedTemplateId && templates.some((template) => template.id === trimmedTemplateId) + ? trimmedTemplateId + : null; + const progress = templateId + ? await connection.action("lane", "applyTemplate", { laneId, templateId }) + : await connection.action("lane", "initEnv", { laneId }); + return { progress, templateId }; +} + export async function listGitBranches( connection: AdeCodeConnection, laneId: string, diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index a30a0d88f..bc0ed48ec 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -85,6 +85,7 @@ import { resizeTerminal, reloadClaudePlugins, respondToInput, + runDefaultLaneSetup, saveRuntimeTempAttachment, sendChatMessage, sendToTerminalSession, @@ -5300,6 +5301,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, ]); }, []); + const runLaneSetupAfterCreate = useCallback((conn: AdeCodeConnection, lane: LaneSummary) => { + void runDefaultLaneSetup(conn, lane.id) + .then(({ progress }) => { + if (progress.overallStatus !== "failed") return; + const failedStep = progress.steps.find((step) => step.status === "failed"); + const detail = failedStep?.error?.trim() + || (failedStep ? `${failedStep.label} failed.` : "Environment setup failed."); + addNotice(`Lane setup failed for ${lane.name}: ${detail}`, "error"); + }) + .catch((err) => { + addNotice(`Lane setup failed for ${lane.name}: ${err instanceof Error ? err.message : String(err)}`, "error"); + }); + }, [addNotice]); + const activateLaneWithLastChat = useCallback((lane: LaneSummary, options: { notify?: boolean } = {}) => { const laneSessions = displaySessions.filter((entry) => entry.laneId === lane.id); const lastSessionId = lastChatByLaneRef.current.get(lane.id); @@ -8225,6 +8240,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setDrawerLaneId(created.id); setSelectedDrawerLaneId(created.id); setSelectedDrawerLaneAction(null); + runLaneSetupAfterCreate(conn, created); return; } if (name === "/rename" || name === "/chat rename") { @@ -8874,7 +8890,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, : result; setRightPane({ kind: "details", title: `ADE ${domain}.${action}`, body: renderObject(body, 24) }); } - }, [activateLaneWithLastChat, activeCommandProvider, activeLane?.name, activeSession?.provider, activeSession?.sessionId, activeSession?.title, addNotice, applyDrawerChatSelection, archiveChat, archiveLane, ensureActiveSession, focusDetails, lanes, mode, modelState.modelId, modelState.reasoningEffort, models, openChatDeleteForm, openChatRenameForm, openFeedbackForm, openForm, openLaneDeleteForm, openLaneRenameForm, openModelPicker, openNewChatSetup, openNewLaneForm, openSubagentsPane, pendingSteers, project, refreshState, renameLane, selectActiveLaneId, selectActiveSessionId, sendOrSteerChatMessage, sessions, setChatScrollOffset, subagentPaneCommandAvailable, unarchiveChat, unarchiveLane]); + }, [activateLaneWithLastChat, activeCommandProvider, activeLane?.name, activeSession?.provider, activeSession?.sessionId, activeSession?.title, addNotice, applyDrawerChatSelection, archiveChat, archiveLane, ensureActiveSession, focusDetails, lanes, mode, modelState.modelId, modelState.reasoningEffort, models, openChatDeleteForm, openChatRenameForm, openFeedbackForm, openForm, openLaneDeleteForm, openLaneRenameForm, openModelPicker, openNewChatSetup, openNewLaneForm, openSubagentsPane, pendingSteers, project, refreshState, renameLane, runLaneSetupAfterCreate, selectActiveLaneId, selectActiveSessionId, sendOrSteerChatMessage, sessions, setChatScrollOffset, subagentPaneCommandAvailable, unarchiveChat, unarchiveLane]); const runInlineCommand = useCallback(async (name: string, args: string) => { if (name === "/quit") { @@ -9196,6 +9212,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, lastUserOpenedPaneRef.current = null; focusAfterDetails(); addNotice(`Created lane ${created.name}.`, "success"); + runLaneSetupAfterCreate(conn, created); await refreshState(); setDrawerLaneId(created.id); setSelectedDrawerLaneId(created.id); @@ -9457,7 +9474,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, addNotice(`Feedback failed: ${message}`, "error"); } } - }, [activeLaneId, addNotice, focusAfterDetails, lanes, refreshState, renameLane, selectActiveLaneId, selectActiveSessionId, selectFallbackChatAfterRemoval, sessions]); + }, [activeLaneId, addNotice, focusAfterDetails, lanes, refreshState, renameLane, runLaneSetupAfterCreate, selectActiveLaneId, selectActiveSessionId, selectFallbackChatAfterRemoval, sessions]); const openLatestImage = useCallback(() => { const target = latestOpenableImageTarget(events); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index c49474c6d..b5b2615b7 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -2274,6 +2274,7 @@ app.whenReady().then(async () => { } }, onDeleteEvent: (event) => emitProjectEvent(projectRoot, IPC.lanesDeleteEvent, event), + onLifecycleEvent: (event) => emitProjectEvent(projectRoot, IPC.lanesLifecycleEvent, event), onLinearIssueLinked: ({ lane, issue, linkedAt }) => { const tracker = linearIssueTrackerRef; if (!tracker) return; diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index f12e92d91..9a0e17a26 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -28,6 +28,7 @@ import type { CreateLaneFromUnstagedArgs, DeleteLaneArgs, LaneDeleteEvent, + LaneLifecycleEvent, LaneDeleteProgress, LaneDeleteRisk, LaneDeleteStep, @@ -931,6 +932,7 @@ export function createLaneService({ onHeadChanged, onRebaseEvent, onDeleteEvent, + onLifecycleEvent, onLinearIssueLinked, onLinearIssueSessionLinked, teardownDeps, @@ -945,6 +947,7 @@ export function createLaneService({ onHeadChanged?: (args: { laneId: string; reason: string; preHeadSha: string | null; postHeadSha: string | null }) => void; onRebaseEvent?: (event: RebaseRunEventPayload) => void; onDeleteEvent?: (event: LaneDeleteEvent) => void; + onLifecycleEvent?: (event: LaneLifecycleEvent) => void; onLinearIssueLinked?: (args: { lane: LaneSummary; issue: LaneLinearIssue; linkedAt: string }) => void | Promise; onLinearIssueSessionLinked?: (args: { laneId: string; @@ -2697,6 +2700,14 @@ export function createLaneService({ }); } + broadcastLifecycleEvent({ + type: "lane-created", + laneId: summary.id, + laneName: summary.name, + color: summary.color, + lane: summary, + }); + return summary; }; @@ -2811,6 +2822,19 @@ export function createLaneService({ } }; + const broadcastLifecycleEvent = (event: LaneLifecycleEvent): void => { + if (!onLifecycleEvent) return; + try { + onLifecycleEvent(event); + } catch (err) { + logger.warn("lane.lifecycle.broadcast_failed", { + laneId: event.laneId, + type: event.type, + error: err instanceof Error ? err.message : String(err), + }); + } + }; + const cleanupLaneDatabaseRows = (laneId: string): void => { db.run("update lanes set parent_lane_id = null where parent_lane_id = ? and project_id = ?", [laneId, projectId]); db.run("update lane_branch_profiles set parent_lane_id = null where parent_lane_id = ? and project_id = ?", [laneId, projectId]); @@ -3781,7 +3805,7 @@ export function createLaneService({ } } - return toLaneSummary({ + const summary = toLaneSummary({ row, status, parentStatus, @@ -3789,6 +3813,14 @@ export function createLaneService({ stackDepth: computeStackDepth({ laneId, rowsById, memo: new Map() }), activeBranchProfile: ensureBranchProfileForRow(row) }); + broadcastLifecycleEvent({ + type: "lane-created", + laneId: summary.id, + laneName: summary.name, + color: summary.color, + lane: summary, + }); + return summary; } catch (error) { if (laneInserted) { const persistedRow = getLaneRow(laneId); @@ -4880,6 +4912,12 @@ export function createLaneService({ const now = new Date().toISOString(); db.run("update lanes set status = 'archived', archived_at = ? where id = ? and project_id = ?", [now, laneId, projectId]); invalidateLaneListCache(); + broadcastLifecycleEvent({ + type: "lane-archived", + laneId, + laneName: row.name, + color: row.color, + }); }, unarchive({ laneId }: { laneId: string }): void { @@ -5273,6 +5311,12 @@ export function createLaneService({ invalidateLaneListCache(); finalize(nonFatalFailures.length > 0 ? "completed_with_warnings" : "completed"); + broadcastLifecycleEvent({ + type: "lane-deleted", + laneId, + laneName: row.name, + color: row.color, + }); finishDeleteOperation("succeeded", { overallStatus: progress.overallStatus, warnings: nonFatalFailures, diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 12940d827..82fee1f92 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -498,6 +498,7 @@ import type { GetLaneEnvStatusArgs, GetLaneOverlayArgs, LaneDeleteEvent, + LaneLifecycleEvent, LaneDeleteProgress, LaneDeleteRisk, LaneEnvInitProgress, @@ -1159,6 +1160,7 @@ declare global { listDeleteProgress: () => Promise; getDeleteRisk: (args: { laneId: string }) => Promise; onDeleteEvent: (cb: (ev: LaneDeleteEvent) => void) => () => void; + onLifecycleEvent: (cb: (ev: LaneLifecycleEvent) => void) => () => void; getStackChain: (laneId: string) => Promise; getChildren: (laneId: string) => Promise; attachLinearIssueToSession: (args: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index ae2968302..427fe44a3 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -492,6 +492,7 @@ import type { GetLaneEnvStatusArgs, GetLaneOverlayArgs, LaneDeleteEvent, + LaneLifecycleEvent, LaneDeleteProgress, LaneDeleteRisk, LaneEnvInitProgress, @@ -1497,6 +1498,9 @@ const remoteSessionChangedCallbacks = new Set< const remoteLaneDeleteEventCallbacks = new Set< (payload: LaneDeleteEvent) => void >(); +const remoteLaneLifecycleEventCallbacks = new Set< + (payload: LaneLifecycleEvent) => void +>(); const remoteLaneRebaseEventCallbacks = new Set< (payload: RebaseRunEventPayload) => void >(); @@ -1695,6 +1699,7 @@ function hasRemoteRuntimeEventSubscribers(): boolean { remoteReviewEventCallbacks.size > 0 || remoteSessionChangedCallbacks.size > 0 || remoteLaneDeleteEventCallbacks.size > 0 || + remoteLaneLifecycleEventCallbacks.size > 0 || remoteLaneRebaseEventCallbacks.size > 0 || remoteLaneRebaseSuggestionsEventCallbacks.size > 0 || remoteLaneAutoRebaseEventCallbacks.size > 0 || @@ -2210,6 +2215,21 @@ function dispatchRemoteRuntimeEventPayload( } } + const laneLifecycleEvent = toWrappedEvent( + payload, + "lane_lifecycle_event", + ); + if (laneLifecycleEvent) { + clearGitReadCaches(); + for (const cb of [...remoteLaneLifecycleEventCallbacks]) { + try { + cb(laneLifecycleEvent); + } catch (error) { + console.error("preload remote lane lifecycle listener failed", error); + } + } + } + const laneRebaseEvent = toWrappedEvent( payload, "lane_rebase_event", @@ -2478,6 +2498,16 @@ function subscribeRemoteLaneDeleteEvents( }; } +function subscribeRemoteLaneLifecycleEvents( + cb: (payload: LaneLifecycleEvent) => void, +): () => void { + remoteLaneLifecycleEventCallbacks.add(cb); + ensureRemoteRuntimeEventPump(); + return () => { + remoteLaneLifecycleEventCallbacks.delete(cb); + }; +} + function subscribeRemoteLaneRebaseEvents( cb: (payload: RebaseRunEventPayload) => void, ): () => void { @@ -4439,6 +4469,18 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.removeListener(IPC.lanesDeleteEvent, listener); }; }, + onLifecycleEvent: (cb: (ev: LaneLifecycleEvent) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: LaneLifecycleEvent, + ) => cb(payload); + ipcRenderer.on(IPC.lanesLifecycleEvent, listener); + const removeRemote = subscribeRemoteLaneLifecycleEvents(cb); + return () => { + removeRemote(); + ipcRenderer.removeListener(IPC.lanesLifecycleEvent, listener); + }; + }, getStackChain: async (laneId: string): Promise => callProjectRuntimeActionOr("lane", "getStackChain", { arg: laneId }, () => ipcRenderer.invoke(IPC.lanesGetStackChain, { laneId }), diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 6a049543c..6c7215380 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4231,6 +4231,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { envInitialized: false, }), onDeleteEvent: noop, + onLifecycleEvent: noop, getStackChain: resolvedArg([]), getChildren: resolvedArg([]), rebaseStart: resolvedArg({ @@ -5167,6 +5168,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { undoLastHeadChange: resolvedArg({ ok: true }), redoLastHeadChange: resolvedArg({ ok: true }), getSyncStatus: resolvedArg({ ahead: 0, behind: 0 }), + getUserIdentity: resolvedArg({ name: "Mock User", email: "mock@example.com" }), sync: resolvedArg({ ok: true }), push: resolvedArg({ ok: true }), getConflictState: resolvedArg({ hasConflicts: false }), diff --git a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx index 5fbeac753..cfeafa667 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.aiStatus.test.tsx @@ -131,6 +131,8 @@ describe("AppShell AI provider status", () => { lanes: { list: vi.fn(async () => []), listSnapshots: vi.fn(async () => []), + onLifecycleEvent: vi.fn(() => () => {}), + rebaseSubscribe: vi.fn(() => () => {}), }, onboarding: { getStatus: vi.fn(async () => ({ freshProject: false, completedAt: null, dismissedAt: null })), diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index ea0c3cd02..715a50fac 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -69,6 +69,9 @@ import { logRendererDebugEvent } from "../../lib/debugLog"; import { cn } from "../ui/cn"; import { disposeTerminalRuntimesForProjectChange } from "../terminals/TerminalView"; import { buildPrsRouteSearch, type PrDetailRouteTab } from "../prs/prsRouteState"; +import { ToastStack } from "./toast/ToastStack"; +import { useToasts } from "./toast/toastStore"; +import { useLaneEventToasts } from "./toast/useLaneEventToasts"; type PrToast = { id: string; @@ -302,6 +305,7 @@ function cleanRemoteConnectionError(message: string | null): string { export function AppShell({ children }: { children: React.ReactNode }) { const location = useLocation(); const navigate = useNavigate(); + useLaneEventToasts(navigate); const setProject = useAppStore((s) => s.setProject); const setProjectHydrated = useAppStore((s) => s.setProjectHydrated); const setProjectBinding = useAppStore((s) => s.setProjectBinding); @@ -326,6 +330,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const [commandOpen, setCommandOpen] = useState(false); const visitedTabsRef = useRef(new Set()); const isFirstVisit = !visitedTabsRef.current.has(location.pathname); + const storeToasts = useToasts(); const [prToasts, setPrToasts] = useState([]); const toastTimersRef = useRef>(new Map()); const dismissPrToast = (id: string) => { @@ -1444,7 +1449,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { children )} - {visibleRemoteConnectionNotice || staleCliNotice || prToasts.length > 0 || autoLinkToasts.length > 0 ? ( + {visibleRemoteConnectionNotice || staleCliNotice || prToasts.length > 0 || autoLinkToasts.length > 0 || storeToasts.length > 0 ? (
{visibleRemoteConnectionNotice ? (
); })} +
) : null} diff --git a/apps/desktop/src/renderer/components/app/toast/ToastStack.tsx b/apps/desktop/src/renderer/components/app/toast/ToastStack.tsx new file mode 100644 index 000000000..ab90d8449 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/toast/ToastStack.tsx @@ -0,0 +1,105 @@ +import { cn } from "../../ui/cn"; +import { + dismissToast, + pauseToast, + resumeToast, + useToasts, + type ToastTone, +} from "./toastStore"; + +/** + * Renders the shared toast store as compact cards, matching the bespoke + * bottom-right notices in `AppShell`. Mounted inside AppShell's existing + * bottom-right container so all toasts share one visual stack; the container is + * `pointer-events-none`, so each card re-enables `pointer-events-auto`. + */ + +function toneClasses(tone: ToastTone): { panel: string; action: string } { + if (tone === "success") { + return { + panel: "border-emerald-500/25 bg-card/95", + action: "text-emerald-300 hover:text-emerald-200", + }; + } + if (tone === "error") { + return { + panel: "border-red-500/25 bg-card/95", + action: "text-red-300 hover:text-red-200", + }; + } + return { + panel: "border-border/60 bg-card/95", + action: "text-[#A78BFA] hover:text-[#C4B5FD]", + }; +} + +export function ToastStack() { + const toasts = useToasts(); + if (toasts.length === 0) return null; + + return ( + <> + {toasts.map((toast) => { + const tone = toneClasses(toast.tone); + return ( +
pauseToast(toast.id)} + onMouseLeave={() => resumeToast(toast.id)} + > +
+
+
+ {toast.colorDot ? ( + + ) : null} +
+ {toast.title} +
+
+ {toast.message ? ( +
+ {toast.message} +
+ ) : null} + {toast.action ? ( +
+ +
+ ) : null} +
+ +
+
+ ); + })} + + ); +} diff --git a/apps/desktop/src/renderer/components/app/toast/toastStore.test.ts b/apps/desktop/src/renderer/components/app/toast/toastStore.test.ts new file mode 100644 index 000000000..8b03a66e1 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/toast/toastStore.test.ts @@ -0,0 +1,322 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { cleanup, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + LaneLifecycleEvent, + RebaseRun, + RebaseRunEventPayload, +} from "../../../../shared/types"; + +import { + dismissToast, + getToasts, + pauseToast, + resumeToast, + showToast, + updateToast, +} from "./toastStore"; +import { useLaneEventToasts } from "./useLaneEventToasts"; + +// The store is a module-level singleton with no reset hook; each test clears +// the stack it created so state doesn't leak between cases. +function clearAll(): void { + for (const toast of [...getToasts()]) dismissToast(toast.id); +} + +describe("toastStore", () => { + beforeEach(() => { + vi.useFakeTimers(); + clearAll(); + }); + + afterEach(() => { + cleanup(); + clearAll(); + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("caps the stack at 4, dropping the oldest and keeping newest at the end", () => { + const ids = [1, 2, 3, 4, 5].map((n) => showToast({ title: `t${n}` })); + const stack = getToasts(); + expect(stack).toHaveLength(4); + // Oldest (ids[0]) dropped; order is oldest -> newest. + expect(stack.map((t) => t.id)).toEqual([ids[1], ids[2], ids[3], ids[4]]); + expect(stack[stack.length - 1].title).toBe("t5"); + }); + + it("auto-dismisses after the default duration", () => { + showToast({ title: "hi" }); + expect(getToasts()).toHaveLength(1); + vi.advanceTimersByTime(5999); + expect(getToasts()).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(getToasts()).toHaveLength(0); + }); + + it("honors an explicit durationMs", () => { + showToast({ id: "a", title: "hi", durationMs: 1000 }); + vi.advanceTimersByTime(999); + expect(getToasts()).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(getToasts()).toHaveLength(0); + }); + + it("does not auto-dismiss when durationMs <= 0", () => { + showToast({ id: "sticky", title: "hi", durationMs: 0 }); + vi.advanceTimersByTime(60_000); + expect(getToasts()).toHaveLength(1); + }); + + it("pauses and resumes with the remaining time", () => { + showToast({ id: "a", title: "hi", durationMs: 6000 }); + vi.advanceTimersByTime(2000); + pauseToast("a"); + // Frozen: no amount of time dismisses it while paused. + vi.advanceTimersByTime(10_000); + expect(getToasts()).toHaveLength(1); + resumeToast("a"); + // 4000ms remained (6000 - 2000 elapsed). + vi.advanceTimersByTime(3999); + expect(getToasts()).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(getToasts()).toHaveLength(0); + }); + + it("resume is a no-op when never paused", () => { + showToast({ id: "a", title: "hi", durationMs: 6000 }); + resumeToast("a"); + vi.advanceTimersByTime(6000); + expect(getToasts()).toHaveLength(0); + }); + + it("merge-patches an existing toast without touching untouched fields", () => { + showToast({ id: "a", title: "orig", message: "m", tone: "info" }); + updateToast("a", { title: "patched" }); + const toast = getToasts().find((t) => t.id === "a"); + expect(toast?.title).toBe("patched"); + expect(toast?.message).toBe("m"); + expect(toast?.tone).toBe("info"); + }); + + it("updateToast resets the timer only when durationMs is patched", () => { + showToast({ id: "a", title: "hi", durationMs: 6000 }); + vi.advanceTimersByTime(5000); + // Patch without durationMs: timer keeps counting from the original start. + updateToast("a", { title: "still counting" }); + vi.advanceTimersByTime(1000); + expect(getToasts()).toHaveLength(0); + }); + + it("updateToast with durationMs restarts the countdown", () => { + showToast({ id: "a", title: "hi", durationMs: 6000 }); + vi.advanceTimersByTime(5000); + updateToast("a", { durationMs: 3000 }); + // Old timer would have fired at 6000; new one runs a fresh 3000. + vi.advanceTimersByTime(1000); + expect(getToasts()).toHaveLength(1); + vi.advanceTimersByTime(2000); + expect(getToasts()).toHaveLength(0); + }); + + it("updateToast is a no-op for unknown ids", () => { + updateToast("nope", { title: "x" }); + expect(getToasts()).toHaveLength(0); + }); + + it("showToast with an existing id replaces in place and keeps position", () => { + const a = showToast({ title: "a" }); + showToast({ title: "b" }); + showToast({ id: a, title: "a-updated" }); + const stack = getToasts(); + expect(stack).toHaveLength(2); + expect(stack[0].id).toBe(a); + expect(stack[0].title).toBe("a-updated"); + }); + + it("dismissToast removes the toast and clears its timer", () => { + showToast({ id: "a", title: "hi", durationMs: 6000 }); + dismissToast("a"); + expect(getToasts()).toHaveLength(0); + // No lingering timer should fire and error. + vi.advanceTimersByTime(6000); + expect(getToasts()).toHaveLength(0); + }); +}); + +type LaneEventHarnessProps = { + navigate: Parameters[0]; +}; + +function LaneEventHarness({ navigate }: LaneEventHarnessProps) { + useLaneEventToasts(navigate); + return null; +} + +function installLaneEventApi() { + let lifecycleListener: ((event: LaneLifecycleEvent) => void) | null = null; + let rebaseListener: ((event: RebaseRunEventPayload) => void) | null = null; + const lanes = { + onLifecycleEvent: vi.fn((listener: (event: LaneLifecycleEvent) => void) => { + lifecycleListener = listener; + return () => { + if (lifecycleListener === listener) lifecycleListener = null; + }; + }), + rebaseSubscribe: vi.fn((listener: (event: RebaseRunEventPayload) => void) => { + rebaseListener = listener; + return () => { + if (rebaseListener === listener) rebaseListener = null; + }; + }), + }; + + Object.defineProperty(window, "ade", { + configurable: true, + value: { lanes }, + }); + + return { + lanes, + emitLifecycle: (event: LaneLifecycleEvent) => lifecycleListener?.(event), + emitRebase: (event: RebaseRunEventPayload) => rebaseListener?.(event), + hasLifecycleListener: () => lifecycleListener !== null, + hasRebaseListener: () => rebaseListener !== null, + }; +} + +function makeRebaseRun(overrides: Partial = {}): RebaseRun { + return { + runId: "run-1", + rootLaneId: "lane-1", + scope: "lane_only", + pushMode: "none", + state: "completed", + startedAt: "2026-07-02T12:00:00.000Z", + finishedAt: "2026-07-02T12:00:01.000Z", + actor: "auto", + baseBranch: null, + lanes: [ + { + laneId: "lane-1", + laneName: "Root Lane", + parentLaneId: null, + status: "succeeded", + preHeadSha: null, + postHeadSha: "sha-after", + error: null, + conflictingFiles: [], + pushed: false, + }, + ], + currentLaneId: null, + failedLaneId: null, + error: null, + pushedLaneIds: [], + canRollback: false, + ...overrides, + }; +} + +describe("useLaneEventToasts", () => { + it("turns lane-created lifecycle events into navigable success toasts", () => { + const api = installLaneEventApi(); + const navigate = vi.fn(); + render(React.createElement(LaneEventHarness, { navigate })); + + expect(api.lanes.onLifecycleEvent).toHaveBeenCalledTimes(1); + expect(api.hasLifecycleListener()).toBe(true); + + api.emitLifecycle({ + type: "lane-created", + laneId: "lane-1", + laneName: "Feature Lane", + color: "#ffaa00", + }); + + const [toast] = getToasts(); + expect(toast).toMatchObject({ + id: "lane-lane-created-lane-1", + title: "Feature Lane", + message: "Lane created", + tone: "success", + colorDot: "#ffaa00", + }); + expect(toast?.action?.label).toBe("View"); + + toast?.action?.onClick(); + expect(navigate).toHaveBeenCalledWith("/lanes?laneId=lane-1&focus=single"); + }); + + it("skips user rebase success toasts but surfaces automated success and failures", () => { + const api = installLaneEventApi(); + render(React.createElement(LaneEventHarness, { navigate: vi.fn() })); + + expect(api.lanes.rebaseSubscribe).toHaveBeenCalledTimes(1); + expect(api.hasRebaseListener()).toBe(true); + clearAll(); + expect(getToasts()).toHaveLength(0); + + api.emitRebase({ + type: "rebase-run-updated", + run: makeRebaseRun({ actor: "user", runId: "run-user" }), + timestamp: "2026-07-02T12:00:01.000Z", + }); + expect(getToasts()).toHaveLength(0); + + api.emitRebase({ + type: "rebase-run-updated", + run: makeRebaseRun({ actor: "auto", runId: "run-auto" }), + timestamp: "2026-07-02T12:00:02.000Z", + }); + expect(getToasts()[0]).toMatchObject({ + id: "lane-rebase-run-auto", + title: "Root Lane", + message: "Rebase completed", + tone: "success", + }); + + api.emitRebase({ + type: "rebase-run-updated", + run: makeRebaseRun({ + runId: "run-failed", + state: "failed", + failedLaneId: "lane-2", + error: "conflict on package-lock.json", + lanes: [ + { + laneId: "lane-1", + laneName: "Root Lane", + parentLaneId: null, + status: "succeeded", + preHeadSha: null, + postHeadSha: "sha-after", + error: null, + conflictingFiles: [], + pushed: false, + }, + { + laneId: "lane-2", + laneName: "Child Lane", + parentLaneId: "lane-1", + status: "conflict", + preHeadSha: "sha-before", + postHeadSha: null, + error: "conflict on package-lock.json", + conflictingFiles: ["package-lock.json"], + pushed: false, + }, + ], + }), + timestamp: "2026-07-02T12:00:03.000Z", + }); + expect(getToasts()[1]).toMatchObject({ + id: "lane-rebase-run-failed", + title: "Child Lane", + message: "conflict on package-lock.json", + tone: "error", + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/app/toast/toastStore.ts b/apps/desktop/src/renderer/components/app/toast/toastStore.ts new file mode 100644 index 000000000..b1c5bd964 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/toast/toastStore.ts @@ -0,0 +1,204 @@ +import { useSyncExternalStore } from "react"; + +/** + * Reusable, renderer-only toast store. Bespoke bottom-right notices in + * `AppShell` (PR notifications, auto-link, remote/stale banners) predate this + * and are intentionally left alone; this is the shared primitive new product + * code should push through. Timers (auto-dismiss + hover pause/resume) live in + * the store so rendering components stay dumb; see `ToastStack`. + */ + +export type ToastTone = "info" | "success" | "error"; + +export type ToastAction = { + label: string; + onClick: () => void; +}; + +export type ToastInput = { + id?: string; + title: string; + message?: string; + tone?: ToastTone; + /** CSS color for the small lane dot rendered before the title. */ + colorDot?: string; + action?: ToastAction; + /** Auto-dismiss delay; <= 0 or non-finite keeps the toast until dismissed. */ + durationMs?: number; +}; + +export type Toast = { + id: string; + title: string; + message?: string; + tone: ToastTone; + colorDot?: string; + action?: ToastAction; + durationMs: number; +}; + +/** Merge-patch shape for {@link updateToast}. */ +export type ToastPatch = Partial>; + +const DEFAULT_DURATION_MS = 6000; +const MAX_TOASTS = 4; + +type TimerEntry = { + handle: ReturnType | null; + /** Time left on the current countdown; the full duration while running. */ + remaining: number; + /** `Date.now()` when the current run started (for elapsed math on pause). */ + startedAt: number; + paused: boolean; +}; + +// Ordered oldest -> newest. `ToastStack` renders in array order inside a +// bottom-anchored flex column, so the newest toast sits closest to the corner. +let toasts: Toast[] = []; +const listeners = new Set<() => void>(); +const timers = new Map(); + +function emit(): void { + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): Toast[] { + return toasts; +} + +function generateId(): string { + return globalThis.crypto?.randomUUID + ? globalThis.crypto.randomUUID() + : `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function clearTimer(id: string): void { + const entry = timers.get(id); + if (entry?.handle != null) clearTimeout(entry.handle); +} + +/** (Re)arm the auto-dismiss countdown for a toast, clearing any prior timer. */ +function scheduleTimer(id: string, durationMs: number): void { + clearTimer(id); + if (!Number.isFinite(durationMs) || durationMs <= 0) { + timers.delete(id); + return; + } + const handle = setTimeout(() => dismissToast(id), durationMs); + timers.set(id, { + handle, + remaining: durationMs, + startedAt: Date.now(), + paused: false, + }); +} + +/** + * Show a toast, or replace an in-place one when `id` matches an existing toast + * (keeping its stack position). Returns the toast id. + */ +export function showToast(input: ToastInput): string { + const id = input.id ?? generateId(); + const durationMs = input.durationMs ?? DEFAULT_DURATION_MS; + const toast: Toast = { + id, + title: input.title, + message: input.message, + tone: input.tone ?? "info", + colorDot: input.colorDot, + action: input.action, + durationMs, + }; + + const existingIndex = toasts.findIndex((t) => t.id === id); + if (existingIndex >= 0) { + const next = toasts.slice(); + next[existingIndex] = toast; + toasts = next; + } else { + let next = [...toasts, toast]; + if (next.length > MAX_TOASTS) { + const dropCount = next.length - MAX_TOASTS; + for (const dropped of next.slice(0, dropCount)) { + clearTimer(dropped.id); + timers.delete(dropped.id); + } + next = next.slice(dropCount); + } + toasts = next; + } + + scheduleTimer(id, durationMs); + emit(); + return id; +} + +/** + * Merge-patch an existing toast (no-op if unknown). Only resets the dismiss + * timer when `durationMs` is part of the patch. + */ +export function updateToast(id: string, patch: ToastPatch): void { + const index = toasts.findIndex((t) => t.id === id); + if (index < 0) return; + const next = toasts.slice(); + const merged: Toast = { ...next[index], ...patch }; + next[index] = merged; + toasts = next; + if (patch.durationMs !== undefined) { + scheduleTimer(id, merged.durationMs); + } + emit(); +} + +export function dismissToast(id: string): void { + clearTimer(id); + timers.delete(id); + const next = toasts.filter((t) => t.id !== id); + if (next.length === toasts.length) return; + toasts = next; + emit(); +} + +/** Freeze a toast's countdown (e.g. while hovered), recording time remaining. */ +export function pauseToast(id: string): void { + const entry = timers.get(id); + if (!entry || entry.paused) return; + if (entry.handle != null) clearTimeout(entry.handle); + const elapsed = Date.now() - entry.startedAt; + const remaining = Math.max(0, entry.remaining - elapsed); + timers.set(id, { handle: null, remaining, startedAt: Date.now(), paused: true }); +} + +/** Resume a paused countdown from the remaining time recorded at pause. */ +export function resumeToast(id: string): void { + const entry = timers.get(id); + if (!entry || !entry.paused) return; + if (!Number.isFinite(entry.remaining) || entry.remaining <= 0) { + dismissToast(id); + return; + } + const handle = setTimeout(() => dismissToast(id), entry.remaining); + timers.set(id, { + handle, + remaining: entry.remaining, + startedAt: Date.now(), + paused: false, + }); +} + +/** Imperative read of the current toast stack (oldest -> newest). */ +export function getToasts(): readonly Toast[] { + return toasts; +} + +/** Subscribe a component to the current toast stack. */ +export function useToasts(): Toast[] { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/apps/desktop/src/renderer/components/app/toast/useLaneEventToasts.ts b/apps/desktop/src/renderer/components/app/toast/useLaneEventToasts.ts new file mode 100644 index 000000000..957105b4f --- /dev/null +++ b/apps/desktop/src/renderer/components/app/toast/useLaneEventToasts.ts @@ -0,0 +1,83 @@ +import { useEffect } from "react"; +import type { NavigateFunction } from "react-router-dom"; +import type { + LaneLifecycleEvent, + RebaseRunEventPayload, +} from "../../../../shared/types"; +import { showToast } from "./toastStore"; + +export function useLaneEventToasts(navigate: NavigateFunction): void { + // Lane lifecycle events arrive through both in-process Electron IPC and the + // runtime event stream. The preload merges those paths for this subscription. + useEffect(() => { + const dispose = window.ade.lanes.onLifecycleEvent( + (event: LaneLifecycleEvent) => { + const dot = event.color ?? undefined; + if (event.type === "lane-created") { + showToast({ + id: `lane-${event.type}-${event.laneId}`, + title: event.laneName, + message: "Lane created", + tone: "success", + colorDot: dot, + action: { + label: "View", + onClick: () => + navigate(`/lanes?laneId=${event.laneId}&focus=single`), + }, + }); + return; + } + showToast({ + id: `lane-${event.type}-${event.laneId}`, + title: event.laneName, + message: + event.type === "lane-archived" ? "Lane archived" : "Lane deleted", + tone: "info", + colorDot: dot, + }); + }, + ); + return dispose; + }, [navigate]); + + // Rebase outcome events can stream multiple progress updates. Only terminal + // states produce toasts, keyed by run id so repeated terminal updates collapse. + useEffect(() => { + const dispose = window.ade.lanes.rebaseSubscribe( + (event: RebaseRunEventPayload) => { + if (event.type !== "rebase-run-updated") return; + const { run } = event; + if (run.state === "running") return; + const failedLane = run.failedLaneId + ? run.lanes.find((lane) => lane.laneId === run.failedLaneId) + : null; + const rootLane = run.lanes.find( + (lane) => lane.laneId === run.rootLaneId, + ); + const laneLabel = + failedLane?.laneName ?? rootLane?.laneName ?? run.rootLaneId; + if (run.state === "completed") { + if (run.actor === "user") return; + showToast({ + id: `lane-rebase-${run.runId}`, + title: laneLabel, + message: "Rebase completed", + tone: "success", + }); + return; + } + showToast({ + id: `lane-rebase-${run.runId}`, + title: laneLabel, + message: + run.state === "aborted" + ? "Rebase aborted" + : run.error?.trim() || "Rebase failed", + tone: "error", + }); + }, + ); + return dispose; + }, []); +} diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index 48e7b5a33..fef1d2ac3 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -60,6 +60,7 @@ import { Button } from "../ui/Button"; import { Chip } from "../ui/Chip"; import { EmptyState } from "../ui/EmptyState"; import { cn } from "../ui/cn"; +import { showToast } from "../app/toast/toastStore"; import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition"; import { useLaneAgents, type LaneAgent } from "../lanes/laneAgents"; import { openAgentInWorkTabPath } from "../../lib/laneNavigation"; @@ -2190,6 +2191,7 @@ function GraphInner({ active = true }: { active?: boolean }) { mode: plan.mode, baseRef: plan.baseRef }); + showToast({ title: `Synced ${laneById.get(plan.laneId)?.name ?? plan.laneId}`, tone: "success" }); setReparentDialog(null); await refreshLanes().catch(() => {}); } catch (error) { @@ -2585,17 +2587,21 @@ function GraphInner({ active = true }: { active?: boolean }) { const outcome = await runRebaseAndPublishLane(lane.id, { confirmPublish: true, recursive: false }); if (outcome.status === "skipped") { setErrorBanner(`Rebase + push skipped for '${lane.name}': ${outcome.message}`); + } else { + showToast({ title: `Rebased & pushed ${lane.name}`, tone: "success" }); } await refreshLanes(); shouldRefreshSync = true; } else if (action === "push") { await window.ade.git.push({ laneId: lane.id }); + showToast({ title: `Pushed ${lane.name}`, tone: "success" }); shouldRefreshSync = true; } else if (action === "fetch") { await window.ade.git.fetch({ laneId: lane.id }); shouldRefreshSync = true; } else if (action === "sync") { await runPullFromUpstream(lane.id, "rebase"); + showToast({ title: `Synced ${lane.name}`, tone: "success" }); await refreshLanes(); shouldRefreshSync = true; } else if (action === "reparent") { @@ -4169,6 +4175,10 @@ function GraphInner({ active = true }: { active?: boolean }) { }); } setIntegrationDialog((prev) => (prev ? { ...prev, step: "Done." } : prev)); + showToast({ + title: `Integrated ${ordered.length} lane${ordered.length === 1 ? "" : "s"} into ${newLane.name}`, + tone: "success", + }); window.setTimeout(() => setIntegrationDialog(null), 300); await refreshLanes(); await refreshIntegrationProposals(); diff --git a/apps/desktop/src/renderer/components/history/historyLaneActions.ts b/apps/desktop/src/renderer/components/history/historyLaneActions.ts index 67068b23a..e4e248ec8 100644 --- a/apps/desktop/src/renderer/components/history/historyLaneActions.ts +++ b/apps/desktop/src/renderer/components/history/historyLaneActions.ts @@ -1,4 +1,5 @@ import type { GitConflictState, GitStashSummary } from "../../../shared/types"; +import { showToast } from "../app/toast/toastStore"; export type HistoryLaneActionId = | "fetch" @@ -358,6 +359,7 @@ export async function runHistoryLaneAction(args: { const { actionId, laneId, laneName, onNotice, onError, onComplete, navigate } = args; const target = laneName ? ` for ${laneName}` : ""; + const laneLabel = laneName?.trim() || laneId; try { switch (actionId) { @@ -418,6 +420,7 @@ export async function runHistoryLaneAction(args: { } await window.ade.git.push({ laneId }); onNotice?.("Pushed to upstream"); + showToast({ title: `Pushed ${laneLabel}`, tone: "success" }); onComplete?.(); return; case "force_push_lease": @@ -426,6 +429,7 @@ export async function runHistoryLaneAction(args: { } await window.ade.git.push({ laneId, forceWithLease: true }); onNotice?.("Force-pushed with lease"); + showToast({ title: `Force-pushed ${laneLabel}`, tone: "success" }); onComplete?.(); return; case "copy_branch_name": { @@ -528,6 +532,7 @@ export async function runHistoryLaneAction(args: { } await window.ade.git.sync({ laneId, mode: "merge" }); onNotice?.("Merged lane base"); + showToast({ title: `Merged base into ${laneLabel}`, tone: "success" }); onComplete?.(); return; case "rebase_upstream": @@ -536,6 +541,7 @@ export async function runHistoryLaneAction(args: { } await window.ade.git.sync({ laneId, mode: "rebase" }); onNotice?.("Rebased onto lane base"); + showToast({ title: `Rebased ${laneLabel}`, tone: "success" }); onComplete?.(); return; case "stash": { @@ -622,6 +628,20 @@ export async function runHistoryLaneAction(args: { return; } } catch (err) { - onError?.(stripIpcError(err)); + const message = stripIpcError(err); + // Global error toast for remote push/sync failures; the inline toolbar + // error surface is transient (auto-clears) and easy to miss, and these + // actions are typically fired from a context menu that closes on click. + const pushSyncFailTitle: Partial> = { + push: "Push failed", + force_push_lease: "Force push failed", + merge_upstream: "Merge failed", + rebase_upstream: "Rebase failed", + }; + const failTitle = pushSyncFailTitle[actionId]; + if (failTitle) { + showToast({ title: `${failTitle}: ${laneLabel}`, message, tone: "error" }); + } + onError?.(message); } } diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx index 4ed544549..8efa37032 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx @@ -302,7 +302,6 @@ export function CreateLaneDialog({ : "Create a lane from Primary, an existing branch, or another lane."} icon={Plus} widthClassName="w-[min(560px,calc(100vw-24px))]" - heightClassName="h-[min(760px,calc(100vh-24px))]" busy={busy} onCloseAutoFocus={(event) => { event.preventDefault(); @@ -311,6 +310,27 @@ export function CreateLaneDialog({ ); target?.focus?.(); }} + footer={pickerOpen ? undefined : ( +
+ {error ? ( +
+ {error} +
+ ) : null} +
+ + +
+
+ )} > {pickerOpen ? ( ) : null} - {error ? ( -
- {error} -
- ) : null} - -
- - -
- {envInitProgress ? : null}
)} diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialogHost.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialogHost.tsx new file mode 100644 index 000000000..d64578f58 --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialogHost.tsx @@ -0,0 +1,677 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; +import { CreateLaneDialog, type CreateLaneMode, type CreateLaneSetupStep } from "./CreateLaneDialog"; +import { + DEFAULT_NEW_LANE_BASE_SOURCE, + effectiveNewLaneBaseSource, + fetchNewLaneBaseBranches, + listNewLaneBaseOptions, + selectDefaultNewLaneBaseRef, +} from "./newLaneBaseSource"; +import { resolveCreateLaneRequest } from "./lanePageModel"; +import { linearIssueBranchName, linearIssueLaneName } from "../../../shared/linearIssueBranch"; +import { dismissToast, showToast } from "../app/toast/toastStore"; +import type { LaneBranchOption } from "./laneUtils"; +import type { + BranchPullRequest, + LaneEnvInitEvent, + LaneEnvInitProgress, + LaneLinearIssue, + LaneSummary, + LaneTemplate, + NewLaneBaseSource, +} from "../../../shared/types"; + +type CreateSetupPhase = + | "creating" + | "appearance" + | "refreshing" + | "environment"; + +export type CreateLaneBehavior = "stay-open-setup" | "close-on-create"; + +export type CreateLanePrefill = { + /** Pre-fill the lane name (e.g. dialog-bus `props.name`). */ + name?: string; + /** Pre-connect a Linear issue. */ + linearIssue?: LaneLinearIssue | null; +}; + +/* --------------------------------------------------------------------------- + * Detached background env setup (close-on-create mode). + * + * The Work-tab pane that opens the dialog can unmount the moment the lane is + * created, so env setup must not be tied to any component lifetime. These + * module-level helpers run the setup and surface a sticky, retryable failure + * toast entirely outside React. + * ------------------------------------------------------------------------- */ + +type DetachedSetupParams = { + laneId: string; + laneName: string; + templateId: string; +}; + +async function applyLaneEnvSetup(laneId: string, templateId: string): Promise { + return templateId + ? await window.ade.lanes.applyTemplate({ laneId, templateId }) + : await window.ade.lanes.initEnv({ laneId }); +} + +function setupFailureToastId(laneId: string): string { + return `lane-setup-failed:${laneId}`; +} + +function showSetupFailureToast(params: DetachedSetupParams, detail?: string): void { + showToast({ + // Keyed by lane so a retry replaces the existing toast in place. + id: setupFailureToastId(params.laneId), + title: params.laneName, + message: detail ?? "Environment setup failed. Retry to finish setting up this lane.", + tone: "error", + durationMs: 0, + action: { + label: "Retry", + onClick: () => runDetachedLaneSetup(params), + }, + }); +} + +/** Run env setup for an already-created lane, detached from any component. */ +function runDetachedLaneSetup(params: DetachedSetupParams): void { + void (async () => { + try { + const progress = await applyLaneEnvSetup(params.laneId, params.templateId); + if (progress.overallStatus === "failed") { + showSetupFailureToast(params, "Environment setup failed. Retry to finish setting up this lane."); + } else { + // A successful retry must clear the sticky failure toast; on the first + // run this is a no-op. + dismissToast(setupFailureToastId(params.laneId)); + } + } catch (err) { + showSetupFailureToast(params, err instanceof Error ? err.message : String(err)); + } + })(); +} + +/** + * Self-contained host for {@link CreateLaneDialog}: owns all of the create-lane + * form state, base-branch loading, submit + env-setup orchestration, and the + * two post-create behaviors. + * + * - `stay-open-setup` (Lanes tab): keeps today's flow. After the lane record + * is created it navigates (via `onCreated`) and keeps the dialog open to + * stream env-setup progress, with in-dialog error + "Retry setup". + * - `close-on-create` (Work tab): closes the dialog as soon as the lane record + * exists and runs env setup in the background; a failure surfaces a sticky, + * retryable toast instead of in-dialog UI. + */ +export function CreateLaneDialogHost({ + open, + onOpenChange, + behavior, + prefill, + onCreated, + onBusyChange, + onNavigateToTemplates, + onOpenLinearSettings, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + behavior: CreateLaneBehavior; + prefill?: CreateLanePrefill | null; + /** Called after the lane record is created + refreshed (before env setup). */ + onCreated?: (lane: LaneSummary) => void; + /** Mirrors the in-flight create/setup state so callers can guard forced closes. */ + onBusyChange?: (busy: boolean) => void; + onNavigateToTemplates?: () => void; + onOpenLinearSettings?: () => void; +}) { + const lanes = useAppStore((s) => s.lanes); + const refreshLanes = useAppStore((s) => s.refreshLanes); + const activeProjectRoot = useAppStore(selectActiveProjectRoot); + + const [createLaneName, setCreateLaneName] = useState(""); + const [createParentLaneId, setCreateParentLaneId] = useState(""); + const [createMode, setCreateMode] = useState("primary"); + const [createBaseSource, setCreateBaseSource] = useState(DEFAULT_NEW_LANE_BASE_SOURCE); + const createBaseSourceRef = useRef(DEFAULT_NEW_LANE_BASE_SOURCE); + const createBaseSourceUserPickedRef = useRef(false); + const createBaseBranchesLoadSeqRef = useRef(0); + const createBaseSourceSaveInFlightRef = useRef(false); + const createBaseSourceSavePendingRef = useRef(null); + const [createBaseBranch, setCreateBaseBranch] = useState(""); + const [createImportBranch, setCreateImportBranch] = useState(""); + const [createChildBaseBranch, setCreateChildBaseBranch] = useState(""); + const [createBranches, setCreateBranches] = useState([]); + const [createBranchesLoading, setCreateBranchesLoading] = useState(false); + const [createBranchPullRequests, setCreateBranchPullRequests] = useState([]); + const [createBranchPullRequestsLoading, setCreateBranchPullRequestsLoading] = useState(false); + const [createGitUserName, setCreateGitUserName] = useState(""); + const [createBusy, setCreateBusy] = useState(false); + const [createError, setCreateError] = useState(null); + const [createEnvInitProgress, setCreateEnvInitProgress] = useState(null); + const [laneCreated, setLaneCreated] = useState(false); + const [createSetupPhase, setCreateSetupPhase] = useState(null); + const createEnvInitLaneIdRef = useRef(null); + const createBaseBranchUserPickedRef = useRef(false); + const [templates, setTemplates] = useState([]); + const [selectedTemplateId, setSelectedTemplateId] = useState(""); + const [createSelectedColor, setCreateSelectedColor] = useState(null); + const [createSelectedLinearIssue, setCreateSelectedLinearIssue] = useState(null); + const createLinearIssueAutoNameRef = useRef(null); + + const primaryLane = useMemo(() => lanes.find((l) => l.laneType === "primary") ?? null, [lanes]); + + // Mirror busy so callers can block a forced close mid-create (parity with the + // old `handleCreateDialogOpenChange` guard). + const busyRef = useRef(false); + useEffect(() => { + busyRef.current = createBusy; + onBusyChange?.(createBusy); + }, [createBusy, onBusyChange]); + + // Env-init progress events for the lane currently being created. Only matters + // while the dialog is open (stay-open mode); close-on-create runs setup + // detached after the dialog is gone. + useEffect(() => { + return window.ade.lanes.onEnvEvent((event: LaneEnvInitEvent) => { + if (event.progress.laneId !== createEnvInitLaneIdRef.current) return; + setCreateEnvInitProgress(event.progress); + }); + }, []); + + const resetCreateDialogState = useCallback(() => { + createEnvInitLaneIdRef.current = null; + createBaseBranchUserPickedRef.current = false; + createBaseBranchesLoadSeqRef.current += 1; + setLaneCreated(false); + setCreateLaneName(""); + setCreateParentLaneId(""); + setCreateMode("primary"); + setCreateBaseBranch(""); + setCreateImportBranch(""); + setCreateChildBaseBranch(""); + setCreateBusy(false); + setCreateError(null); + setCreateEnvInitProgress(null); + setCreateSetupPhase(null); + setSelectedTemplateId(""); + setCreateSelectedColor(null); + setCreateSelectedLinearIssue(null); + createLinearIssueAutoNameRef.current = null; + }, []); + + 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); + }, []); + + const prepareCreateDialog = useCallback((prefillInput?: CreateLanePrefill | null) => { + setCreateLaneName(""); + setCreateParentLaneId(""); + setCreateMode("primary"); + setCreateBaseSource(DEFAULT_NEW_LANE_BASE_SOURCE); + createBaseSourceRef.current = DEFAULT_NEW_LANE_BASE_SOURCE; + createBaseSourceUserPickedRef.current = false; + setCreateBaseBranch(""); + setCreateImportBranch(""); + setCreateChildBaseBranch(""); + setCreateBranches([]); + setCreateBranchPullRequests([]); + setCreateGitUserName(""); + setCreateSelectedColor(null); + setCreateSelectedLinearIssue(null); + createLinearIssueAutoNameRef.current = null; + setCreateBranchesLoading(false); + setCreateBranchPullRequestsLoading(false); + setCreateBusy(false); + setCreateError(null); + setCreateEnvInitProgress(null); + setCreateSetupPhase(null); + setLaneCreated(false); + createEnvInitLaneIdRef.current = null; + createBaseBranchUserPickedRef.current = false; + const primary = lanes.find((l) => l.laneType === "primary"); + if (primary) { + const loadSeq = ++createBaseBranchesLoadSeqRef.current; + setCreateBranchesLoading(true); + window.ade.projectConfig.get() + .catch(() => null) + .then(async (snapshot) => { + const baseSource = effectiveNewLaneBaseSource(snapshot); + const selectedBaseSource = createBaseSourceUserPickedRef.current + ? createBaseSourceRef.current + : baseSource; + if (!createBaseSourceUserPickedRef.current) { + createBaseSourceRef.current = baseSource; + setCreateBaseSource(baseSource); + } + const branches = await fetchNewLaneBaseBranches({ + source: selectedBaseSource, + fetchRemoteBranches: () => window.ade.git.fetch({ laneId: primary.id }), + listBranches: () => window.ade.git.listBranches({ laneId: primary.id }), + }); + if (createBaseBranchesLoadSeqRef.current !== loadSeq) return; + setCreateBranches(branches); + if (!createBaseBranchUserPickedRef.current) { + const defaultBaseRef = selectDefaultNewLaneBaseRef({ + branches, + source: createBaseSourceUserPickedRef.current + ? createBaseSourceRef.current + : selectedBaseSource, + primaryBaseRef: primary.baseRef, + }); + if (defaultBaseRef) setCreateBaseBranch(defaultBaseRef); + } + }) + .catch(() => {}) + .finally(() => { + if (createBaseBranchesLoadSeqRef.current === loadSeq) setCreateBranchesLoading(false); + }); + + // Capture git user.name so the picker can resolve `mine` / `author:me`. + window.ade.git.getUserIdentity({ laneId: primary.id }) + .then((identity) => setCreateGitUserName(identity?.name ?? "")) + .catch(() => setCreateGitUserName("")); + + // Lazily attach open-PR metadata. Fail-soft; picker degrades gracefully. + setCreateBranchPullRequestsLoading(true); + window.ade.prs.listOpenForRepo() + .then(setCreateBranchPullRequests) + .catch(() => setCreateBranchPullRequests([])) + .finally(() => setCreateBranchPullRequestsLoading(false)); + } + Promise.all([ + window.ade.lanes.listTemplates().catch(() => [] as LaneTemplate[]), + window.ade.lanes.getDefaultTemplate().catch(() => null), + ]).then(([nextTemplates, defaultTemplateId]) => { + setTemplates(nextTemplates); + setSelectedTemplateId( + defaultTemplateId && nextTemplates.some((template) => template.id === defaultTemplateId) + ? defaultTemplateId + : "" + ); + }); + + // Apply caller prefill after resetting to defaults. + if (prefillInput?.name) setCreateLaneName(prefillInput.name.trim()); + if (prefillInput?.linearIssue) handleSetCreateLinearIssue(prefillInput.linearIssue); + }, [lanes, handleSetCreateLinearIssue]); + + // Prepare on open; reset on close. `open` is the single source of truth, so + // any external trigger (deeplink, button, dialog bus, Work-tab pane) that sets + // it to true runs prepare exactly once per open. + const prevOpenRef = useRef(false); + const prefillRef = useRef(prefill); + prefillRef.current = prefill; + useEffect(() => { + if (open === prevOpenRef.current) return; + prevOpenRef.current = open; + if (open) prepareCreateDialog(prefillRef.current); + else resetCreateDialogState(); + }, [open, prepareCreateDialog, resetCreateDialogState]); + + const createSetupStatus = useMemo(() => { + switch (createSetupPhase) { + case "creating": + return createMode === "existing" + ? "Importing branch and creating the lane worktree..." + : "Creating the lane branch and worktree..."; + case "appearance": + return "Saving lane appearance..."; + case "refreshing": + return "Refreshing the lane list..."; + case "environment": + return selectedTemplateId ? "Applying the lane template..." : "Running lane environment setup..."; + default: + return laneCreated ? "Lane exists. Finish setup or retry the failed step." : null; + } + }, [createMode, createSetupPhase, laneCreated, selectedTemplateId]); + + const createSetupSteps = useMemo(() => { + if (!createBusy && !laneCreated) return []; + let laneLabel: string; + if (createMode === "child") laneLabel = "Create child lane"; + else if (createMode === "existing") laneLabel = "Import branch"; + else laneLabel = "Create lane"; + let laneState: CreateLaneSetupStep["state"]; + if (createSetupPhase === "creating") laneState = "active"; + else if (laneCreated) laneState = "done"; + else laneState = "pending"; + const steps: CreateLaneSetupStep[] = [{ + label: laneLabel, + detail: "Create the branch metadata and worktree on disk.", + state: laneState, + }]; + steps.push({ + label: selectedTemplateId ? "Apply template" : "Initialize environment", + detail: selectedTemplateId + ? "Run the selected lane template setup." + : "Run the default lane setup checks.", + state: createSetupPhase === "environment" ? "active" : "pending", + }); + return steps; + }, [createBusy, createMode, createSetupPhase, laneCreated, selectedTemplateId]); + + /** Wraps setCreateBaseBranch so we can track user-driven selections and avoid + * the async branch-list fetch from overwriting a value the user already picked. */ + const handleSetCreateBaseBranch = useCallback((v: string) => { + createBaseBranchUserPickedRef.current = true; + setCreateBaseBranch(v); + }, []); + + const persistCreateBaseSourceConfig = useCallback(() => { + if (createBaseSourceSaveInFlightRef.current) return; + if (!createBaseSourceSavePendingRef.current) return; + createBaseSourceSaveInFlightRef.current = true; + let failed = false; + + void (async () => { + try { + while (createBaseSourceSavePendingRef.current) { + const source: NewLaneBaseSource = createBaseSourceSavePendingRef.current; + const snapshot = await window.ade.projectConfig.get(); + const currentGit = snapshot.local.git ?? {}; + await window.ade.projectConfig.save({ + shared: snapshot.shared, + local: { + ...snapshot.local, + git: { + ...currentGit, + newLaneBaseSource: source, + }, + }, + }); + if (createBaseSourceSavePendingRef.current === source) { + createBaseSourceSavePendingRef.current = null; + } + } + } catch (saveError) { + failed = true; + setCreateError(saveError instanceof Error ? saveError.message : String(saveError)); + } finally { + createBaseSourceSaveInFlightRef.current = false; + if (!failed && createBaseSourceSavePendingRef.current) { + persistCreateBaseSourceConfig(); + } + } + })(); + }, []); + + const handleSetCreateBaseSource = useCallback((source: NewLaneBaseSource) => { + createBaseSourceRef.current = source; + createBaseSourceUserPickedRef.current = true; + createBaseBranchUserPickedRef.current = false; + const loadSeq = ++createBaseBranchesLoadSeqRef.current; + setCreateBaseSource(source); + setCreateBaseBranch(""); + setCreateBranches([]); + const primary = lanes.find((l) => l.laneType === "primary"); + if (primary) { + setCreateBranchesLoading(true); + fetchNewLaneBaseBranches({ + source, + fetchRemoteBranches: () => window.ade.git.fetch({ laneId: primary.id }), + listBranches: () => window.ade.git.listBranches({ laneId: primary.id }), + }) + .then((branches) => { + if (createBaseSourceRef.current !== source || createBaseBranchesLoadSeqRef.current !== loadSeq) return; + setCreateBranches(branches); + if (!createBaseBranchUserPickedRef.current) { + setCreateBaseBranch(selectDefaultNewLaneBaseRef({ + branches, + source, + primaryBaseRef: primary.baseRef, + })); + } + }) + .catch(() => {}) + .finally(() => { + if (createBaseSourceRef.current === source && createBaseBranchesLoadSeqRef.current === loadSeq) { + setCreateBranchesLoading(false); + } + }); + } else { + setCreateBranchesLoading(false); + } + createBaseSourceSavePendingRef.current = source; + persistCreateBaseSourceConfig(); + }, [lanes, persistCreateBaseSourceConfig]); + + /** Run post-create setup for a lane that already exists. Used as the retry path + * when environment setup fails (stay-open mode). */ + const runSetupForCreatedLane = useCallback(async (laneId: string) => { + setCreateBusy(true); + setCreateError(null); + setCreateEnvInitProgress(null); + setCreateSetupPhase("environment"); + + try { + const envProgress = selectedTemplateId + ? await window.ade.lanes.applyTemplate({ laneId, templateId: selectedTemplateId }) + : await window.ade.lanes.initEnv({ laneId }); + setCreateEnvInitProgress(envProgress); + + if (envProgress.overallStatus === "failed") { + setCreateError("Environment setup failed. Review the progress log and retry."); + return; + } + + resetCreateDialogState(); + onOpenChange(false); + } catch (err) { + setCreateError(err instanceof Error ? err.message : String(err)); + } finally { + setCreateSetupPhase(null); + setCreateBusy(false); + } + }, [selectedTemplateId, resetCreateDialogState, onOpenChange]); + + const handleCreateSubmit = useCallback(async () => { + // If the lane was already created (e.g. env setup failed on a previous + // attempt), retry setup only; never re-run creation. + if (createEnvInitLaneIdRef.current) { + await runSetupForCreatedLane(createEnvInitLaneIdRef.current); + return; + } + + const name = createLaneName.trim(); + if (!name || createBusy) return; + if (createMode === "child" && !createParentLaneId) return; + if (createMode === "primary") { + const validBaseBranch = listNewLaneBaseOptions(createBranches, createBaseSource) + .some((option) => option.ref === createBaseBranch); + if (createBranchesLoading || !validBaseBranch) { + setCreateError(createBranchesLoading + ? "Still loading base branches. Try again in a moment." + : "Choose a valid base branch for the selected source."); + 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; + } + + setCreateBusy(true); + setCreateError(null); + setCreateEnvInitProgress(null); + setCreateSetupPhase("creating"); + + try { + const request = resolveCreateLaneRequest({ + name, + createMode, + createParentLaneId, + 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); + } else if (request.kind === "child") { + const trimmedBase = createChildBaseBranch.trim(); + const parentLane = lanes.find((l) => l.id === request.args.parentLaneId); + if (!parentLane) { + setCreateError("Parent lane no longer exists. Please close and reopen the dialog."); + setCreateBusy(false); + setCreateSetupPhase(null); + return; + } + const childArgs = trimmedBase && trimmedBase !== parentLane.branchRef + ? { ...request.args, baseBranchRef: trimmedBase, ...linearIssueArgs } + : { ...request.args, ...linearIssueArgs }; + lane = await window.ade.lanes.createChild(childArgs); + } else { + lane = await window.ade.lanes.create({ ...request.args, ...linearIssueArgs }); + } + + // Lane created successfully: record its id so retries skip creation. + createEnvInitLaneIdRef.current = lane.id; + setLaneCreated(true); + + if (createSelectedColor) { + try { + setCreateSetupPhase("appearance"); + await window.ade.lanes.updateAppearance({ laneId: lane.id, color: createSelectedColor }); + } catch { + // Color collisions or transient errors shouldn't block lane creation. + } + } + + setCreateSetupPhase("refreshing"); + await refreshLanes(); + onCreated?.(lane); + + if (behavior === "close-on-create") { + // Detach env setup from this component's lifetime; the opening pane may + // unmount as soon as the lane exists. Capture everything the background + // runner needs before we reset/close. + const detachParams: DetachedSetupParams = { + laneId: lane.id, + laneName: lane.name, + templateId: selectedTemplateId, + }; + resetCreateDialogState(); + onOpenChange(false); + runDetachedLaneSetup(detachParams); + return; + } + + // stay-open-setup: keep the dialog open and stream env-setup progress. + await runSetupForCreatedLane(lane.id); + } catch (err) { + setCreateError(err instanceof Error ? err.message : String(err)); + setCreateSetupPhase(null); + setCreateBusy(false); + } + }, [ + behavior, + createLaneName, + createMode, + createParentLaneId, + createBaseSource, + createBaseBranch, + createBranches, + createBranchesLoading, + createImportBranch, + createChildBaseBranch, + lanes, + createBusy, + refreshLanes, + onCreated, + resetCreateDialogState, + onOpenChange, + runSetupForCreatedLane, + selectedTemplateId, + templates, + createSelectedColor, + createSelectedLinearIssue, + ]); + + const handleDialogOpenChange = useCallback((next: boolean) => { + // Never dismiss the dialog while a create/setup is in flight. + if (!next && busyRef.current) return; + onOpenChange(next); + }, [onOpenChange]); + + const importBranchWarning = createMode === "existing" && createImportBranch && primaryLane?.status.dirty + && createBranches.find((b) => b.name === createImportBranch && !b.isRemote)?.isCurrent + ? "This branch is currently checked out and has uncommitted changes. The new lane will only include committed changes - uncommitted work will not carry over." + : null; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx b/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx index 358a0fbaf..b58824a55 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx @@ -15,6 +15,7 @@ export function LaneDialogShell({ busy = false, onCloseAutoFocus, children, + footer, }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -27,6 +28,7 @@ export function LaneDialogShell({ busy?: boolean; onCloseAutoFocus?: (event: Event) => void; children: ReactNode; + footer?: ReactNode; }) { const width = widthClassName ?? "w-[min(720px,calc(100vw-1rem))]"; const maxHeight = "max-h-[min(92dvh,calc(100vh-1rem))]"; @@ -79,6 +81,14 @@ export function LaneDialogShell({
{children}
+ {footer ? ( +
+ {footer} +
+ ) : null} diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 77ff41733..3f6f9e5bd 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -5,6 +5,7 @@ import { selectActiveProjectRoot, useAppStore } from "../../state/appStore"; import { getProjectConfigCached } from "../../lib/projectConfigCache"; import { modifierKeyLabel } from "../../lib/platform"; import { cn } from "../ui/cn"; +import { showToast } from "../app/toast/toastStore"; import { BranchIcon } from "../ui/vcsIcons"; import { SmartTooltip, type SmartTooltipContent } from "../ui/SmartTooltip"; import { COLORS, LABEL_STYLE, MONO_FONT, inlineBadge, outlineButton, primaryButton, dangerButton } from "./laneDesignTokens"; @@ -906,6 +907,19 @@ export function LaneGitActionsPane({ notice: `${actionName} completed`, error: null, }); + // Global success toast for remote push/sync completion (the inline notice + // above is subtle and auto-clears in 3s). Errors keep the persistent + // inline banner below and are intentionally not re-toasted. + const laneLabel = lane?.name ?? actionLaneId; + if (actionName === "push") { + showToast({ title: `Pushed ${laneLabel}`, tone: "success" }); + } else if (actionName === "force push") { + showToast({ title: `Force-pushed ${laneLabel}`, tone: "success" }); + } else if (actionName === "pull") { + showToast({ title: `Pulled ${laneLabel}`, tone: "success" }); + } else if (actionName === "rebase and push") { + showToast({ title: `Rebased & pushed ${laneLabel}`, tone: "success" }); + } scheduleLaneGitActionRuntimeClear(actionLaneId, actionVersion, 3_000, { notice: null, }); diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 574d7edda..e3af05fe1 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -27,7 +27,7 @@ import { import { LaneGitActionsPane } from "./LaneGitActionsPane"; import { LaneWorkPane } from "./LaneWorkPane"; import { QuickRunMenu } from "../run/QuickRunMenu"; -import { CreateLaneDialog, type CreateLaneMode, type CreateLaneSetupStep } from "./CreateLaneDialog"; +import { CreateLaneDialogHost, type CreateLanePrefill } from "./CreateLaneDialogHost"; import { AttachLaneDialog } from "./AttachLaneDialog"; import { MultiAttachWorktreeDialog } from "./MultiAttachWorktreeDialog"; import { ManageLaneDialog, EMPTY_LANE_DELETE_SELECTION, type LaneDeleteSelection } from "./ManageLaneDialog"; @@ -36,13 +36,6 @@ import { getLaneAccent } from "./laneColorPalette"; import { LaneRebaseBanner } from "./LaneRebaseBanner"; import { LinearIssueBadge } from "./LinearIssueBadge"; import { LanePrBadgePopover } from "./LanePrBadgePopover"; -import { - DEFAULT_NEW_LANE_BASE_SOURCE, - effectiveNewLaneBaseSource, - fetchNewLaneBaseBranches, - listNewLaneBaseOptions, - selectDefaultNewLaneBaseRef, -} from "./newLaneBaseSource"; import { HelpChip } from "../onboarding/HelpChip"; import { useDialogBus } from "../../lib/useDialogBus"; import { @@ -51,7 +44,6 @@ import { parseLaneIdsParam, laneHasAncestor, planLaneDeleteBatches, - resolveCreateLaneRequest, resolveLaneDeleteStartSelection, resolveLaneIdsDeepLinkSelection, resolveVisibleLaneIds, @@ -85,14 +77,10 @@ import { getProjectConfigCached } from "../../lib/projectConfigCache"; import { getGitHubSnapshotCoalesced, listPrsCoalesced, refreshPrsCoalesced, warmPrSurfaceCoalesced } from "../../lib/prReadCache"; import { logRendererDebugEvent } from "../../lib/debugLog"; import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; -import { linearIssueBranchName, linearIssueLaneName } from "../../../shared/linearIssueBranch"; import type { - BranchPullRequest, DeleteLaneArgs, GitCommitSummary, GitHubPrListItem, - LaneEnvInitEvent, - LaneEnvInitProgress, LaneBranchActiveWorkItem, LaneListSnapshot, LaneLinearIssue, @@ -102,8 +90,6 @@ import type { RebaseScope, IntegrationProposal, LaneDeleteProgress, - LaneTemplate, - NewLaneBaseSource, } from "../../../shared/types"; import { eventMatchesBinding, getEffectiveBinding } from "../../lib/keybindings"; import { SmartTooltip } from "../ui/SmartTooltip"; @@ -116,11 +102,6 @@ type RebaseScopePromptState = { }; type LanePaneSurface = "inline" | "git-actions-fullscreen" | "lane-fullscreen"; -type CreateSetupPhase = - | "creating" - | "appearance" - | "refreshing" - | "environment"; export function shouldMountGitActionsPane({ laneId, @@ -450,36 +431,13 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const [gridResetKey, setGridResetKey] = useState(0); const [laneFilter, setLaneFilter] = useState(""); const [manageOpen, setManageOpen] = useState(false); + // Create-lane dialog is hosted by CreateLaneDialogHost, which owns all of the + // form + submit + env-setup state. LanesPage only tracks whether it is open, + // the prefill to seed it with, and (via a ref) whether a create is in flight + // so a forced close (dialog bus) can be blocked. const [createOpen, setCreateOpen] = useState(false); - const [createLaneName, setCreateLaneName] = useState(""); - const [createParentLaneId, setCreateParentLaneId] = useState(""); - const [createMode, setCreateMode] = useState("primary"); - const [createBaseSource, setCreateBaseSource] = useState(DEFAULT_NEW_LANE_BASE_SOURCE); - const createBaseSourceRef = useRef(DEFAULT_NEW_LANE_BASE_SOURCE); - const createBaseSourceUserPickedRef = useRef(false); - const createBaseBranchesLoadSeqRef = useRef(0); - const createBaseSourceSaveInFlightRef = useRef(false); - const createBaseSourceSavePendingRef = useRef(null); - const [createBaseBranch, setCreateBaseBranch] = useState(""); - const [createImportBranch, setCreateImportBranch] = useState(""); - const [createChildBaseBranch, setCreateChildBaseBranch] = useState(""); - const [createBranches, setCreateBranches] = useState([]); - const [createBranchesLoading, setCreateBranchesLoading] = useState(false); - const [createBranchPullRequests, setCreateBranchPullRequests] = useState([]); - const [createBranchPullRequestsLoading, setCreateBranchPullRequestsLoading] = useState(false); - const [createGitUserName, setCreateGitUserName] = useState(""); - const [createBusy, setCreateBusy] = useState(false); - const [createError, setCreateError] = useState(null); - const [createEnvInitProgress, setCreateEnvInitProgress] = useState(null); - const [laneCreated, setLaneCreated] = useState(false); - const [createSetupPhase, setCreateSetupPhase] = useState(null); - const createEnvInitLaneIdRef = useRef(null); - const createBaseBranchUserPickedRef = useRef(false); - const [templates, setTemplates] = useState([]); - const [selectedTemplateId, setSelectedTemplateId] = useState(""); - const [createSelectedColor, setCreateSelectedColor] = useState(null); - const [createSelectedLinearIssue, setCreateSelectedLinearIssue] = useState(null); - const createLinearIssueAutoNameRef = useRef(null); + const [createPrefill, setCreatePrefill] = useState(null); + const createBusyRef = useRef(false); const [multiAttachOpen, setMultiAttachOpen] = useState(false); const [attachOpen, setAttachOpen] = useState(false); const [attachName, setAttachName] = useState(""); @@ -699,13 +657,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { return map; }, [sortedLanes]); - useEffect(() => { - return window.ade.lanes.onEnvEvent((event: LaneEnvInitEvent) => { - if (event.progress.laneId !== createEnvInitLaneIdRef.current) return; - setCreateEnvInitProgress(event.progress); - }); - }, []); - const filteredLanes = useMemo(() => { return sortLaneListRows({ lanes: laneFilterMatchedLanes, @@ -2132,109 +2083,13 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { /* ---- Create/Attach lane submit handlers ---- */ - const resetCreateDialogState = useCallback(() => { - createEnvInitLaneIdRef.current = null; - createBaseBranchUserPickedRef.current = false; - createBaseBranchesLoadSeqRef.current += 1; - setLaneCreated(false); - setCreateLaneName(""); - setCreateParentLaneId(""); - setCreateMode("primary"); - setCreateBaseBranch(""); - setCreateImportBranch(""); - setCreateChildBaseBranch(""); - setCreateBusy(false); - setCreateError(null); - setCreateEnvInitProgress(null); - setCreateSetupPhase(null); - setSelectedTemplateId(""); - setCreateSelectedColor(null); - setCreateSelectedLinearIssue(null); - createLinearIssueAutoNameRef.current = null; - }, []); - - const prepareCreateDialog = useCallback(() => { - setCreateLaneName(""); - setCreateParentLaneId(""); - setCreateMode("primary"); - setCreateBaseSource(DEFAULT_NEW_LANE_BASE_SOURCE); - createBaseSourceRef.current = DEFAULT_NEW_LANE_BASE_SOURCE; - createBaseSourceUserPickedRef.current = false; - setCreateBaseBranch(""); - setCreateImportBranch(""); - setCreateChildBaseBranch(""); - setCreateBranches([]); - setCreateBranchPullRequests([]); - setCreateGitUserName(""); - setCreateSelectedLinearIssue(null); - createLinearIssueAutoNameRef.current = null; - setCreateBranchesLoading(false); - setCreateBranchPullRequestsLoading(false); - setLaneCreated(false); - createBaseBranchUserPickedRef.current = false; - const primary = lanes.find((l) => l.laneType === "primary"); - if (primary) { - const loadSeq = ++createBaseBranchesLoadSeqRef.current; - setCreateBranchesLoading(true); - window.ade.projectConfig.get() - .catch(() => null) - .then(async (snapshot) => { - const baseSource = effectiveNewLaneBaseSource(snapshot); - const selectedBaseSource = createBaseSourceUserPickedRef.current - ? createBaseSourceRef.current - : baseSource; - if (!createBaseSourceUserPickedRef.current) { - createBaseSourceRef.current = baseSource; - setCreateBaseSource(baseSource); - } - const branches = await fetchNewLaneBaseBranches({ - source: selectedBaseSource, - fetchRemoteBranches: () => window.ade.git.fetch({ laneId: primary.id }), - listBranches: () => window.ade.git.listBranches({ laneId: primary.id }), - }); - if (createBaseBranchesLoadSeqRef.current !== loadSeq) return; - setCreateBranches(branches); - if (!createBaseBranchUserPickedRef.current) { - const defaultBaseRef = selectDefaultNewLaneBaseRef({ - branches, - source: createBaseSourceUserPickedRef.current - ? createBaseSourceRef.current - : selectedBaseSource, - primaryBaseRef: primary.baseRef, - }); - if (defaultBaseRef) setCreateBaseBranch(defaultBaseRef); - } - }) - .catch(() => {}) - .finally(() => { - if (createBaseBranchesLoadSeqRef.current === loadSeq) setCreateBranchesLoading(false); - }); - - // Capture git user.name so the picker can resolve `mine` / `author:me`. - window.ade.git.getUserIdentity({ laneId: primary.id }) - .then((identity) => setCreateGitUserName(identity?.name ?? "")) - .catch(() => setCreateGitUserName("")); - - // Lazily attach open-PR metadata. Fail-soft — picker degrades gracefully. - setCreateBranchPullRequestsLoading(true); - window.ade.prs.listOpenForRepo() - .then(setCreateBranchPullRequests) - .catch(() => setCreateBranchPullRequests([])) - .finally(() => setCreateBranchPullRequestsLoading(false)); - } - Promise.all([ - window.ade.lanes.listTemplates().catch(() => [] as LaneTemplate[]), - window.ade.lanes.getDefaultTemplate().catch(() => null), - ]).then(([nextTemplates, defaultTemplateId]) => { - setTemplates(nextTemplates); - setSelectedTemplateId( - defaultTemplateId && nextTemplates.some((template) => template.id === defaultTemplateId) - ? defaultTemplateId - : "" - ); - }); + // Open the create-lane host with an optional prefill. The host seeds itself + // (branch loading, templates, defaults) when `open` flips true, so LanesPage + // only tracks the open flag + prefill. + const openCreateDialog = useCallback((prefill?: CreateLanePrefill | null) => { + setCreatePrefill(prefill ?? null); setCreateOpen(true); - }, [lanes]); + }, []); // Deep link handling: must not re-run on lane list refreshes, or a stale // ?laneId / focus=single from the URL overwrites the user's current tab/split @@ -2242,7 +2097,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { useEffect(() => { if (urlLaneDeeplinks.action !== "create") return; - prepareCreateDialog(); + openCreateDialog(); const next = new URLSearchParams(location.search); next.delete("action"); const search = next.toString(); @@ -2251,7 +2106,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { location.pathname, location.search, navigate, - prepareCreateDialog, + openCreateDialog, urlLaneDeeplinks.action, ]); @@ -2554,303 +2409,21 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { }, [creatingIssues, laneIdByLinearIssueId]); const handleCreateDialogOpenChange = useCallback((open: boolean) => { - if (!open && createBusy) return; - if (!open) resetCreateDialogState(); setCreateOpen(open); - }, [createBusy, resetCreateDialogState]); - - const createSetupStatus = useMemo(() => { - switch (createSetupPhase) { - case "creating": - return createMode === "existing" - ? "Importing branch and creating the lane worktree..." - : "Creating the lane branch and worktree..."; - case "appearance": - return "Saving lane appearance..."; - case "refreshing": - return "Refreshing the lane list..."; - case "environment": - return selectedTemplateId ? "Applying the lane template..." : "Running lane environment setup..."; - default: - return laneCreated ? "Lane exists. Finish setup or retry the failed step." : null; - } - }, [createMode, createSetupPhase, laneCreated, selectedTemplateId]); - const createSetupSteps = useMemo(() => { - if (!createBusy && !laneCreated) return []; - let laneLabel: string; - if (createMode === "child") laneLabel = "Create child lane"; - else if (createMode === "existing") laneLabel = "Import branch"; - else laneLabel = "Create lane"; - let laneState: CreateLaneSetupStep["state"]; - if (createSetupPhase === "creating") laneState = "active"; - else if (laneCreated) laneState = "done"; - else laneState = "pending"; - const steps: CreateLaneSetupStep[] = [{ - label: laneLabel, - detail: "Create the branch metadata and worktree on disk.", - state: laneState, - }]; - steps.push({ - label: selectedTemplateId ? "Apply template" : "Initialize environment", - detail: selectedTemplateId - ? "Run the selected lane template setup." - : "Run the default lane setup checks.", - state: createSetupPhase === "environment" ? "active" : "pending", - }); - return steps; - }, [createBusy, createMode, createSetupPhase, laneCreated, selectedTemplateId]); - - /** Wraps setCreateBaseBranch so we can track user-driven selections and avoid - * the async branch-list fetch from overwriting a value the user already picked. */ - const handleSetCreateBaseBranch = useCallback((v: string) => { - createBaseBranchUserPickedRef.current = true; - setCreateBaseBranch(v); }, []); - const persistCreateBaseSourceConfig = useCallback(() => { - if (createBaseSourceSaveInFlightRef.current) return; - if (!createBaseSourceSavePendingRef.current) return; - createBaseSourceSaveInFlightRef.current = true; - let failed = false; - - void (async () => { - try { - while (createBaseSourceSavePendingRef.current) { - const source: NewLaneBaseSource = createBaseSourceSavePendingRef.current; - const snapshot = await window.ade.projectConfig.get(); - const currentGit = snapshot.local.git ?? {}; - await window.ade.projectConfig.save({ - shared: snapshot.shared, - local: { - ...snapshot.local, - git: { - ...currentGit, - newLaneBaseSource: source, - }, - }, - }); - if (createBaseSourceSavePendingRef.current === source) { - createBaseSourceSavePendingRef.current = null; - } - } - } catch (saveError) { - failed = true; - setCreateError(saveError instanceof Error ? saveError.message : String(saveError)); - } finally { - createBaseSourceSaveInFlightRef.current = false; - if (!failed && createBaseSourceSavePendingRef.current) { - persistCreateBaseSourceConfig(); - } - } - })(); + // Blocked by the dialog bus while a create/setup is in flight (parity with the + // old busy guard); the host owns the busy state and mirrors it into the ref. + const handleCreateDialogBusClose = useCallback(() => { + if (createBusyRef.current) return; + setCreateOpen(false); }, []); - const handleSetCreateBaseSource = useCallback((source: NewLaneBaseSource) => { - createBaseSourceRef.current = source; - createBaseSourceUserPickedRef.current = true; - createBaseBranchUserPickedRef.current = false; - const loadSeq = ++createBaseBranchesLoadSeqRef.current; - setCreateBaseSource(source); - setCreateBaseBranch(""); - setCreateBranches([]); - const primary = lanes.find((l) => l.laneType === "primary"); - if (primary) { - setCreateBranchesLoading(true); - fetchNewLaneBaseBranches({ - source, - fetchRemoteBranches: () => window.ade.git.fetch({ laneId: primary.id }), - listBranches: () => window.ade.git.listBranches({ laneId: primary.id }), - }) - .then((branches) => { - if (createBaseSourceRef.current !== source || createBaseBranchesLoadSeqRef.current !== loadSeq) return; - setCreateBranches(branches); - if (!createBaseBranchUserPickedRef.current) { - setCreateBaseBranch(selectDefaultNewLaneBaseRef({ - branches, - source, - primaryBaseRef: primary.baseRef, - })); - } - }) - .catch(() => {}) - .finally(() => { - if (createBaseSourceRef.current === source && createBaseBranchesLoadSeqRef.current === loadSeq) { - setCreateBranchesLoading(false); - } - }); - } else { - setCreateBranchesLoading(false); - } - createBaseSourceSavePendingRef.current = source; - persistCreateBaseSourceConfig(); - }, [lanes, persistCreateBaseSourceConfig]); - - 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 post-create setup for a lane that already exists. Used as the retry path - * when environment setup fails. */ - const runSetupForCreatedLane = useCallback(async (laneId: string) => { - setCreateBusy(true); - setCreateError(null); - setCreateEnvInitProgress(null); - setCreateSetupPhase("environment"); - - try { - const envProgress = selectedTemplateId - ? await window.ade.lanes.applyTemplate({ laneId, templateId: selectedTemplateId }) - : await window.ade.lanes.initEnv({ laneId }); - setCreateEnvInitProgress(envProgress); - - if (envProgress.overallStatus === "failed") { - setCreateError("Environment setup failed. Review the progress log and retry."); - return; - } - - resetCreateDialogState(); - setCreateOpen(false); - } catch (err) { - setCreateError(err instanceof Error ? err.message : String(err)); - } finally { - setCreateSetupPhase(null); - setCreateBusy(false); - } - }, [selectedTemplateId, resetCreateDialogState]); - - const handleCreateSubmit = useCallback(async () => { - // If the lane was already created (e.g. env setup failed on a previous - // attempt), retry setup only — never re-run creation. - if (createEnvInitLaneIdRef.current) { - await runSetupForCreatedLane(createEnvInitLaneIdRef.current); - return; - } - - const name = createLaneName.trim(); - if (!name || createBusy) return; - if (createMode === "child" && !createParentLaneId) return; - if (createMode === "primary") { - const validBaseBranch = listNewLaneBaseOptions(createBranches, createBaseSource) - .some((option) => option.ref === createBaseBranch); - if (createBranchesLoading || !validBaseBranch) { - setCreateError(createBranchesLoading - ? "Still loading base branches. Try again in a moment." - : "Choose a valid base branch for the selected source."); - 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; - } - - setCreateBusy(true); - setCreateError(null); - setCreateEnvInitProgress(null); - setCreateSetupPhase("creating"); - - try { - const request = resolveCreateLaneRequest({ - name, - createMode, - createParentLaneId, - 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); - } else if (request.kind === "child") { - const trimmedBase = createChildBaseBranch.trim(); - const parentLane = lanes.find((l) => l.id === request.args.parentLaneId); - if (!parentLane) { - setCreateError("Parent lane no longer exists. Please close and reopen the dialog."); - setCreateBusy(false); - setCreateSetupPhase(null); - return; - } - const childArgs = trimmedBase && trimmedBase !== parentLane.branchRef - ? { ...request.args, baseBranchRef: trimmedBase, ...linearIssueArgs } - : { ...request.args, ...linearIssueArgs }; - lane = await window.ade.lanes.createChild(childArgs); - } else { - lane = await window.ade.lanes.create({ ...request.args, ...linearIssueArgs }); - } - - // Lane created successfully — record its id so retries skip creation. - createEnvInitLaneIdRef.current = lane.id; - setLaneCreated(true); - - if (createSelectedColor) { - try { - setCreateSetupPhase("appearance"); - await window.ade.lanes.updateAppearance({ laneId: lane.id, color: createSelectedColor }); - } catch { - // Color collisions or transient errors shouldn't block lane creation. - } - } - - setCreateSetupPhase("refreshing"); - await refreshLanes(); - navigate(`/lanes?laneId=${encodeURIComponent(lane.id)}&focus=single`); - - // Now run environment setup as a separate phase. - await runSetupForCreatedLane(lane.id); - } catch (err) { - setCreateError(err instanceof Error ? err.message : String(err)); - setCreateSetupPhase(null); - setCreateBusy(false); - } - }, [ - createLaneName, - createMode, - createParentLaneId, - createBaseSource, - createBaseBranch, - createBranches, - createBranchesLoading, - createImportBranch, - createChildBaseBranch, - lanes, - createBusy, - navigate, - refreshLanes, - resetCreateDialogState, - runSetupForCreatedLane, - selectedTemplateId, - templates, - createSelectedColor, - createSelectedLinearIssue, - ]); + // After the lane record exists + list is refreshed, focus the new lane (the + // host still streams env-setup progress in the dialog in stay-open mode). + const handleLaneCreated = useCallback((lane: LaneSummary) => { + navigate(`/lanes?laneId=${encodeURIComponent(lane.id)}&focus=single`); + }, [navigate]); const handleAttachSubmit = useCallback(async () => { const name = attachName.trim(); @@ -2889,10 +2462,9 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const handleCreateDialogBusOpen = useCallback((props?: Record) => { setAddLaneDropdownOpen(false); setStackGraphHeaderOpen(false); - prepareCreateDialog(); const name = typeof props?.name === "string" ? props.name.trim() : ""; - if (name) setCreateLaneName(name); - }, [prepareCreateDialog]); + openCreateDialog(name ? { name } : null); + }, [openCreateDialog]); const handleManageDialogBusOpen = useCallback((props?: Record) => { const requestedLaneId = typeof props?.laneId === "string" ? props.laneId : null; @@ -2911,7 +2483,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { useDialogBus("lanes.create", { onOpen: handleCreateDialogBusOpen, - onClose: () => handleCreateDialogOpenChange(false), + onClose: handleCreateDialogBusClose, }); useDialogBus("lanes.manage", { @@ -3431,7 +3003,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm text-muted-fg transition-colors hover:bg-white/[0.04] hover:text-fg" onClick={() => { setAddLaneDropdownOpen(false); - prepareCreateDialog(); + openCreateDialog(); }} > @@ -4081,7 +3653,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { variant="primary" disabled={!canCreateLane} onClick={() => { - prepareCreateDialog(); + openCreateDialog(); }} > Create Lane @@ -4254,52 +3826,15 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { {/* Create Lane dialog */} - { createBusyRef.current = busy; }} onOpenLinearSettings={() => navigate("/settings?tab=general#linear-connection")} onNavigateToTemplates={() => navigate("/settings?tab=lane-templates")} - importBranchWarning={ - createMode === "existing" && createImportBranch && primaryLane?.status.dirty - && createBranches.find((b) => b.name === createImportBranch && !b.isRemote)?.isCurrent - ? `This branch is currently checked out and has uncommitted changes. The new lane will only include committed changes\u2009—\u2009uncommitted work will not carry over.` - : null - } /> {/* Attach Lane dialog */} diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 3fb05ac68..481e1137d 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -8,6 +8,7 @@ import { SessionCard } from "./SessionCard"; import { ToolLogo } from "./ToolLogos"; import { LaneCombobox } from "./LaneCombobox"; import { sortLanesForTabs } from "../lanes/laneUtils"; +import { CreateLaneDialogHost } from "../lanes/CreateLaneDialogHost"; import type { WorkDraftKind, WorkGridSet, WorkSessionListOrganization } from "../../state/appStore"; import { findGridSetForSession } from "../../lib/workGrid"; import { iconGlyph } from "../graph/graphHelpers"; @@ -320,6 +321,7 @@ export const SessionListPane = React.memo(function SessionListPane({ handoffJobs?: HandoffLaunchJob[]; }) { const navigate = useNavigate(); + const [createLaneOpen, setCreateLaneOpen] = useState(false); const orderedLanes = useMemo(() => sortLanesForTabs(lanes), [lanes]); const { trigger: triggerLaneContextMenu, menu: laneContextMenuPortal } = useWorkLaneContextMenu(); @@ -942,23 +944,32 @@ export const SessionListPane = React.memo(function SessionListPane({ {/* Add Lane button */}
- +
+ {createLaneOpen ? ( + navigate("/settings?tab=lane-templates")} + onOpenLinearSettings={() => navigate("/settings?tab=general#linear-connection")} + /> + ) : null} {laneContextMenuPortal} ); diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index fabf96140..26c44dce3 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -4068,3 +4068,17 @@ button:active, [role="button"]:active { .ade-welcome-card__qr:hover { transform: none; } .ade-welcome-card__poster:hover .ade-welcome-card__play { transform: translate(-50%, -50%); } } + +/* Shared toast primitive (ToastStack): slide-up + fade-in on entrance. */ +@keyframes ade-toast-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.ade-toast-enter { + animation: ade-toast-in 180ms cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@media (prefers-reduced-motion: reduce) { + .ade-toast-enter { animation: none; } +} diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 7d014a46e..46e731d3d 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -107,6 +107,7 @@ export const IPC = { lanesDelete: "ade.lanes.delete", lanesDeleteCancel: "ade.lanes.delete.cancel", lanesDeleteEvent: "ade.lanes.delete.event", + lanesLifecycleEvent: "ade.lanes.lifecycle.event", lanesListDeleteProgress: "ade.lanes.delete.progress.list", lanesGetDeleteRisk: "ade.lanes.delete.risk", lanesGetStackChain: "ade.lanes.getStackChain", diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index 3e828c1d4..dd3ad00d5 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -392,6 +392,22 @@ export type LaneDeleteEvent = { progress: LaneDeleteProgress; }; +/** + * Fired once when a lane reaches a terminal lifecycle transition, so the + * renderer can surface a toast without polling. Distinct from + * {@link LaneDeleteEvent}, which streams per-step delete progress; this fires a + * single time on successful completion. `lane` carries the full summary for + * created lanes (the create paths already have it); archive/delete only need + * name + color for the notice. + */ +export type LaneLifecycleEvent = { + type: "lane-created" | "lane-archived" | "lane-deleted"; + laneId: string; + laneName: string; + color?: string | null; + lane?: LaneSummary; +}; + export type LaneDeleteRisk = { laneId: string; branchRef: string | null; diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index 649ddbac4..031ae05f6 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -611,7 +611,8 @@ private struct WorkRootPreviewHarness: View { needsInputCount: presentation.globalNeedsInputCount, isLive: true, onClear: clearFilters, - onNewChat: {} + onNewChat: {}, + onAddLane: {} ) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 8, trailing: 16)) .listRowBackground(Color.clear) diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index fb614aa84..3ff38aab3 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -18,6 +18,7 @@ struct WorkFiltersSection: View { let isLive: Bool let onClear: () -> Void let onNewChat: () -> Void + let onAddLane: () -> Void private var selectedLaneName: String { if selectedLaneId == "all" { return "All lanes" } @@ -85,27 +86,51 @@ struct WorkFiltersSection: View { .accessibilityLabel("Toggle filter panel") } - Button(action: onNewChat) { - HStack(spacing: 8) { - Image(systemName: "plus") - .font(.system(size: 13, weight: .bold)) - Text("Start new chat") - .font(.subheadline.weight(.semibold)) + HStack(spacing: 8) { + Button(action: onNewChat) { + HStack(spacing: 8) { + Image(systemName: "plus") + .font(.system(size: 13, weight: .bold)) + Text("Start new chat") + .font(.subheadline.weight(.semibold)) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity, minHeight: 44) + .background(ADEColor.accent, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(.white.opacity(0.18), lineWidth: 0.6) + ) + .shadow(color: ADEColor.accent.opacity(0.18), radius: 6, x: 0, y: 2) } - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 11) - .background(ADEColor.accent, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(.white.opacity(0.18), lineWidth: 0.6) - ) - .shadow(color: ADEColor.accent.opacity(0.18), radius: 6, x: 0, y: 2) + .buttonStyle(.plain) + .disabled(!isLive) + .opacity(isLive ? 1 : 0.55) + .accessibilityLabel("Start new chat") + + Button(action: onAddLane) { + HStack(spacing: 7) { + Image(systemName: "plus.square.on.square") + .font(.system(size: 13, weight: .semibold)) + Text("Add lane") + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + } + .foregroundStyle(isLive ? ADEColor.accent : ADEColor.textMuted) + .frame(minWidth: 116, minHeight: 44) + .padding(.horizontal, 12) + .background(ADEColor.composerBackground, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(isLive ? ADEColor.accent.opacity(0.3) : ADEColor.glassBorder, lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + .disabled(!isLive) + .opacity(isLive ? 1 : 0.55) + .accessibilityLabel("Add lane") + .accessibilityHint(isLive ? "Opens lane creation options" : "Reconnect to machine before creating lanes") } - .buttonStyle(.plain) - .disabled(!isLive) - .opacity(isLive ? 1 : 0.55) - .accessibilityLabel("Start new chat") if needsInputCount > 0 || hasActiveFilters { HStack(spacing: 6) { diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 3ea9cd091..230f77760 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -93,6 +93,7 @@ struct WorkRootScreen: View { @AppStorage("ade.work.sessionOrganization") var sessionOrganizationRaw = WorkSessionOrganization.byLane.rawValue @AppStorage("ade.work.collapsedSectionIds") var collapsedSectionIdsStorage = "" @State var filterPanelOpen = false + @State var addLaneSheetPresented = false var selectedStatus: WorkSessionStatusFilter { get { WorkSessionStatusFilter(rawValue: selectedStatusRawValue) ?? .all } @@ -210,6 +211,11 @@ struct WorkRootScreen: View { } } + func presentAddLaneSheet() { + guard isLive else { return } + addLaneSheetPresented = true + } + var sessionGroups: [WorkSessionGroup] { sessionPresentation.sessionGroups } @@ -290,7 +296,8 @@ struct WorkRootScreen: View { needsInputCount: globalNeedsInputCount, isLive: isLive, onClear: clearWorkFilters, - onNewChat: pushNewChatRoute + onNewChat: pushNewChatRoute, + onAddLane: presentAddLaneSheet ) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 8, trailing: 16)) .listRowBackground(Color.clear) @@ -504,6 +511,17 @@ struct WorkRootScreen: View { .sheet(item: $bulkExportShare) { share in WorkActivityViewController(items: share.items) } + .sheet(isPresented: $addLaneSheetPresented) { + AddLaneSheet( + primaryLane: lanes.first(where: { $0.laneType == "primary" }), + lanes: lanes, + onLaneCreated: { createdLaneId in + addLaneSheetPresented = false + selectedLaneId = createdLaneId + await reload(refreshRemote: true) + } + ) + } .alert("Delete \(bulkSelectedDeletableCount) chat\(bulkSelectedDeletableCount == 1 ? "" : "s")?", isPresented: $bulkDeleteConfirmPresented) { Button("Cancel", role: .cancel) {} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eb58b2806..63757955f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -397,6 +397,7 @@ Related feature docs: [Chat](./features/chat/README.md), [Agents](./features/age - `contextBridge.exposeInMainWorld("ade", { ... })` — the only cross-isolated-world surface. - Methods are typed via TypeScript imports from `apps/desktop/src/shared/types/`. - Two categories: **invoke methods** (`ipcRenderer.invoke(channel, args)` returning `Promise`) and **event subscriptions** (`ipcRenderer.on(channel, handler)`). +- Runtime-backed event subscriptions can merge local Electron IPC and the runtime event stream behind one renderer API. For example, `window.ade.lanes.onLifecycleEvent` listens to `ade.lanes.lifecycle.event` for desktop-local fallback paths and to runtime `lane_lifecycle_event` payloads for local-brain or SSH-bound windows. - `contextIsolation: true`, `nodeIntegration: false`, `sandbox: false` (required for preload functionality). - Global window type: `apps/desktop/src/preload/global.d.ts`. - `window.ade.project.getDroppedPath(file)` wraps Electron's `webUtils.getPathForFile()` so renderer drag-drop handlers can resolve the absolute path of a `File` payload without the renderer needing Node APIs. Used by the Command Palette project browser to accept dropped folders. @@ -412,6 +413,9 @@ ade.onboarding.* ade.lanes.* # lane list/create/delete/stack/template/env/port/proxy/rebase # delete pipeline: ade.lanes.delete + ade.lanes.delete.cancel # + ade.lanes.delete.risk preflight + ade.lanes.delete.event push + # one-shot create/archive/delete notifications: + # ade.lanes.lifecycle.event push, mirrored from + # runtime event type lane_lifecycle_event # Linear linkage: ade.lanes.linkLinearIssues / unlinkLinearIssues # (lane-scoped) + attachLinearIssueToSession / # detachLinearIssueFromSession / listLinearIssuesForSession / @@ -502,7 +506,8 @@ High-frequency events flow from main → renderer via `webContents.send(channel, | `ade.conflicts.event` | conflictService | Conflicts page, Graph overlay | | `ade.prs.event` | prPollingService | PRs page, stacked queue | | `ade.agents.event` | CTO/worker services | CTO tab feed | -| `ade.lanes.rebaseSuggestions.event` / `ade.lanes.autoRebase.event` / `ade.lanes.rebase.event` | rebase services | Lanes + Graph | +| `ade.lanes.lifecycle.event` | laneService / runtime `lane_lifecycle_event` | AppShell toast stack | +| `ade.lanes.rebaseSuggestions.event` / `ade.lanes.autoRebase.event` / `ade.lanes.rebase.event` | rebase services | Lanes + Graph; automated terminal-state rebase outcomes also feed AppShell toasts | | `ade.project.missing` | projectService | Shell banner | | `ade.project.state.event` | projectState | Startup flow | | `ade.sync.*` events | syncService | Settings → Sync | @@ -668,6 +673,7 @@ Themes: six shipped themes (`e-paper`, `bloomberg`, `github`, `rainbow`, `sky`, ### 7.6 Renderer primitives - `renderer/lib/dialogBus.ts` — tiny pub/sub that lets shared UI open/close dialogs by a stable id (`lanes.create`, `settings.ai`, etc.) without prop-drilling. Dialogs subscribe by id; a `subscribeAll` channel exists for devtools. Default singleton export `dialogBus`. +- `renderer/components/app/toast/` - shared renderer-only toast primitive. `toastStore.ts` owns stack order, timers, hover pause/resume, sticky toasts, and in-place replacement; `ToastStack.tsx` renders inside AppShell's existing bottom-right notice container. Lane lifecycle and automated rebase terminal events subscribe through `useLaneEventToasts.ts`. - `renderer/onboarding/docsLinks.ts` — typed registry of internal/public doc URLs (`docs.lanes`, `docs.cto`, …) used by `DidYouKnow`, glossary/help surfaces, and the `HelpMenu`. - `renderer/components/onboarding/WelcomeVideoGate.tsx` — app-level one-time welcome card overlay with sanitized bundled screenshots/assets, a lazy intro video, and an ADE Mobile TestFlight QR/download/copy panel. Seen/dismissed state is stored in the global app state file, separate from per-project setup onboarding. diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 8899831e5..0a9a3a848 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -56,7 +56,7 @@ Desktop fallback services (`apps/desktop/src/main/services/lanes/`): | File | Responsibility | |------|---------------| -| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, startup repair routines, branch switching, lane + session Linear issue linkage, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, verifies residual worktree files are gone before DB cleanup, records retryable residual-cleanup debt when manual deletion fails, and cleans the pack directory + DB rows. Lane creation is wrapped so that any failure after the worktree is on disk routes through `cleanupCreatedWorktreeLaneAfterCreateFailure`, which removes the orphaned checkout rather than leaving a worktree no lane row references. Independent deletes can progress through teardown concurrently; only the `git_worktree_remove` step enters the shared worktree-mutation guard, so lane creation is not held behind unrelated stop/cleanup steps but still avoids concurrent edits to Git's worktree registry. Deletes run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `list()` also runs the residual-worktree cleanup retry sweep before duplicate/stale worktree repair so previous delete warnings can self-heal without blocking lane row cleanup. `getSummary(laneId, { includeStatus })` is the scoped summary path used by mobile detail commands so opening a lane does not rebuild the full lane list; `refreshSnapshots` honors `includeStatus` for light runtime-bucket refreshes. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. Branch switching rolls git checkout back to the previous branch when the database update fails. **Linear issue linkage:** `linkLinearIssues` / `unlinkLinearIssues` manage lane-scoped links in `lane_linear_issue_links` (never touching the primary `lane_linear_issues` row); `attachLinearIssueToSession` / `detachLinearIssueFromSession` / `listLinearIssuesForSession` / `listLinearIssuesForLaneSessions` manage session-scoped links in `session_linear_issues`. `attachLinearIssueToSession` resolves the session's lane from `claude_sessions` / `terminal_sessions` and mirrors each issue into the lane's `chat_attach` links when a lane exists, without ever promoting the lane's primary issue. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). | +| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, startup repair routines, branch switching, lane + session Linear issue linkage, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, verifies residual worktree files are gone before DB cleanup, records retryable residual-cleanup debt when manual deletion fails, and cleans the pack directory + DB rows. It also emits one-shot `LaneLifecycleEvent` notifications after successful create/archive/delete transitions so renderer surfaces can toast completed lifecycle changes without polling. Lane creation is wrapped so that any failure after the worktree is on disk routes through `cleanupCreatedWorktreeLaneAfterCreateFailure`, which removes the orphaned checkout rather than leaving a worktree no lane row references. Independent deletes can progress through teardown concurrently; only the `git_worktree_remove` step enters the shared worktree-mutation guard, so lane creation is not held behind unrelated stop/cleanup steps but still avoids concurrent edits to Git's worktree registry. Deletes run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `list()` also runs the residual-worktree cleanup retry sweep before duplicate/stale worktree repair so previous delete warnings can self-heal without blocking lane row cleanup. `getSummary(laneId, { includeStatus })` is the scoped summary path used by mobile detail commands so opening a lane does not rebuild the full lane list; `refreshSnapshots` honors `includeStatus` for light runtime-bucket refreshes. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. Branch switching rolls git checkout back to the previous branch when the database update fails. **Linear issue linkage:** `linkLinearIssues` / `unlinkLinearIssues` manage lane-scoped links in `lane_linear_issue_links` (never touching the primary `lane_linear_issues` row); `attachLinearIssueToSession` / `detachLinearIssueFromSession` / `listLinearIssuesForSession` / `listLinearIssuesForLaneSessions` manage session-scoped links in `session_linear_issues`. `attachLinearIssueToSession` resolves the session's lane from `claude_sessions` / `terminal_sessions` and mirrors each issue into the lane's `chat_attach` links when a lane exists, without ever promoting the lane's primary issue. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). | | `worktreeResidualCleanup.ts` | Machine-local retry worker for managed worktree directories that survive lane deletion. It stores cleanup debt in `local_worktree_residual_cleanups`, retries during `laneService.list()`, drops unsafe records, skips registered Git worktrees, active lane paths, and pending creations, removes old empty untracked directories under the managed worktrees directory, and leaves unknown non-empty directories alone unless they were explicitly recorded from the delete path. | | `autoRebaseService.ts` | Auto-rebase worker for stacked lanes, attention state, head-change handlers. Consults `resolvePrRebaseMode` to determine whether a lane with a linked PR should auto-rebase (`pr_target` strategy) or only surface manual attention (`lane_base` strategy). `listStatuses({ includeAll: true })` returns stored statuses without recomputing lane git status for PR workflow views. | | `rebaseSuggestionService.ts` | Emits rebase suggestions when a parent lane advances, dismiss/defer lifecycle. Each suggestion may include up to 20 `RebaseTargetCommit` entries showing the behind commits the rebase would pull in. | @@ -74,7 +74,8 @@ Renderer components: | File | Responsibility | |------|---------------| | `renderer/components/app/App.tsx` | Project tab host and route keep-alive shell. Keeps the Work surface mounted after first visit and now does the same for `/lanes`, parking the inactive Lanes surface with `inert` / `aria-hidden` instead of unmounting it. Surfaces are keyed by runtime binding (`local:` or the remote binding key) so local and remote views of the same root do not share lane/work state. During cold project switches it renders a transition veil over the old project surface until the target project hydrates. | -| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR. The pure selectors in `lanePageModel.ts` prefer live same-branch GitHub repo inventory over terminal/stale ADE rows, then fall back to ADE-linked PR rows, so externally created PRs and open-after-closed branch reuse stay visible; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. The page forces one GitHub snapshot refresh on project/branch-signature changes and otherwise uses cached snapshot/event refreshes to avoid repeated PR polling from the Lanes tab. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat/process buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` through `useAppStore().laneDeleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). On mount/project switch it hydrates active backend delete progress when available, but also keeps stored active delete progress long enough to move selection away and queue a refresh if the backend list is missing, stale, or temporarily failed. Batch deletes still run selected child lanes before their selected parents; within each dependency-safe batch the page dispatches up to two lane deletes at a time and records per-lane failures, and a parent remains blocked if a selected descendant fails. Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures and non-fatal cleanup warnings through `laneActionError`. Work-tab action deeplinks scrub `action`, `laneId`, and `laneIds` after handling so modal routing cannot also rewrite split selection state. | +| `renderer/components/app/toast/{toastStore.ts,ToastStack.tsx,useLaneEventToasts.ts}` | Shared renderer toast primitive mounted from `AppShell`. `useLaneEventToasts` subscribes to `lanes.onLifecycleEvent` and `lanes.rebaseSubscribe`, turning lane-created/archive/delete and final automated rebase outcomes into compact global notices; created-lane toasts include a `View` action that routes to `/lanes?laneId=...&focus=single`. | +| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Create-lane state lives in `CreateLaneDialogHost`; `LanesPage` owns only open/prefill routing, blocks forced close while the host is busy, and focuses the new lane after the host refreshes the lane list while the dialog stays open for setup progress. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR. The pure selectors in `lanePageModel.ts` prefer live same-branch GitHub repo inventory over terminal/stale ADE rows, then fall back to ADE-linked PR rows, so externally created PRs and open-after-closed branch reuse stay visible; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. The page forces one GitHub snapshot refresh on project/branch-signature changes and otherwise uses cached snapshot/event refreshes to avoid repeated PR polling from the Lanes tab. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat/process buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` through `useAppStore().laneDeleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). On mount/project switch it hydrates active backend delete progress when available, but also keeps stored active delete progress long enough to move selection away and queue a refresh if the backend list is missing, stale, or temporarily failed. Batch deletes still run selected child lanes before their selected parents; within each dependency-safe batch the page dispatches up to two lane deletes at a time and records per-lane failures, and a parent remains blocked if a selected descendant fails. Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures and non-fatal cleanup warnings through `laneActionError`. Work-tab action deeplinks scrub `action`, `laneId`, and `laneIds` after handling so modal routing cannot also rewrite split selection state. | | `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR matching, same-repo GitHub PR guardrails for fork branch-name collisions, ADE-vs-GitHub PR tag precedence, terminal-state GitHub overrides for stale ADE PR rows, deep-link lane selection, action-deeplink query cleanup, create-lane request normalization, delete-start selection fallback, parent-before-child-safe batch delete planning, and `runLaneDeleteBatchWithConcurrency` for limited parallel teardown inside each dependency-safe batch. | | `renderer/state/appStore.ts` | Shared renderer project/lane state. Stores `laneDeleteProgressByLaneId` so in-flight lane deletion UI survives local `LanesPage` remounts and project metadata updates; the map clears only when the project root changes or the project is closed/reset. Warm project-tab switches restore cached lanes/snapshots, lane selection, focused session, and loading state before the backend round trip finishes, and cache pruning retains Work/lane/session state for all open project tabs in addition to the active and recent projects. | | `renderer/components/lanes/laneUtils.ts` | Pure lane list/filter helpers plus default pane trees, including the work-focused tiling tree used by parallel chat launch deep links. | @@ -89,7 +90,7 @@ Renderer components: | `renderer/components/lanes/useLaneWorkSessions.ts` | Hook behind the lane Work pane's chat/session list. Tracks the latest lane id, project root, and scope key in refs so a refresh that was queued during a lane or project switch replays against the newest target and ignores stale rows from the old scope. `launchPtySession` accepts `WorkPtyLaunchArgs` (including `disposition` and `startupDelayMs`) and returns `WorkPtyLaunchResult`; background disposition skips `selectLane`/`focusSession`/`openSessionTab`. The launcher creates an optimistic `TerminalSessionSummary` snapshot from the `ptyCreate` result and upserts it into the session list immediately, then fires the forced session-list refresh as fire-and-forget so the tab and session card appear without waiting for the IPC round-trip. | | `renderer/components/lanes/LaneRebaseBanner.tsx` | Inline banner driven by `rebaseSuggestionService` | | `renderer/components/lanes/LaneEnvInitProgress.tsx` | Env init step progress inside create dialog | -| `renderer/components/lanes/CreateLaneDialog.tsx`, `AttachLaneDialog.tsx`, `MultiAttachWorktreeDialog.tsx`, `LaneDialogShell.tsx` | Lane creation / attach dialogs and shared dialog chrome. `LaneDialogShell` is viewport-centered (`top-1/2 -translate-y-1/2`), capped at `min(92dvh, calc(100vh-1rem))`, and renders a sticky header strip plus a single scrollable body — every lane modal (create, attach, multi-attach, manage) inherits this layout so long content scrolls instead of overflowing the dialog. `CreateLaneDialog` pins a stable shell height so the "import existing branch" sub-view keeps the same frame as the main add-lane form while `BranchPickerView` fills the available body area. Selecting a branch seeds the editable lane name from the branch name until the user customizes it. The "Connect Linear issue" affordance in the always-open Advanced section swaps it for `LinearIssuePickerView`. The dialog title/description/icon switch in lockstep with the active sub-view, and connecting a Linear issue auto-flips the create mode out of `existing` (the import-branch tab is locked while an issue is attached). | +| `renderer/components/lanes/CreateLaneDialogHost.tsx`, `CreateLaneDialog.tsx`, `AttachLaneDialog.tsx`, `MultiAttachWorktreeDialog.tsx`, `LaneDialogShell.tsx` | Lane creation / attach dialogs and shared dialog chrome. `CreateLaneDialogHost` owns create-form state, base-branch loading, template/default-template selection, Linear prefill, submit orchestration, appearance save, lane-list refresh, and env setup. It has two post-create behaviors: `stay-open-setup` keeps the Lanes-tab dialog open and streams `LaneEnvInitProgress`; `close-on-create` closes the Work-tab dialog after the lane record exists and runs env setup detached, surfacing a sticky retry toast if setup fails. `CreateLaneDialog` renders the fields and branch/Linear picker subviews; `LaneDialogShell` is viewport-centered (`top-1/2 -translate-y-1/2`), capped at `min(92dvh, calc(100vh-1rem))`, and renders a sticky header, single scrollable body, and optional footer so long content scrolls instead of overflowing the dialog. Selecting a branch seeds the editable lane name from the branch name until the user customizes it. The "Connect Linear issue" affordance in the always-open Advanced section swaps it for `LinearIssuePickerView`. The dialog title/description/icon switch in lockstep with the active sub-view, and connecting a Linear issue auto-flips the create mode out of `existing` (the import-branch tab is locked while an issue is attached). | | `renderer/components/lanes/laneDialogTokens.ts` | Shared Tailwind class-name tokens for lane dialog sections: `SECTION_CLASS_NAME` (neutral), `SECTION_ACCENT_CLASS_NAME` (accent wash used by stack/integration callouts like the Stack position panel), `SECTION_HERO_CLASS_NAME` (the hero strip at the top of Manage Lane), `LABEL_CLASS_NAME`, `INPUT_CLASS_NAME`, `SELECT_CLASS_NAME`. | | `renderer/components/lanes/BranchPickerView.tsx` | Filterable virtualized branch list rendered inside `CreateLaneDialog`. Each row shows branch name, last-commit author + relative date, and an inline PR pill (`#NNN`, dim for drafts) when the branch has an open PR. Loading/empty/error states are handled inline. Backed by `branchPickerSearch.ts`. | | `renderer/components/lanes/branchPickerSearch.ts` | Pure parser + matcher. Tokens AND together: `pr:open` / `pr:none` / `pr:draft`, `author:NAME` (or `author:me` / `mine` resolved against the local git user), `stale:Nd` (older than N days), `#PRNUMBER` (exact match), and free text fuzzy-matched across branch name / PR title / author. Also exposes `formatRelativeTime` for the row subtitle. | @@ -303,7 +304,10 @@ a lane parented to primary would always show zero behind. the resolved branch already exists locally or under `origin/`, and writes the issue payload into `lane_linear_issues` so the PR / commit / chat surfaces can pick it up later. The same path runs - for `createChild`. + for `createChild`. Successful create/import paths emit a + `LaneLifecycleEvent` with the created `LaneSummary` so the renderer + can show a global success toast without waiting for another list + poll. 2. **Create child** — same as create but with `parentLaneId`. Child's base ref defaults to the parent's branch ref. Callers can override with `baseBranchRef` on `CreateChildLaneArgs` to fork from any local @@ -340,7 +344,8 @@ a lane parented to primary would always show zero behind. unchanged, reparent short-circuits without touching git so a redundant apply is a no-op rather than a stack rebase. 7. **Archive** — `archive` sets `archived_at` and `status = 'archived'` - but keeps the worktree on disk. `unarchive` reverses it. + but keeps the worktree on disk, then emits a `lane-archived` + lifecycle event. `unarchive` reverses the database state. 8. **Delete** — `delete({ laneId, deleteBranch?, deleteRemoteBranch?, remoteBranchName?, force? })` runs an explicit teardown pipeline and emits `lanes.delete.event` per step. Steps execute in order: @@ -355,6 +360,10 @@ a lane parented to primary would always show zero behind. retained for contract compatibility but always returns `{ cancelled: false, reason }`; once a delete starts, teardown runs to completion. + After successful cleanup, the service emits a `lane-deleted` + lifecycle event. This is separate from `lanes.delete.event`: delete + progress streams every teardown step, while the lifecycle event is a + single final-state notification for toast consumers. Teardown depends on optional injected services (`processService`, `ptyService`, `autoRebaseService`, `rebaseSuggestionService`, `fileWatcherService`); when one is not @@ -485,6 +494,7 @@ Lane management (selected): | `ade.lanes.delete.risk` | `(args: { laneId }) => LaneDeleteRisk` — preflight read for the manage dialog: dirty state, unpushed commit count, remote-branch existence, active processes/PTYs/watchers, env-init flag. | | `ade.lanes.delete.cancel` | `(args: { laneId }) => { cancelled, reason? }` — cooperative cancel during the early teardown steps. After `git_worktree_remove` starts the lane is unrecoverable and cancel is a no-op. | | `ade.lanes.delete.event` (push) | `LaneDeleteEvent` carrying `LaneDeleteProgress` — `steps[]` with per-step status (`pending` / `running` / `completed` / `failed` / `skipped`) plus `overallStatus` (`running` / `completed` / `failed` / `cancelled`) and `cancellable`. | +| `ade.lanes.lifecycle.event` (push) | `LaneLifecycleEvent` - one-shot `lane-created`, `lane-archived`, or `lane-deleted` event. Local desktop paths emit this IPC channel directly; runtime-backed paths push `lane_lifecycle_event`, and preload merges both sources behind `window.ade.lanes.onLifecycleEvent`. | | `ade.lanes.delete.progress.list` | replay of the in-memory `LaneDeleteProgress` map for currently running deletes. Completed delete results are delivered through the live event stream; a remount after completion refreshes the lane list instead of replaying historical progress. | | `ade.lanes.getStackChain` | `(args: { laneId: string }) => StackChainItem[]` | | `ade.lanes.rebaseStart` / `.rebaseAbort` / `.rebaseRollback` / `.rebasePush` | rebase run lifecycle | diff --git a/docs/features/lanes/runtime.md b/docs/features/lanes/runtime.md index 4bd33965c..b940b0875 100644 --- a/docs/features/lanes/runtime.md +++ b/docs/features/lanes/runtime.md @@ -76,8 +76,12 @@ steps in order: Each step is reported through `LaneEnvInitProgress` IPC events with status (`pending | running | done | failed`) and a duration. -`CreateLaneDialog` renders `LaneEnvInitProgress` inline so the user -can watch the lane bootstrap. +`CreateLaneDialogHost` decides how that progress is surfaced. In the +Lanes tab it keeps `CreateLaneDialog` open and renders +`LaneEnvInitProgress` inline so the user can watch or retry setup in the +modal. In the Work tab it closes as soon as the lane row exists and runs +setup detached; failures create a sticky toast with a Retry action so +the session sidebar does not need to stay mounted. Config types live in `src/shared/types/config.ts`: diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index ee8ac0e72..028afbdca 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -250,7 +250,11 @@ Renderer surfaces: auto-fit grid and the embedded lane selector can fill its parent. Lane group headers expose the same lane context menu used by the Work tab so color, manage, split, and batch actions stay reachable without - leaving the session list. + leaving the session list. The bottom Add Lane button opens + `CreateLaneDialogHost` in `close-on-create` mode, so a new lane can + be created from Work without navigating away; once the lane record + exists, env setup continues detached and failures surface as a sticky + retry toast. - `apps/desktop/src/renderer/components/terminals/SessionCard.tsx` — per-session card (status dot, title, preview line, tool type, lane, delta chips). Surfaces a small amber warning pip next to the title diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index a9b376978..515e6bfd1 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -125,6 +125,11 @@ Also renders: - the actual list of `SessionCard` rows (memoized) - an "Open new" button that sets `draftKind` and routes to `WorkStartSurface` +- an Add Lane button that opens `CreateLaneDialogHost` in-place. The + Work flow uses the host's `close-on-create` behavior: it closes as + soon as the lane row is created, then runs lane environment setup + detached from the sidebar component and leaves a sticky retry toast if + setup fails. - a bulk-action footer that appears when `selectedSessionIds` is non-empty: "Close N running", "Archive N" (chats only), "Restore N" (archived chats), "Export" (any selection, opens a markdown bundle diff --git a/docs/perf/work-tab-action-inventory.md b/docs/perf/work-tab-action-inventory.md index 310925d62..8e3297a65 100644 --- a/docs/perf/work-tab-action-inventory.md +++ b/docs/perf/work-tab-action-inventory.md @@ -104,7 +104,7 @@ Coverage states: | work.sessions.bulk.export | Export selected session bundle | external-skip | `SessionListPane.tsx` | | work.sessions.bulk.delete | Delete selected ended sessions | prompt-only | `SessionListPane.tsx` | | work.sessions.bulk.clear | Clear current multi-selection | measured | `SessionListPane.tsx` | -| work.sessions.add-lane | Click Add Lane navigation | external-skip | `SessionListPane.tsx` | +| work.sessions.add-lane | Open Add Lane modal | measured | `SessionListPane.tsx` | ## Start surfaces From 0e91364d1eb63e137fafd2500eacbb3a2e6e7633 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:40:14 -0400 Subject: [PATCH 2/4] ship: address toast dismiss review --- apps/desktop/src/renderer/components/app/toast/ToastStack.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/app/toast/ToastStack.tsx b/apps/desktop/src/renderer/components/app/toast/ToastStack.tsx index ab90d8449..edc5e2bb0 100644 --- a/apps/desktop/src/renderer/components/app/toast/ToastStack.tsx +++ b/apps/desktop/src/renderer/components/app/toast/ToastStack.tsx @@ -1,3 +1,5 @@ +import { X } from "@phosphor-icons/react"; + import { cn } from "../../ui/cn"; import { dismissToast, @@ -94,7 +96,7 @@ export function ToastStack() { aria-label="Dismiss notification" title="Dismiss" > - x + From 3edb6d662aa6f95e59985824d1c249ff6e6b4fc0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:07:39 -0400 Subject: [PATCH 3/4] ship: scope lane setup retry to project --- .../components/lanes/CreateLaneDialogHost.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialogHost.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialogHost.tsx index d64578f58..6f1e4190a 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialogHost.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialogHost.tsx @@ -50,6 +50,7 @@ type DetachedSetupParams = { laneId: string; laneName: string; templateId: string; + projectRoot: string | null; }; async function applyLaneEnvSetup(laneId: string, templateId: string): Promise { @@ -58,6 +59,18 @@ async function applyLaneEnvSetup(laneId: string, templateId: string): Promise { try { + if (!isDetachedSetupProjectActive(params)) { + showSetupFailureToast(params, "Open the original project to retry this lane setup."); + return; + } const progress = await applyLaneEnvSetup(params.laneId, params.templateId); if (progress.overallStatus === "failed") { showSetupFailureToast(params, "Environment setup failed. Retry to finish setting up this lane."); @@ -581,6 +598,7 @@ export function CreateLaneDialogHost({ laneId: lane.id, laneName: lane.name, templateId: selectedTemplateId, + projectRoot: activeProjectRoot, }; resetCreateDialogState(); onOpenChange(false); @@ -617,6 +635,7 @@ export function CreateLaneDialogHost({ templates, createSelectedColor, createSelectedLinearIssue, + activeProjectRoot, ]); const handleDialogOpenChange = useCallback((next: boolean) => { From 4c62a96c0a73eba2684f1c2eb27a3f82161268a1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:29:48 -0400 Subject: [PATCH 4/4] ship: suppress pull success toast when conflicts remain Co-Authored-By: Claude Fable 5 --- .../components/lanes/LaneGitActionsPane.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 3f6f9e5bd..0fe78e875 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -916,7 +916,19 @@ export function LaneGitActionsPane({ } else if (actionName === "force push") { showToast({ title: `Force-pushed ${laneLabel}`, tone: "success" }); } else if (actionName === "pull") { - showToast({ title: `Pulled ${laneLabel}`, tone: "success" }); + // A pull that stops on merge/rebase conflicts still resolves (the git + // service treats detected conflicts as a completed operation), so check + // conflict state before claiming success. + const postPullConflicts = await window.ade.git.getConflictState(actionLaneId).catch(() => null); + if (postPullConflicts?.inProgress) { + showToast({ + title: laneLabel, + message: `Pull stopped on ${postPullConflicts.kind === "merge" ? "merge" : "rebase"} conflicts — resolve them to finish.`, + tone: "error", + }); + } else { + showToast({ title: `Pulled ${laneLabel}`, tone: "success" }); + } } else if (actionName === "rebase and push") { showToast({ title: `Rebased & pushed ${laneLabel}`, tone: "success" }); }