diff --git a/.factory/init.sh b/.factory/init.sh new file mode 100755 index 000000000..e75ec6470 --- /dev/null +++ b/.factory/init.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +# No dependencies to install - iOS app uses only system frameworks (no SPM/CocoaPods/Carthage) +# Verify xcodebuild is available +if ! command -v xcodebuild &> /dev/null; then + echo "ERROR: xcodebuild not found. Xcode must be installed." >&2 + exit 1 +fi + +echo "Environment ready. iOS app at apps/ios/" diff --git a/.factory/library/architecture.md b/.factory/library/architecture.md index f5d01170d..f49b0bdd5 100644 --- a/.factory/library/architecture.md +++ b/.factory/library/architecture.md @@ -1,67 +1,49 @@ # Architecture -Architectural decisions, patterns discovered, and design principles. +Architectural decisions, patterns discovered, and conventions. -**What belongs here:** Architectural patterns, data flow, component organization, design decisions. +**What belongs here:** Design patterns, module boundaries, data flow, naming conventions. --- -## iOS App Architecture - -### Pattern: MVVM-like with Shared Service -- `SyncService` is the shared `@MainActor ObservableObject` injected via `.environmentObject()` -- Views own local `@State` for UI concerns -- Views call into `SyncService` for remote operations and data fetching -- Data flow: `SyncService` → `DatabaseService` (SQLite) → `localStateRevision` increment → SwiftUI reactivity via `.task(id: syncService.localStateRevision)` - -### File Structure -``` -ADE/ -├── App/ -│ ├── ADEApp.swift # App entry point, UIKit theme config -│ └── ContentView.swift # Root TabView, Settings tab, design system components -├── Views/ -│ ├── LanesTabView.swift # ~3,706 lines - complete -│ ├── FilesTabView.swift # ~500 lines - baseline -│ ├── WorkTabView.swift # ~300 lines - baseline -│ └── PRsTabView.swift # ~500 lines - baseline -├── Models/ -│ └── RemoteModels.swift # ~700 lines - all domain models -├── Services/ -│ ├── Database.swift # ~1,949 lines - SQLite + cr-sqlite sync -│ ├── KeychainService.swift # ~50 lines - token persistence -│ └── SyncService.swift # ~1,781 lines - WebSocket + Bonjour + RPC -└── Resources/ - └── DatabaseBootstrap.sql # ~2,260 lines - full schema -``` - -### Database -- Direct SQLite3 C API (no ORM) -- cr-sqlite change tracking with custom triggers (insert/update/delete) -- Bidirectional changeset sync via WebSocket -- Site ID management (persistent 128-bit random) -- Full bootstrap SQL schema (~2,260 lines) mirroring desktop - -### Networking -- Raw `URLSessionWebSocketTask` — no third-party dependencies -- JSON envelopes with optional gzip compression (>4KB) -- Heartbeat ping/pong protocol -- Auto-reconnect with exponential backoff -- Bonjour (`NetServiceBrowser`) for LAN discovery -- Connection-scoped async work in `SyncService` must be tied to the active socket/session: store long-lived tasks so `disconnect()` and host switching can cancel them, and ignore stale send/receive callbacks unless they still belong to the current `socket` - -### Command Routing -- State-only operations: write locally → cr-sqlite syncs to host -- Execution operations: send command via WebSocket → host executes → state syncs back -- Offline command queue: persisted to UserDefaults, flushed on reconnect - -### Key Model Types (RemoteModels.swift) -- `RemoteLane`, `RemoteLaneDetail`, `LaneStateSnapshot` -- `RemoteTerminalSession`, `SessionHistoryEntry` -- `PullRequestRow`, `PullRequestSnapshot`, `PRDetailPayload` -- `RemoteFileNode`, `RemoteSearchResult` -- `ChatMessage`, `ToolCallResult` - -### Adding New Swift Files -New .swift files MUST be added to the Xcode project by editing `ADE.xcodeproj/project.pbxproj`. -Both `PBXFileReference` and `PBXSourcesBuildPhase` sections need entries. +## iOS App Structure +- Entry: `ADEApp.swift` → creates SyncService → passes to ContentView +- ContentView: TabView with 5 tabs (Lanes, Files, Work, PRs, Settings) +- SyncService: `@MainActor` `ObservableObject`, Bonjour discovery + pairing + `ws://` socket transport to the desktop host, CRDT sync via cr-sqlite +- Database: SQLite with cr-sqlite for local caching +- Models: RemoteModels.swift (Codable structs for all API types) +- Host discovery: Bonjour browser maintains discovered LAN hosts, while Settings also supports manual host/port entry and QR pairing payloads with candidate addresses +- Authentication: pairing exchanges a short-lived host code for a shared secret, stores that secret in Keychain, and persists host identity/device metadata in `HostConnectionProfile` +- Transport security: the current implementation uses plain `ws://` on the trusted local network or saved address set; host trust is enforced by the pairing secret plus host-identity checks, not TLS or certificate pinning + +## Data Flow +1. `SyncService` discovers or reuses the host address, opens a `ws://` socket, and sends `hello` with either bootstrap auth or paired-device auth +2. Commands sent (for example `lanes.refreshSnapshots`) are decoded into local models, while `changeset_batch` payloads are applied into the SQLite cache +3. Data is cached in SQLite tables including `lane_list_snapshots` and `lane_detail_snapshots`, and cached rows remain readable when the host is offline +4. Views observe `syncService.localStateRevision` via `.task(id:)`; when live refresh fails they keep the last cached state visible and surface a user-facing error banner or reconnect CTA instead of blanking the screen +5. Pull-to-refresh triggers `reload(refreshRemote: true)`: the remote refresh runs first, then the view reloads from SQLite; on failure the view keeps cached rows, records the localized `SyncUserFacingError`, and offers retry/reconnect UI +6. Disconnects and send/receive failures tear down the active socket, fail pending requests, and schedule automatic reconnect with exponential backoff (1s, 2s, 4s, 8s, 16s; immediate retry for heartbeat close code `4001`) +7. Session lifecycle is stateful: a successful `hello` refreshes the saved host profile and starts relay/hydration tasks, `auth_failed` invalidates the saved pairing, and manual disconnect stops reconnect attempts until the user reconnects or pairs again + +## Design System (ADEDesignSystem.swift) +- Colors: ADEColor (pageBackground, surfaceBackground, accent, success, warning, danger, etc.) +- Motion: ADEMotion (standard, quick, emphasis, pulse - all respect reduceMotion) +- Components: ADENoticeCard, ADEStatusPill, ADEEmptyStateView, ADESkeletonView, ADECardSkeleton +- Modifiers: .adeGlassCard(), .adeScreenBackground(), .adeNavigationGlass(), .adeInsetField(), .adeListCard() +- Image caching: ADEImageCache with memory + disk cache + +## Coding Conventions +- Private views as nested structs within the parent file +- @EnvironmentObject for SyncService access +- @State for local view state +- Computed properties for filtered/derived data +- .task(id:) for reactive data loading +- .sensoryFeedback for haptics +- accessibilityLabel on all interactive elements +- ADEMotion helpers for all animations (respects reduceMotion) + +## Lane Types +- `LaneListSnapshot`: List item (lane + runtime + rebaseSuggestion + autoRebase + conflict + stateSnapshot + adoptable) +- `LaneDetailPayload`: Full detail (lane + runtime + stack + children + state + suggestions + conflicts + commits + changes + stashes + sessions) +- Lane types: "primary", "worktree", "attached" +- Runtime buckets: "running", "awaiting-input", "ended", "none" diff --git a/.factory/library/environment.md b/.factory/library/environment.md new file mode 100644 index 000000000..1ee0578f5 --- /dev/null +++ b/.factory/library/environment.md @@ -0,0 +1,25 @@ +# Environment + +Environment variables, external dependencies, and setup notes. + +**What belongs here:** Required env vars, external API keys/services, dependency quirks, platform-specific notes. +**What does NOT belong here:** Service ports/commands (use `.factory/services.yaml`). + +--- + +## Platform +- iOS 26.0+ deployment target +- Swift 5.0 +- Xcode 26.2+ +- No external package dependencies (no SPM, CocoaPods, Carthage) +- Uses SQLite3 via system framework import (cr-sqlite for CRDT sync) + +## Simulators +- Required: iOS 26.3.1 simulators +- Recommended: iPhone 17 Pro +- iOS 18.x simulators are older than the minimum deployment target (26.0), so they are incompatible. + +## Build Notes +- Development team: configured in Xcode project settings +- Code signing disabled for tests (CODE_SIGNING_ALLOWED = NO) +- Asset catalog warning: BrandMark.imageset references missing logo.png (cosmetic only) diff --git a/.factory/library/user-testing.md b/.factory/library/user-testing.md new file mode 100644 index 000000000..f2a1acef4 --- /dev/null +++ b/.factory/library/user-testing.md @@ -0,0 +1,26 @@ +# User Testing + +Testing surface, tools, setup steps, isolation notes, known quirks. + +**What belongs here:** How to manually test the app, what surfaces to check, tools available. + +--- + +## Testing Surface +- iOS Simulator (iPhone 17 Pro, iOS 26.3.1) +- Build + run in simulator via xcodebuild or Xcode +- App requires pairing with a desktop ADE host for full runtime testing (WebSocket connection) + +## Limitations +- Cannot do full runtime/integration testing without a paired desktop host +- Validation focuses on: build success, unit tests, code review +- SwiftUI previews may be available for individual views but are not set up in the current codebase + +## Testing Commands +- Build: `xcodebuild build -project apps/ios/ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.3.1' -quiet` +- Test: `xcodebuild test -project apps/ios/ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.3.1' -quiet` +- Line count check: `find apps/ios/ADE -name '*.swift' -exec wc -l {} + | sort -rn | head -20` + +## Test Coverage +- Unit tests in ADETests.swift cover: sync protocol, database CRDT, lane hydration, PR workflows, syntax highlighting, work tab, utilities +- Tests use @testable import ADE diff --git a/.factory/services.yaml b/.factory/services.yaml index d7f38571c..1ad9491ec 100644 --- a/.factory/services.yaml +++ b/.factory/services.yaml @@ -1,5 +1,9 @@ commands: - build: cd apps/ios && xcodebuild -project ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,id=2107A402-C2A7-4323-AF26-74A0AC406C44' -derivedDataPath /tmp/ade-build build - test: cd apps/ios && xcodebuild -project ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,id=2107A402-C2A7-4323-AF26-74A0AC406C44' -derivedDataPath /tmp/ade-build test - typecheck: cd apps/ios && xcodebuild -project ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,id=2107A402-C2A7-4323-AF26-74A0AC406C44' -derivedDataPath /tmp/ade-build build - lint: cd apps/ios && xcodebuild -project ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,id=2107A402-C2A7-4323-AF26-74A0AC406C44' -derivedDataPath /tmp/ade-build analyze + build: xcodebuild build -project apps/ios/ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.3.1' -quiet + test: xcodebuild test -project apps/ios/ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.3.1' -quiet + build-for-testing: xcodebuild build-for-testing -project apps/ios/ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.3.1' -quiet + typecheck: cd apps/ios && xcodebuild -project ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.3.1' -derivedDataPath /tmp/ade-build build + lint: cd apps/ios && xcodebuild -project ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.3.1' -derivedDataPath /tmp/ade-build analyze + line-count: find apps/ios/ADE -name '*.swift' -exec wc -l {} + | sort -rn | head -20 + +services: {} diff --git a/.factory/skills/ios-worker/SKILL.md b/.factory/skills/ios-worker/SKILL.md new file mode 100644 index 000000000..e0ec870cb --- /dev/null +++ b/.factory/skills/ios-worker/SKILL.md @@ -0,0 +1,145 @@ +--- +name: ios-worker +description: Implements and tests SwiftUI views and components for the iOS app +--- + +# iOS Worker + +NOTE: Startup and cleanup are handled by `worker-base`. This skill defines the WORK PROCEDURE. + +## When to Use This Skill + +Use for any feature that involves creating or modifying Swift/SwiftUI code in the iOS app at `apps/ios/ADE/`. This includes view extraction, UI redesign, component creation, and performance optimization. + +## Work Procedure + +### 1. Understand the Feature + +Read the feature description, preconditions, expectedBehavior, and verificationSteps carefully. Then: + +- Read `mission.md` for overall mission context +- Read `AGENTS.md` for boundaries and conventions +- Read `.factory/library/architecture.md` for app architecture and patterns +- Read the existing code files you'll be modifying +- If the feature modifies or replaces existing views, read the FULL original `LanesTabView.swift` to understand the complete existing implementation before making changes + +### 2. Plan the Changes + +Before writing any code: +- List every file you'll create or modify +- Identify which existing code to preserve vs. rewrite +- Note which SyncService methods the views need to call +- Check that your plan doesn't violate any boundary (no changes to SyncService, RemoteModels, Database) + +### 3. Write Tests First (Red) + +For any testable logic (filters, computed properties, data transformations, directory grouping): +- Add test cases to `ADETests.swift` (the single test file) +- Tests should fail initially (red phase) +- Use `@testable import ADE` and XCTest patterns matching existing tests +- Focus on logic tests, not UI rendering tests + +### 4. Implement (Green) + +Write the SwiftUI code: +- **File organization**: Each major view gets its own file under `apps/ios/ADE/Views/Lanes/`. Keep files under 500 lines. +- **Design system**: Use `ADEColor.*` for colors, `ADEMotion.*` for animations, `.adeGlassCard()` for card surfaces, `.adeInsetField()` for inputs, `ADENoticeCard` for notices, `ADEStatusPill` for badges, `ADEEmptyStateView` for empty states, `ADESkeletonView`/`ADECardSkeleton` for loading. +- **Accessibility**: Add `.accessibilityLabel` to all interactive elements. Use `ADEMotion` which respects `reduceMotion`. Preserve `.sensoryFeedback` on appropriate interactions. +- **Data access**: Use `@EnvironmentObject var syncService: SyncService`. Call existing SyncService methods — never add new ones. +- **State management**: Use `@State` for local view state. Use `.task(id: syncService.localStateRevision)` for reactive data loading. +- **Lazy loading**: Use `LazyVStack` for lists with many items (file changes, commits, lane list). +- **No changes** to SyncService.swift, RemoteModels.swift, Database.swift, or DatabaseBootstrap.sql. + +### 5. Update Xcode Project + +When creating new Swift files, you MUST add them to the Xcode project: +- Edit `apps/ios/ADE.xcodeproj/project.pbxproj` to add file references and build phase entries +- Follow the existing pattern in the pbxproj for file references (PBXFileReference, PBXBuildFile, PBXGroup children) +- Alternatively, if the project uses folder references, ensure files are in the correct directory + +### 6. Verify + +Run these commands and fix any issues: + +```bash +# Pick an available simulator pair on the current machine first. +xcrun simctl list runtimes +xcrun simctl list devices available +DESTINATION="platform=iOS Simulator,name=,OS=" + +# Build +xcodebuild build -project apps/ios/ADE.xcodeproj -scheme ADE -destination "$DESTINATION" -quiet + +# Test +xcodebuild test -project apps/ios/ADE.xcodeproj -scheme ADE -destination "$DESTINATION" -quiet + +# Check file sizes (no file should exceed 500 lines) +find apps/ios/ADE -name '*.swift' -exec wc -l {} + | sort -rn | head -20 +``` + +All must pass. If build fails, fix immediately. If tests fail, fix immediately. If any file exceeds 500 lines, split it. + +### 7. Manual Verification + +Since the app requires a paired desktop host for runtime testing: +- Review your code for correctness by tracing data flow from SyncService through to the view +- Verify all SyncService method calls match the existing API exactly (check method signatures) +- Verify all RemoteModels properties are accessed correctly (check property names and types) +- Ensure no unused imports, dead code, or TODO placeholders remain +- Confirm view hierarchy is correct (NavigationStack, sheets, navigation links) + +### 8. Commit + +Commit with a clear message describing what was implemented. + +## Example Handoff + +```json +{ + "salientSummary": "Extracted LaneDetailScreen, LaneCreateSheet, LaneAttachSheet, LaneDiffScreen, LaneBatchManageSheet, LaneStackGraphSheet, and shared components from the monolithic LanesTabView.swift into 12 separate files under Views/Lanes/. Created LaneTypes.swift for shared enums/structs and LaneHelpers.swift for utility functions. All 50 existing tests pass. No file exceeds 500 lines. Build succeeds.", + "whatWasImplemented": "Split LanesTabView.swift (3,749 lines) into 12 files: LanesTabView.swift (thin coordinator, ~300 lines), LaneDetailScreen.swift (~450 lines), LaneCreateSheet.swift (~180 lines), LaneAttachSheet.swift (~90 lines), LaneDiffScreen.swift (~200 lines), LaneBatchManageSheet.swift (~150 lines), LaneStackGraphSheet.swift (~80 lines), LaneChatLaunchSheet.swift (~120 lines), LaneSessionTranscriptView.swift (~100 lines), LaneChatSessionView.swift (~100 lines), LaneComponents.swift (~350 lines, shared small components), LaneTypes.swift (~80 lines, enums and model structs), LaneHelpers.swift (~120 lines, search/format helpers). Updated project.pbxproj with all new file references.", + "whatWasLeftUndone": "", + "verification": { + "commandsRun": [ + { + "command": "xcodebuild build -project apps/ios/ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.3.1' -quiet", + "exitCode": 0, + "observation": "Build succeeded with no errors or warnings" + }, + { + "command": "xcodebuild test -project apps/ios/ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.3.1' -quiet", + "exitCode": 0, + "observation": "All 50 tests passed" + }, + { + "command": "find apps/ios/ADE -name '*.swift' -exec wc -l {} + | sort -rn | head -20", + "exitCode": 0, + "observation": "Largest file is LaneDetailScreen.swift at 448 lines. All files under 500 line limit." + } + ], + "interactiveChecks": [ + { + "action": "Traced data flow from SyncService.fetchLaneListSnapshots through LanesTabView to LaneListRow", + "observed": "All property accesses match RemoteModels.LaneListSnapshot fields. No missing or renamed properties." + }, + { + "action": "Verified all SyncService method calls in extracted views match original signatures", + "observed": "All 35 service calls preserved with correct parameter names and types." + } + ] + }, + "tests": { + "added": [] + }, + "discoveredIssues": [] +} +``` + +## When to Return to Orchestrator + +- SyncService method signatures don't match what the view expects (API contract issue) +- RemoteModels properties are missing or have different types than expected +- Xcode project file is corrupted or in an unrecoverable state +- Build fails due to issues outside the lanes tab code +- A feature requires changes to SyncService, RemoteModels, or Database (boundary violation) +- File exceeds 500 lines and cannot be reasonably split without changing the feature boundary diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index b5d1ad01e..967903407 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -750,7 +750,8 @@ app.whenReady().then(async () => { }); }); } - } + }, + logger, }); await laneService.ensurePrimaryLane(); @@ -2294,7 +2295,16 @@ app.whenReady().then(async () => { dispose: () => {} // desktop manages service lifecycle }; - const mcpSocketPath = adePaths.socketPath; + // When ADE_MCP_SOCKET_PATH is set, derive a per-project socket path from + // the override so each project context gets its own socket and avoids + // EADDRINUSE. The first context uses the env path as-is for compatibility; + // subsequent contexts append a project-root hash suffix. + const envSocketOverride = process.env.ADE_MCP_SOCKET_PATH?.trim(); + const mcpSocketPath = envSocketOverride + ? (projectContexts.size === 0 + ? envSocketOverride + : `${envSocketOverride}.${Buffer.from(normalizeProjectRoot(projectRoot)).toString("base64url").slice(0, 8)}`) + : adePaths.socketPath; const activeMcpConnections = new Set(); const destroyActiveMcpConnections = (): void => { @@ -2796,15 +2806,14 @@ app.whenReady().then(async () => { // --- Auto-update service (global, not per-project) --- const updateLogger = createFileLogger(path.join(app.getPath("userData"), "ade-update.jsonl")); - const autoUpdateService = createAutoUpdateService(updateLogger); - autoUpdateService.onUpdateAvailable((info) => { - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(IPC.updateEvent, { type: "available", version: info.version }); - }); + const autoUpdateService = createAutoUpdateService({ + logger: updateLogger, + currentVersion: app.getVersion(), + globalStatePath, }); - autoUpdateService.onUpdateDownloaded((info) => { + autoUpdateService.onStateChange((snapshot) => { BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(IPC.updateEvent, { type: "downloaded", version: info.version }); + win.webContents.send(IPC.updateEvent, snapshot); }); }); diff --git a/apps/desktop/src/main/services/devTools/devToolsService.test.ts b/apps/desktop/src/main/services/devTools/devToolsService.test.ts index 05452fd65..cc5577987 100644 --- a/apps/desktop/src/main/services/devTools/devToolsService.test.ts +++ b/apps/desktop/src/main/services/devTools/devToolsService.test.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Logger } from "../logging/logger"; import type * as SharedUtilsModule from "../shared/utils"; @@ -44,28 +43,37 @@ describe("devToolsService", () => { resolveExecutableFromKnownLocationsMock.mockReset(); }); - it("detects GitHub CLI from known install locations and reads version via the resolved path", async () => { + it("detects git from known install locations and reads version via the resolved path", async () => { resolveExecutableFromKnownLocationsMock.mockImplementation((command: string) => { if (command === "git") return { path: "/usr/bin/git", source: "path" }; - if (command === "gh") return { path: "/opt/homebrew/bin/gh", source: "known-dir" }; return null; }); - spawnAsyncMock.mockImplementation(async (command: string) => ({ + spawnAsyncMock.mockImplementation(async () => ({ status: 0, - stdout: `${path.basename(command)} version 1.0.0\n`, + stdout: "git version 2.50.1\n", stderr: "", })); const service = createDevToolsService({ logger: createLogger() }); const result = await service.detect(true); - const gh = result.tools.find((tool) => tool.id === "gh"); + const git = result.tools.find((tool) => tool.id === "git"); - expect(gh).toMatchObject({ + expect(git).toMatchObject({ installed: true, - detectedPath: "/opt/homebrew/bin/gh", - detectedVersion: "gh version 1.0.0", + detectedPath: "/usr/bin/git", + detectedVersion: "git version 2.50.1", }); - expect(spawnAsyncMock).toHaveBeenCalledWith("/opt/homebrew/bin/gh", ["--version"]); - expect(whichCommandMock).not.toHaveBeenCalledWith("gh"); + expect(spawnAsyncMock).toHaveBeenCalledWith("/usr/bin/git", ["--version"]); + }); + + it("only checks for git (no gh)", async () => { + resolveExecutableFromKnownLocationsMock.mockReturnValue(null); + whichCommandMock.mockResolvedValue(null); + + const service = createDevToolsService({ logger: createLogger() }); + const result = await service.detect(true); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].id).toBe("git"); }); }); diff --git a/apps/desktop/src/main/services/devTools/devToolsService.ts b/apps/desktop/src/main/services/devTools/devToolsService.ts index dda43cc3f..1327fcae3 100644 --- a/apps/desktop/src/main/services/devTools/devToolsService.ts +++ b/apps/desktop/src/main/services/devTools/devToolsService.ts @@ -4,7 +4,7 @@ import { firstLine, spawnAsync, whichCommand } from "../shared/utils"; import { resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver"; type ToolSpec = { - id: "git" | "gh"; + id: "git"; label: string; command: string; versionArgs: string[]; @@ -13,7 +13,6 @@ type ToolSpec = { const TOOL_SPECS: ToolSpec[] = [ { id: "git", label: "Git", command: "git", versionArgs: ["--version"], required: true }, - { id: "gh", label: "GitHub CLI", command: "gh", versionArgs: ["--version"], required: false }, ]; async function readVersion(commandPath: string, versionArgs: string[]): Promise { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 29c99d9ae..226fc929c 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1,5 +1,5 @@ import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"; -import type { createAutoUpdateService } from "../updates/autoUpdateService"; +import { createEmptyAutoUpdateSnapshot, type createAutoUpdateService } from "../updates/autoUpdateService"; import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; @@ -199,6 +199,8 @@ import type { OnboardingDetectionResult, OnboardingExistingLaneCandidate, OnboardingStatus, + LaneListSnapshot, + LaneRuntimeSummary, LaneSummary, ListOperationsArgs, ListOverlapsArgs, @@ -691,6 +693,126 @@ function escapeCsvCell(value: string | null | undefined): string { return /[",\r\n]/.test(input) ? `"${input.replace(/"/g, "\"\"")}"` : input; } +function sessionStatusBucket(args: { + status: string; + lastOutputPreview: string | null | undefined; + runtimeState?: string | null; +}): "running" | "awaiting-input" | "ended" { + if (args.status === "running") { + if (args.runtimeState === "waiting-input") return "awaiting-input"; + const preview = args.lastOutputPreview ?? ""; + if (/\b(?:waiting|awaiting)\b.{0,28}\b(?:input|confirmation|response|prompt)\b/i.test(preview)) { + return "awaiting-input"; + } + if (/\((?:y\/n|yes\/no)\)/i.test(preview) || /\[(?:y\/n|yes\/no)\]/i.test(preview)) { + return "awaiting-input"; + } + return "running"; + } + return "ended"; +} + +function summarizeLaneRuntime( + laneId: string, + sessions: Array<{ + laneId: string; + status: string; + lastOutputPreview: string | null; + runtimeState?: string | null; + }>, +): LaneRuntimeSummary { + let runningCount = 0; + let awaitingInputCount = 0; + let endedCount = 0; + let sessionCount = 0; + + for (const session of sessions) { + if (session.laneId !== laneId) continue; + sessionCount += 1; + const bucket = sessionStatusBucket(session); + if (bucket === "running") runningCount += 1; + else if (bucket === "awaiting-input") awaitingInputCount += 1; + else endedCount += 1; + } + + const bucket = awaitingInputCount > 0 + ? "awaiting-input" + : runningCount > 0 + ? "running" + : endedCount > 0 + ? "ended" + : "none"; + + return { + bucket, + runningCount, + awaitingInputCount, + endedCount, + sessionCount, + }; +} + +async function enrichSessionsForLaneList( + args: Pick, +): Promise { + let sessions = args.ptyService.enrichSessions(args.sessionService.list({})); + let allChats: AgentChatSessionSummary[] = []; + try { + allChats = await args.agentChatService.listSessions(undefined, { includeIdentity: true }); + } catch { + allChats = []; + } + const identitySessionIds = new Set( + allChats + .filter((chat) => Boolean(chat.identityKey)) + .map((chat) => chat.sessionId), + ); + if (identitySessionIds.size > 0) { + sessions = sessions.filter((session) => !identitySessionIds.has(session.id)); + } + const chats = allChats.filter((chat) => !chat.identityKey); + if (chats.length === 0) return sessions; + const chatSummaryBySessionId = new Map(chats.map((chat) => [chat.sessionId, chat] as const)); + return sessions.map((session) => { + if (!isChatToolType(session.toolType)) return session; + if (session.status !== "running") return session; + const chat = chatSummaryBySessionId.get(session.id); + if (!chat) return session; + if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const }; + if (chat.status === "active") return { ...session, runtimeState: "running" as const }; + if (chat.status === "idle") return { ...session, runtimeState: "idle" as const }; + return session; + }); +} + +async function buildLaneListSnapshots( + args: Pick, + lanes: LaneSummary[], +): Promise { + const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ + enrichSessionsForLaneList(args), + Promise.resolve(args.rebaseSuggestionService?.listSuggestions() ?? []).catch(() => []), + Promise.resolve(args.autoRebaseService?.listStatuses() ?? []).catch(() => []), + Promise.resolve(args.laneService.listStateSnapshots()).catch(() => []), + args.conflictService?.getBatchAssessment().catch(() => null) ?? Promise.resolve(null), + ]); + + const rebaseByLaneId = new Map(rebaseSuggestions.map((entry) => [entry.laneId, entry] as const)); + const autoRebaseByLaneId = new Map(autoRebaseStatuses.map((entry) => [entry.laneId, entry] as const)); + const stateByLaneId = new Map(stateSnapshots.map((entry) => [entry.laneId, entry] as const)); + const conflictByLaneId = new Map((batchAssessment?.lanes ?? []).map((entry) => [entry.laneId, entry] as const)); + + return lanes.map((lane) => ({ + lane, + runtime: summarizeLaneRuntime(lane.id, sessions), + rebaseSuggestion: rebaseByLaneId.get(lane.id) ?? null, + autoRebaseStatus: autoRebaseByLaneId.get(lane.id) ?? null, + conflictStatus: conflictByLaneId.get(lane.id) ?? null, + stateSnapshot: stateByLaneId.get(lane.id) ?? null, + adoptableAttached: lane.laneType === "attached" && lane.archivedAt == null, + })); +} + const AI_USAGE_FEATURE_KEYS: AiFeatureKey[] = [ "narratives", "conflict_proposals", @@ -3106,6 +3228,25 @@ export function registerIpc({ ); }); + ipcMain.handle(IPC.lanesListSnapshots, async (_event, arg: ListLanesArgs): Promise => { + const ctx = getCtx(); + return await withIpcTiming( + ctx, + "lanes.listSnapshots", + async () => { + const lanes = await ctx.laneService.list({ + includeArchived: Boolean(arg?.includeArchived), + includeStatus: arg?.includeStatus !== false, + }); + return await buildLaneListSnapshots(ctx, lanes); + }, + { + includeArchived: Boolean(arg?.includeArchived), + includeStatus: arg?.includeStatus !== false, + } + ); + }); + ipcMain.handle(IPC.lanesCreate, async (_event, arg: CreateLaneArgs): Promise => { const ctx = getCtx(); const lane = await ctx.laneService.create({ name: arg.name, description: arg.description, parentLaneId: arg.parentLaneId }); @@ -5669,6 +5810,16 @@ export function registerIpc({ } const embeddingStatus = ctx.embeddingService.getStatus(); const localFilesOnly = embeddingStatus.installState === "installed" && embeddingStatus.state !== "unavailable"; + // When we're about to attempt a remote download and stale/corrupted files + // exist on disk, clear them first so transformers.js re-downloads fresh + // files instead of reloading the same broken artifacts from its FS cache. + if (!localFilesOnly && embeddingStatus.installState !== "missing") { + ctx.logger.info("memory.embedding.clearing_cache_for_repair", { + installState: embeddingStatus.installState, + state: embeddingStatus.state, + }); + await ctx.embeddingService.clearCache(); + } void ctx.embeddingService.preload({ forceRetry: true, localFilesOnly }).catch(() => { // Health polling will pick up the unavailable state; the click itself should remain responsive. }); @@ -6173,7 +6324,15 @@ export function registerIpc({ getCtx().autoUpdateService?.checkForUpdates(); }); + ipcMain.handle(IPC.updateGetState, () => { + return getCtx().autoUpdateService?.getSnapshot() ?? createEmptyAutoUpdateSnapshot(); + }); + ipcMain.handle(IPC.updateQuitAndInstall, () => { getCtx().autoUpdateService?.quitAndInstall(); }); + + ipcMain.handle(IPC.updateDismissInstalledNotice, () => { + getCtx().autoUpdateService?.dismissInstalledNotice(); + }); } diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 3d89eee38..14c48c539 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -8,6 +8,7 @@ import { fetchRemoteTrackingBranch, resolveQueueRebaseOverride, type QueueRebase import { detectConflictKind } from "../git/gitConflictState"; import { shouldLaneTrackParent } from "../../../shared/laneBaseResolution"; import type { createOperationService } from "../history/operationService"; +import type { Logger } from "../logging/logger"; import type { AdoptAttachedLaneArgs, AttachLaneArgs, @@ -509,7 +510,8 @@ export function createLaneService({ worktreesDir, operationService, onHeadChanged, - onRebaseEvent + onRebaseEvent, + logger: injectedLogger }: { db: AdeDb; projectRoot: string; @@ -519,7 +521,14 @@ export function createLaneService({ operationService?: ReturnType; onHeadChanged?: (args: { laneId: string; reason: string; preHeadSha: string | null; postHeadSha: string | null }) => void; onRebaseEvent?: (event: RebaseRunEventPayload) => void; + logger?: Logger; }) { + const logger: Logger = injectedLogger ?? { + debug: () => {}, + info: () => {}, + warn: (event, meta) => console.warn(event, meta ?? ""), + error: (event, meta) => console.error(event, meta ?? ""), + }; const upsertLaneStateSnapshot = (args: { laneId: string; status: LaneStatus; @@ -849,22 +858,22 @@ export function createLaneService({ try { await ensurePrimaryLane(); } catch (err) { - console.warn("[laneService] ensurePrimaryLane failed, continuing with existing lanes:", err instanceof Error ? err.message : String(err)); + logger.warn("laneService.ensurePrimaryLane_failed", { error: err instanceof Error ? err.message : String(err) }); } try { await syncPrimaryLaneBranchRef(); } catch (err) { - console.warn("[laneService] syncPrimaryLaneBranchRef failed, continuing:", err instanceof Error ? err.message : String(err)); + logger.warn("laneService.syncPrimaryLaneBranchRef_failed", { error: err instanceof Error ? err.message : String(err) }); } try { repairPrimaryParentedRootLanes(); } catch (err) { - console.warn("[laneService] repairPrimaryParentedRootLanes failed, continuing:", err instanceof Error ? err.message : String(err)); + logger.warn("laneService.repairPrimaryParentedRootLanes_failed", { error: err instanceof Error ? err.message : String(err) }); } try { repairLegacyPrimaryBaseRootLanes(); } catch (err) { - console.warn("[laneService] repairLegacyPrimaryBaseRootLanes failed, continuing:", err instanceof Error ? err.message : String(err)); + logger.warn("laneService.repairLegacyPrimaryBaseRootLanes_failed", { error: err instanceof Error ? err.message : String(err) }); } const cacheKey = `arch:${includeArchived ? 1 : 0}|status:${includeStatus ? 1 : 0}`; @@ -907,7 +916,7 @@ export function createLaneService({ }); queueOverrideCache.set(laneId, override); } catch (err) { - console.warn("[laneService] lane_list.queue_override_failed", { laneId, err: String(err) }); + logger.warn("laneService.lane_list.queue_override_failed", { laneId, error: String(err) }); queueOverrideCache.set(laneId, null); } }), @@ -960,14 +969,14 @@ export function createLaneService({ try { status = await resolveStatus(row.id); } catch { - console.warn(`[laneService] resolveStatus failed for lane ${row.id}, using default`); + logger.warn("laneService.resolveStatus_failed", { laneId: row.id }); status = cloneLaneStatus(DEFAULT_LANE_STATUS); } if (row.parent_lane_id) { try { parentStatus = await resolveStatus(row.parent_lane_id); } catch { - console.warn(`[laneService] resolveStatus failed for parent lane ${row.parent_lane_id}, using default`); + logger.warn("laneService.resolveStatus_failed", { laneId: row.parent_lane_id, context: "parent" }); parentStatus = cloneLaneStatus(DEFAULT_LANE_STATUS); } } @@ -977,7 +986,7 @@ export function createLaneService({ try { stackDepth = computeStackDepth({ laneId: row.id, rowsById, memo: depthMemo }); } catch { - console.warn(`[laneService] computeStackDepth failed for lane ${row.id}, defaulting to 0`); + logger.warn("laneService.computeStackDepth_failed", { laneId: row.id }); } out.push( toLaneSummary({ @@ -998,7 +1007,7 @@ export function createLaneService({ } catch (err) { // If building the summary for a single lane fails entirely, skip it // rather than crashing the whole list operation. - console.warn(`[laneService] Failed to build summary for lane ${row.id}, skipping:`, err instanceof Error ? err.message : String(err)); + logger.warn("laneService.build_summary_failed", { laneId: row.id, error: err instanceof Error ? err.message : String(err) }); } } laneListCache.set(cacheKey, { @@ -1092,7 +1101,7 @@ export function createLaneService({ try { repairLegacyPrimaryBaseRootLanes(); } catch (err) { - console.warn("[laneService] initial repairLegacyPrimaryBaseRootLanes failed, continuing:", err instanceof Error ? err.message : String(err)); + logger.warn("laneService.initial_repairLegacyPrimaryBaseRootLanes_failed", { error: err instanceof Error ? err.message : String(err) }); } const isDescendant = (rowsById: Map, laneId: string, possibleDescendantId: string): boolean => { @@ -1413,10 +1422,7 @@ export function createLaneService({ }); stashRef = null; } catch (error) { - console.warn( - "[laneService] Failed to drop temporary rescue stash:", - error instanceof Error ? error.message : String(error), - ); + logger.warn("laneService.drop_rescue_stash_failed", { error: error instanceof Error ? error.message : String(error) }); } const refreshedLane = (await listLanes({ includeArchived: false, includeStatus: true })).find( @@ -1552,18 +1558,59 @@ export function createLaneService({ } } + // Also score against defaultBaseRef (main) directly — if main is + // equally good or better, there is no real parent lane. if (bestLaneId) { - if (explicitParentLaneId && explicitParentLaneId !== bestLaneId) { - console.warn( - `[laneService] importBranch: explicit parentLaneId '${explicitParentLaneId}' differs from ` + - `git-detected parent '${bestLaneId}' — using detected parent`, + let mainScore = Infinity; + try { + // Prefer the remote-tracking ref so the comparison uses the + // latest fetched state rather than a potentially stale local tip. + let mainShaRes = await runGit( + ["rev-parse", `origin/${defaultBaseRef}`], + { cwd: projectRoot, timeoutMs: 10_000 }, ); + if (mainShaRes.exitCode !== 0) { + mainShaRes = await runGit( + ["rev-parse", defaultBaseRef], + { cwd: projectRoot, timeoutMs: 10_000 }, + ); + } + const mainSha = mainShaRes.exitCode === 0 ? mainShaRes.stdout.trim() : null; + if (mainSha) { + const mbRes = await runGit( + ["merge-base", mainSha, importedHeadSha], + { cwd: projectRoot, timeoutMs: 10_000 }, + ); + if (mbRes.exitCode === 0 && mbRes.stdout.trim()) { + const mb = mbRes.stdout.trim(); + const d1 = await runGit(["rev-list", "--count", `${mb}..${importedHeadSha}`], { cwd: projectRoot, timeoutMs: 10_000 }); + const d2 = await runGit(["rev-list", "--count", `${mb}..${mainSha}`], { cwd: projectRoot, timeoutMs: 10_000 }); + if (d1.exitCode === 0 && d2.exitCode === 0) { + mainScore = parseInt(d1.stdout.trim(), 10) + parseInt(d2.stdout.trim(), 10); + } + } + } + } catch { + // If main scoring fails, fall through to lane-based parent. + } + + if (mainScore <= bestScore) { + // The branch is based on main (or closer to it), no parent lane + // — unless the caller explicitly provided one. + parentLaneId = explicitParentLaneId ?? null; + } else { + if (explicitParentLaneId && explicitParentLaneId !== bestLaneId) { + logger.warn("laneService.importBranch.parent_mismatch", { + explicitParentLaneId, + detectedParentLaneId: bestLaneId, + }); + } + parentLaneId = bestLaneId; } - parentLaneId = bestLaneId; } } } catch (err) { - console.warn("[laneService] importBranch: merge-base parent detection failed, falling back", err); + logger.warn("laneService.importBranch.parent_detection_failed", { error: err instanceof Error ? err.message : String(err) }); } // Fallback: use only the explicit parent when detection yielded nothing. diff --git a/apps/desktop/src/main/services/memory/embeddingService.test.ts b/apps/desktop/src/main/services/memory/embeddingService.test.ts index 8faf18f6f..a4e6ec76c 100644 --- a/apps/desktop/src/main/services/memory/embeddingService.test.ts +++ b/apps/desktop/src/main/services/memory/embeddingService.test.ts @@ -529,6 +529,99 @@ describe("embeddingService", () => { })); }); + it("clears the installed cache after disposing an active extractor", async () => { + const logger = createLogger(); + const cacheDir = createTempCacheDir(); + const installPath = writeInstalledModel(cacheDir); + const extractor = Object.assign( + vi.fn(async (text: string) => ({ data: buildVector(text), dims: [1, EXPECTED_EMBEDDING_DIMENSIONS] })), + { dispose: vi.fn(async () => {}) }, + ); + const service = createEmbeddingService({ + logger, + cacheDir, + loadRuntime: async () => ({ + env: { + cacheDir: "", + allowRemoteModels: true, + allowLocalModels: true, + useFSCache: true, + }, + pipeline: vi.fn(async () => extractor), + }), + }); + + await service.preload({ forceRetry: true, localFilesOnly: true }); + expect(service.getStatus()).toEqual(expect.objectContaining({ + state: "ready", + installState: "installed", + installPath, + })); + + await service.clearCache(); + + expect(extractor.dispose).toHaveBeenCalledTimes(1); + expect(fs.existsSync(installPath)).toBe(false); + expect(service.getStatus()).toEqual(expect.objectContaining({ + state: "idle", + activity: "idle", + installState: "missing", + error: null, + })); + }); + + it("invalidates an in-flight load before removing cached model files", async () => { + const logger = createLogger(); + const cacheDir = createTempCacheDir(); + const installPath = writeInstalledModel(cacheDir); + let releasePipeline: (() => void) | null = null; + let resolvePipelineStarted: (() => void) | null = null; + const pipelineStarted = new Promise((resolve) => { + resolvePipelineStarted = resolve; + }); + const extractor = Object.assign( + vi.fn(async (text: string) => ({ data: buildVector(text), dims: [1, EXPECTED_EMBEDDING_DIMENSIONS] })), + { dispose: vi.fn(async () => {}) }, + ); + const service = createEmbeddingService({ + logger, + cacheDir, + loadRuntime: async () => ({ + env: { + cacheDir: "", + allowRemoteModels: true, + allowLocalModels: true, + useFSCache: true, + }, + pipeline: vi.fn(async () => { + resolvePipelineStarted?.(); + await new Promise((resolve) => { + releasePipeline = resolve; + }); + return extractor; + }), + }), + }); + + const preloadPromise = service.preload({ forceRetry: true, localFilesOnly: true }); + await pipelineStarted; + + await service.clearCache(); + + expect(fs.existsSync(installPath)).toBe(false); + expect(service.getStatus()).toEqual(expect.objectContaining({ + state: "idle", + activity: "idle", + installState: "missing", + error: null, + })); + + expect(releasePipeline).toBeTypeOf("function"); + releasePipeline!(); + await expect(preloadPromise).rejects.toThrow("Embedding extractor load became stale."); + expect(extractor.dispose).toHaveBeenCalledTimes(1); + }); + it("re-checks the install state after a failed download before normalizing the error", async () => { const logger = createLogger(); const cacheDir = createTempCacheDir(); diff --git a/apps/desktop/src/main/services/memory/embeddingService.ts b/apps/desktop/src/main/services/memory/embeddingService.ts index f1d1cad2d..9c0b801f7 100644 --- a/apps/desktop/src/main/services/memory/embeddingService.ts +++ b/apps/desktop/src/main/services/memory/embeddingService.ts @@ -562,11 +562,37 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { } } + async function clearCache(): Promise { + // Capture the pending load promise before dispose() nulls it out. + const pendingLoad = extractorPromise; + await dispose(); + // Let the in-flight load settle in the background — dispose() already + // incremented loadAttemptId so the load will see it is stale and reject. + // We must not await it synchronously because the pipeline may be blocked + // on an external resource, which would deadlock clearCache. + if (pendingLoad) { + void pendingLoad.catch(() => {}); + } + cache.clear(); + try { + await fs.promises.rm(installPath, { recursive: true, force: true }); + } catch (clearError) { + logger.warn("memory.embedding.cache_clear_failed", { + modelId, + installPath, + error: getErrorMessage(clearError), + }); + } + refreshCachedInstall(); + emitStatus(); + } + return { embed: trackedEmbed, dispose, preload, probeCache, + clearCache, getModelId: () => modelId, getStatus, hashContent: hashEmbeddingContent, diff --git a/apps/desktop/src/main/services/projects/recentProjectSummary.ts b/apps/desktop/src/main/services/projects/recentProjectSummary.ts index c6e69fdfe..b2e5419f4 100644 --- a/apps/desktop/src/main/services/projects/recentProjectSummary.ts +++ b/apps/desktop/src/main/services/projects/recentProjectSummary.ts @@ -42,6 +42,7 @@ function readAdeLaneCount(projectRoot: string): number | null { let db: DatabaseSyncType | null = null; try { db = new DatabaseSync(dbPath); + db.exec("PRAGMA busy_timeout = 5000"); const hasLanesTable = Boolean( db.prepare("select 1 as present from sqlite_master where type = 'table' and name = ? limit 1") .get<{ present?: number }>("lanes")?.present, diff --git a/apps/desktop/src/main/services/state/globalState.ts b/apps/desktop/src/main/services/state/globalState.ts index 2293e2f31..61281c387 100644 --- a/apps/desktop/src/main/services/state/globalState.ts +++ b/apps/desktop/src/main/services/state/globalState.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import type { RecentlyInstalledUpdate } from "../../../shared/types"; export type RecentProject = { rootPath: string; @@ -7,9 +8,18 @@ export type RecentProject = { lastOpenedAt: string; }; +export type PendingInstallUpdate = { + fromVersion: string; + targetVersion: string; + releaseNotesUrl: string | null; + requestedAt: string; +}; + export type GlobalState = { lastProjectRoot?: string; recentProjects?: RecentProject[]; + pendingInstallUpdate?: PendingInstallUpdate; + recentlyInstalledUpdate?: RecentlyInstalledUpdate; }; export function readGlobalState(filePath: string): GlobalState { diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 620f2c178..10be68101 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -68,7 +68,11 @@ function ensureParentDir(filePath: string) { function openRawDatabase(dbPath: string): DatabaseSyncType { ensureParentDir(dbPath); - return new DatabaseSync(dbPath, { allowExtension: true }); + const db = new DatabaseSync(dbPath, { allowExtension: true }); + // Allow concurrent access from multiple ADE processes (e.g. dogfooding). + // Without this, a second instance gets SQLITE_BUSY immediately on writes. + db.exec("PRAGMA busy_timeout = 5000"); + return db; } function toDbValue(value: SqlValue | SyncScalar): string | number | null | Uint8Array { diff --git a/apps/desktop/src/main/services/updates/autoUpdateService.test.ts b/apps/desktop/src/main/services/updates/autoUpdateService.test.ts new file mode 100644 index 000000000..d7276e30b --- /dev/null +++ b/apps/desktop/src/main/services/updates/autoUpdateService.test.ts @@ -0,0 +1,132 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAutoUpdateService } from "./autoUpdateService"; +import type { Logger } from "../logging/logger"; + +class FakeAutoUpdater extends EventEmitter { + logger: Logger | null = null; + autoDownload = false; + autoInstallOnAppQuit = true; + checkForUpdates = vi.fn(async () => null); + quitAndInstall = vi.fn(); +} + +function makeLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function makeStatePath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-auto-update-")); + return path.join(dir, "ade-state.json"); +} + +describe("createAutoUpdateService", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("converts a pending install into a post-install notice on matching relaunch", () => { + const globalStatePath = makeStatePath(); + fs.writeFileSync(globalStatePath, JSON.stringify({ + pendingInstallUpdate: { + fromVersion: "1.2.2", + targetVersion: "1.2.3", + releaseNotesUrl: "https://www.ade-app.dev/changelog/v1.2.3", + requestedAt: "2026-04-06T15:20:00.000Z", + }, + }), "utf8"); + + const service = createAutoUpdateService({ + logger: makeLogger(), + currentVersion: "1.2.3", + globalStatePath, + startupDelayMs: 60_000, + periodicCheckMs: 60_000, + now: () => "2026-04-06T15:21:00.000Z", + updater: new FakeAutoUpdater(), + }); + + expect(service.getSnapshot().recentlyInstalled).toEqual({ + version: "1.2.3", + installedAt: "2026-04-06T15:21:00.000Z", + releaseNotesUrl: "https://www.ade-app.dev/changelog/v1.2.3", + }); + + expect(JSON.parse(fs.readFileSync(globalStatePath, "utf8"))).toEqual({ + recentlyInstalledUpdate: { + version: "1.2.3", + installedAt: "2026-04-06T15:21:00.000Z", + releaseNotesUrl: "https://www.ade-app.dev/changelog/v1.2.3", + }, + }); + + service.dispose(); + }); + + it("tracks download progress and persists the target version before quit-and-install", () => { + const globalStatePath = makeStatePath(); + const updater = new FakeAutoUpdater(); + const service = createAutoUpdateService({ + logger: makeLogger(), + currentVersion: "1.2.2", + globalStatePath, + startupDelayMs: 60_000, + periodicCheckMs: 60_000, + now: () => "2026-04-06T15:21:00.000Z", + updater, + }); + + updater.emit("update-available", { + version: "1.2.3", + }); + updater.emit("download-progress", { + percent: 62.4, + bytesPerSecond: 128_000, + transferred: 6_240_000, + total: 10_000_000, + }); + + expect(service.getSnapshot()).toMatchObject({ + status: "downloading", + version: "1.2.3", + progressPercent: 62.4, + bytesPerSecond: 128_000, + transferredBytes: 6_240_000, + totalBytes: 10_000_000, + releaseNotesUrl: "https://www.ade-app.dev/changelog/v1.2.3", + }); + + updater.emit("update-downloaded", { + version: "1.2.3", + }); + + expect(service.getSnapshot()).toMatchObject({ + status: "ready", + version: "1.2.3", + progressPercent: 100, + releaseNotesUrl: "https://www.ade-app.dev/changelog/v1.2.3", + }); + + expect(service.quitAndInstall()).toBe(true); + expect(updater.quitAndInstall).toHaveBeenCalledWith(false, true); + + expect(JSON.parse(fs.readFileSync(globalStatePath, "utf8"))).toEqual({ + pendingInstallUpdate: { + fromVersion: "1.2.2", + targetVersion: "1.2.3", + releaseNotesUrl: "https://www.ade-app.dev/changelog/v1.2.3", + requestedAt: "2026-04-06T15:21:00.000Z", + }, + }); + + service.dispose(); + }); +}); diff --git a/apps/desktop/src/main/services/updates/autoUpdateService.ts b/apps/desktop/src/main/services/updates/autoUpdateService.ts index 538ffdd7e..1ef6d0995 100644 --- a/apps/desktop/src/main/services/updates/autoUpdateService.ts +++ b/apps/desktop/src/main/services/updates/autoUpdateService.ts @@ -1,52 +1,323 @@ +import type { ProgressInfo } from "builder-util-runtime"; import { autoUpdater, type UpdateInfo } from "electron-updater"; +import type { AutoUpdateSnapshot, RecentlyInstalledUpdate } from "../../../shared/types"; import type { Logger } from "../logging/logger"; +import { readGlobalState, writeGlobalState, type GlobalState } from "../state/globalState"; -export function createAutoUpdateService(logger: Logger) { - autoUpdater.logger = null; - autoUpdater.autoDownload = true; - autoUpdater.autoInstallOnAppQuit = true; +const DEFAULT_RELEASE_NOTES_BASE_URL = "https://www.ade-app.dev"; - let updateAvailableCallback: ((info: UpdateInfo) => void) | null = null; - let updateDownloadedCallback: ((info: UpdateInfo) => void) | null = null; +type AutoUpdaterLike = { + logger: typeof autoUpdater.logger; + autoDownload: boolean; + autoInstallOnAppQuit: boolean; + checkForUpdates: () => Promise; + quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void; + on: (event: string, listener: (...args: any[]) => void) => unknown; + removeListener: (event: string, listener: (...args: any[]) => void) => unknown; +}; - autoUpdater.on("update-available", (info) => { - logger.info("autoUpdate.update_available", { version: info.version }); - updateAvailableCallback?.(info); +type CreateAutoUpdateServiceArgs = { + logger: Logger; + currentVersion: string; + globalStatePath: string; + updater?: AutoUpdaterLike; + now?: () => string; + releaseNotesBaseUrl?: string; + startupDelayMs?: number; + periodicCheckMs?: number; +}; + +export function createEmptyAutoUpdateSnapshot(): AutoUpdateSnapshot { + return { + status: "idle", + version: null, + progressPercent: null, + bytesPerSecond: null, + transferredBytes: null, + totalBytes: null, + releaseNotesUrl: null, + error: null, + recentlyInstalled: null, + }; +} + +export function buildReleaseNotesUrl( + version: string, + baseUrl = DEFAULT_RELEASE_NOTES_BASE_URL, +): string | null { + const normalizedVersion = version.trim().replace(/^v/i, ""); + const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, ""); + if (!normalizedVersion || !normalizedBaseUrl) return null; + return `${normalizedBaseUrl}/changelog/${encodeURIComponent(`v${normalizedVersion}`)}`; +} + +function cloneRecentlyInstalledUpdate( + update: RecentlyInstalledUpdate | null, +): RecentlyInstalledUpdate | null { + return update ? { ...update } : null; +} + +function cloneSnapshot(snapshot: AutoUpdateSnapshot): AutoUpdateSnapshot { + return { + ...snapshot, + recentlyInstalled: cloneRecentlyInstalledUpdate(snapshot.recentlyInstalled), + }; +} + +function reconcilePersistedUpdateState(args: { + state: GlobalState; + currentVersion: string; + now: string; + releaseNotesBaseUrl: string; +}): { state: GlobalState; changed: boolean; recentlyInstalled: RecentlyInstalledUpdate | null } { + const nextState: GlobalState = { ...args.state }; + let changed = false; + + if ( + nextState.recentlyInstalledUpdate + && nextState.recentlyInstalledUpdate.version !== args.currentVersion + ) { + nextState.recentlyInstalledUpdate = undefined; + changed = true; + } + + const pendingInstall = nextState.pendingInstallUpdate; + if (pendingInstall) { + if (pendingInstall.targetVersion === args.currentVersion) { + nextState.recentlyInstalledUpdate = { + version: pendingInstall.targetVersion, + installedAt: args.now, + releaseNotesUrl: + pendingInstall.releaseNotesUrl + ?? buildReleaseNotesUrl(pendingInstall.targetVersion, args.releaseNotesBaseUrl), + }; + } + nextState.pendingInstallUpdate = undefined; + changed = true; + } + + return { + state: nextState, + changed, + recentlyInstalled: cloneRecentlyInstalledUpdate(nextState.recentlyInstalledUpdate ?? null), + }; +} + +function applyUpdateInfo( + info: Pick, + releaseNotesBaseUrl: string, +): Partial { + return { + version: info.version, + releaseNotesUrl: buildReleaseNotesUrl(info.version, releaseNotesBaseUrl), + error: null, + }; +} + +export function createAutoUpdateService({ + logger, + currentVersion, + globalStatePath, + updater = autoUpdater as unknown as AutoUpdaterLike, + now = () => new Date().toISOString(), + releaseNotesBaseUrl = DEFAULT_RELEASE_NOTES_BASE_URL, + startupDelayMs = 5_000, + periodicCheckMs = 30 * 60 * 1_000, +}: CreateAutoUpdateServiceArgs) { + updater.logger = null; + updater.autoDownload = true; + updater.autoInstallOnAppQuit = false; + + const initialState = reconcilePersistedUpdateState({ + state: readGlobalState(globalStatePath), + currentVersion, + now: now(), + releaseNotesBaseUrl, }); + if (initialState.changed) { + writeGlobalState(globalStatePath, initialState.state); + } + + let snapshot: AutoUpdateSnapshot = { + ...createEmptyAutoUpdateSnapshot(), + recentlyInstalled: initialState.recentlyInstalled, + }; + let checkPromise: Promise | null = null; + const listeners = new Set<(snapshot: AutoUpdateSnapshot) => void>(); - autoUpdater.on("update-downloaded", (info) => { + function emit(): void { + const nextSnapshot = cloneSnapshot(snapshot); + for (const listener of listeners) { + listener(nextSnapshot); + } + } + + function patchSnapshot(partial: Partial): void { + snapshot = { ...snapshot, ...partial }; + emit(); + } + + const onCheckingForUpdate = () => { + logger.info("autoUpdate.checking"); + patchSnapshot({ + status: snapshot.status === "ready" ? snapshot.status : "checking", + version: snapshot.status === "ready" ? snapshot.version : null, + progressPercent: snapshot.status === "ready" ? snapshot.progressPercent : null, + bytesPerSecond: snapshot.status === "ready" ? snapshot.bytesPerSecond : null, + transferredBytes: snapshot.status === "ready" ? snapshot.transferredBytes : null, + totalBytes: snapshot.status === "ready" ? snapshot.totalBytes : null, + releaseNotesUrl: snapshot.status === "ready" ? snapshot.releaseNotesUrl : null, + error: null, + }); + }; + + const onUpdateAvailable = (info: UpdateInfo) => { + logger.info("autoUpdate.update_available", { version: info.version }); + patchSnapshot({ + status: "downloading", + progressPercent: 0, + bytesPerSecond: null, + transferredBytes: null, + totalBytes: null, + ...applyUpdateInfo(info, releaseNotesBaseUrl), + }); + }; + + const onDownloadProgress = (info: ProgressInfo) => { + patchSnapshot({ + status: "downloading", + progressPercent: info.percent, + bytesPerSecond: info.bytesPerSecond, + transferredBytes: info.transferred, + totalBytes: info.total, + error: null, + }); + }; + + const onUpdateDownloaded = (info: UpdateInfo) => { logger.info("autoUpdate.update_downloaded", { version: info.version }); - updateDownloadedCallback?.(info); - }); + patchSnapshot({ + status: "ready", + progressPercent: 100, + bytesPerSecond: null, + transferredBytes: null, + totalBytes: null, + ...applyUpdateInfo(info, releaseNotesBaseUrl), + }); + }; - autoUpdater.on("error", (err) => { - logger.warn("autoUpdate.error", { message: err?.message ?? "unknown" }); - }); + const onUpdateNotAvailable = () => { + logger.info("autoUpdate.update_not_available"); + patchSnapshot({ + status: snapshot.status === "ready" ? "ready" : "idle", + version: snapshot.status === "ready" ? snapshot.version : null, + progressPercent: snapshot.status === "ready" ? snapshot.progressPercent : null, + bytesPerSecond: snapshot.status === "ready" ? snapshot.bytesPerSecond : null, + transferredBytes: snapshot.status === "ready" ? snapshot.transferredBytes : null, + totalBytes: snapshot.status === "ready" ? snapshot.totalBytes : null, + releaseNotesUrl: snapshot.status === "ready" ? snapshot.releaseNotesUrl : null, + error: null, + }); + }; + + const onUpdateCancelled = (info: UpdateInfo) => { + logger.warn("autoUpdate.update_cancelled", { version: info.version }); + patchSnapshot({ + ...createEmptyAutoUpdateSnapshot(), + recentlyInstalled: snapshot.recentlyInstalled, + }); + }; - function checkForUpdates() { - autoUpdater.checkForUpdates().catch((err) => { - logger.warn("autoUpdate.check_failed", { message: err?.message ?? "unknown" }); + const onError = (err: unknown) => { + const message = err instanceof Error ? err.message : String(err ?? "unknown"); + logger.warn("autoUpdate.error", { message }); + if (snapshot.status === "ready") return; + patchSnapshot({ + ...createEmptyAutoUpdateSnapshot(), + status: "error", + error: message, + recentlyInstalled: snapshot.recentlyInstalled, + }); + }; + + updater.on("checking-for-update", onCheckingForUpdate); + updater.on("update-available", onUpdateAvailable); + updater.on("download-progress", onDownloadProgress); + updater.on("update-downloaded", onUpdateDownloaded); + updater.on("update-not-available", onUpdateNotAvailable); + updater.on("update-cancelled", onUpdateCancelled); + updater.on("error", onError); + + function checkForUpdates(): void { + if ( + checkPromise + || snapshot.status === "checking" + || snapshot.status === "downloading" + || snapshot.status === "ready" + ) { + return; + } + checkPromise = updater.checkForUpdates() + .catch(() => { + // `error` is emitted separately by electron-updater. + }) + .finally(() => { + checkPromise = null; + }); + } + + function dismissInstalledNotice(): void { + if (!snapshot.recentlyInstalled) return; + const currentState = readGlobalState(globalStatePath); + writeGlobalState(globalStatePath, { + ...currentState, + recentlyInstalledUpdate: undefined, + }); + patchSnapshot({ + recentlyInstalled: null, }); } - // Check after 5s delay, then every 30 minutes - const startupTimer = setTimeout(checkForUpdates, 5_000); - const periodicTimer = setInterval(checkForUpdates, 30 * 60 * 1_000); + const startupTimer = setTimeout(checkForUpdates, startupDelayMs); + const periodicTimer = setInterval(checkForUpdates, periodicCheckMs); return { checkForUpdates, - onUpdateAvailable(cb: (info: UpdateInfo) => void) { - updateAvailableCallback = cb; + getSnapshot(): AutoUpdateSnapshot { + return cloneSnapshot(snapshot); }, - onUpdateDownloaded(cb: (info: UpdateInfo) => void) { - updateDownloadedCallback = cb; + onStateChange(cb: (snapshot: AutoUpdateSnapshot) => void) { + listeners.add(cb); + return () => listeners.delete(cb); }, - quitAndInstall() { - autoUpdater.quitAndInstall(false, true); + dismissInstalledNotice, + quitAndInstall(): boolean { + if (snapshot.status !== "ready" || !snapshot.version) return false; + writeGlobalState(globalStatePath, { + ...readGlobalState(globalStatePath), + pendingInstallUpdate: { + fromVersion: currentVersion, + targetVersion: snapshot.version, + releaseNotesUrl: snapshot.releaseNotesUrl, + requestedAt: now(), + }, + recentlyInstalledUpdate: undefined, + }); + logger.info("autoUpdate.quit_and_install", { version: snapshot.version }); + updater.quitAndInstall(false, true); + return true; }, dispose() { clearTimeout(startupTimer); clearInterval(periodicTimer); + listeners.clear(); + updater.removeListener("checking-for-update", onCheckingForUpdate); + updater.removeListener("update-available", onUpdateAvailable); + updater.removeListener("download-progress", onDownloadProgress); + updater.removeListener("update-downloaded", onUpdateDownloaded); + updater.removeListener("update-not-available", onUpdateNotAvailable); + updater.removeListener("update-cancelled", onUpdateCancelled); + updater.removeListener("error", onError); }, }; } diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 37f2a3383..b6c84446d 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -7,6 +7,7 @@ import type { AttachLaneArgs, AdoptAttachedLaneArgs, AppInfo, + AutoUpdateSnapshot, ClearLocalAdeDataArgs, ClearLocalAdeDataResult, ArchiveLaneArgs, @@ -509,6 +510,7 @@ import type { LaneEnvInitEvent, LaneOverlayOverrides, LaneTemplate, + LaneListSnapshot, GetLaneTemplateArgs, SetDefaultLaneTemplateArgs, ApplyLaneTemplateArgs, @@ -754,6 +756,7 @@ declare global { }; lanes: { list: (args?: ListLanesArgs) => Promise; + listSnapshots: (args?: ListLanesArgs) => Promise; create: (args: CreateLaneArgs) => Promise; createChild: (args: CreateChildLaneArgs) => Promise; createFromUnstaged: (args: CreateLaneFromUnstagedArgs) => Promise; @@ -1213,8 +1216,10 @@ declare global { runProjectScan: () => Promise; }; updateCheckForUpdates: () => Promise; + updateGetState: () => Promise; updateQuitAndInstall: () => Promise; - onUpdateEvent: (cb: (data: { type: string; version?: string }) => void) => () => void; + updateDismissInstalledNotice: () => Promise; + onUpdateEvent: (cb: (snapshot: AutoUpdateSnapshot) => void) => () => void; }; } } diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index b42c899ac..e919d79b5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -7,6 +7,7 @@ import type { AttachLaneArgs, AdoptAttachedLaneArgs, AppInfo, + AutoUpdateSnapshot, ClearLocalAdeDataArgs, ClearLocalAdeDataResult, ArchiveLaneArgs, @@ -257,6 +258,7 @@ import type { OnboardingDetectionResult, OnboardingExistingLaneCandidate, OnboardingStatus, + LaneListSnapshot, LaneSummary, ListOverlapsArgs, ListLanesArgs, @@ -962,6 +964,8 @@ contextBridge.exposeInMainWorld("ade", { }, lanes: { list: async (args: ListLanesArgs = {}): Promise => ipcRenderer.invoke(IPC.lanesList, args), + listSnapshots: async (args: ListLanesArgs = {}): Promise => + ipcRenderer.invoke(IPC.lanesListSnapshots, args), create: async (args: CreateLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesCreate, args), createChild: async (args: CreateChildLaneArgs): Promise => ipcRenderer.invoke(IPC.lanesCreateChild, args), createFromUnstaged: async (args: CreateLaneFromUnstagedArgs): Promise => @@ -1769,9 +1773,11 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.ctoRunProjectScan), }, updateCheckForUpdates: () => ipcRenderer.invoke(IPC.updateCheckForUpdates), + updateGetState: (): Promise => ipcRenderer.invoke(IPC.updateGetState), updateQuitAndInstall: () => ipcRenderer.invoke(IPC.updateQuitAndInstall), - onUpdateEvent: (cb: (data: { type: string; version?: string }) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { type: string; version?: string }) => cb(payload); + updateDismissInstalledNotice: () => ipcRenderer.invoke(IPC.updateDismissInstalledNotice), + onUpdateEvent: (cb: (snapshot: AutoUpdateSnapshot) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: AutoUpdateSnapshot) => cb(payload); ipcRenderer.on(IPC.updateEvent, listener); return () => ipcRenderer.removeListener(IPC.updateEvent, listener); }, diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 9620e8735..0304c362d 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -158,6 +158,38 @@ const MOCK_LANES: any[] = [ makeLane("lane-a11y", "feature/accessibility", "refs/heads/feature/accessibility"), ]; +/** Returns a fresh snapshot object on every call to avoid shared-state leakage. */ +function makeLaneSnapshot(lane: any): any { + const runtimeBucket = lane.id === "lane-auth" || lane.id === "lane-checkout" + ? "running" + : lane.id === "lane-dashboard" || lane.id === "lane-api" + ? "awaiting-input" + : lane.id === "lane-perf" + ? "ended" + : "none"; + return { + lane: { ...lane }, + runtime: { + bucket: runtimeBucket, + runningCount: runtimeBucket === "running" ? 1 : 0, + awaitingInputCount: runtimeBucket === "awaiting-input" ? 1 : 0, + endedCount: runtimeBucket === "ended" ? 1 : 0, + sessionCount: runtimeBucket === "none" ? 0 : 1, + }, + rebaseSuggestion: lane.id === "lane-dashboard" || lane.id === "lane-onboard" + ? { laneId: lane.id, parentLaneId: "lane-main", parentHeadSha: "mock", behindCount: 2, lastSuggestedAt: now, deferredUntil: null, dismissedAt: null, hasPr: true } + : null, + autoRebaseStatus: lane.id === "lane-perf" + ? { laneId: lane.id, parentLaneId: "lane-main", parentHeadSha: "mock", state: "autoRebased", updatedAt: now, conflictCount: 0, message: "Mock auto-rebase" } + : null, + conflictStatus: lane.id === "lane-dashboard" || lane.id === "lane-search" + ? { laneId: lane.id, status: "conflict-active", conflictCount: 2, warningCount: 0, updatedAt: now, summary: "Mock conflict" } + : null, + stateSnapshot: null, + adoptableAttached: lane.laneType === "attached" && lane.archivedAt == null, + }; +} + // ── Helper for PrWithConflicts ──────────────────────────────── function makePr(id: string, laneId: string, num: number, title: string, opts: Partial = {}): any { return { @@ -1095,6 +1127,7 @@ if (typeof window !== "undefined" && !(window as any).ade) { }, lanes: { list: resolved(MOCK_LANES), + listSnapshots: async () => MOCK_LANES.map((lane) => makeLaneSnapshot(lane)), create: resolvedArg({ id: "mock", name: "mock" }), createChild: resolvedArg({ id: "mock", name: "mock" }), importBranch: resolvedArg({ id: "mock", name: "mock" }), @@ -1767,7 +1800,19 @@ if (typeof window !== "undefined" && !(window as any).ade) { getFactor: () => 1, }, updateCheckForUpdates: resolved(undefined), + updateGetState: resolved({ + status: "idle", + version: null, + progressPercent: null, + bytesPerSecond: null, + transferredBytes: null, + totalBytes: null, + releaseNotesUrl: null, + error: null, + recentlyInstalled: null, + }), updateQuitAndInstall: resolved(undefined), + updateDismissInstalledNotice: resolved(undefined), onUpdateEvent: noop, }; } diff --git a/apps/desktop/src/renderer/components/app/AutoUpdateControl.test.tsx b/apps/desktop/src/renderer/components/app/AutoUpdateControl.test.tsx new file mode 100644 index 000000000..64b66aeaf --- /dev/null +++ b/apps/desktop/src/renderer/components/app/AutoUpdateControl.test.tsx @@ -0,0 +1,100 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AutoUpdateSnapshot } from "../../../shared/types"; +import { AutoUpdateControl } from "./AutoUpdateControl"; + +const EMPTY_SNAPSHOT: AutoUpdateSnapshot = { + status: "idle", + version: null, + progressPercent: null, + bytesPerSecond: null, + transferredBytes: null, + totalBytes: null, + releaseNotesUrl: null, + error: null, + recentlyInstalled: null, +}; + +describe("AutoUpdateControl", () => { + const originalAde = globalThis.window.ade; + const originalConfirm = globalThis.window.confirm; + let onUpdateEvent: ((snapshot: AutoUpdateSnapshot) => void) | null = null; + + beforeEach(() => { + onUpdateEvent = null; + globalThis.window.confirm = vi.fn(() => true); + globalThis.window.ade = { + app: { + openExternal: vi.fn(async () => undefined), + }, + updateCheckForUpdates: vi.fn(async () => undefined), + updateGetState: vi.fn(async () => EMPTY_SNAPSHOT), + updateQuitAndInstall: vi.fn(async () => undefined), + updateDismissInstalledNotice: vi.fn(async () => undefined), + onUpdateEvent: vi.fn((callback: (snapshot: AutoUpdateSnapshot) => void) => { + onUpdateEvent = callback; + return () => { + onUpdateEvent = null; + }; + }), + } as any; + }); + + afterEach(() => { + cleanup(); + if (originalAde === undefined) { + delete (globalThis.window as any).ade; + } else { + globalThis.window.ade = originalAde; + } + globalThis.window.confirm = originalConfirm; + }); + + it("shows the post-install modal and opens Mintlify release notes", async () => { + const snapshot: AutoUpdateSnapshot = { + ...EMPTY_SNAPSHOT, + recentlyInstalled: { + version: "1.2.3", + installedAt: "2026-04-06T15:23:00.000Z", + releaseNotesUrl: "https://www.ade-app.dev/changelog/v1.2.3", + }, + }; + globalThis.window.ade.updateGetState = vi.fn(async () => snapshot); + + render(); + + expect(await screen.findByText(/ADE updated/i)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /open release notes/i })); + + expect(globalThis.window.ade.app.openExternal).toHaveBeenCalledWith( + "https://www.ade-app.dev/changelog/v1.2.3", + ); + expect(globalThis.window.ade.updateDismissInstalledNotice).toHaveBeenCalledTimes(1); + }); + + it("switches to restart-to-install once the update is downloaded", async () => { + render(); + + await waitFor(() => { + expect(globalThis.window.ade.onUpdateEvent).toHaveBeenCalledTimes(1); + }); + + await act(async () => { + onUpdateEvent?.({ + ...EMPTY_SNAPSHOT, + status: "ready", + version: "1.2.3", + releaseNotesUrl: "https://www.ade-app.dev/changelog/v1.2.3", + }); + }); + + fireEvent.click(await screen.findByRole("button", { name: /restart to install v1.2.3/i })); + + expect(globalThis.window.confirm).toHaveBeenCalledTimes(1); + expect(globalThis.window.ade.updateQuitAndInstall).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/renderer/components/app/AutoUpdateControl.tsx b/apps/desktop/src/renderer/components/app/AutoUpdateControl.tsx new file mode 100644 index 000000000..8ea6f44ad --- /dev/null +++ b/apps/desktop/src/renderer/components/app/AutoUpdateControl.tsx @@ -0,0 +1,205 @@ +import React, { useCallback, useEffect, useState } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import { ArrowSquareOut, ArrowsClockwise, X } from "@phosphor-icons/react"; +import type { AutoUpdateSnapshot } from "../../../shared/types"; +import { Button } from "../ui/Button"; +import { cn } from "../ui/cn"; + +const EMPTY_UPDATE_SNAPSHOT: AutoUpdateSnapshot = { + status: "idle", + version: null, + progressPercent: null, + bytesPerSecond: null, + transferredBytes: null, + totalBytes: null, + releaseNotesUrl: null, + error: null, + recentlyInstalled: null, +}; + +function versionLabel(version: string | null): string { + return version ? `v${version}` : "the latest update"; +} + +function progressLabel(progressPercent: number | null): string | null { + if (progressPercent == null || !Number.isFinite(progressPercent)) return null; + return `${Math.max(0, Math.min(100, Math.round(progressPercent)))}%`; +} + +export function AutoUpdateControl() { + const [snapshot, setSnapshot] = useState(EMPTY_UPDATE_SNAPSHOT); + const [releaseNotesOpen, setReleaseNotesOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + + void window.ade.updateGetState() + .then((nextSnapshot) => { + if (cancelled) return; + setSnapshot(nextSnapshot); + setReleaseNotesOpen(Boolean(nextSnapshot.recentlyInstalled)); + }) + .catch(() => { + // Best effort only. + }); + + const unsubscribe = window.ade.onUpdateEvent((nextSnapshot) => { + if (cancelled) return; + setSnapshot(nextSnapshot); + if (nextSnapshot.recentlyInstalled) { + setReleaseNotesOpen(true); + } + }); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); + + const dismissInstalledNotice = useCallback(() => { + setReleaseNotesOpen(false); + setSnapshot((current) => ({ + ...current, + recentlyInstalled: null, + })); + void window.ade.updateDismissInstalledNotice().catch(() => { + // Ignore renderer-side dismissal failures. + }); + }, []); + + const handleRestartToInstall = useCallback(() => { + const confirmed = window.confirm( + `ADE will quit and restart automatically to install ${versionLabel(snapshot.version)}.\n\nAny unsaved work may be lost. Continue?`, + ); + if (!confirmed) return; + void window.ade.updateQuitAndInstall().catch(() => { + // The main process logs updater failures. + }); + }, [snapshot.version]); + + const shouldShowIndicator = + snapshot.status === "checking" + || snapshot.status === "downloading" + || snapshot.status === "ready"; + const downloadProgress = progressLabel(snapshot.progressPercent); + const releaseNotesUrl = snapshot.recentlyInstalled?.releaseNotesUrl ?? null; + + return ( + <> + {shouldShowIndicator ? ( + + ) : null} + + { + if (!nextOpen) { + dismissInstalledNotice(); + } else { + setReleaseNotesOpen(true); + } + }} + > + + + +
+
+ + ADE updated + + + {snapshot.recentlyInstalled + ? `ADE restarted on v${snapshot.recentlyInstalled.version}.` + : "ADE finished installing the latest version."} + +
+ + + +
+ +
+ The update is installed. You can reopen the Mintlify release notes to see what changed in this build. +
+ +
+ + {releaseNotesUrl ? ( + + ) : null} +
+
+
+
+ + ); +} diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 61badd1fb..4b25d2907 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import { ArrowsClockwise, Folder, FolderOpen, Plus, Minus, Trash, X } from "@phosphor-icons/react"; +import { Folder, FolderOpen, Plus, Minus, Trash, X } from "@phosphor-icons/react"; import { useNavigate } from "react-router-dom"; import { useAppStore } from "../../state/appStore"; @@ -13,6 +13,7 @@ import { } from "../../lib/zoom"; import { cn } from "../ui/cn"; import type { ProcessRuntime, RecentProjectSummary, SyncRoleSnapshot } from "../../../shared/types"; +import { AutoUpdateControl } from "./AutoUpdateControl"; const RUNNING_LANE_PROCESS_STATES: ProcessRuntime["status"][] = ["starting", "running", "degraded"]; @@ -51,8 +52,6 @@ export function TopBar() { const [recentProjects, setRecentProjects] = useState([]); const [relocatingPath, setRelocatingPath] = useState(null); const [zoom, setZoom] = useState(getStoredZoomLevel); - const [updateState, setUpdateState] = useState<"idle" | "downloading" | "ready">("idle"); - const [updateVersion, setUpdateVersion] = useState(); const [syncSnapshot, setSyncSnapshot] = useState(null); const [dragIdx, setDragIdx] = useState(null); const [dropIdx, setDropIdx] = useState(null); @@ -92,21 +91,6 @@ export function TopBar() { return unsub; }, [fetchRecent]); - // Listen for auto-update events. - useEffect(() => { - const unsub = window.ade.onUpdateEvent((data) => { - if (data.type === "available") { - setUpdateState("downloading"); - setUpdateVersion(data.version); - } - if (data.type === "downloaded") { - setUpdateState("ready"); - setUpdateVersion(data.version); - } - }); - return unsub; - }, []); - useEffect(() => { let cancelled = false; void window.ade.sync.getStatus().then((snapshot) => { @@ -457,46 +441,7 @@ export function TopBar() { ) : null} - {/* Update indicator */} - {updateState !== "idle" && ( - - )} + {/* Zoom controls */}
(null); const [managedLaneIds, setManagedLaneIds] = useState([]); - const [conflictStatusByLane, setConflictStatusByLane] = useState>({}); const [conflictChipsByLane, setConflictChipsByLane] = useState>({}); const chipTimersRef = useRef>(new Map()); - const hasActiveLaneSessionsRef = useRef(false); - const [rebaseSuggestions, setRebaseSuggestions] = useState([]); - const [autoRebaseStatuses, setAutoRebaseStatuses] = useState([]); + const hasActiveLaneRuntimeRef = useRef(false); const [autoRebaseEnabled, setAutoRebaseEnabled] = useState(false); const [rebaseBusyLaneId, setRebaseBusyLaneId] = useState(null); const [rebaseSuggestionError, setRebaseSuggestionError] = useState(null); @@ -198,6 +178,8 @@ export function LanesPage() { const [branchDropdownOpen, setBranchDropdownOpen] = useState(false); const [branchCheckoutBusy, setBranchCheckoutBusy] = useState(false); const [branchCheckoutError, setBranchCheckoutError] = useState(null); + const [branchSearchQuery, setBranchSearchQuery] = useState(""); + const branchSearchInputRef = useRef(null); const branchDropdownRef = useRef(null); const [addLaneDropdownOpen, setAddLaneDropdownOpen] = useState(false); @@ -207,57 +189,38 @@ export function LanesPage() { const [laneContextMenu, setLaneContextMenu] = useState<{ laneId: string; x: number; y: number } | null>(null); const [expandedLaneId, setExpandedLaneId] = useState(null); const [expandedGitActionsLaneId, setExpandedGitActionsLaneId] = useState(null); - const [allSessions, setAllSessions] = useState([]); const [integrationProposals, setIntegrationProposals] = useState([]); + const laneSnapshots = useAppStore((s) => s.laneSnapshots); + const laneSnapshotByLaneId = useMemo( + () => new Map(laneSnapshots.map((snapshot) => [snapshot.lane.id, snapshot] as const)), + [laneSnapshots], + ); const sortedLanes = useMemo(() => sortLanesForTabs(lanes), [lanes]); const lanesById = useMemo(() => new Map(sortedLanes.map((lane) => [lane.id, lane])), [sortedLanes]); const integrationSourcesByLaneId = useMemo( () => buildIntegrationSourcesByLaneId(integrationProposals, lanesById), [integrationProposals, lanesById], ); - const rebaseByLaneId = useMemo( - () => new Map(rebaseSuggestions.map((s) => [s.laneId, s] as const)), - [rebaseSuggestions] - ); - const autoRebaseByLaneId = useMemo( - () => new Map(autoRebaseStatuses.map((s) => [s.laneId, s] as const)), - [autoRebaseStatuses] - ); const laneRuntimeById = useMemo(() => { - const summaryByLane = new Map(); - for (const lane of sortedLanes) { - summaryByLane.set(lane.id, { - bucket: "none", - runningCount: 0, - awaitingInputCount: 0, - endedCount: 0, - sessionCount: 0, - }); + const summaryByLane = new Map(); + for (const snapshot of laneSnapshots) { + summaryByLane.set(snapshot.lane.id, snapshot.runtime); } - for (const session of allSessions) { - const laneSummary = summaryByLane.get(session.laneId); - if (!laneSummary) continue; - laneSummary.sessionCount += 1; - const bucket = sessionStatusBucket({ - status: session.status, - lastOutputPreview: session.lastOutputPreview, - runtimeState: session.runtimeState, - toolType: session.toolType, - }); - if (bucket === "running") laneSummary.runningCount += 1; - else if (bucket === "awaiting-input") laneSummary.awaitingInputCount += 1; - else laneSummary.endedCount += 1; - } - for (const laneSummary of summaryByLane.values()) { - if (laneSummary.runningCount > 0) laneSummary.bucket = "running"; - else if (laneSummary.awaitingInputCount > 0) laneSummary.bucket = "awaiting-input"; - else if (laneSummary.endedCount > 0) laneSummary.bucket = "ended"; - else laneSummary.bucket = "none"; + for (const lane of sortedLanes) { + if (!summaryByLane.has(lane.id)) { + summaryByLane.set(lane.id, { + bucket: "none", + runningCount: 0, + awaitingInputCount: 0, + endedCount: 0, + sessionCount: 0, + }); + } } return summaryByLane; - }, [sortedLanes, allSessions]); + }, [sortedLanes, laneSnapshots]); const laneFilterMatchedLanes = useMemo( () => sortedLanes.filter((lane) => laneMatchesFilter(lane, pinnedLaneIds.has(lane.id), laneFilter)), @@ -296,9 +259,9 @@ export function LanesPage() { }, []); const filteredLanes = useMemo(() => { - const bucketRank: Record = { - running: 0, - "awaiting-input": 1, + const bucketRank: Record = { + "awaiting-input": 0, + running: 1, ended: 2, none: 3, }; @@ -323,12 +286,26 @@ export function LanesPage() { const filteredSet = useMemo(() => new Set(filteredLaneIds), [filteredLaneIds]); const visibleRebaseSuggestions = useMemo(() => { const laneIdSet = new Set(filteredLaneIds); - return rebaseSuggestions.filter((s) => laneIdSet.has(s.laneId)); - }, [rebaseSuggestions, filteredLaneIds]); + return laneSnapshots + .map((snapshot) => snapshot.rebaseSuggestion) + .filter( + (suggestion): suggestion is NonNullable => { + if (suggestion == null) return false; + return laneIdSet.has(suggestion.laneId); + }, + ); + }, [laneSnapshots, filteredLaneIds]); const visibleAutoRebaseNeedsAttention = useMemo(() => { const laneIdSet = new Set(filteredLaneIds); - return autoRebaseStatuses.filter((s) => laneIdSet.has(s.laneId) && s.state !== "autoRebased"); - }, [autoRebaseStatuses, filteredLaneIds]); + return laneSnapshots + .map((snapshot) => snapshot.autoRebaseStatus) + .filter( + (status): status is NonNullable => { + if (status == null) return false; + return laneIdSet.has(status.laneId) && status.state !== "autoRebased"; + }, + ); + }, [laneSnapshots, filteredLaneIds]); const showAutoRebaseSettingsHint = !autoRebaseEnabled && (visibleRebaseSuggestions.length > 0 || visibleAutoRebaseNeedsAttention.length > 0); const activeWithPins = useMemo( @@ -373,34 +350,15 @@ export function LanesPage() { refreshLanes().catch(() => {}); }, [primaryBranches, primaryLane?.id, primaryLane?.branchRef, refreshLanes]); + useEffect(() => { + if (branchDropdownOpen) { + setBranchSearchQuery(""); + setTimeout(() => branchSearchInputRef.current?.focus(), 0); + } + }, [branchDropdownOpen]); useClickOutside(branchDropdownRef, () => setBranchDropdownOpen(false), branchDropdownOpen); useClickOutside(addLaneDropdownRef, () => setAddLaneDropdownOpen(false), addLaneDropdownOpen); - /* ---- Conflict loading ---- */ - - const loadConflictStatuses = useCallback(async () => { - try { - const assessment = await window.ade.conflicts.getBatchAssessment(); - const next: Record = {}; - for (const status of assessment.lanes) next[status.laneId] = status; - setConflictStatusByLane(next); - } catch { /* best effort */ } - }, []); - - const refreshRebaseSuggestions = useCallback(async () => { - try { - const next = await window.ade.lanes.listRebaseSuggestions(); - setRebaseSuggestions(next); - } catch { /* best effort */ } - }, []); - - const refreshAutoRebaseStatuses = useCallback(async () => { - try { - const next = await window.ade.lanes.listAutoRebaseStatuses(); - setAutoRebaseStatuses(next); - } catch { /* best effort */ } - }, []); - const refreshAutoRebaseEnabled = useCallback(async () => { try { const snapshot = await window.ade.projectConfig.get(); @@ -414,15 +372,6 @@ export function LanesPage() { } }, []); - const refreshAllSessions = useCallback(async () => { - try { - const rows = await listSessionsCached({ limit: 500 }); - setAllSessions(rows.filter((session) => !isRunOwnedSession(session))); - } catch { - setAllSessions([]); - } - }, []); - const refreshIntegrationProposals = useCallback(async () => { try { const proposals = await window.ade.prs.listProposals(); @@ -465,34 +414,44 @@ export function LanesPage() { /* ---- Effects ---- */ - useEffect(() => { void loadConflictStatuses(); }, [loadConflictStatuses, lanes.length]); - useEffect(() => { const unsubscribe = window.ade.conflicts.onEvent((event) => { if (event.type !== "prediction-complete") return; - void loadConflictStatuses(); + // Only refresh conflict statuses — avoid a full refreshLanes() which fires + // five parallel queries via buildLaneListSnapshots. + void window.ade.conflicts.getBatchAssessment().then((assessment) => { + const conflictByLaneId = new Map( + assessment.lanes.map((entry) => [entry.laneId, entry] as const), + ); + const prev = useAppStore.getState().laneSnapshots; + const next = prev.map((snapshot) => { + const updated = conflictByLaneId.get(snapshot.lane.id) ?? null; + return updated !== snapshot.conflictStatus + ? { ...snapshot, conflictStatus: updated } + : snapshot; + }); + useAppStore.setState({ laneSnapshots: next }); + }).catch((err) => { console.error("getBatchAssessment failed:", err); }); pushConflictChips(event.chips); }); return unsubscribe; - }, [loadConflictStatuses, pushConflictChips]); + }, [pushConflictChips]); useEffect(() => { - void refreshRebaseSuggestions(); const unsubscribe = window.ade.lanes.onRebaseSuggestionsEvent((event) => { if (event.type !== "rebase-suggestions-updated") return; - setRebaseSuggestions(event.suggestions); + void refreshLanes(); }); return unsubscribe; - }, [refreshRebaseSuggestions]); + }, [refreshLanes]); useEffect(() => { - void refreshAutoRebaseStatuses(); const unsubscribe = window.ade.lanes.onAutoRebaseEvent((event) => { if (event.type !== "auto-rebase-updated") return; - setAutoRebaseStatuses(event.statuses); + void refreshLanes(); }); return unsubscribe; - }, [refreshAutoRebaseStatuses]); + }, [refreshLanes]); useEffect(() => { const unsubscribe = window.ade.lanes.rebaseSubscribe((event) => { @@ -506,22 +465,6 @@ export function LanesPage() { useEffect(() => { void refreshAutoRebaseEnabled(); }, [refreshAutoRebaseEnabled]); - useEffect(() => { - void refreshAllSessions(); - }, [refreshAllSessions, project?.rootPath]); - - useEffect(() => { - hasActiveLaneSessionsRef.current = allSessions.some((session) => { - const bucket = sessionStatusBucket({ - status: session.status, - lastOutputPreview: session.lastOutputPreview, - runtimeState: session.runtimeState, - toolType: session.toolType, - }); - return bucket === "running" || bucket === "awaiting-input"; - }); - }, [allSessions]); - useEffect(() => { void refreshIntegrationProposals(); }, [refreshIntegrationProposals, lanes.length, project?.rootPath]); @@ -533,19 +476,16 @@ export function LanesPage() { if (timer) return; // already scheduled timer = setTimeout(() => { timer = null; - void refreshAllSessions(); + void refreshLanes(); }, 300); }; const unsubPtyData = window.ade.pty.onData(scheduleRefresh); const unsubPtyExit = window.ade.pty.onExit(scheduleRefresh); - const unsubChat = window.ade.agentChat.onEvent((payload) => { - if (!shouldRefreshSessionListForChatEvent(payload)) return; - scheduleRefresh(); - }); + const unsubChat = window.ade.agentChat.onEvent(scheduleRefresh); const intervalId = window.setInterval(() => { if (document.visibilityState !== "visible") return; - if (!hasActiveLaneSessionsRef.current) return; - void refreshAllSessions(); + if (!hasActiveLaneRuntimeRef.current) return; + void refreshLanes(); }, 15_000); return () => { if (timer) clearTimeout(timer); @@ -566,7 +506,13 @@ export function LanesPage() { } window.clearInterval(intervalId); }; - }, [refreshAllSessions]); + }, [refreshLanes]); + + useEffect(() => { + hasActiveLaneRuntimeRef.current = laneSnapshots.some((snapshot) => + snapshot.runtime.bucket === "running" || snapshot.runtime.bucket === "awaiting-input", + ); + }, [laneSnapshots]); useEffect(() => { const onFocus = () => { void refreshAutoRebaseEnabled(); }; @@ -725,14 +671,14 @@ export function LanesPage() { () => primaryBranches.find((branch) => branch.isCurrent)?.name ?? primaryLane?.branchRef ?? "", [primaryBranches, primaryLane?.branchRef] ); - const localPrimaryBranches = useMemo( - () => primaryBranches.filter((branch) => !branch.isRemote), - [primaryBranches] - ); - const remotePrimaryBranches = useMemo( - () => primaryBranches.filter((branch) => branch.isRemote), - [primaryBranches] - ); + const localPrimaryBranches = useMemo(() => { + const q = branchSearchQuery.toLowerCase(); + return primaryBranches.filter((branch) => !branch.isRemote && (!q || branch.name.toLowerCase().includes(q))); + }, [primaryBranches, branchSearchQuery]); + const remotePrimaryBranches = useMemo(() => { + const q = branchSearchQuery.toLowerCase(); + return primaryBranches.filter((branch) => branch.isRemote && (!q || branch.name.toLowerCase().includes(q))); + }, [primaryBranches, branchSearchQuery]); const runLaneAction = async (fn: () => Promise) => { setLaneActionBusy(true); @@ -978,11 +924,10 @@ export function LanesPage() { } } - const results = await Promise.allSettled([refreshLanes(), refreshRebaseSuggestions(), refreshAutoRebaseStatuses()]); - for (const r of results) { - if (r.status === "rejected") { - console.error("Lane refresh partially failed:", r.reason); - } + try { + await refreshLanes(); + } catch (refreshErr) { + console.error("Lane refresh failed:", refreshErr); } } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -991,14 +936,14 @@ export function LanesPage() { } finally { setRebaseBusyLaneId(null); } - }, [lanesById, navigate, refreshAutoRebaseStatuses, refreshLanes, refreshRebaseSuggestions, requestPushSelection, requestRebaseScope]); + }, [lanesById, navigate, refreshLanes, requestPushSelection, requestRebaseScope]); const dismissRebaseSuggestion = async (laneId: string) => { setRebaseSuggestionError(null); setRebaseBusyLaneId(laneId); try { await window.ade.lanes.dismissRebaseSuggestion({ laneId }); - await refreshRebaseSuggestions(); + await refreshLanes(); } catch (err) { setRebaseSuggestionError(err instanceof Error ? err.message : String(err)); } finally { @@ -1011,7 +956,7 @@ export function LanesPage() { setRebaseBusyLaneId(laneId); try { await window.ade.lanes.deferRebaseSuggestion({ laneId, minutes }); - await refreshRebaseSuggestions(); + await refreshLanes(); } catch (err) { setRebaseSuggestionError(err instanceof Error ? err.message : String(err)); } finally { @@ -1360,7 +1305,7 @@ export function LanesPage() { return (
{/* Header bar */} -
+
{/* Numbered title group */}
05 @@ -1388,7 +1333,25 @@ export function LanesPage() { {branchDropdownOpen ? ( -
+
+
+ + setBranchSearchQuery(e.target.value)} + style={{ + width: "100%", padding: "5px 8px 5px 28px", fontSize: 12, fontFamily: MONO_FONT, + color: COLORS.textPrimary, background: "rgba(255,255,255,0.04)", + border: `1px solid ${COLORS.outlineBorder}`, borderRadius: 6, outline: "none", + }} + onFocus={(e) => { e.currentTarget.style.borderColor = COLORS.accent; }} + onBlur={(e) => { e.currentTarget.style.borderColor = COLORS.outlineBorder; }} + /> +
+
LOCAL BRANCHES
{localPrimaryBranches.map((branch) => (
) : null}
@@ -1645,7 +1609,8 @@ export function LanesPage() { const isPrimary = lane.laneType === "primary"; const isPinned = pinnedLaneIds.has(lane.id); const closable = isVisible && visibleLaneIds.length > 1 && !isPinned; - const conflictStatus = conflictStatusByLane[lane.id]; + const laneSnapshot = laneSnapshotByLaneId.get(lane.id) ?? null; + const conflictStatus = laneSnapshot?.conflictStatus ?? null; const chips = conflictChipsByLane[lane.id] ?? []; const laneRuntime = laneRuntimeById.get(lane.id) ?? { bucket: "none", @@ -1654,8 +1619,8 @@ export function LanesPage() { endedCount: 0, sessionCount: 0, }; - const rebaseSuggestion = rebaseByLaneId.get(lane.id) ?? null; - const autoRebaseStatus = autoRebaseByLaneId.get(lane.id) ?? null; + const rebaseSuggestion = laneSnapshot?.rebaseSuggestion ?? null; + const autoRebaseStatus = laneSnapshot?.autoRebaseStatus ?? null; const tabNumber = String(index + 1).padStart(2, "0"); return ( diff --git a/apps/desktop/src/renderer/components/onboarding/DevToolsSection.test.tsx b/apps/desktop/src/renderer/components/onboarding/DevToolsSection.test.tsx index ca77bc888..b70ed67f9 100644 --- a/apps/desktop/src/renderer/components/onboarding/DevToolsSection.test.tsx +++ b/apps/desktop/src/renderer/components/onboarding/DevToolsSection.test.tsx @@ -23,15 +23,6 @@ describe("DevToolsSection", () => { detectedVersion: "git version 2.50.1", required: true, }, - { - id: "gh", - label: "GitHub CLI", - command: "gh", - installed: false, - detectedPath: null, - detectedVersion: null, - required: false, - }, ], }); @@ -57,7 +48,6 @@ describe("DevToolsSection", () => { expect(screen.queryByText("REQUIRED")).toBeNull(); expect(screen.queryByText("RECOMMENDED")).toBeNull(); expect(screen.getByText("Required to continue setup.")).toBeTruthy(); - expect(screen.getByText("Optional, but recommended for PR workflows.")).toBeTruthy(); expect(onStatusChange).toHaveBeenCalledWith(true); }); diff --git a/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx b/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx index afbfa5157..f023a7f00 100644 --- a/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx +++ b/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useCallback } from "react"; -import { ArrowsClockwise, GitBranch, Terminal } from "@phosphor-icons/react"; +import { ArrowsClockwise, GitBranch } from "@phosphor-icons/react"; import type { DevToolsCheckResult, DevToolStatus } from "../../../shared/types"; import { COLORS, SANS_FONT, MONO_FONT, inlineBadge } from "../lanes/laneDesignTokens"; import { Button } from "../ui/Button"; @@ -29,7 +29,6 @@ export function DevToolsSection({ onStatusChange }: Props) { useEffect(() => { void detect(); }, [detect]); const git = result?.tools.find((t) => t.id === "git") ?? null; - const gh = result?.tools.find((t) => t.id === "gh") ?? null; const platform = result?.platform ?? "darwin"; return ( @@ -53,15 +52,10 @@ export function DevToolsSection({ onStatusChange }: Props) { git — version control, branching, and lane isolation
-
- - gh — PR creation, review, and GitHub workflows -
- - +