From 00e6049f6c78bb5d22c84d32b6aa0bda701b90a8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 27 Mar 2026 03:45:51 -0400 Subject: [PATCH 1/7] Add mission infrastructure for lanes tab redesign Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .factory/init.sh | 11 +++ .factory/library/architecture.md | 99 ++++++++------------ .factory/library/environment.md | 25 +++++ .factory/library/user-testing.md | 27 ++++++ .factory/services.yaml | 10 +- .factory/skills/ios-worker/SKILL.md | 140 ++++++++++++++++++++++++++++ 6 files changed, 247 insertions(+), 65 deletions(-) create mode 100755 .factory/init.sh create mode 100644 .factory/library/environment.md create mode 100644 .factory/library/user-testing.md create mode 100644 .factory/skills/ios-worker/SKILL.md diff --git a/.factory/init.sh b/.factory/init.sh new file mode 100755 index 000000000..9ebd88233 --- /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." + 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..7f6e2f388 100644 --- a/.factory/library/architecture.md +++ b/.factory/library/architecture.md @@ -1,67 +1,44 @@ # 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, WebSocket to desktop host, CRDT sync via cr-sqlite +- Database: SQLite with cr-sqlite for local caching +- Models: RemoteModels.swift (Codable structs for all API types) + +## Data Flow +1. SyncService connects to desktop ADE host via WebSocket +2. Commands sent (e.g., `lanes.refreshSnapshots`) → responses decoded +3. Data cached in SQLite (lane_list_snapshots, lane_detail_snapshots tables) +4. Views observe `syncService.localStateRevision` via `.task(id:)` for reactive updates +5. Pull-to-refresh triggers `reload(refreshRemote: true)` + +## 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..4a4737cf9 --- /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 cannot be used (deployment target is 26.0) + +## Build Notes +- Development team: VQ372F39G6 +- 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..92b05698b --- /dev/null +++ b/.factory/library/user-testing.md @@ -0,0 +1,27 @@ +# 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 +- 50 existing unit tests in ADETests.swift +- Tests 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..c14d0c299 100644 --- a/.factory/services.yaml +++ b/.factory/services.yaml @@ -1,5 +1,7 @@ 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 + 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..b1a6191ef --- /dev/null +++ b/.factory/skills/ios-worker/SKILL.md @@ -0,0 +1,140 @@ +--- +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 +# 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 + +# 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 From 594e9083d75c1480f273ffea80804bc9cf1ae724 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:59:35 -0400 Subject: [PATCH 2/7] mobile lane --- apps/ios/ADE.xcodeproj/project.pbxproj | 76 + .../ios/ADE/Views/Lanes/LaneAttachSheet.swift | 78 + .../Views/Lanes/LaneBatchManageSheet.swift | 170 + .../ADE/Views/Lanes/LaneChatLaunchSheet.swift | 212 + .../ADE/Views/Lanes/LaneChatSessionView.swift | 150 + apps/ios/ADE/Views/Lanes/LaneComponents.swift | 445 ++ .../ios/ADE/Views/Lanes/LaneCreateSheet.swift | 167 + .../Lanes/LaneDetailContentSections.swift | 260 ++ .../Views/Lanes/LaneDetailGitSection.swift | 337 ++ .../ADE/Views/Lanes/LaneDetailScreen.swift | 256 ++ apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift | 170 + .../Views/Lanes/LaneFileTreeComponents.swift | 275 ++ apps/ios/ADE/Views/Lanes/LaneHelpers.swift | 289 ++ .../ADE/Views/Lanes/LaneListViewParts.swift | 435 ++ .../ios/ADE/Views/Lanes/LaneManageSheet.swift | 216 + .../Lanes/LaneSessionTranscriptView.swift | 43 + .../ADE/Views/Lanes/LaneStackGraphSheet.swift | 92 + apps/ios/ADE/Views/Lanes/LaneTypes.swift | 108 + apps/ios/ADE/Views/LanesTabView.swift | 3705 +---------------- apps/ios/ADETests/ADETests.swift | 146 + 20 files changed, 4010 insertions(+), 3620 deletions(-) create mode 100644 apps/ios/ADE/Views/Lanes/LaneAttachSheet.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneBatchManageSheet.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneChatSessionView.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneComponents.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneCreateSheet.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneDetailContentSections.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneFileTreeComponents.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneHelpers.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneListViewParts.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneManageSheet.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneSessionTranscriptView.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneStackGraphSheet.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneTypes.swift diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index a055b87ef..759ef0987 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -21,6 +21,23 @@ 6E32ED1BF961DFEFCA12EF52 /* DatabaseBootstrap.sql in Resources */ = {isa = PBXBuildFile; fileRef = 2856F8D2F2D630DD985B870A /* DatabaseBootstrap.sql */; }; 7B70BE6839672E5D2D006B28 /* ADETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */; }; 889573D8A5ED468D3BD12894 /* PRsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EE4D463D21266B62B422D11 /* PRsTabView.swift */; }; + B10000000000000000000002 /* LaneAttachSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* LaneAttachSheet.swift */; }; + B10000000000000000000003 /* LaneBatchManageSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000003 /* LaneBatchManageSheet.swift */; }; + B10000000000000000000004 /* LaneChatLaunchSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000004 /* LaneChatLaunchSheet.swift */; }; + B10000000000000000000005 /* LaneChatSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000005 /* LaneChatSessionView.swift */; }; + B10000000000000000000006 /* LaneComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000006 /* LaneComponents.swift */; }; + B10000000000000000000007 /* LaneCreateSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000007 /* LaneCreateSheet.swift */; }; + B10000000000000000000008 /* LaneDetailContentSections.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000008 /* LaneDetailContentSections.swift */; }; + B10000000000000000000009 /* LaneDetailGitSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000009 /* LaneDetailGitSection.swift */; }; + B1000000000000000000000A /* LaneDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000000A /* LaneDetailScreen.swift */; }; + B1000000000000000000000B /* LaneDiffScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000000B /* LaneDiffScreen.swift */; }; + B1000000000000000000000C /* LaneFileTreeComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000000C /* LaneFileTreeComponents.swift */; }; + B1000000000000000000000D /* LaneHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000000D /* LaneHelpers.swift */; }; + B1000000000000000000000E /* LaneListViewParts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000000E /* LaneListViewParts.swift */; }; + B1000000000000000000000F /* LaneManageSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000000F /* LaneManageSheet.swift */; }; + B10000000000000000000010 /* LaneSessionTranscriptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000010 /* LaneSessionTranscriptView.swift */; }; + B10000000000000000000011 /* LaneStackGraphSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000011 /* LaneStackGraphSheet.swift */; }; + B10000000000000000000012 /* LaneTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000012 /* LaneTypes.swift */; }; E689F42D41A500BB8CA233E4 /* LanesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9270CF8A67F3FA79089F39C1 /* LanesTabView.swift */; }; F2A1C9D8456E7B3C1D2E4F90 /* FilesCodeSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F9B21C0E4A6D8F1B3C5A77 /* FilesCodeSupport.swift */; }; FBEEF09EFB4911FEAC6A7E87 /* RemoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 483C5F1818BAE74B19B84617 /* RemoteModels.swift */; }; @@ -51,6 +68,23 @@ 73B17472FAC08845853BC6B4 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ContentView.swift; path = ADE/App/ContentView.swift; sourceTree = ""; }; 8943C47805A871A4E4A4BF68 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = ADE/Assets.xcassets; sourceTree = ""; }; 9270CF8A67F3FA79089F39C1 /* LanesTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LanesTabView.swift; path = ADE/Views/LanesTabView.swift; sourceTree = ""; }; + A10000000000000000000002 /* LaneAttachSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneAttachSheet.swift; path = ADE/Views/Lanes/LaneAttachSheet.swift; sourceTree = ""; }; + A10000000000000000000003 /* LaneBatchManageSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneBatchManageSheet.swift; path = ADE/Views/Lanes/LaneBatchManageSheet.swift; sourceTree = ""; }; + A10000000000000000000004 /* LaneChatLaunchSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneChatLaunchSheet.swift; path = ADE/Views/Lanes/LaneChatLaunchSheet.swift; sourceTree = ""; }; + A10000000000000000000005 /* LaneChatSessionView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneChatSessionView.swift; path = ADE/Views/Lanes/LaneChatSessionView.swift; sourceTree = ""; }; + A10000000000000000000006 /* LaneComponents.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneComponents.swift; path = ADE/Views/Lanes/LaneComponents.swift; sourceTree = ""; }; + A10000000000000000000007 /* LaneCreateSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneCreateSheet.swift; path = ADE/Views/Lanes/LaneCreateSheet.swift; sourceTree = ""; }; + A10000000000000000000008 /* LaneDetailContentSections.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDetailContentSections.swift; path = ADE/Views/Lanes/LaneDetailContentSections.swift; sourceTree = ""; }; + A10000000000000000000009 /* LaneDetailGitSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDetailGitSection.swift; path = ADE/Views/Lanes/LaneDetailGitSection.swift; sourceTree = ""; }; + A1000000000000000000000A /* LaneDetailScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDetailScreen.swift; path = ADE/Views/Lanes/LaneDetailScreen.swift; sourceTree = ""; }; + A1000000000000000000000B /* LaneDiffScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDiffScreen.swift; path = ADE/Views/Lanes/LaneDiffScreen.swift; sourceTree = ""; }; + A1000000000000000000000C /* LaneFileTreeComponents.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneFileTreeComponents.swift; path = ADE/Views/Lanes/LaneFileTreeComponents.swift; sourceTree = ""; }; + A1000000000000000000000D /* LaneHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneHelpers.swift; path = ADE/Views/Lanes/LaneHelpers.swift; sourceTree = ""; }; + A1000000000000000000000E /* LaneListViewParts.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneListViewParts.swift; path = ADE/Views/Lanes/LaneListViewParts.swift; sourceTree = ""; }; + A1000000000000000000000F /* LaneManageSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneManageSheet.swift; path = ADE/Views/Lanes/LaneManageSheet.swift; sourceTree = ""; }; + A10000000000000000000010 /* LaneSessionTranscriptView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneSessionTranscriptView.swift; path = ADE/Views/Lanes/LaneSessionTranscriptView.swift; sourceTree = ""; }; + A10000000000000000000011 /* LaneStackGraphSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneStackGraphSheet.swift; path = ADE/Views/Lanes/LaneStackGraphSheet.swift; sourceTree = ""; }; + A10000000000000000000012 /* LaneTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneTypes.swift; path = ADE/Views/Lanes/LaneTypes.swift; sourceTree = ""; }; B5D5B5B87564C73F2FF34B0D /* KeychainService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = KeychainService.swift; path = ADE/Services/KeychainService.swift; sourceTree = ""; }; C9411193AF56B236BA32EFF5 /* ADEApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEApp.swift; path = ADE/App/ADEApp.swift; sourceTree = ""; }; CCAB2414C359E971B780BF99 /* PreviewHost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PreviewHost.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -87,10 +121,35 @@ name = Components; sourceTree = ""; }; + A10000000000000000000001 /* Lanes */ = { + isa = PBXGroup; + children = ( + A10000000000000000000002 /* LaneAttachSheet.swift */, + A10000000000000000000003 /* LaneBatchManageSheet.swift */, + A10000000000000000000004 /* LaneChatLaunchSheet.swift */, + A10000000000000000000005 /* LaneChatSessionView.swift */, + A10000000000000000000006 /* LaneComponents.swift */, + A10000000000000000000007 /* LaneCreateSheet.swift */, + A10000000000000000000008 /* LaneDetailContentSections.swift */, + A10000000000000000000009 /* LaneDetailGitSection.swift */, + A1000000000000000000000A /* LaneDetailScreen.swift */, + A1000000000000000000000B /* LaneDiffScreen.swift */, + A1000000000000000000000C /* LaneFileTreeComponents.swift */, + A1000000000000000000000D /* LaneHelpers.swift */, + A1000000000000000000000E /* LaneListViewParts.swift */, + A1000000000000000000000F /* LaneManageSheet.swift */, + A10000000000000000000010 /* LaneSessionTranscriptView.swift */, + A10000000000000000000011 /* LaneStackGraphSheet.swift */, + A10000000000000000000012 /* LaneTypes.swift */, + ); + name = Lanes; + sourceTree = ""; + }; 0379509A19E782882C2D84BB /* Views */ = { isa = PBXGroup; children = ( 02B9E655310A24835B5CFC3B /* Components */, + A10000000000000000000001 /* Lanes */, 4EBFB65019F4C3F8A89428CE /* FilesTabView.swift */, 9270CF8A67F3FA79089F39C1 /* LanesTabView.swift */, 5EE4D463D21266B62B422D11 /* PRsTabView.swift */, @@ -299,6 +358,23 @@ 0375D32BA5870617FA1758C6 /* KeychainService.swift in Sources */, 28CFE3D489EA1B208D231519 /* SyncService.swift in Sources */, 6BDC22C6450AF0B3CBDB2650 /* FilesTabView.swift in Sources */, + B10000000000000000000002 /* LaneAttachSheet.swift in Sources */, + B10000000000000000000003 /* LaneBatchManageSheet.swift in Sources */, + B10000000000000000000004 /* LaneChatLaunchSheet.swift in Sources */, + B10000000000000000000005 /* LaneChatSessionView.swift in Sources */, + B10000000000000000000006 /* LaneComponents.swift in Sources */, + B10000000000000000000007 /* LaneCreateSheet.swift in Sources */, + B10000000000000000000008 /* LaneDetailContentSections.swift in Sources */, + B10000000000000000000009 /* LaneDetailGitSection.swift in Sources */, + B1000000000000000000000A /* LaneDetailScreen.swift in Sources */, + B1000000000000000000000B /* LaneDiffScreen.swift in Sources */, + B1000000000000000000000C /* LaneFileTreeComponents.swift in Sources */, + B1000000000000000000000D /* LaneHelpers.swift in Sources */, + B1000000000000000000000E /* LaneListViewParts.swift in Sources */, + B1000000000000000000000F /* LaneManageSheet.swift in Sources */, + B10000000000000000000010 /* LaneSessionTranscriptView.swift in Sources */, + B10000000000000000000011 /* LaneStackGraphSheet.swift in Sources */, + B10000000000000000000012 /* LaneTypes.swift in Sources */, E689F42D41A500BB8CA233E4 /* LanesTabView.swift in Sources */, 889573D8A5ED468D3BD12894 /* PRsTabView.swift in Sources */, 56223CC3AF5A01B710CDC4CF /* WorkTabView.swift in Sources */, diff --git a/apps/ios/ADE/Views/Lanes/LaneAttachSheet.swift b/apps/ios/ADE/Views/Lanes/LaneAttachSheet.swift new file mode 100644 index 000000000..7759913cc --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneAttachSheet.swift @@ -0,0 +1,78 @@ +import SwiftUI + +// MARK: - Attach lane sheet + +struct LaneAttachSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var syncService: SyncService + + let onComplete: @MainActor (String) async -> Void + + @State private var name = "" + @State private var attachedPath = "" + @State private var description = "" + @State private var busy = false + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 14) { + GlassSection(title: "Attach worktree", subtitle: "Register an existing worktree as a lane.") { + VStack(alignment: .leading, spacing: 12) { + LaneTextField("Lane name", text: $name) + LaneTextField("Worktree path", text: $attachedPath) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + LaneTextField("Description", text: $description) + } + } + + if let errorMessage { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ADEColor.danger) + Text(errorMessage) + .font(.caption) + .foregroundStyle(ADEColor.danger) + Spacer() + } + .padding(12) + .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } + .padding(16) + } + .adeScreenBackground() + .adeNavigationGlass() + .navigationTitle("Attach worktree") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(busy) + } + ToolbarItem(placement: .confirmationAction) { + Button("Attach") { + Task { await submit() } + } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || attachedPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || busy) + } + } + } + } + + @MainActor + private func submit() async { + do { + busy = true + errorMessage = nil + let lane = try await syncService.attachLane(name: name, attachedPath: attachedPath, description: description) + await onComplete(lane.id) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + busy = false + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneBatchManageSheet.swift b/apps/ios/ADE/Views/Lanes/LaneBatchManageSheet.swift new file mode 100644 index 000000000..f51037c66 --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneBatchManageSheet.swift @@ -0,0 +1,170 @@ +import SwiftUI + +// MARK: - Batch manage sheet + +struct LaneBatchManageSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var syncService: SyncService + + let snapshots: [LaneListSnapshot] + let onComplete: @MainActor () async -> Void + + @State private var deleteMode: LaneDeleteMode = .worktree + @State private var deleteRemoteName = "origin" + @State private var deleteForce = false + @State private var confirmText = "" + @State private var errorMessage: String? + @State private var busy = false + + private var laneIds: [String] { + snapshots.map(\.lane.id) + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 14) { + GlassSection(title: "Selected lanes (\(laneIds.count))") { + VStack(alignment: .leading, spacing: 8) { + ForEach(snapshots) { snapshot in + HStack(alignment: .center, spacing: 10) { + LaneStatusIndicator(bucket: snapshot.runtime.bucket, size: 8) + VStack(alignment: .leading, spacing: 2) { + Text(snapshot.lane.name) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text(snapshot.lane.branchRef) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ADEColor.textSecondary) + } + Spacer() + if snapshot.lane.status.dirty { + LaneTypeBadge(text: "Dirty", tint: ADEColor.warning) + } + } + } + } + } + + GlassSection(title: "Archive") { + Button { + Task { await archiveSelected() } + } label: { + HStack { + Image(systemName: "archivebox.fill") + Text("Archive selected lanes") + .font(.subheadline.weight(.semibold)) + Spacer() + } + .foregroundStyle(ADEColor.warning) + .padding(12) + .background(ADEColor.warning.opacity(0.1), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(busy || laneIds.isEmpty) + } + + GlassSection(title: "Delete") { + VStack(alignment: .leading, spacing: 12) { + Picker("Delete mode", selection: $deleteMode) { + ForEach(LaneDeleteMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.menu) + + if deleteMode == .remoteBranch { + LaneTextField("Remote name", text: $deleteRemoteName) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + Toggle("Force delete", isOn: $deleteForce) + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + + LaneTextField("Type delete open lanes to confirm", text: $confirmText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button(role: .destructive) { + Task { await deleteSelected() } + } label: { + HStack { + Image(systemName: "trash.fill") + Text("Delete selected lanes") + .font(.subheadline.weight(.semibold)) + Spacer() + } + .padding(12) + .background(ADEColor.danger.opacity(0.1), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(confirmText.lowercased() != "delete open lanes" || busy || laneIds.isEmpty) + } + } + + if let errorMessage { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ADEColor.danger) + Text(errorMessage) + .font(.caption) + .foregroundStyle(ADEColor.danger) + Spacer() + } + .padding(12) + .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } + .padding(16) + } + .adeScreenBackground() + .adeNavigationGlass() + .navigationTitle("Manage lanes") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + .disabled(busy) + } + } + } + } + + @MainActor + private func archiveSelected() async { + do { + busy = true + for laneId in laneIds { + try await syncService.archiveLane(laneId) + } + await onComplete() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + busy = false + } + + @MainActor + private func deleteSelected() async { + do { + busy = true + for laneId in laneIds { + try await syncService.deleteLane( + laneId, + deleteBranch: deleteMode != .worktree, + deleteRemoteBranch: deleteMode == .remoteBranch, + remoteName: deleteRemoteName, + force: deleteForce + ) + } + await onComplete() + dismiss() + } catch { + errorMessage = error.localizedDescription + } + busy = false + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift b/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift new file mode 100644 index 000000000..3efd27f11 --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift @@ -0,0 +1,212 @@ +import SwiftUI + +// MARK: - Chat launch sheet + +struct LaneChatLaunchSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var syncService: SyncService + + let laneId: String + let onComplete: @MainActor (AgentChatSessionSummary) async -> Void + + @State private var provider: String + @State private var models: [AgentChatModelInfo] = [] + @State private var selectedModelId = "" + @State private var selectedReasoningEffort = "" + @State private var busy = false + @State private var errorMessage: String? + + init( + laneId: String, + provider: String, + onComplete: @escaping @MainActor (AgentChatSessionSummary) async -> Void + ) { + self.laneId = laneId + self.onComplete = onComplete + _provider = State(initialValue: provider) + } + + private var selectedModel: AgentChatModelInfo? { + models.first(where: { $0.id == selectedModelId }) + } + + private var providerTitle: String { + provider == "claude" ? "Claude" : "Codex" + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 14) { + GlassSection(title: "Provider") { + VStack(alignment: .leading, spacing: 12) { + Picker("Provider", selection: $provider) { + Text("Codex").tag("codex") + Text("Claude").tag("claude") + } + .pickerStyle(.segmented) + + Text("Session stays lane-scoped.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + } + + GlassSection(title: providerTitle) { + HStack(alignment: .center, spacing: 12) { + Image(systemName: provider == "claude" ? "brain.head.profile" : "sparkle") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(ADEColor.accent) + VStack(alignment: .leading, spacing: 3) { + Text(selectedModel?.displayName ?? "Choose a model") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("Session stays lane-scoped.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + Spacer() + } + } + + if !models.isEmpty { + GlassSection(title: "Model") { + VStack(alignment: .leading, spacing: 12) { + Picker("Model", selection: $selectedModelId) { + ForEach(models) { model in + Text(model.displayName).tag(model.id) + } + } + .pickerStyle(.menu) + + if let selectedModel { + VStack(alignment: .leading, spacing: 8) { + if let description = selectedModel.description, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + HStack(spacing: 6) { + if let family = selectedModel.family, !family.isEmpty { + LaneMicroChip(icon: "circle.grid.2x2.fill", text: family, tint: ADEColor.textSecondary) + } + if selectedModel.supportsReasoning == true { + LaneMicroChip(icon: "brain", text: "Reasoning", tint: ADEColor.accent) + } + if selectedModel.supportsTools == true { + LaneMicroChip(icon: "hammer.fill", text: "Tools", tint: ADEColor.success) + } + } + } + } + } + } + } + + if let reasoningEfforts = selectedModel?.reasoningEfforts, !reasoningEfforts.isEmpty { + GlassSection(title: "Reasoning") { + VStack(alignment: .leading, spacing: 12) { + Picker("Reasoning", selection: $selectedReasoningEffort) { + Text("Default").tag("") + ForEach(reasoningEfforts) { effort in + Text(effort.effort.capitalized).tag(effort.effort) + } + } + .pickerStyle(.segmented) + + if let effort = reasoningEfforts.first(where: { $0.effort == selectedReasoningEffort }) { + Text(effort.description) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + } + } + } + + if let errorMessage { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ADEColor.danger) + Text(errorMessage) + .font(.caption) + .foregroundStyle(ADEColor.danger) + Spacer() + } + .padding(12) + .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + if busy { + HStack(spacing: 10) { + ProgressView() + .tint(ADEColor.accent) + Text("Creating \(providerTitle) chat...") + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + Spacer() + } + .adeGlassCard(cornerRadius: 12, padding: 12) + } + } + .padding(16) + } + .adeScreenBackground() + .adeNavigationGlass() + .navigationTitle("New \(providerTitle) chat") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(busy) + } + ToolbarItem(placement: .confirmationAction) { + Button("Launch") { + Task { await submit() } + } + .disabled(busy || (models.isEmpty == false && selectedModelId.isEmpty)) + } + } + .task(id: provider) { + await loadModels(resetSelection: true) + } + } + } + + @MainActor + private func loadModels(resetSelection: Bool) async { + do { + let loadedModels = try await syncService.listChatModels(provider: provider) + models = loadedModels + if resetSelection || loadedModels.contains(where: { $0.id == selectedModelId }) == false { + if let preferred = loadedModels.first(where: \.isDefault) ?? loadedModels.first { + selectedModelId = preferred.id + selectedReasoningEffort = preferred.reasoningEfforts?.first?.effort ?? "" + } else { + selectedModelId = "" + selectedReasoningEffort = "" + } + } + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + @MainActor + private func submit() async { + do { + busy = true + let session = try await syncService.createChatSession( + laneId: laneId, + provider: provider, + model: selectedModelId, + reasoningEffort: selectedReasoningEffort.isEmpty ? nil : selectedReasoningEffort + ) + await onComplete(session) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + busy = false + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneChatSessionView.swift b/apps/ios/ADE/Views/Lanes/LaneChatSessionView.swift new file mode 100644 index 000000000..d93c8d79f --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneChatSessionView.swift @@ -0,0 +1,150 @@ +import SwiftUI + +// MARK: - Chat session view + +struct LaneChatSessionView: View { + @EnvironmentObject private var syncService: SyncService + let summary: AgentChatSessionSummary + + @State private var transcript: [AgentChatTranscriptEntry] = [] + @State private var composer = "" + @State private var errorMessage: String? + @State private var sending = false + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 14) { + GlassSection(title: summary.title ?? summary.provider.uppercased()) { + HStack(spacing: 8) { + LaneTypeBadge(text: summary.status.uppercased(), tint: summary.status == "active" ? ADEColor.success : ADEColor.textSecondary) + Text(summary.model) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + } + + if let errorMessage { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ADEColor.danger) + Text(errorMessage) + .font(.caption) + .foregroundStyle(ADEColor.danger) + Spacer() + } + .padding(12) + .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + if transcript.isEmpty { + GlassSection(title: "Transcript") { + Text("No chat messages yet.") + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + } + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(transcript) { entry in + VStack(alignment: .leading, spacing: 4) { + Text(entry.role.uppercased()) + .font(.caption2.weight(.bold)) + .foregroundStyle(entry.role == "assistant" ? ADEColor.accent : ADEColor.textMuted) + Text(entry.text) + .font(.body) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + Text(relativeTimestamp(entry.timestamp)) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(entry.role == "assistant" ? ADEColor.accent.opacity(0.08) : ADEColor.surfaceBackground.opacity(0.6)) + ) + } + } + } + + Color.clear + .frame(height: 1) + .id("lane-chat-end") + } + .padding(16) + } + .safeAreaInset(edge: .bottom) { + VStack(spacing: 10) { + HStack(spacing: 10) { + TextField("Send a message", text: $composer, axis: .vertical) + .textFieldStyle(.plain) + .adeInsetField(cornerRadius: 12, padding: 10) + + Button { + Task { + await sendMessage() + withAnimation(.snappy) { + proxy.scrollTo("lane-chat-end", anchor: .bottom) + } + } + } label: { + Image(systemName: sending ? "ellipsis.circle" : "paperplane.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(ADEColor.accent) + .frame(width: 40, height: 40) + .background(ADEColor.accent.opacity(0.15), in: Circle()) + } + .disabled(composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || sending) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(ADEColor.surfaceBackground.opacity(0.08)) + .glassEffect() + } + .onChange(of: transcript.count) { _, _ in + withAnimation(.snappy) { + proxy.scrollTo("lane-chat-end", anchor: .bottom) + } + } + } + .adeScreenBackground() + .adeNavigationGlass() + .navigationTitle(summary.title ?? summary.provider.uppercased()) + .navigationBarTitleDisplayMode(.inline) + .task { + await loadTranscript() + } + } + + @MainActor + private func loadTranscript() async { + do { + transcript = try await syncService.fetchChatTranscript(sessionId: summary.sessionId) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + @MainActor + private func sendMessage() async { + do { + sending = true + let text = composer.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + sending = false + return + } + try await syncService.sendChatMessage(sessionId: summary.sessionId, text: text) + composer = "" + transcript = try await syncService.fetchChatTranscript(sessionId: summary.sessionId) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + sending = false + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneComponents.swift b/apps/ios/ADE/Views/Lanes/LaneComponents.swift new file mode 100644 index 000000000..029bf1add --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneComponents.swift @@ -0,0 +1,445 @@ +import SwiftUI + +// MARK: - Glass section + +struct GlassSection: View { + let title: String + let subtitle: String? + let content: Content + + init(title: String, subtitle: String? = nil, @ViewBuilder content: () -> Content) { + self.title = title + self.subtitle = subtitle + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + } + content + } + .adeGlassCard(cornerRadius: 16, padding: 14) + } +} + +// MARK: - Lane status indicator + +struct LaneStatusIndicator: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + let bucket: String + var size: CGFloat = 10 + + @State private var isPulsing = false + + var body: some View { + Circle() + .fill(runtimeTint(bucket: bucket)) + .frame(width: size, height: size) + .shadow(color: runtimeTint(bucket: bucket).opacity(isAnimating ? 0.5 : 0), radius: isAnimating ? 6 : 0) + .scaleEffect(isPulsing && isAnimating ? 1.3 : 1.0) + .animation(ADEMotion.pulse(reduceMotion: reduceMotion), value: isPulsing) + .onAppear { + if isAnimating { + isPulsing = true + } + } + .onChange(of: isAnimating) { _, animating in + if !animating { isPulsing = false } + } + } + + private var isAnimating: Bool { + (bucket == "running" || bucket == "awaiting-input") && !reduceMotion + } +} + +// MARK: - Type badge + +struct LaneTypeBadge: View { + let text: String + let tint: Color + + var body: some View { + Text(text) + .font(.caption2.weight(.semibold)) + .foregroundStyle(tint) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(tint.opacity(0.12), in: Capsule()) + .glassEffect() + } +} + +// MARK: - Micro chip + +struct LaneMicroChip: View { + let icon: String + let text: String? + let tint: Color + + var body: some View { + HStack(spacing: 3) { + Image(systemName: icon) + .font(.system(size: 8, weight: .semibold)) + if let text { + Text(text) + .font(.system(.caption2).weight(.medium)) + } + } + .foregroundStyle(tint) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(tint.opacity(0.1), in: Capsule()) + .glassEffect() + } +} + +// MARK: - Action button + +struct LaneActionButton: View { + let title: String + let symbol: String + let tint: Color + let action: () -> Void + + init(title: String, symbol: String, tint: Color = ADEColor.textSecondary, action: @escaping () -> Void) { + self.title = title + self.symbol = symbol + self.tint = tint + self.action = action + } + + var body: some View { + Button(action: action) { + HStack(spacing: 5) { + Image(systemName: symbol) + .font(.system(size: 11, weight: .semibold)) + Text(title) + .font(.caption.weight(.medium)) + } + .foregroundStyle(tint) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(tint.opacity(0.1), in: Capsule()) + .glassEffect() + } + .buttonStyle(.plain) + } +} + +// MARK: - Quick action + +struct LaneQuickAction: View { + let title: String + let symbol: String + let tint: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 4) { + Image(systemName: symbol) + .font(.system(size: 16, weight: .medium)) + .symbolRenderingMode(.hierarchical) + Text(title) + .font(.caption2.weight(.medium)) + } + .foregroundStyle(tint) + .frame(width: 64, height: 54) + .background(ADEColor.surfaceBackground.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .glassEffect(in: .rect(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.border.opacity(0.16), lineWidth: 0.5) + ) + } + .buttonStyle(ADEScaleButtonStyle()) + } +} + +// MARK: - Menu label + +struct LaneMenuLabel: View { + let title: String + + var body: some View { + HStack(spacing: 4) { + Text(title) + .font(.caption.weight(.medium)) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .foregroundStyle(ADEColor.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(ADEColor.surfaceBackground.opacity(0.55), in: Capsule()) + .glassEffect() + .overlay( + Capsule() + .stroke(ADEColor.border.opacity(0.16), lineWidth: 0.5) + ) + } +} + +// MARK: - Open chip + +struct LaneOpenChip: View { + let snapshot: LaneListSnapshot + let isPinned: Bool + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(runtimeTint(bucket: snapshot.runtime.bucket)) + .frame(width: 6, height: 6) + Text(snapshot.lane.name) + .font(.caption.weight(.medium)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + if isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 8)) + .foregroundStyle(ADEColor.accent) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(ADEColor.surfaceBackground.opacity(0.55), in: Capsule()) + .glassEffect() + .overlay( + Capsule() + .stroke(ADEColor.border.opacity(0.16), lineWidth: 0.5) + ) + .accessibilityLabel("\(snapshot.lane.name)\(isPinned ? ", pinned" : "")") + } +} + +// MARK: - Launch tile + +struct LaneLaunchTile: View { + let title: String + let symbol: String + let tint: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + Image(systemName: symbol) + .font(.system(size: 18, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + Text(title) + .font(.caption.weight(.medium)) + } + .foregroundStyle(tint) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(ADEColor.surfaceBackground.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .glassEffect(in: .rect(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(tint.opacity(0.14), lineWidth: 0.5) + ) + } + .buttonStyle(ADEScaleButtonStyle()) + .accessibilityLabel("Launch \(title)") + } +} + +// MARK: - Session card + +struct LaneSessionCard: View { + let session: TerminalSessionSummary + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(session.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Spacer() + LaneTypeBadge(text: session.status.uppercased(), tint: session.status == "running" ? ADEColor.success : ADEColor.textSecondary) + } + if let preview = session.lastOutputPreview { + Text(preview) + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(2) + } + } + .adeGlassCard(cornerRadius: 10, padding: 10) + } +} + +// MARK: - Chat card + +struct LaneChatCard: View { + let chat: AgentChatSessionSummary + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(chat.title ?? chat.provider.uppercased()) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Spacer() + LaneTypeBadge(text: chat.status.uppercased(), tint: chat.status == "active" ? ADEColor.success : ADEColor.textSecondary) + } + Text(chat.model) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ADEColor.textSecondary) + if let preview = chat.lastOutputPreview { + Text(preview) + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(2) + } + } + .adeGlassCard(cornerRadius: 10, padding: 10) + } +} + +// MARK: - Info row + +struct LaneInfoRow: View { + let label: String + let value: String + var isMonospaced = false + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Text(label) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .frame(width: 54, alignment: .leading) + Text(value) + .font(isMonospaced ? .system(.caption, design: .monospaced) : .subheadline) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +// MARK: - Text field + +struct LaneTextField: View { + let title: String + @Binding var text: String + + init(_ title: String, text: Binding) { + self.title = title + self._text = text + } + + var body: some View { + TextField(title, text: $text, axis: .vertical) + .textFieldStyle(.plain) + .foregroundStyle(ADEColor.textPrimary) + .adeInsetField() + } +} + +// MARK: - Scale button style + +struct ADEScaleButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.97 : 1.0) + .opacity(configuration.isPressed ? 0.85 : 1.0) + .animation(.snappy(duration: 0.2), value: configuration.isPressed) + } +} + +// MARK: - Lane list row + +struct LaneListRow: View { + let snapshot: LaneListSnapshot + let isPinned: Bool + let isOpen: Bool + + var body: some View { + HStack(spacing: 14) { + LaneStatusIndicator(bucket: snapshot.runtime.bucket) + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text(snapshot.lane.name) + .font(.body.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + if snapshot.lane.laneType == "primary" { + LaneTypeBadge(text: "Primary", tint: ADEColor.accent) + } else if snapshot.lane.laneType == "attached" { + LaneTypeBadge(text: "Attached", tint: ADEColor.textMuted) + } + } + Text(snapshot.lane.branchRef) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + HStack(spacing: 6) { + if snapshot.lane.status.ahead > 0 { + LaneMicroChip(icon: "arrow.up", text: "\(snapshot.lane.status.ahead)", tint: ADEColor.success) + } + if snapshot.lane.status.behind > 0 { + LaneMicroChip(icon: "arrow.down", text: "\(snapshot.lane.status.behind)", tint: ADEColor.warning) + } + if snapshot.runtime.sessionCount > 0 { + LaneMicroChip( + icon: runtimeSymbol(snapshot.runtime.bucket), + text: "\(snapshot.runtime.sessionCount)", + tint: runtimeTint(bucket: snapshot.runtime.bucket) + ) + } + if snapshot.lane.childCount > 0 { + LaneMicroChip(icon: "square.stack.3d.up", text: "\(snapshot.lane.childCount)", tint: ADEColor.textMuted) + } + if isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 9)) + .foregroundStyle(ADEColor.accent) + } + } + if let activity = laneActivitySummary(snapshot) { + Text(activity) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 6) { + lanePriorityBadge(snapshot: snapshot) + } + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + } + .adeGlassCard(cornerRadius: 16, padding: 14) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(isOpen ? ADEColor.accent.opacity(0.35) : ADEColor.border.opacity(0.14), lineWidth: isOpen ? 1 : 0.75) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel(laneRowAccessibilityLabel) + } + + private var laneRowAccessibilityLabel: String { + var parts = [snapshot.lane.name, snapshot.lane.branchRef] + if snapshot.lane.status.dirty { parts.append("dirty") } + if isPinned { parts.append("pinned") } + if isOpen { parts.append("open") } + if snapshot.lane.status.ahead > 0 { parts.append("\(snapshot.lane.status.ahead) ahead") } + if snapshot.lane.status.behind > 0 { parts.append("\(snapshot.lane.status.behind) behind") } + return parts.joined(separator: ", ") + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneCreateSheet.swift b/apps/ios/ADE/Views/Lanes/LaneCreateSheet.swift new file mode 100644 index 000000000..e8f92ca65 --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneCreateSheet.swift @@ -0,0 +1,167 @@ +import SwiftUI + +// MARK: - Create lane sheet + +struct LaneCreateSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var syncService: SyncService + + let primaryLane: LaneSummary? + let lanes: [LaneSummary] + let onComplete: @MainActor (String) async -> Void + + @State private var name = "" + @State private var description = "" + @State private var createAsChild = false + @State private var selectedParentLaneId = "" + @State private var selectedBaseBranch = "" + @State private var templates: [LaneTemplate] = [] + @State private var selectedTemplateId = "" + @State private var branches: [GitBranchSummary] = [] + @State private var errorMessage: String? + @State private var busy = false + @State private var envProgress: LaneEnvInitProgress? + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 14) { + GlassSection(title: "Create lane", subtitle: createAsChild ? "Branches from another ADE lane." : "Branches from the selected base.") { + VStack(alignment: .leading, spacing: 12) { + LaneTextField("Lane name", text: $name) + LaneTextField("Description", text: $description) + } + } + + GlassSection(title: "Branching") { + VStack(alignment: .leading, spacing: 12) { + Toggle("Create as child lane", isOn: $createAsChild) + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + + if createAsChild { + Picker("Parent lane", selection: $selectedParentLaneId) { + Text("Select parent").tag("") + ForEach(lanes.filter { $0.archivedAt == nil }) { lane in + Text("\(lane.name) (\(lane.branchRef))").tag(lane.id) + } + } + .pickerStyle(.menu) + } else { + Picker("Base branch", selection: $selectedBaseBranch) { + ForEach(branches.filter { !$0.isRemote }) { branch in + Text(branch.name).tag(branch.name) + } + } + .pickerStyle(.menu) + } + } + } + + GlassSection(title: "Template") { + Picker("Template", selection: $selectedTemplateId) { + Text("No template").tag("") + ForEach(templates) { template in + Text(template.name).tag(template.id) + } + } + .pickerStyle(.menu) + } + + if let envProgress { + GlassSection(title: "Environment setup") { + VStack(alignment: .leading, spacing: 10) { + ForEach(envProgress.steps) { step in + HStack { + Text(step.label) + .font(.subheadline) + .foregroundStyle(ADEColor.textPrimary) + Spacer() + Text(step.status) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ADEColor.textSecondary) + } + } + } + } + } + + if let errorMessage { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ADEColor.danger) + Text(errorMessage) + .font(.caption) + .foregroundStyle(ADEColor.danger) + Spacer() + } + .padding(12) + .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } + .padding(16) + } + .adeScreenBackground() + .adeNavigationGlass() + .navigationTitle("Create lane") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(busy) + } + ToolbarItem(placement: .confirmationAction) { + Button("Create") { + Task { await submit() } + } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (createAsChild && selectedParentLaneId.isEmpty) || busy) + } + } + .task { + await loadOptions() + } + } + } + + @MainActor + private func loadOptions() async { + do { + templates = try await syncService.fetchLaneTemplates() + selectedTemplateId = try await syncService.fetchDefaultLaneTemplateId() ?? "" + if let primaryLane { + branches = try await syncService.listBranches(laneId: primaryLane.id) + selectedBaseBranch = branches.first(where: { $0.isCurrent })?.name ?? branches.first?.name ?? primaryLane.branchRef + } + } catch { + errorMessage = error.localizedDescription + } + } + + @MainActor + private func submit() async { + do { + busy = true + errorMessage = nil + let created: LaneSummary + if createAsChild { + created = try await syncService.createChildLane(name: name, parentLaneId: selectedParentLaneId, description: description) + } else { + created = try await syncService.createLane( + name: name, + description: description, + parentLaneId: nil, + baseBranch: selectedBaseBranch + ) + } + let progress = selectedTemplateId.isEmpty + ? try await syncService.initializeLaneEnvironment(laneId: created.id) + : try await syncService.applyLaneTemplate(laneId: created.id, templateId: selectedTemplateId) + envProgress = progress + await onComplete(created.id) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + busy = false + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailContentSections.swift b/apps/ios/ADE/Views/Lanes/LaneDetailContentSections.swift new file mode 100644 index 000000000..6bfbc2b2f --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneDetailContentSections.swift @@ -0,0 +1,260 @@ +import SwiftUI + +// MARK: - Header card + +struct LaneDetailHeaderCard: View { + let snapshot: LaneListSnapshot + let detail: LaneDetailPayload? + let linkedPullRequests: [PullRequestListItem] + let isExpanded: Bool + let onToggleExpanded: () -> Void + let onManageTapped: () -> Void + let onStackTapped: () -> Void + let onOpenLinkedPullRequest: (PullRequestListItem) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + headerTopRow + if isExpanded { + VStack(alignment: .leading, spacing: 10) { + detailMetadataRow + statusRow + if let summary = headerSummaryText { + Text(summary) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + if let detail { + stackRow(detail: detail) + } + } + } + } + .adeGlassCard(cornerRadius: 18, padding: 16) + .accessibilityElement(children: .combine) + .accessibilityLabel(headerAccessibilityLabel) + } + + private var headerTopRow: some View { + HStack(alignment: .top, spacing: 10) { + LaneStatusIndicator(bucket: snapshot.runtime.bucket, size: 12) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(detail?.lane.name ?? snapshot.lane.name) + .font(.headline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(2) + + laneTypeBadge + } + + if !isExpanded { + Text(snapshot.lane.branchRef) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 8) { + Button(action: onManageTapped) { + Image(systemName: "gearshape.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.textSecondary) + .padding(8) + .background(ADEColor.surfaceBackground.opacity(0.45), in: Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Manage lane") + + Button(action: onToggleExpanded) { + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ADEColor.textSecondary) + .padding(8) + .background(ADEColor.surfaceBackground.opacity(0.45), in: Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isExpanded ? "Collapse lane header" : "Expand lane header") + } + } + } + + @ViewBuilder + private var detailMetadataRow: some View { + VStack(alignment: .leading, spacing: 4) { + Text(snapshot.lane.branchRef) + .font(.system(.subheadline, design: .monospaced)) + .foregroundStyle(ADEColor.textPrimary) + if snapshot.lane.baseRef != snapshot.lane.branchRef { + Text("from \(snapshot.lane.baseRef)") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + } + } + + private var statusRow: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + laneStatusBadge + if snapshot.lane.status.ahead > 0 { + LaneMicroChip(icon: "arrow.up", text: "\(snapshot.lane.status.ahead) ahead", tint: ADEColor.success) + } + if snapshot.lane.status.behind > 0 { + LaneMicroChip(icon: "arrow.down", text: "\(snapshot.lane.status.behind) behind", tint: ADEColor.warning) + } + if snapshot.lane.childCount > 0 { + LaneMicroChip(icon: "square.stack.3d.up", text: "\(snapshot.lane.childCount) child\(snapshot.lane.childCount == 1 ? "" : "ren")", tint: ADEColor.textMuted) + } + linkedPullRequestBadge + } + } + } + + @ViewBuilder + private func stackRow(detail: LaneDetailPayload) -> some View { + if !detail.stackChain.isEmpty { + Button(action: onStackTapped) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "list.number") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ADEColor.textSecondary) + Text("Stack") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + Spacer() + Text("\(detail.stackChain.count) lane\(detail.stackChain.count == 1 ? "" : "s")") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + } + + VStack(alignment: .leading, spacing: 4) { + ForEach(detail.stackChain.prefix(3)) { item in + HStack(spacing: 8) { + Circle() + .fill(item.laneId == snapshot.lane.id ? ADEColor.accent : runtimeTint(bucket: detail.runtime.bucket)) + .frame(width: 6, height: 6) + .padding(.leading, CGFloat(item.depth) * 10) + Text(item.laneName) + .font(.caption) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + Spacer(minLength: 8) + Text(item.branchRef) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(ADEColor.textSecondary) + } + } + if detail.stackChain.count > 3 { + Text("+ \(detail.stackChain.count - 3) more") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } + } + } + .padding(12) + .background(ADEColor.surfaceBackground.opacity(0.4), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + } + } + + @ViewBuilder + private var laneTypeBadge: some View { + switch snapshot.lane.laneType { + case "primary": + LaneTypeBadge(text: "Primary", tint: ADEColor.accent) + case "attached": + LaneTypeBadge(text: "Attached", tint: ADEColor.textSecondary) + default: + LaneTypeBadge(text: "Worktree", tint: ADEColor.textSecondary) + } + } + + private var laneStatusBadge: some View { + Group { + if let detail, let conflictStatus = detail.conflictStatus, conflictStatus.status == "conflict-active" { + LaneTypeBadge(text: "Conflict", tint: ADEColor.danger) + } else if let detail, let autoRebaseStatus = detail.autoRebaseStatus, autoRebaseStatus.state != "autoRebased" { + LaneTypeBadge(text: "Rebase attention", tint: ADEColor.warning) + } else if snapshot.lane.archivedAt != nil { + LaneTypeBadge(text: "Archived", tint: ADEColor.textMuted) + } else if snapshot.lane.status.dirty { + LaneTypeBadge(text: "Dirty", tint: ADEColor.warning) + } else { + LaneTypeBadge(text: "Clean", tint: ADEColor.success) + } + } + } + + @ViewBuilder + private var linkedPullRequestBadge: some View { + if linkedPullRequests.count == 1, let pr = linkedPullRequests.first { + Button { + onOpenLinkedPullRequest(pr) + } label: { + LaneTypeBadge( + text: "PR", + tint: lanePullRequestTint(pr.state) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Open linked pull request") + } else if linkedPullRequests.count > 1 { + Menu { + ForEach(Array(linkedPullRequests.enumerated()), id: \.offset) { _, pr in + Button(pr.title.isEmpty ? "PR #\(pr.githubPrNumber)" : pr.title) { + onOpenLinkedPullRequest(pr) + } + } + } label: { + LaneTypeBadge( + text: "\(linkedPullRequests.count) PRs", + tint: lanePullRequestTint(linkedPullRequests.first?.state ?? "open") + ) + } + .accessibilityLabel("\(linkedPullRequests.count) linked pull requests") + } + } + + private var headerAccessibilityLabel: String { + var pieces = [snapshot.lane.name, snapshot.lane.branchRef] + if snapshot.lane.status.dirty { + pieces.append("dirty") + } else { + pieces.append("clean") + } + if snapshot.lane.status.ahead > 0 { + pieces.append("\(snapshot.lane.status.ahead) ahead") + } + if snapshot.lane.status.behind > 0 { + pieces.append("\(snapshot.lane.status.behind) behind") + } + if snapshot.lane.childCount > 0 { + pieces.append("\(snapshot.lane.childCount) children") + } + if !linkedPullRequests.isEmpty { + pieces.append("\(linkedPullRequests.count) linked pull request\(linkedPullRequests.count == 1 ? "" : "s")") + } + return pieces.joined(separator: ", ") + } + + private var headerSummaryText: String? { + guard let detail else { return nil } + if let conflictStatus = detail.conflictStatus { + return conflictSummary(conflictStatus) + } + if let autoRebaseStatus = detail.autoRebaseStatus, autoRebaseStatus.state != "autoRebased" { + return autoRebaseStatus.message ?? "Rebase attention required." + } + if let rebaseSuggestion = detail.rebaseSuggestion { + return "Behind parent by \(rebaseSuggestion.behindCount) commit\(rebaseSuggestion.behindCount == 1 ? "" : "s")." + } + return nil + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift b/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift new file mode 100644 index 000000000..a1274f453 --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift @@ -0,0 +1,337 @@ +import SwiftUI +import UIKit + +// MARK: - Git section + +extension LaneDetailScreen { + @ViewBuilder + var gitSections: some View { + if let detail { + VStack(spacing: 14) { + GlassSection(title: "Launch") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + LaneActionButton(title: "Files", symbol: "folder", tint: ADEColor.accent) { + Task { await openFiles() } + } + LaneActionButton(title: "Shell", symbol: "terminal") { + Task { + await performAction("launch shell") { + try await syncService.runQuickCommand(laneId: laneId, title: "Shell", toolType: "shell", tracked: true) + } + } + } + LaneActionButton(title: "Codex", symbol: "sparkle", tint: ADEColor.accent) { + chatLaunchTarget = LaneChatLaunchTarget(provider: "codex") + } + LaneActionButton(title: "Claude", symbol: "brain.head.profile", tint: ADEColor.warning) { + chatLaunchTarget = LaneChatLaunchTarget(provider: "claude") + } + } + } + } + + GlassSection(title: "Sync", subtitle: detail.syncStatus.map(syncSummary)) { + VStack(alignment: .leading, spacing: 12) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + LaneActionButton(title: "Fetch", symbol: "arrow.down.circle") { + Task { await performAction("fetch") { try await syncService.fetchGit(laneId: laneId) } } + } + Menu { + Button("Pull (merge)") { + Task { await performAction("pull merge") { try await syncService.pullGit(laneId: laneId) } } + } + Button("Pull (rebase)") { + Task { await performAction("pull rebase") { try await syncService.syncGit(laneId: laneId, mode: "rebase") } } + } + } label: { + LaneMenuLabel(title: "Pull") + } + LaneActionButton( + title: detail.syncStatus?.hasUpstream == false ? "Publish" : "Push", + symbol: "arrow.up.circle", + tint: ADEColor.accent + ) { + Task { await performAction("push") { try await syncService.pushGit(laneId: laneId) } } + } + Menu { + Button("Force push") { + confirmForcePush = true + } + Divider() + Button("Rebase lane only") { + Task { await performAction("rebase lane") { try await syncService.startLaneRebase(laneId: laneId, scope: "lane_only") } } + } + Button("Rebase lane + descendants") { + Task { await performAction("rebase descendants") { try await syncService.startLaneRebase(laneId: laneId, scope: "lane_and_descendants") } } + } + Button("Rebase and push") { + Task { await performAction("rebase and push") { try await runRebaseAndPush() } } + } + } label: { + LaneMenuLabel(title: "More") + } + } + } + + if let upstreamRef = detail.syncStatus?.upstreamRef { + LaneInfoRow(label: "Upstream", value: upstreamRef, isMonospaced: true) + } + } + } + + GlassSection(title: "Commit") { + VStack(alignment: .leading, spacing: 12) { + TextField("Commit message", text: $commitMessage, axis: .vertical) + .textFieldStyle(.plain) + .adeInsetField() + Toggle("Amend latest commit", isOn: $amendCommit) + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + HStack(spacing: 8) { + LaneActionButton(title: "Generate", symbol: "sparkles") { + Task { + do { + commitMessage = try await syncService.generateCommitMessage(laneId: laneId, amend: amendCommit) + } catch { + errorMessage = error.localizedDescription + } + } + } + LaneActionButton(title: "Commit", symbol: "checkmark.circle.fill", tint: ADEColor.accent) { + let msg = commitMessage + Task { + await performAction("commit") { + try await syncService.commitLane(laneId: laneId, message: msg, amend: amendCommit) + } + if errorMessage == nil { commitMessage = "" } + } + } + } + } + } + + if let diffChanges = detail.diffChanges, !diffChanges.unstaged.isEmpty { + LaneFileTreeSection( + title: "Unstaged files", + subtitle: "\(diffChanges.unstaged.count) file\(diffChanges.unstaged.count == 1 ? "" : "s")", + changes: diffChanges.unstaged, + bulkActionTitle: diffChanges.unstaged.count > 1 ? "Stage all" : nil, + bulkActionSymbol: "plus.circle.fill", + bulkActionTint: ADEColor.accent, + primaryActionTitle: "Stage", + primaryActionSymbol: "plus.circle.fill", + primaryActionTint: ADEColor.accent, + secondaryActionTitle: "Discard", + secondaryActionSymbol: "trash", + secondaryActionTint: ADEColor.danger, + onBulkAction: { + Task { + await performAction("stage all") { + try await syncService.stageAll(laneId: laneId, paths: diffChanges.unstaged.map(\.path)) + } + } + }, + onDiff: { file in + selectedDiffRequest = LaneDiffRequest(laneId: laneId, path: file.path, mode: "unstaged", compareRef: nil, compareTo: nil, title: file.path) + }, + onPrimaryAction: { file in + Task { await performAction("stage file") { try await syncService.stageFile(laneId: laneId, path: file.path) } } + }, + onSecondaryAction: { file in + confirmDiscardFile = file + }, + onOpenFiles: { file in + Task { await openFiles(path: file.path) } + } + ) + } + + if let diffChanges = detail.diffChanges, !diffChanges.staged.isEmpty { + LaneFileTreeSection( + title: "Staged files", + subtitle: "\(diffChanges.staged.count) file\(diffChanges.staged.count == 1 ? "" : "s")", + changes: diffChanges.staged, + bulkActionTitle: diffChanges.staged.count > 1 ? "Unstage all" : nil, + bulkActionSymbol: "minus.circle", + bulkActionTint: ADEColor.warning, + primaryActionTitle: "Unstage", + primaryActionSymbol: "minus.circle", + primaryActionTint: ADEColor.warning, + secondaryActionTitle: "Restore", + secondaryActionSymbol: "trash", + secondaryActionTint: ADEColor.danger, + onBulkAction: { + Task { + await performAction("unstage all") { + try await syncService.unstageAll(laneId: laneId, paths: diffChanges.staged.map(\.path)) + } + } + }, + onDiff: { file in + selectedDiffRequest = LaneDiffRequest(laneId: laneId, path: file.path, mode: "staged", compareRef: nil, compareTo: nil, title: file.path) + }, + onPrimaryAction: { file in + Task { await performAction("unstage file") { try await syncService.unstageFile(laneId: laneId, path: file.path) } } + }, + onSecondaryAction: { file in + Task { await performAction("restore staged file") { try await syncService.restoreStagedFile(laneId: laneId, path: file.path) } } + }, + onOpenFiles: { file in + Task { await openFiles(path: file.path) } + } + ) + } + + if !detail.stashes.isEmpty || canRunLiveActions { + GlassSection(title: "Stashes") { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + TextField("Stash message", text: $stashMessage) + .textFieldStyle(.plain) + .adeInsetField(cornerRadius: 10, padding: 10) + LaneActionButton(title: "Stash", symbol: "tray.and.arrow.down", tint: ADEColor.accent) { + Task { + await performAction("stash") { + try await syncService.stashPush(laneId: laneId, message: stashMessage, includeUntracked: true) + } + if errorMessage == nil { stashMessage = "" } + } + } + } + + ForEach(detail.stashes) { stash in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(stash.subject) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Spacer() + if let createdAt = stash.createdAt { + Text(relativeTimestamp(createdAt)) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } + } + HStack(spacing: 8) { + LaneActionButton(title: "Apply", symbol: "tray.and.arrow.up") { + Task { await performAction("stash apply") { try await syncService.stashApply(laneId: laneId, stashRef: stash.ref) } } + } + LaneActionButton(title: "Pop", symbol: "arrow.up.right.square") { + Task { await performAction("stash pop") { try await syncService.stashPop(laneId: laneId, stashRef: stash.ref) } } + } + LaneActionButton(title: "Drop", symbol: "trash", tint: ADEColor.danger) { + Task { await performAction("stash drop") { try await syncService.stashDrop(laneId: laneId, stashRef: stash.ref) } } + } + } + } + if stash.id != detail.stashes.last?.id { Divider() } + } + } + } + } + + if !detail.recentCommits.isEmpty { + GlassSection(title: "Recent commits") { + VStack(alignment: .leading, spacing: 12) { + ForEach(detail.recentCommits) { commit in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(commit.subject) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(2) + if commit == detail.recentCommits.first { + LaneTypeBadge(text: "HEAD", tint: ADEColor.accent) + } + if commit.parents.count > 1 { + LaneTypeBadge(text: "MERGE", tint: ADEColor.warning) + } + Spacer() + Text(commit.shortSha) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + } + Text("\(commit.authorName) • \(relativeTimestamp(commit.authoredAt))") + .font(.caption2) + .foregroundStyle(ADEColor.textSecondary) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + LaneActionButton(title: "Diff", symbol: "doc.text.magnifyingglass") { + Task { + do { + let files = try await syncService.listCommitFiles(laneId: laneId, commitSha: commit.sha) + guard let path = files.first else { + errorMessage = "This commit has no file diffs." + return + } + selectedDiffRequest = LaneDiffRequest( + laneId: laneId, + path: path, + mode: "commit", + compareRef: commit.sha, + compareTo: "parent", + title: commit.subject + ) + } catch { + errorMessage = error.localizedDescription + } + } + } + LaneActionButton(title: "Copy message", symbol: "doc.on.doc") { + Task { + do { + UIPasteboard.general.string = try await syncService.getCommitMessage(laneId: laneId, commitSha: commit.sha) + } catch { + errorMessage = error.localizedDescription + } + } + } + LaneActionButton(title: "Revert", symbol: "arrow.uturn.backward", tint: ADEColor.warning) { + Task { await performAction("revert commit") { try await syncService.revertCommit(laneId: laneId, commitSha: commit.sha) } } + } + LaneActionButton(title: "Cherry-pick", symbol: "arrow.triangle.merge") { + Task { await performAction("cherry pick") { try await syncService.cherryPickCommit(laneId: laneId, commitSha: commit.sha) } } + } + } + } + } + if commit.id != detail.recentCommits.last?.id { Divider() } + } + } + } + } + + if let conflictState = detail.conflictState, conflictState.inProgress { + GlassSection(title: "Rebase conflict") { + VStack(alignment: .leading, spacing: 12) { + Text("\(conflictState.conflictedFiles.count) conflicted file\(conflictState.conflictedFiles.count == 1 ? "" : "s") in progress.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + + if !conflictState.conflictedFiles.isEmpty { + ForEach(conflictState.conflictedFiles, id: \.self) { path in + Text(path) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ADEColor.textSecondary) + } + } + + HStack(spacing: 8) { + LaneActionButton(title: "Continue", symbol: "play.fill", tint: ADEColor.accent) { + Task { await performAction("rebase continue") { try await syncService.rebaseContinueGit(laneId: laneId) } } + } + .disabled(!conflictState.canContinue) + LaneActionButton(title: "Abort", symbol: "xmark.circle", tint: ADEColor.danger) { + Task { await performAction("rebase abort") { try await syncService.rebaseAbortGit(laneId: laneId) } } + } + .disabled(!conflictState.canAbort) + } + } + } + } + } + } + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift new file mode 100644 index 000000000..274a866fc --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift @@ -0,0 +1,256 @@ +import SwiftUI + +// MARK: - Lane detail screen + +struct LaneDetailScreen: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @EnvironmentObject var syncService: SyncService + + let laneId: String + let initialSnapshot: LaneListSnapshot + let allLaneSnapshots: [LaneListSnapshot] + let onRefreshRoot: @MainActor () async -> Void + + @State var detail: LaneDetailPayload? + @State var errorMessage: String? + @State var busyAction: String? + @State var selectedDiffRequest: LaneDiffRequest? + @State var showStackGraph = false + @State var managePresented = false + @State var chatLaunchTarget: LaneChatLaunchTarget? + @State var lanePullRequests: [PullRequestListItem] = [] + @State var headerExpanded = true + @State var presentManageOnLoad = false + @State var commitMessage = "" + @State var amendCommit = false + @State var stashMessage = "" + @State var confirmForcePush = false + @State var confirmDiscardFile: FileChange? + + init( + laneId: String, + initialSnapshot: LaneListSnapshot, + allLaneSnapshots: [LaneListSnapshot], + initialSection: LaneDetailSection = .git, + onRefreshRoot: @escaping @MainActor () async -> Void + ) { + self.laneId = laneId + self.initialSnapshot = initialSnapshot + self.allLaneSnapshots = allLaneSnapshots + self.onRefreshRoot = onRefreshRoot + _presentManageOnLoad = State(initialValue: initialSection == .manage) + } + + var currentSnapshot: LaneListSnapshot { + allLaneSnapshots.first(where: { $0.lane.id == laneId }) ?? initialSnapshot + } + + var body: some View { + ScrollView { + LazyVStack(spacing: 14) { + if let banner = connectionBanner { banner } + + if let busyAction { + HStack(spacing: 10) { + ProgressView() + .tint(ADEColor.accent) + Text(busyAction.capitalized) + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + Spacer() + } + .adeGlassCard(cornerRadius: 12, padding: 12) + } + + if let errorMessage { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ADEColor.danger) + Text(errorMessage) + .font(.footnote) + .foregroundStyle(ADEColor.danger) + Spacer() + } + .padding(12) + .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + if detail == nil && errorMessage == nil { + ADECardSkeleton(rows: 4) + } + + detailHeader + gitSections + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .adeScreenBackground() + .adeNavigationGlass() + .navigationTitle(detail?.lane.name ?? initialSnapshot.lane.name) + .navigationBarTitleDisplayMode(.inline) + .task { await loadDetail(refreshRemote: true) } + .refreshable { await loadDetail(refreshRemote: true) } + .sheet(item: $selectedDiffRequest) { request in + LaneDiffScreen(request: request) + } + .sheet(isPresented: $showStackGraph) { + LaneStackGraphSheet(snapshots: allLaneSnapshots, selectedLaneId: laneId) + } + .sheet(item: $chatLaunchTarget) { target in + LaneChatLaunchSheet(laneId: laneId, provider: target.provider) { _ in + await loadDetail(refreshRemote: true) + } + } + .alert("Force push?", isPresented: $confirmForcePush) { + Button("Force push", role: .destructive) { + Task { await performAction("force push") { try await syncService.pushGit(laneId: laneId, forceWithLease: true) } } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This rewrites remote history. Other collaborators may lose work.") + } + .alert("Discard changes?", isPresented: Binding( + get: { confirmDiscardFile != nil }, + set: { if !$0 { confirmDiscardFile = nil } } + )) { + Button("Discard", role: .destructive) { + if let file = confirmDiscardFile { + Task { await performAction("discard file") { try await syncService.discardFile(laneId: laneId, path: file.path) } } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Unstaged changes to this file will be permanently lost.") + } + .sheet(isPresented: $managePresented) { + LaneManageSheet( + snapshot: currentSnapshot, + allLaneSnapshots: allLaneSnapshots + ) { + await loadDetail(refreshRemote: true) + } + } + } + + // MARK: - Detail helpers + + @ViewBuilder + var detailHeader: some View { + LaneDetailHeaderCard( + snapshot: currentSnapshot, + detail: detail, + linkedPullRequests: lanePullRequests, + isExpanded: headerExpanded, + onToggleExpanded: { + withAnimation(ADEMotion.emphasis(reduceMotion: reduceMotion)) { + headerExpanded.toggle() + } + }, + onManageTapped: { managePresented = true }, + onStackTapped: { showStackGraph = true }, + onOpenLinkedPullRequest: { pr in + openPullRequest(pr.id) + } + ) + } + + var connectionBanner: ADENoticeCard? { + guard !canRunLiveActions else { return nil } + return ADENoticeCard( + title: "Offline — cached data", + message: "Reconnect to refresh git state and lane actions.", + icon: "icloud.slash", + tint: ADEColor.warning, + actionTitle: syncService.activeHostProfile == nil ? "Pair again" : "Reconnect", + action: { + if syncService.activeHostProfile == nil { + syncService.settingsPresented = true + } else { + Task { + await syncService.reconnectIfPossible() + await loadDetail(refreshRemote: true) + } + } + } + ) + } + + var canRunLiveActions: Bool { + syncService.connectionState == .connected || syncService.connectionState == .syncing + } + + @MainActor + func loadDetail(refreshRemote: Bool) async { + let shouldPresentManageSheet = presentManageOnLoad && !managePresented + do { + if let cached = try await syncService.fetchLaneDetail(laneId: laneId) { + detail = cached + } + if refreshRemote { + let refreshed = try await syncService.refreshLaneDetail(laneId: laneId) + detail = refreshed + await onRefreshRoot() + } + lanePullRequests = (try? await syncService.fetchPullRequestListItems().filter { $0.laneId == laneId }) ?? [] + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + if shouldPresentManageSheet { + managePresented = true + presentManageOnLoad = false + } + } + + @MainActor + func performAction(_ label: String, operation: () async throws -> Void) async { + do { + busyAction = label + try await operation() + await loadDetail(refreshRemote: true) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + busyAction = nil + } + + func runRebaseAndPush() async throws { + try await syncService.startLaneRebase(laneId: laneId, scope: "lane_only", pushMode: "none") + try? await syncService.fetchGit(laneId: laneId) + let syncStatus = try await syncService.fetchSyncStatus(laneId: laneId) + if syncStatus.hasUpstream == false { + try await syncService.pushGit(laneId: laneId) + return + } + if syncStatus.diverged && syncStatus.ahead > 0 { + try await syncService.pushGit(laneId: laneId, forceWithLease: true) + return + } + if syncStatus.ahead > 0 { + try await syncService.pushGit(laneId: laneId) + } + } + + func openPullRequest(_ prId: String) { + syncService.requestedPrNavigation = PrNavigationRequest(prId: prId) + } + + @MainActor + func openFiles(path: String? = nil) async { + do { + let workspaces = try await syncService.listWorkspaces() + guard let workspace = workspaces.first(where: { $0.laneId == laneId }) else { + errorMessage = "No Files workspace for this lane." + return + } + syncService.requestedFilesNavigation = FilesNavigationRequest( + workspaceId: workspace.id, + relativePath: path + ) + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift new file mode 100644 index 000000000..1a977e1a0 --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift @@ -0,0 +1,170 @@ +import SwiftUI + +// MARK: - Diff screen + +struct LaneDiffScreen: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var syncService: SyncService + + let request: LaneDiffRequest + + @State private var diff: FileDiff? + @State private var editedText = "" + @State private var errorMessage: String? + @State private var side = "modified" + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 14) { + GlassSection(title: request.title) { + VStack(alignment: .leading, spacing: 8) { + if let path = request.path { + Text(path) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ADEColor.textSecondary) + } + if let compareRef = request.compareRef, !compareRef.isEmpty { + LaneInfoRow(label: "Base", value: compareRef, isMonospaced: true) + } + if let compareTo = request.compareTo, !compareTo.isEmpty { + LaneInfoRow(label: "Against", value: compareTo, isMonospaced: true) + } + } + } + + if let errorMessage { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ADEColor.danger) + Text(errorMessage) + .font(.caption) + .foregroundStyle(ADEColor.danger) + Spacer() + } + .padding(12) + .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + if diff != nil { + Picker("Side", selection: $side) { + Text("Original").tag("original") + Text("Modified").tag("modified") + } + .pickerStyle(.segmented) + } + } + .padding(16) + } + + if let diff { + if diff.isBinary == true { + GlassSection(title: "Binary diff") { + Text("Binary content is view-only on iPhone.") + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + } + .padding(.horizontal, 16) + } else { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(side == "original" ? "Original" : "Modified") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + Spacer() + if request.mode == "unstaged" && side == "modified" { + Text("Editable") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + } + } + TextEditor(text: Binding( + get: { + side == "original" ? diff.original.text : editedText + }, + set: { newValue in + editedText = newValue + } + )) + .font(.system(.footnote, design: .monospaced)) + .scrollContentBackground(.hidden) + .adeInsetField(cornerRadius: 14, padding: 12) + .disabled(side == "original") + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } else { + Spacer() + ProgressView() + .tint(ADEColor.accent) + Spacer() + } + } + .adeScreenBackground() + .adeNavigationGlass() + .navigationTitle(request.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + if request.mode == "unstaged", let path = request.path, side == "modified" { + Button("Save") { + Task { + do { + try await syncService.writeLaneFileText(laneId: request.laneId, path: path, text: editedText) + try await load() + } catch { + errorMessage = error.localizedDescription + } + } + } + } + } + ToolbarItem(placement: .topBarTrailing) { + if let path = request.path { + Button("Files") { + Task { + do { + let workspaces = try await syncService.listWorkspaces() + guard let workspace = workspaces.first(where: { $0.laneId == request.laneId }) else { return } + syncService.requestedFilesNavigation = FilesNavigationRequest( + workspaceId: workspace.id, + relativePath: path + ) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } + } + } + } + } + .task { + do { + try await load() + } catch { + errorMessage = error.localizedDescription + } + } + } + } + + @MainActor + private func load() async throws { + guard let path = request.path else { return } + let loaded = try await syncService.fetchFileDiff( + laneId: request.laneId, + path: path, + mode: request.mode, + compareRef: request.compareRef, + compareTo: request.compareTo + ) + diff = loaded + editedText = loaded.modified.text + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneFileTreeComponents.swift b/apps/ios/ADE/Views/Lanes/LaneFileTreeComponents.swift new file mode 100644 index 000000000..f2b8d5291 --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneFileTreeComponents.swift @@ -0,0 +1,275 @@ +import SwiftUI + +// MARK: - File tree section + +struct LaneFileTreeSection: View { + let title: String + let subtitle: String? + let changes: [FileChange] + let bulkActionTitle: String? + let bulkActionSymbol: String + let bulkActionTint: Color + let primaryActionTitle: String + let primaryActionSymbol: String + let primaryActionTint: Color + let secondaryActionTitle: String + let secondaryActionSymbol: String + let secondaryActionTint: Color + let onBulkAction: (() -> Void)? + let onDiff: (FileChange) -> Void + let onPrimaryAction: (FileChange) -> Void + let onSecondaryAction: (FileChange) -> Void + let onOpenFiles: ((FileChange) -> Void)? + + @State private var collapsedPaths = Set() + + var body: some View { + GlassSection(title: title, subtitle: subtitle) { + VStack(alignment: .leading, spacing: 12) { + if let bulkActionTitle, let onBulkAction, changes.count > 1 { + LaneActionButton(title: bulkActionTitle, symbol: bulkActionSymbol, tint: bulkActionTint) { + onBulkAction() + } + } + + if changes.isEmpty { + Text("No files.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } else { + let root = laneFileTreeRoot(from: changes) + LaneFileTreeNodeView( + node: root, + collapsedPaths: $collapsedPaths, + onDiff: onDiff, + onPrimaryAction: onPrimaryAction, + onSecondaryAction: onSecondaryAction, + onOpenFiles: onOpenFiles, + primaryActionTitle: primaryActionTitle, + primaryActionSymbol: primaryActionSymbol, + primaryActionTint: primaryActionTint, + secondaryActionTitle: secondaryActionTitle, + secondaryActionSymbol: secondaryActionSymbol, + secondaryActionTint: secondaryActionTint + ) + } + } + } + } +} + +private struct LaneFileTreeNode: Identifiable { + let path: String + let name: String + var files: [FileChange] + var children: [LaneFileTreeNode] + + var id: String { path.isEmpty ? "__root__" : path } + + var totalFileCount: Int { + files.count + children.reduce(0) { $0 + $1.totalFileCount } + } +} + +private func laneFileTreeRoot(from changes: [FileChange]) -> LaneFileTreeNode { + let sortedChanges = changes.sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } + var root = LaneFileTreeNode(path: "", name: "Root", files: [], children: []) + + for change in sortedChanges { + let components = change.path.split(separator: "/").map(String.init) + guard components.count > 1 else { + root.files.append(change) + continue + } + insert(change, components: Array(components.dropLast()), into: &root) + } + + root.children = root.children.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + return root +} + +private func insert(_ change: FileChange, components: [String], into node: inout LaneFileTreeNode) { + guard let first = components.first else { + node.files.append(change) + node.files.sort { $0.path.localizedStandardCompare($1.path) == .orderedAscending } + return + } + + if let index = node.children.firstIndex(where: { $0.name == first }) { + var child = node.children[index] + if components.count == 1 { + child.files.append(change) + child.files.sort { $0.path.localizedStandardCompare($1.path) == .orderedAscending } + } else { + insert(change, components: Array(components.dropFirst()), into: &child) + } + child.children = child.children.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + node.children[index] = child + } else { + let childPath = node.path.isEmpty ? first : "\(node.path)/\(first)" + var child = LaneFileTreeNode(path: childPath, name: first, files: [], children: []) + if components.count == 1 { + child.files.append(change) + } else { + insert(change, components: Array(components.dropFirst()), into: &child) + } + node.children.append(child) + node.children = node.children.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } +} + +private struct LaneFileTreeNodeView: View { + let node: LaneFileTreeNode + @Binding var collapsedPaths: Set + let onDiff: (FileChange) -> Void + let onPrimaryAction: (FileChange) -> Void + let onSecondaryAction: (FileChange) -> Void + let onOpenFiles: ((FileChange) -> Void)? + let primaryActionTitle: String + let primaryActionSymbol: String + let primaryActionTint: Color + let secondaryActionTitle: String + let secondaryActionSymbol: String + let secondaryActionTint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if !node.files.isEmpty { + ForEach(node.files) { file in + let openFilesAction: (() -> Void)? = onOpenFiles.map { handler in + { handler(file) } + } + LaneFileRow( + file: file, + onDiff: { onDiff(file) }, + onPrimaryAction: { onPrimaryAction(file) }, + onSecondaryAction: { onSecondaryAction(file) }, + onOpenFiles: openFilesAction, + primaryActionTitle: primaryActionTitle, + primaryActionSymbol: primaryActionSymbol, + primaryActionTint: primaryActionTint, + secondaryActionTitle: secondaryActionTitle, + secondaryActionSymbol: secondaryActionSymbol, + secondaryActionTint: secondaryActionTint + ) + } + } + + ForEach(node.children) { child in + DisclosureGroup(isExpanded: binding(for: child.id)) { + LaneFileTreeNodeView( + node: child, + collapsedPaths: $collapsedPaths, + onDiff: onDiff, + onPrimaryAction: onPrimaryAction, + onSecondaryAction: onSecondaryAction, + onOpenFiles: onOpenFiles, + primaryActionTitle: primaryActionTitle, + primaryActionSymbol: primaryActionSymbol, + primaryActionTint: primaryActionTint, + secondaryActionTitle: secondaryActionTitle, + secondaryActionSymbol: secondaryActionSymbol, + secondaryActionTint: secondaryActionTint + ) + .padding(.top, 8) + } label: { + HStack(spacing: 8) { + Image(systemName: "folder.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ADEColor.warning) + Text(child.name) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("\(child.totalFileCount)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ADEColor.surfaceBackground.opacity(0.45), in: Capsule()) + Spacer() + } + .contentShape(Rectangle()) + } + .tint(ADEColor.textSecondary) + } + } + .padding(12) + .background(ADEColor.surfaceBackground.opacity(0.24), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + private func binding(for path: String) -> Binding { + Binding( + get: { !collapsedPaths.contains(path) }, + set: { isExpanded in + if isExpanded { + collapsedPaths.remove(path) + } else { + collapsedPaths.insert(path) + } + } + ) + } +} + +private struct LaneFileRow: View { + let file: FileChange + let onDiff: () -> Void + let onPrimaryAction: () -> Void + let onSecondaryAction: () -> Void + let onOpenFiles: (() -> Void)? + let primaryActionTitle: String + let primaryActionSymbol: String + let primaryActionTint: Color + let secondaryActionTitle: String + let secondaryActionSymbol: String + let secondaryActionTint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(fileKindTint(file.kind)) + .frame(width: 6, height: 6) + .padding(.top, 7) + VStack(alignment: .leading, spacing: 2) { + Text((file.path as NSString).lastPathComponent) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + Text(file.kind.capitalized) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } + Spacer() + } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + LaneActionButton(title: "Diff", symbol: "doc.text.magnifyingglass") { onDiff() } + if let onOpenFiles { + LaneActionButton(title: "Files", symbol: "folder") { onOpenFiles() } + } + LaneActionButton(title: primaryActionTitle, symbol: primaryActionSymbol, tint: primaryActionTint) { + onPrimaryAction() + } + LaneActionButton(title: secondaryActionTitle, symbol: secondaryActionSymbol, tint: secondaryActionTint) { + onSecondaryAction() + } + } + } + } + .adeGlassCard(cornerRadius: 10, padding: 10) + } + + private func fileKindTint(_ kind: String) -> Color { + switch kind.lowercased() { + case "added", "created": + return ADEColor.success + case "deleted", "removed": + return ADEColor.danger + case "renamed", "moved": + return ADEColor.accent + default: + return ADEColor.warning + } + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift new file mode 100644 index 000000000..9134c6519 --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift @@ -0,0 +1,289 @@ +import SwiftUI + +// MARK: - Utility functions + +@ViewBuilder +func lanePriorityBadge(snapshot: LaneListSnapshot) -> some View { + if snapshot.autoRebaseStatus?.state == "rebaseConflict" { + LaneTypeBadge(text: "Conflict", tint: ADEColor.danger) + } else if snapshot.lane.status.dirty { + LaneTypeBadge(text: "Dirty", tint: ADEColor.warning) + } else if snapshot.runtime.bucket == "running" { + LaneTypeBadge(text: "Running", tint: ADEColor.success) + } else if snapshot.runtime.bucket == "awaiting-input" { + LaneTypeBadge(text: "Attention", tint: ADEColor.warning) + } else if snapshot.lane.archivedAt != nil { + LaneTypeBadge(text: "Archived", tint: ADEColor.textMuted) + } else if let rebaseSuggestion = snapshot.rebaseSuggestion { + LaneTypeBadge(text: "\(rebaseSuggestion.behindCount)\u{2193}", tint: ADEColor.warning) + } else { + EmptyView() + } +} + +func laneActivitySummary(_ snapshot: LaneListSnapshot) -> String? { + if let agentText = summarizeState(snapshot.stateSnapshot?.agentSummary) { + return agentText + } + if let missionText = summarizeState(snapshot.stateSnapshot?.missionSummary) { + return missionText + } + return nil +} + +func laneListFilteredSnapshots( + _ snapshots: [LaneListSnapshot], + scope: LaneListScope, + runtimeFilter: LaneRuntimeFilter, + searchText: String, + pinnedLaneIds: Set +) -> [LaneListSnapshot] { + snapshots + .filter { snapshot in + switch scope { + case .active: + return snapshot.lane.archivedAt == nil + case .archived: + return snapshot.lane.archivedAt != nil + case .all: + return true + } + } + .filter { snapshot in + runtimeFilter == .all || snapshot.runtime.bucket == runtimeFilter.rawValue + } + .filter { snapshot in + laneMatchesSearch(snapshot: snapshot, isPinned: pinnedLaneIds.contains(snapshot.lane.id), query: searchText) + } + .sorted(by: laneListSortSnapshots) +} + +func laneListSortSnapshots(_ lhs: LaneListSnapshot, _ rhs: LaneListSnapshot) -> Bool { + if lhs.lane.laneType == "primary" && rhs.lane.laneType != "primary" { return true } + if lhs.lane.laneType != "primary" && rhs.lane.laneType == "primary" { return false } + if lhs.lane.createdAt != rhs.lane.createdAt { + return lhs.lane.createdAt > rhs.lane.createdAt + } + return lhs.lane.name.localizedCaseInsensitiveCompare(rhs.lane.name) == .orderedAscending +} + +func laneScopeCount(_ snapshots: [LaneListSnapshot], scope: LaneListScope) -> Int { + snapshots.filter { snapshot in + switch scope { + case .active: + return snapshot.lane.archivedAt == nil + case .archived: + return snapshot.lane.archivedAt != nil + case .all: + return true + } + }.count +} + +func laneRuntimeCount(_ snapshots: [LaneListSnapshot], filter: LaneRuntimeFilter) -> Int { + if filter == .all { + return snapshots.count + } + return snapshots.filter { $0.runtime.bucket == filter.rawValue }.count +} + +func laneListEmptyStateTitle(scope: LaneListScope) -> String { + switch scope { + case .active: return "No active lanes" + case .archived: return "No archived lanes" + case .all: return "No lanes" + } +} + +func laneListEmptyStateMessage(scope: LaneListScope, searchText: String, hasFilters: Bool) -> String { + if !searchText.isEmpty { + return "Try a different search or clear the filter." + } + if hasFilters { + return "Try clearing the current filters." + } + switch scope { + case .active: return "Create a new lane or connect to a host." + case .archived: return "Archived lanes will appear here." + case .all: return "No lanes yet. Create a lane or connect to a host." + } +} + +func laneMatchesSearch(snapshot: LaneListSnapshot, isPinned: Bool, query: String) -> Bool { + let tokens = query + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .split(whereSeparator: \.isWhitespace) + .map(String.init) + guard !tokens.isEmpty else { return true } + return tokens.allSatisfy { token in + matchesLaneToken(snapshot: snapshot, isPinned: isPinned, token: token) + } +} + +func matchesLaneToken(snapshot: LaneListSnapshot, isPinned: Bool, token: String) -> Bool { + if token.hasPrefix("is:") { + switch String(token.dropFirst(3)) { + case "dirty": return snapshot.lane.status.dirty + case "clean": return !snapshot.lane.status.dirty + case "pinned": return isPinned + case "primary": return snapshot.lane.laneType == "primary" + case "worktree": return snapshot.lane.laneType == "worktree" + case "attached": return snapshot.lane.laneType == "attached" + default: return false + } + } + if token.hasPrefix("type:") { + return snapshot.lane.laneType.lowercased() == String(token.dropFirst(5)) + } + let indexed = [ + snapshot.lane.name, + snapshot.lane.branchRef, + snapshot.lane.baseRef, + snapshot.lane.laneType, + snapshot.lane.description ?? "", + snapshot.lane.worktreePath, + snapshot.lane.archivedAt == nil ? "active" : "archived", + snapshot.lane.status.dirty ? "dirty modified changed" : "clean", + "ahead \(snapshot.lane.status.ahead)", + "behind \(snapshot.lane.status.behind)", + "\(snapshot.lane.status.ahead)", + "\(snapshot.lane.status.behind)", + snapshot.runtime.bucket, + "\(snapshot.runtime.sessionCount)", + summarizeState(snapshot.stateSnapshot?.agentSummary) ?? "", + summarizeState(snapshot.stateSnapshot?.missionSummary) ?? "", + isPinned ? "pinned" : "", + ].joined(separator: " ").lowercased() + return indexed.contains(token) +} + +func summarizeState(_ summary: [String: RemoteJSONValue]?) -> String? { + guard let summary else { return nil } + let preferredKeys = [ + "summary", "status", "state", "label", "title", "objective", + "stepLabel", "step", "name", "agent", "agentName", "assignee", + ] + for key in preferredKeys { + if let value = flattenedString(summary[key]) { + return value + } + } + for value in summary.values { + if let flattened = flattenedString(value) { + return flattened + } + } + return nil +} + +func flattenedString(_ value: RemoteJSONValue?) -> String? { + guard let value else { return nil } + switch value { + case .string(let string): + return string + case .number(let number): + return String(number) + case .bool(let bool): + return bool ? "true" : "false" + case .array(let values): + return values.compactMap(flattenedString).first + case .object(let object): + return summarizeState(object) + case .null: + return nil + } +} + +func runtimeTint(bucket: String) -> Color { + switch bucket { + case "running": + return ADEColor.success + case "awaiting-input": + return ADEColor.warning + case "ended": + return ADEColor.textMuted + default: + return ADEColor.textSecondary + } +} + +func lanePullRequestTint(_ state: String) -> Color { + switch state { + case "open": + return ADEColor.success + case "draft": + return ADEColor.warning + case "closed": + return ADEColor.danger + case "merged": + return ADEColor.accent + default: + return ADEColor.textSecondary + } +} + +func runtimeSymbol(_ bucket: String) -> String { + switch bucket { + case "running": + return "waveform.path.ecg" + case "awaiting-input": + return "exclamationmark.bubble" + case "ended": + return "stop.circle" + default: + return "circle" + } +} + +private let cachedISO8601Formatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f +}() + +private let cachedRelativeDateFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f +}() + +func relativeTimestamp(_ timestamp: String?) -> String { + guard let timestamp else { return "Unknown" } + guard let date = cachedISO8601Formatter.date(from: timestamp) + ?? ISO8601DateFormatter().date(from: timestamp) else { + return "Unknown" + } + return cachedRelativeDateFormatter.localizedString(for: date, relativeTo: Date()) +} + +func syncSummary(_ status: GitUpstreamSyncStatus) -> String { + if !status.hasUpstream { + return "No upstream. Publish to create a remote branch." + } + if status.diverged { + return "Diverged. Rebase or pull before pushing." + } + if status.ahead > 0 && status.behind == 0 { + return "Ahead by \(status.ahead). Push to publish." + } + if status.behind > 0 && status.ahead == 0 { + return "Behind by \(status.behind). Pull to catch up." + } + return "In sync with remote." +} + +func conflictSummary(_ status: ConflictStatus) -> String { + switch status.status { + case "conflict-active": + return "\(status.overlappingFileCount) overlapping file(s) in active conflict." + case "conflict-predicted": + return "\(status.overlappingFileCount) overlapping file(s) predicted across \(status.peerConflictCount) peer(s)." + case "behind-base": + return "Behind base. Rebase before merging." + case "merge-ready": + return "Conflict prediction clear. Merge-ready." + default: + return "Conflict status available from host." + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift b/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift new file mode 100644 index 000000000..1a46755db --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift @@ -0,0 +1,435 @@ +import SwiftUI +import UIKit + +extension LanesTabView { + var filteredSnapshots: [LaneListSnapshot] { + laneListFilteredSnapshots( + laneSnapshots, + scope: scope, + runtimeFilter: runtimeFilter, + searchText: searchText, + pinnedLaneIds: pinnedLaneIds + ) + } + + var visibleSuggestions: [LaneListSnapshot] { + filteredSnapshots.filter { $0.rebaseSuggestion != nil } + } + + var visibleAutoRebaseAttention: [LaneListSnapshot] { + filteredSnapshots.filter { snapshot in + guard let status = snapshot.autoRebaseStatus else { return false } + return status.state != "autoRebased" + } + } + + var primaryLane: LaneSummary? { + laneSnapshots.first(where: { $0.lane.laneType == "primary" })?.lane + } + + var manageableVisibleLaneIds: [String] { + filteredSnapshots + .map(\.lane) + .filter { $0.laneType != "primary" } + .map(\.id) + } + + var openLaneSnapshots: [LaneListSnapshot] { + openLaneIds.compactMap { laneId in + laneSnapshots.first(where: { $0.lane.id == laneId }) + } + } + + var statusNotice: ADENoticeCard? { + switch laneStatus.phase { + case .disconnected: + return ADENoticeCard( + title: laneSnapshots.isEmpty ? "Host disconnected" : "Showing cached lanes", + message: laneSnapshots.isEmpty + ? (syncService.activeHostProfile == nil + ? "Pair with a host to load the current lane graph." + : "Reconnect to load the current lane graph from the host.") + : (needsRepairing + ? "Cached data shown. Re-pair to verify the lane graph." + : "Cached data available. Reconnect to refresh."), + icon: "bolt.horizontal.circle", + tint: ADEColor.warning, + actionTitle: syncService.activeHostProfile == nil ? (needsRepairing ? "Pair again" : "Pair with host") : "Reconnect", + action: { + if syncService.activeHostProfile == nil { + syncService.settingsPresented = true + } else { + Task { + await syncService.reconnectIfPossible() + await reload(refreshRemote: true) + } + } + } + ) + case .hydrating: + return ADENoticeCard( + title: "Hydrating lane graph", + message: "Pulling lane snapshots from the host.", + icon: "arrow.trianglehead.2.clockwise.rotate.90", + tint: ADEColor.accent, + actionTitle: nil, + action: nil + ) + case .syncingInitialData: + return ADENoticeCard( + title: "Syncing initial data", + message: "Waiting for host to finish syncing before lane graph loads.", + icon: "arrow.trianglehead.2.clockwise.rotate.90", + tint: ADEColor.warning, + actionTitle: nil, + action: nil + ) + case .failed: + return ADENoticeCard( + title: "Lane hydration failed", + message: laneStatus.lastError ?? "Lane hydration did not complete.", + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: "Retry", + action: { Task { await reload(refreshRemote: true) } } + ) + case .ready: + return nil + } + } + + var primaryBranchNotice: ADENoticeCard? { + guard let primaryBranchError else { return nil } + return ADENoticeCard( + title: "Primary branch update failed", + message: primaryBranchError, + icon: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + actionTitle: "Retry", + action: { Task { await reload(refreshRemote: false) } } + ) + } + + @ViewBuilder + var openLanesTray: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("Open lanes", systemImage: "square.stack.3d.up.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + Spacer() + Button { + withAnimation(ADEMotion.emphasis(reduceMotion: reduceMotion)) { + openLaneIds = openLaneIds.filter { pinnedLaneIds.contains($0) } + } + } label: { + Text("Clear") + .font(.caption.weight(.medium)) + .foregroundStyle(ADEColor.textMuted) + } + } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(openLaneSnapshots) { snapshot in + NavigationLink { + LaneDetailScreen( + laneId: snapshot.lane.id, + initialSnapshot: snapshot, + allLaneSnapshots: laneSnapshots, + onRefreshRoot: { await reload(refreshRemote: true) } + ) + } label: { + LaneOpenChip(snapshot: snapshot, isPinned: pinnedLaneIds.contains(snapshot.lane.id)) + } + .buttonStyle(.plain) + .contextMenu { + Button("Manage lane") { + detailSheetTarget = LaneDetailSheetTarget( + laneId: snapshot.lane.id, + snapshot: snapshot, + initialSection: .manage + ) + } + Button(pinnedLaneIds.contains(snapshot.lane.id) ? "Unpin" : "Pin") { + togglePin(snapshot.lane.id) + } + Button("Remove from open lanes") { + closeLaneChip(snapshot.lane.id) + } + Button("Close others") { + openLaneIds = [snapshot.lane.id] + } + } + } + } + } + } + .adeGlassCard(cornerRadius: 14, padding: 12) + } + + @ViewBuilder + var attentionSection: some View { + VStack(spacing: 10) { + ForEach(visibleSuggestions.prefix(3)) { snapshot in + HStack(spacing: 12) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.warning) + VStack(alignment: .leading, spacing: 2) { + Text(snapshot.lane.name) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("Behind parent by \(snapshot.rebaseSuggestion?.behindCount ?? 0) commit(s)") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + Spacer(minLength: 8) + Button("Rebase") { + Task { + do { + try await syncService.startLaneRebase(laneId: snapshot.lane.id) + await reload(refreshRemote: true) + } catch { + errorMessage = error.localizedDescription + } + } + } + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .disabled(!canRunLiveActions) + Menu { + Button("Defer") { + Task { + do { + try await syncService.deferRebaseSuggestion(laneId: snapshot.lane.id) + await reload(refreshRemote: true) + } catch { + errorMessage = error.localizedDescription + } + } + } + Button("Dismiss") { + Task { + do { + try await syncService.dismissRebaseSuggestion(laneId: snapshot.lane.id) + await reload(refreshRemote: true) + } catch { + errorMessage = error.localizedDescription + } + } + } + } label: { + Image(systemName: "ellipsis.circle") + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + } + } + .padding(12) + .background(ADEColor.warning.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.warning.opacity(0.2), lineWidth: 0.5) + ) + } + + ForEach(visibleAutoRebaseAttention.prefix(3)) { snapshot in + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(snapshot.autoRebaseStatus?.state == "rebaseConflict" ? ADEColor.danger : ADEColor.warning) + VStack(alignment: .leading, spacing: 2) { + Text(snapshot.lane.name) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text(snapshot.autoRebaseStatus?.message ?? "Manual follow-up required") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(2) + } + Spacer(minLength: 8) + Button("Open") { + detailSheetTarget = LaneDetailSheetTarget( + laneId: snapshot.lane.id, + snapshot: snapshot, + initialSection: .git + ) + } + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + } + .padding(12) + .background(ADEColor.danger.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.danger.opacity(0.15), lineWidth: 0.5) + ) + } + } + } + + @ViewBuilder + var laneList: some View { + if filteredSnapshots.isEmpty { + ADEEmptyStateView( + symbol: "square.stack.3d.up.slash", + title: laneListEmptyStateTitle(scope: scope), + message: laneListEmptyStateMessage(scope: scope, searchText: searchText, hasFilters: scope != .active || runtimeFilter != .all) + ) + .padding(.top, 40) + } else { + ForEach(filteredSnapshots) { snapshot in + NavigationLink { + LaneDetailScreen( + laneId: snapshot.lane.id, + initialSnapshot: snapshot, + allLaneSnapshots: laneSnapshots, + onRefreshRoot: { await reload(refreshRemote: true) } + ) + } label: { + LaneListRow( + snapshot: snapshot, + isPinned: pinnedLaneIds.contains(snapshot.lane.id), + isOpen: openLaneIds.contains(snapshot.lane.id) + ) + } + .buttonStyle(ADEScaleButtonStyle()) + .contextMenu { + Button("Manage lane") { + detailSheetTarget = LaneDetailSheetTarget( + laneId: snapshot.lane.id, + snapshot: snapshot, + initialSection: .manage + ) + } + Button(openLaneIds.contains(snapshot.lane.id) ? "Remove from open lanes" : "Add to open lanes") { + toggleOpenLane(snapshot.lane.id) + } + Button(pinnedLaneIds.contains(snapshot.lane.id) ? "Unpin" : "Pin") { + togglePin(snapshot.lane.id) + } + Button("Close others") { + openLaneIds = [snapshot.lane.id] + } + Button("Select all visible") { + batchManageLaneIds = manageableVisibleLaneIds + batchManagePresented = !manageableVisibleLaneIds.isEmpty + } + if manageableVisibleLaneIds.count > 1 { + Button("Manage \(manageableVisibleLaneIds.count) visible lanes") { + batchManageLaneIds = manageableVisibleLaneIds + batchManagePresented = true + } + } + if snapshot.lane.archivedAt == nil && snapshot.lane.laneType != "primary" { + Button("Archive", role: .destructive) { + Task { + do { + try await syncService.archiveLane(snapshot.lane.id) + await reload(refreshRemote: true) + } catch { + errorMessage = error.localizedDescription + } + } + } + } else if snapshot.lane.archivedAt != nil { + Button("Restore") { + Task { + do { + try await syncService.unarchiveLane(snapshot.lane.id) + await reload(refreshRemote: true) + } catch { + errorMessage = error.localizedDescription + } + } + } + } + Button("Copy path") { + UIPasteboard.general.string = snapshot.lane.worktreePath + } + if snapshot.adoptableAttached { + Button("Move to ADE-managed worktree") { + Task { + do { + _ = try await syncService.adoptAttachedLane(snapshot.lane.id) + await reload(refreshRemote: true) + } catch { + errorMessage = error.localizedDescription + } + } + } + } + } + } + } + } + + @MainActor + func reload(refreshRemote: Bool = false) async { + do { + if refreshRemote { + try await syncService.refreshLaneSnapshots() + } + let loadedSnapshots = try await syncService.fetchLaneListSnapshots(includeArchived: true) + laneSnapshots = loadedSnapshots + let visibleIds = Set(loadedSnapshots.map(\.lane.id)) + openLaneIds = openLaneIds.filter { visibleIds.contains($0) } + pinnedLaneIds = Set(pinnedLaneIds.filter { visibleIds.contains($0) }) + errorMessage = nil + primaryBranchError = nil + if let primaryLane, canRunLiveActions { + do { + primaryBranches = try await syncService.listBranches(laneId: primaryLane.id) + } catch { + primaryBranches = [] + primaryBranchError = error.localizedDescription + } + } else { + primaryBranches = [] + } + } catch { + errorMessage = error.localizedDescription + } + } + + var canRunLiveActions: Bool { + syncService.connectionState == .connected || syncService.connectionState == .syncing + } + + func toggleOpenLane(_ laneId: String) { + withAnimation(ADEMotion.emphasis(reduceMotion: reduceMotion)) { + if openLaneIds.contains(laneId) { + closeLaneChip(laneId) + } else { + openLaneIds.insert(laneId, at: 0) + } + } + } + + func closeLaneChip(_ laneId: String) { + if pinnedLaneIds.contains(laneId) { + return + } + openLaneIds.removeAll { $0 == laneId } + } + + func togglePin(_ laneId: String) { + if pinnedLaneIds.contains(laneId) { + pinnedLaneIds.remove(laneId) + } else { + pinnedLaneIds.insert(laneId) + if !openLaneIds.contains(laneId) { + openLaneIds.insert(laneId, at: 0) + } + } + } + + @MainActor + func refreshFromPullGesture() async { + await reload(refreshRemote: true) + if errorMessage == nil { + withAnimation(ADEMotion.emphasis(reduceMotion: reduceMotion)) { + refreshFeedbackToken += 1 + } + } + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift new file mode 100644 index 000000000..ad969e6ba --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift @@ -0,0 +1,216 @@ +import SwiftUI + +// MARK: - Manage lane sheet + +struct LaneManageSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var syncService: SyncService + + let snapshot: LaneListSnapshot + let allLaneSnapshots: [LaneListSnapshot] + let onComplete: @MainActor () async -> Void + + @State private var renameText: String + @State private var selectedParentLaneId: String + @State private var colorText: String + @State private var iconText: String + @State private var tagsText: String + @State private var deleteMode: LaneDeleteMode + @State private var deleteRemoteName = "origin" + @State private var deleteForce = false + @State private var deleteConfirmText = "" + @State private var busyAction: String? + @State private var errorMessage: String? + + init( + snapshot: LaneListSnapshot, + allLaneSnapshots: [LaneListSnapshot], + onComplete: @escaping @MainActor () async -> Void + ) { + self.snapshot = snapshot + self.allLaneSnapshots = allLaneSnapshots + self.onComplete = onComplete + _renameText = State(initialValue: snapshot.lane.name) + _selectedParentLaneId = State(initialValue: snapshot.lane.parentLaneId ?? "") + _colorText = State(initialValue: snapshot.lane.color ?? "") + _iconText = State(initialValue: snapshot.lane.icon?.rawValue ?? "") + _tagsText = State(initialValue: snapshot.lane.tags.joined(separator: ", ")) + _deleteMode = State(initialValue: .worktree) + } + + private var reparentCandidates: [LaneSummary] { + allLaneSnapshots + .map(\.lane) + .filter { $0.id != snapshot.lane.id && $0.archivedAt == nil } + .sorted { lhs, rhs in + if lhs.laneType == "primary" && rhs.laneType != "primary" { return true } + if lhs.laneType != "primary" && rhs.laneType == "primary" { return false } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + + private var canArchive: Bool { + snapshot.lane.laneType != "primary" + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 14) { + if let errorMessage { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ADEColor.danger) + Text(errorMessage) + .font(.caption) + .foregroundStyle(ADEColor.danger) + Spacer() + } + .padding(12) + .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + GlassSection(title: "Identity") { + VStack(alignment: .leading, spacing: 12) { + LaneTextField("Lane name", text: $renameText) + LaneActionButton(title: "Save name", symbol: "checkmark.circle.fill", tint: ADEColor.accent) { + Task { await performAction("rename lane") { try await syncService.renameLane(snapshot.lane.id, name: renameText) } } + } + .disabled(renameText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || renameText == snapshot.lane.name) + } + } + + GlassSection(title: "Appearance") { + VStack(alignment: .leading, spacing: 12) { + LaneTextField("Color token or hex", text: $colorText).textInputAutocapitalization(.never) + LaneTextField("Icon (star, flag, bolt, shield, tag)", text: $iconText).textInputAutocapitalization(.never) + LaneTextField("Tags (comma separated)", text: $tagsText) + LaneActionButton(title: "Save appearance", symbol: "paintpalette", tint: ADEColor.accent) { + Task { + let tags = tagsText.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } + await performAction("save appearance") { + try await syncService.updateLaneAppearance(snapshot.lane.id, color: colorText, icon: iconText, tags: tags) + } + } + } + } + } + + if snapshot.lane.laneType != "primary" { + GlassSection(title: "Reparent") { + VStack(alignment: .leading, spacing: 12) { + Picker("Parent lane", selection: $selectedParentLaneId) { + Text("Select parent").tag("") + ForEach(reparentCandidates) { lane in + Text("\(lane.name) (\(lane.branchRef))").tag(lane.id) + } + } + .pickerStyle(.menu) + + LaneActionButton(title: "Save parent", symbol: "arrow.triangle.swap", tint: ADEColor.accent) { + Task { + await performAction("reparent lane") { + try await syncService.reparentLane(snapshot.lane.id, newParentLaneId: selectedParentLaneId) + } + } + } + .disabled(selectedParentLaneId.isEmpty) + } + } + } + + GlassSection(title: snapshot.lane.archivedAt == nil ? "Archive" : "Restore") { + if snapshot.lane.archivedAt == nil { + LaneActionButton(title: "Archive lane", symbol: "archivebox", tint: ADEColor.warning) { + Task { await performAction("archive lane") { try await syncService.archiveLane(snapshot.lane.id) } } + } + .disabled(!canArchive) + } else { + LaneActionButton(title: "Restore lane", symbol: "tray.and.arrow.up", tint: ADEColor.accent) { + Task { await performAction("restore lane") { try await syncService.unarchiveLane(snapshot.lane.id) } } + } + } + } + + if snapshot.lane.laneType != "primary" { + GlassSection(title: "Danger zone") { + VStack(alignment: .leading, spacing: 12) { + Picker("Delete mode", selection: $deleteMode) { + ForEach(LaneDeleteMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.menu) + + if deleteMode == .remoteBranch { + LaneTextField("Remote name", text: $deleteRemoteName) + } + + Toggle("Force delete", isOn: $deleteForce) + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + + LaneTextField("Type delete \(snapshot.lane.name) to confirm", text: $deleteConfirmText) + + LaneActionButton(title: "Delete lane", symbol: "trash", tint: ADEColor.danger) { + Task { + await performAction("delete lane") { + try await syncService.deleteLane( + snapshot.lane.id, + deleteBranch: deleteMode != .worktree, + deleteRemoteBranch: deleteMode == .remoteBranch, + remoteName: deleteRemoteName, + force: deleteForce + ) + } + } + } + .disabled(deleteConfirmText.trimmingCharacters(in: .whitespaces).lowercased() != "delete \(snapshot.lane.name)".lowercased()) + } + .padding(.top, 12) + } + } + } + .padding(16) + .allowsHitTesting(busyAction == nil) + } + .adeScreenBackground() + .overlay { + if busyAction != nil { + VStack(spacing: 10) { + ProgressView() + .tint(ADEColor.accent) + Text(busyAction?.capitalized ?? "Working...") + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.ultraThinMaterial) + } + } + .adeNavigationGlass() + .navigationTitle("Manage lane") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { dismiss() } + .disabled(busyAction != nil) + } + } + } + } + + @MainActor + private func performAction(_ label: String, operation: () async throws -> Void) async { + do { + busyAction = label + errorMessage = nil + try await operation() + dismiss() + await onComplete() + } catch { + errorMessage = error.localizedDescription + } + busyAction = nil + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneSessionTranscriptView.swift b/apps/ios/ADE/Views/Lanes/LaneSessionTranscriptView.swift new file mode 100644 index 000000000..8c30b5e35 --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneSessionTranscriptView.swift @@ -0,0 +1,43 @@ +import SwiftUI + +// MARK: - Session transcript view + +struct LaneSessionTranscriptView: View { + @EnvironmentObject private var syncService: SyncService + let session: TerminalSessionSummary + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + GlassSection(title: session.title) { + HStack(spacing: 8) { + LaneTypeBadge(text: session.status.uppercased(), tint: session.status == "running" ? ADEColor.success : ADEColor.textSecondary) + if let goal = session.goal, !goal.isEmpty { + Text(goal) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + } + } + } + + GlassSection(title: "Transcript") { + Text(syncService.terminalBuffers[session.id] ?? session.lastOutputPreview ?? "No output yet.") + .frame(maxWidth: .infinity, alignment: .leading) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ADEColor.textSecondary) + .textSelection(.enabled) + .adeInsetField(cornerRadius: 12, padding: 12) + } + } + .padding(16) + } + .adeScreenBackground() + .adeNavigationGlass() + .navigationTitle(session.title) + .navigationBarTitleDisplayMode(.inline) + .task { + try? await syncService.subscribeTerminal(sessionId: session.id) + } + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneStackGraphSheet.swift b/apps/ios/ADE/Views/Lanes/LaneStackGraphSheet.swift new file mode 100644 index 000000000..9b8a2bec7 --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneStackGraphSheet.swift @@ -0,0 +1,92 @@ +import SwiftUI + +// MARK: - Stack graph sheet + +struct LaneStackGraphSheet: View { + @Environment(\.dismiss) private var dismiss + + let snapshots: [LaneListSnapshot] + let selectedLaneId: String + + private var orderedSnapshots: [LaneListSnapshot] { + let childrenByParent = Dictionary(grouping: snapshots) { snapshot in + snapshot.lane.parentLaneId ?? "__root__" + } + let primaryId = snapshots.first(where: { $0.lane.laneType == "primary" })?.lane.id + + func visit(parentId: String?) -> [LaneListSnapshot] { + let key = parentId ?? "__root__" + let children = (childrenByParent[key] ?? []).sorted { lhs, rhs in + lhs.lane.createdAt < rhs.lane.createdAt + } + return children.flatMap { child in + [child] + visit(parentId: child.lane.id) + } + } + + let primaryBranch = primaryId.flatMap { id in snapshots.first(where: { $0.lane.id == id }) }.map { [$0] + visit(parentId: $0.lane.id) } ?? [] + let seen = Set(primaryBranch.map(\.lane.id)) + let remaining = snapshots.filter { !seen.contains($0.lane.id) }.sorted { $0.lane.createdAt < $1.lane.createdAt } + return primaryBranch + remaining + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 14) { + GlassSection(title: "Stack graph") { + VStack(alignment: .leading, spacing: 8) { + ForEach(orderedSnapshots) { snapshot in + HStack(alignment: .top, spacing: 12) { + HStack(spacing: 0) { + if snapshot.lane.stackDepth > 0 { + Rectangle() + .fill(ADEColor.border.opacity(0.4)) + .frame(width: CGFloat(snapshot.lane.stackDepth) * 12, height: 1) + .padding(.top, 10) + } + Circle() + .fill(snapshot.lane.id == selectedLaneId ? ADEColor.accent : runtimeTint(bucket: snapshot.runtime.bucket)) + .frame(width: 8, height: 8) + .padding(.top, 6) + } + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 8) { + Text(snapshot.lane.name) + .font(.subheadline.weight(snapshot.lane.id == selectedLaneId ? .semibold : .regular)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + if snapshot.lane.id == selectedLaneId { + LaneTypeBadge(text: "Current", tint: ADEColor.accent) + } + } + Text(snapshot.lane.branchRef) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ADEColor.textSecondary) + } + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(snapshot.lane.id == selectedLaneId ? ADEColor.accent.opacity(0.1) : ADEColor.surfaceBackground.opacity(0.6)) + ) + } + } + } + } + .padding(16) + } + .adeScreenBackground() + .adeNavigationGlass() + .navigationTitle("Stack graph") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneTypes.swift b/apps/ios/ADE/Views/Lanes/LaneTypes.swift new file mode 100644 index 000000000..7d5aba720 --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneTypes.swift @@ -0,0 +1,108 @@ +import SwiftUI + +// MARK: - Enums + +enum LaneListScope: String, CaseIterable, Identifiable { + case active + case archived + case all + + var id: String { rawValue } + + var title: String { + switch self { + case .active: return "Active" + case .archived: return "Archived" + case .all: return "All" + } + } +} + +enum LaneRuntimeFilter: String, CaseIterable, Identifiable { + case all + case running + case awaitingInput = "awaiting-input" + case ended + + var id: String { rawValue } + + var title: String { + switch self { + case .all: return "All" + case .running: return "Running" + case .awaitingInput: return "Awaiting" + case .ended: return "Ended" + } + } + + var symbol: String { + switch self { + case .all: return "line.3.horizontal.decrease.circle" + case .running: return "waveform.path.ecg" + case .awaitingInput: return "exclamationmark.bubble.fill" + case .ended: return "stop.circle.fill" + } + } +} + +enum LaneDetailSection: String, CaseIterable, Identifiable { + case git + case work + case overview + case manage + + var id: String { rawValue } + + var title: String { + rawValue.capitalized + } + + var symbol: String { + switch self { + case .overview: return "square.grid.2x2" + case .git: return "arrow.triangle.branch" + case .work: return "terminal" + case .manage: return "slider.horizontal.3" + } + } +} + +enum LaneDeleteMode: String, CaseIterable, Identifiable { + case worktree + case localBranch = "local_branch" + case remoteBranch = "remote_branch" + + var id: String { rawValue } + + var title: String { + switch self { + case .worktree: return "Worktree only" + case .localBranch: return "Worktree + local" + case .remoteBranch: return "Worktree + local + remote" + } + } +} + +// MARK: - Model structs + +struct LaneDetailSheetTarget: Identifiable { + var id: String { "\(laneId):\(initialSection.rawValue)" } + let laneId: String + let snapshot: LaneListSnapshot + let initialSection: LaneDetailSection +} + +struct LaneDiffRequest: Identifiable { + var id: String { "\(laneId):\(mode):\(path ?? "none"):\(compareRef ?? "none")" } + let laneId: String + let path: String? + let mode: String + let compareRef: String? + let compareTo: String? + let title: String +} + +struct LaneChatLaunchTarget: Identifiable { + var id: String { provider } + let provider: String +} diff --git a/apps/ios/ADE/Views/LanesTabView.swift b/apps/ios/ADE/Views/LanesTabView.swift index 19e17eaec..50b6467da 100644 --- a/apps/ios/ADE/Views/LanesTabView.swift +++ b/apps/ios/ADE/Views/LanesTabView.swift @@ -1,193 +1,39 @@ import SwiftUI -import UIKit - -// MARK: - Enums - -private enum LaneListScope: String, CaseIterable, Identifiable { - case active - case archived - case all - - var id: String { rawValue } - - var title: String { - switch self { - case .active: return "Active" - case .archived: return "Archived" - case .all: return "All" - } - } -} - -private enum LaneRuntimeFilter: String, CaseIterable, Identifiable { - case all - case running - case awaitingInput = "awaiting-input" - case ended - - var id: String { rawValue } - - var title: String { - switch self { - case .all: return "All" - case .running: return "Running" - case .awaitingInput: return "Awaiting" - case .ended: return "Ended" - } - } - - var symbol: String { - switch self { - case .all: return "line.3.horizontal.decrease.circle" - case .running: return "waveform.path.ecg" - case .awaitingInput: return "exclamationmark.bubble.fill" - case .ended: return "stop.circle.fill" - } - } -} - -private enum LaneDetailSection: String, CaseIterable, Identifiable { - case git - case work - case overview - case manage - - var id: String { rawValue } - - var title: String { - rawValue.capitalized - } - - var symbol: String { - switch self { - case .overview: return "square.grid.2x2" - case .git: return "arrow.triangle.branch" - case .work: return "terminal" - case .manage: return "slider.horizontal.3" - } - } -} - -private enum LaneDeleteMode: String, CaseIterable, Identifiable { - case worktree - case localBranch = "local_branch" - case remoteBranch = "remote_branch" - - var id: String { rawValue } - - var title: String { - switch self { - case .worktree: return "Worktree only" - case .localBranch: return "Worktree + local" - case .remoteBranch: return "Worktree + local + remote" - } - } -} // MARK: - Lanes tab struct LanesTabView: View { - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @EnvironmentObject private var syncService: SyncService - - @State private var laneSnapshots: [LaneListSnapshot] = [] - @State private var errorMessage: String? - @State private var searchText = "" - @State private var scope: LaneListScope = .active - @State private var runtimeFilter: LaneRuntimeFilter = .all - @State private var createPresented = false - @State private var attachPresented = false - @State private var openLaneIds: [String] = [] - @State private var pinnedLaneIds = Set() - @State private var primaryBranches: [GitBranchSummary] = [] - @State private var primaryBranchError: String? - @State private var detailSheetTarget: LaneDetailSheetTarget? - @State private var batchManageLaneIds: [String] = [] - @State private var batchManagePresented = false - @State private var showFilters = false - @State private var refreshFeedbackToken = 0 - - private var laneStatus: SyncDomainStatus { + @Environment(\.accessibilityReduceMotion) var reduceMotion + @EnvironmentObject var syncService: SyncService + + @State var laneSnapshots: [LaneListSnapshot] = [] + @State var errorMessage: String? + @State var searchText = "" + @State var scope: LaneListScope = .active + @State var runtimeFilter: LaneRuntimeFilter = .all + @State var createPresented = false + @State var attachPresented = false + @State var openLaneIds: [String] = [] + @State var pinnedLaneIds = Set() + @State var primaryBranches: [GitBranchSummary] = [] + @State var primaryBranchError: String? + @State var detailSheetTarget: LaneDetailSheetTarget? + @State var batchManageLaneIds: [String] = [] + @State var batchManagePresented = false + @State var refreshFeedbackToken = 0 + + var laneStatus: SyncDomainStatus { syncService.status(for: .lanes) } - private var needsRepairing: Bool { + var needsRepairing: Bool { syncService.activeHostProfile == nil && !laneSnapshots.isEmpty } - private var filteredSnapshots: [LaneListSnapshot] { - laneSnapshots - .filter { snapshot in - switch scope { - case .active: - return snapshot.lane.archivedAt == nil - case .archived: - return snapshot.lane.archivedAt != nil - case .all: - return true - } - } - .filter { snapshot in - runtimeFilter == .all || snapshot.runtime.bucket == runtimeFilter.rawValue - } - .filter { snapshot in - laneMatchesSearch(snapshot: snapshot, isPinned: pinnedLaneIds.contains(snapshot.lane.id), query: searchText) - } - .sorted { lhs, rhs in - if lhs.lane.laneType == "primary" && rhs.lane.laneType != "primary" { return true } - if lhs.lane.laneType != "primary" && rhs.lane.laneType == "primary" { return false } - return lhs.lane.createdAt > rhs.lane.createdAt - } - } - - private var visibleSuggestions: [LaneListSnapshot] { - filteredSnapshots.filter { $0.rebaseSuggestion != nil } - } - - private var visibleAutoRebaseAttention: [LaneListSnapshot] { - filteredSnapshots.filter { snapshot in - guard let status = snapshot.autoRebaseStatus else { return false } - return status.state != "autoRebased" - } - } - - private var primaryLane: LaneSummary? { - laneSnapshots.first(where: { $0.lane.laneType == "primary" })?.lane - } - - private var manageableVisibleLaneIds: [String] { - filteredSnapshots - .map(\.lane) - .filter { $0.laneType != "primary" } - .map(\.id) - } - - private var activeLaneCount: Int { - laneSnapshots.filter { $0.lane.archivedAt == nil }.count - } - - private var archivedLaneCount: Int { - laneSnapshots.filter { $0.lane.archivedAt != nil }.count - } - - private var openLaneSnapshots: [LaneListSnapshot] { - openLaneIds.compactMap { laneId in - laneSnapshots.first(where: { $0.lane.id == laneId }) - } - } - - private var manageableOpenLaneIds: [String] { - openLaneSnapshots - .map(\.lane) - .filter { $0.laneType != "primary" } - .map(\.id) - } - var body: some View { NavigationStack { ScrollView { LazyVStack(spacing: 14) { - // Connection status if let notice = statusNotice { notice .transition(.asymmetric( @@ -195,7 +41,6 @@ struct LanesTabView: View { removal: .opacity )) } - if let errorMessage, laneStatus.phase == .ready { ADENoticeCard( title: "Lane view error", @@ -207,28 +52,22 @@ struct LanesTabView: View { ) .transition(.opacity) } - + if let notice = primaryBranchNotice { + notice + .transition(.opacity) + } if laneStatus.phase == .hydrating || laneStatus.phase == .syncingInitialData { ADECardSkeleton(rows: 4) ADECardSkeleton(rows: 3) } - - // Inline filter bar - filterBar - - // Open lanes tray if !openLaneSnapshots.isEmpty { openLanesTray .transition(.move(edge: .top).combined(with: .opacity)) } - - // Attention banners if !visibleSuggestions.isEmpty || !visibleAutoRebaseAttention.isEmpty { attentionSection .transition(.move(edge: .top).combined(with: .opacity)) } - - // Lane list laneList } .padding(.horizontal, 16) @@ -241,96 +80,12 @@ struct LanesTabView: View { .searchable(text: $searchText, prompt: "Filter by lane, branch, is:dirty...") .navigationTitle("Lanes") .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - Menu { - Section("Scope") { - ForEach(LaneListScope.allCases) { option in - Button { - scope = option - } label: { - Label( - "\(option.title) (\(option == .active ? activeLaneCount : option == .archived ? archivedLaneCount : laneSnapshots.count))", - systemImage: scope == option ? "checkmark.circle.fill" : "circle" - ) - } - } - } - - Section("Runtime") { - ForEach(LaneRuntimeFilter.allCases) { filter in - Button { - runtimeFilter = filter - } label: { - Label( - "\(filter.title) (\(count(for: filter)))", - systemImage: runtimeFilter == filter ? "checkmark.circle.fill" : "circle" - ) - } - } - } - - if manageableVisibleLaneIds.count > 1 { - Section { - Button { - batchManageLaneIds = manageableVisibleLaneIds - batchManagePresented = true - } label: { - Label("Manage visible lanes", systemImage: "slider.horizontal.3") - } - } - } - - if let primaryLane { - Section("Primary branch") { - ForEach(primaryBranches) { branch in - Button(branch.name) { - Task { - do { - try await syncService.checkoutPrimaryBranch(laneId: primaryLane.id, branchName: branch.name) - try await syncService.refreshLaneSnapshots() - await reload() - } catch { - primaryBranchError = error.localizedDescription - } - } - } - } - } - } - } label: { - Image(systemName: "line.3.horizontal.decrease.circle") - .symbolVariant(scope != .active || runtimeFilter != .all ? .fill : .none) - } - .accessibilityLabel("Lane filters") - - Menu { - Button { - createPresented = true - } label: { - Label("New lane", systemImage: "plus.square") - } - Button { - attachPresented = true - } label: { - Label("Attach worktree", systemImage: "link") - } - } label: { - Image(systemName: "plus.circle.fill") - .symbolRenderingMode(.hierarchical) - } - .accessibilityLabel("Create or attach lane") - } - } - .refreshable { - await refreshFromPullGesture() - } - .sensoryFeedback(.success, trigger: laneSnapshots.count) + .toolbar { toolbarContent } + .refreshable { await refreshFromPullGesture() } .sensoryFeedback(.success, trigger: refreshFeedbackToken) - .task { - await reload(refreshRemote: true) - } + .task { await reload(refreshRemote: true) } .task(id: syncService.localStateRevision) { + guard laneStatus.phase == .ready else { return } await reload() } .sheet(isPresented: $createPresented) { @@ -373,3376 +128,86 @@ struct LanesTabView: View { } } - // MARK: - Filter bar - - @ViewBuilder - private var filterBar: some View { - VStack(spacing: 10) { - HStack(spacing: 8) { - ForEach(LaneListScope.allCases) { option in - LaneFilterPill( - title: option.title, - count: option == .active ? activeLaneCount : option == .archived ? archivedLaneCount : laneSnapshots.count, - isActive: scope == option, - tint: scope == option ? ADEColor.accent : ADEColor.textSecondary - ) { - withAnimation(ADEMotion.emphasis(reduceMotion: reduceMotion)) { scope = option } - } - } - - Spacer() - - if !searchText.isEmpty || runtimeFilter != .all { - Text("\(filteredSnapshots.count) results") - .font(.caption) - .foregroundStyle(ADEColor.textMuted) - } - } - - if !laneSnapshots.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(LaneRuntimeFilter.allCases) { filter in - LaneFilterPill( - title: filter.title, - count: count(for: filter), - isActive: runtimeFilter == filter, - tint: runtimeFilter == filter ? runtimeTint(bucket: filter.rawValue) : ADEColor.textSecondary - ) { - withAnimation(ADEMotion.emphasis(reduceMotion: reduceMotion)) { runtimeFilter = filter } - } - } - } - } - } - } - } - - // MARK: - Open lanes tray - - @ViewBuilder - private var openLanesTray: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - Label("Open lanes", systemImage: "square.stack.3d.up.fill") - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textSecondary) - Spacer() - Button { - withAnimation(ADEMotion.emphasis(reduceMotion: reduceMotion)) { - openLaneIds = Array(pinnedLaneIds) - } - } label: { - Text("Clear") - .font(.caption.weight(.medium)) - .foregroundStyle(ADEColor.textMuted) - } - } + // MARK: - Toolbar - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(openLaneSnapshots) { snapshot in - NavigationLink { - LaneDetailScreen( - laneId: snapshot.lane.id, - initialSnapshot: snapshot, - allLaneSnapshots: laneSnapshots, - onRefreshRoot: { await reload(refreshRemote: true) } - ) + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItemGroup(placement: .topBarTrailing) { + Menu { + Section("Scope") { + ForEach(LaneListScope.allCases) { option in + Button { + scope = option } label: { - LaneOpenChip( - snapshot: snapshot, - isPinned: pinnedLaneIds.contains(snapshot.lane.id) + Label( + "\(option.title) (\(laneScopeCount(laneSnapshots, scope: option)))", + systemImage: scope == option ? "checkmark.circle.fill" : "circle" ) } - .buttonStyle(.plain) - .contextMenu { - Button("Manage lane") { - detailSheetTarget = LaneDetailSheetTarget( - laneId: snapshot.lane.id, - snapshot: snapshot, - initialSection: .manage - ) - } - Button(pinnedLaneIds.contains(snapshot.lane.id) ? "Unpin" : "Pin") { - togglePin(snapshot.lane.id) - } - Button("Remove from open lanes") { - closeLaneChip(snapshot.lane.id) - } - Button("Close others") { - openLaneIds = [snapshot.lane.id] - } - } - } - } - } - } - .adeGlassCard(cornerRadius: 14, padding: 12) - } - - // MARK: - Attention section - - @ViewBuilder - private var attentionSection: some View { - VStack(spacing: 10) { - ForEach(visibleSuggestions.prefix(3)) { snapshot in - HStack(spacing: 12) { - Image(systemName: "arrow.triangle.2.circlepath") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.warning) - - VStack(alignment: .leading, spacing: 2) { - Text(snapshot.lane.name) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text("Behind parent by \(snapshot.rebaseSuggestion?.behindCount ?? 0) commit(s)") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - - Spacer(minLength: 8) - - Button("Rebase") { - Task { - do { - try await syncService.startLaneRebase(laneId: snapshot.lane.id) - await reload(refreshRemote: true) - } catch { - errorMessage = error.localizedDescription - } - } - } - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.accent) - .disabled(!canRunLiveActions) - - Menu { - Button("Defer") { - Task { - do { - try await syncService.deferRebaseSuggestion(laneId: snapshot.lane.id) - await reload(refreshRemote: true) - } catch { - errorMessage = error.localizedDescription - } - } - } - Button("Dismiss") { - Task { - do { - try await syncService.dismissRebaseSuggestion(laneId: snapshot.lane.id) - await reload(refreshRemote: true) - } catch { - errorMessage = error.localizedDescription - } - } - } - } label: { - Image(systemName: "ellipsis.circle") - .font(.caption) - .foregroundStyle(ADEColor.textMuted) } } - .padding(12) - .background(ADEColor.warning.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(ADEColor.warning.opacity(0.2), lineWidth: 0.5) - ) - } - - ForEach(visibleAutoRebaseAttention.prefix(3)) { snapshot in - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(snapshot.autoRebaseStatus?.state == "rebaseConflict" ? ADEColor.danger : ADEColor.warning) - - VStack(alignment: .leading, spacing: 2) { - Text(snapshot.lane.name) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text(snapshot.autoRebaseStatus?.message ?? "Manual follow-up required") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(2) - } - - Spacer(minLength: 8) - - Button("Open") { - detailSheetTarget = LaneDetailSheetTarget( - laneId: snapshot.lane.id, - snapshot: snapshot, - initialSection: .git - ) - } - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.accent) - } - .padding(12) - .background(ADEColor.danger.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(ADEColor.danger.opacity(0.15), lineWidth: 0.5) - ) - } - } - } - - // MARK: - Lane list - - @ViewBuilder - private var laneList: some View { - if filteredSnapshots.isEmpty { - ADEEmptyStateView( - symbol: "square.stack.3d.up.slash", - title: emptyStateTitle, - message: emptyStateText - ) - .padding(.top, 40) - } else { - ForEach(filteredSnapshots) { snapshot in - NavigationLink { - LaneDetailScreen( - laneId: snapshot.lane.id, - initialSnapshot: snapshot, - allLaneSnapshots: laneSnapshots, - onRefreshRoot: { await reload(refreshRemote: true) } - ) - } label: { - LaneListRow( - snapshot: snapshot, - isPinned: pinnedLaneIds.contains(snapshot.lane.id), - isOpen: openLaneIds.contains(snapshot.lane.id) - ) - } - .buttonStyle(ADEScaleButtonStyle()) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(openLaneIds.contains(snapshot.lane.id) ? "Close" : "Open") { - toggleOpenLane(snapshot.lane.id) - } - .tint(ADEColor.accent) - - if snapshot.lane.archivedAt == nil { - Button("Archive", role: .destructive) { - Task { - do { - try await syncService.archiveLane(snapshot.lane.id) - await reload(refreshRemote: true) - } catch { - errorMessage = error.localizedDescription - } - } - } - } else { - Button("Restore") { - Task { - do { - try await syncService.unarchiveLane(snapshot.lane.id) - await reload(refreshRemote: true) - } catch { - errorMessage = error.localizedDescription - } - } + Section("Runtime") { + ForEach(LaneRuntimeFilter.allCases) { filter in + Button { + runtimeFilter = filter + } label: { + Label( + "\(filter.title) (\(laneRuntimeCount(laneSnapshots, filter: filter)))", + systemImage: runtimeFilter == filter ? "checkmark.circle.fill" : "circle" + ) } - .tint(.green) } } - .contextMenu { - Button("Manage lane") { - detailSheetTarget = LaneDetailSheetTarget( - laneId: snapshot.lane.id, - snapshot: snapshot, - initialSection: .manage - ) - } - Button(openLaneIds.contains(snapshot.lane.id) ? "Remove from open lanes" : "Add to open lanes") { - toggleOpenLane(snapshot.lane.id) - } - Button(pinnedLaneIds.contains(snapshot.lane.id) ? "Unpin" : "Pin") { - togglePin(snapshot.lane.id) - } - Button("Close others") { - openLaneIds = [snapshot.lane.id] - } - Button("Select all visible") { - openLaneIds = filteredSnapshots.map(\.lane.id) - } - if manageableVisibleLaneIds.count > 1 { - Button("Manage \(manageableVisibleLaneIds.count) visible lanes") { + if manageableVisibleLaneIds.count > 1 { + Section { + Button { batchManageLaneIds = manageableVisibleLaneIds batchManagePresented = true + } label: { + Label("Manage visible lanes", systemImage: "slider.horizontal.3") } } - Button("Copy path") { - UIPasteboard.general.string = snapshot.lane.worktreePath - } - if snapshot.adoptableAttached { - Button("Move to ADE-managed worktree") { - Task { - do { - _ = try await syncService.adoptAttachedLane(snapshot.lane.id) - await reload(refreshRemote: true) - } catch { - errorMessage = error.localizedDescription + } + if let primaryLane { + Section("Primary branch") { + ForEach(primaryBranches) { branch in + Button(branch.name) { + Task { + do { + try await syncService.checkoutPrimaryBranch(laneId: primaryLane.id, branchName: branch.name) + try await syncService.refreshLaneSnapshots() + await reload() + } catch { + primaryBranchError = error.localizedDescription + } } } } } } + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + .symbolVariant(scope != .active || runtimeFilter != .all ? .fill : .none) + .foregroundStyle(scope != .active || runtimeFilter != .all ? ADEColor.accent : ADEColor.textSecondary) } - } - } - - // MARK: - Helpers - - @MainActor - private func reload(refreshRemote: Bool = false) async { - do { - if refreshRemote { - try await syncService.refreshLaneSnapshots() - } - let loadedSnapshots = try await syncService.fetchLaneListSnapshots(includeArchived: true) - laneSnapshots = loadedSnapshots - let visibleIds = Set(loadedSnapshots.map(\.lane.id)) - openLaneIds = openLaneIds.filter { visibleIds.contains($0) } - pinnedLaneIds = Set(pinnedLaneIds.filter { visibleIds.contains($0) }) - errorMessage = nil - primaryBranchError = nil - if let primaryLane, canRunLiveActions { - do { - primaryBranches = try await syncService.listBranches(laneId: primaryLane.id) - } catch { - primaryBranches = [] - primaryBranchError = error.localizedDescription - } - } else { - primaryBranches = [] - } - } catch { - errorMessage = error.localizedDescription - } - } - - private var canRunLiveActions: Bool { - syncService.connectionState == .connected || syncService.connectionState == .syncing - } - - private func count(for filter: LaneRuntimeFilter) -> Int { - if filter == .all { return laneSnapshots.count } - return laneSnapshots.filter { $0.runtime.bucket == filter.rawValue }.count - } - - private func toggleOpenLane(_ laneId: String) { - withAnimation(ADEMotion.emphasis(reduceMotion: reduceMotion)) { - if openLaneIds.contains(laneId) { - closeLaneChip(laneId) - } else { - openLaneIds.insert(laneId, at: 0) - } - } - } - - private func closeLaneChip(_ laneId: String) { - if pinnedLaneIds.contains(laneId) { return } - openLaneIds.removeAll { $0 == laneId } - } - - private func togglePin(_ laneId: String) { - if pinnedLaneIds.contains(laneId) { - pinnedLaneIds.remove(laneId) - } else { - pinnedLaneIds.insert(laneId) - if !openLaneIds.contains(laneId) { - openLaneIds.insert(laneId, at: 0) - } - } - } - - @MainActor - private func refreshFromPullGesture() async { - await reload(refreshRemote: true) - if errorMessage == nil { - withAnimation(ADEMotion.emphasis(reduceMotion: reduceMotion)) { - refreshFeedbackToken += 1 - } - } - } - - private var emptyStateTitle: String { - switch scope { - case .active: return "No active lanes" - case .archived: return "No archived lanes" - case .all: return "No lanes" - } - } - - private var emptyStateText: String { - if !searchText.isEmpty { - return "Try a different search or clear the filter." - } - switch scope { - case .active: - return "Create a new lane or connect to a host." - case .archived: - return "Archived lanes will appear here." - case .all: - return "No lanes match the current filters." - } - } - - private var statusNotice: ADENoticeCard? { - switch laneStatus.phase { - case .disconnected: - return ADENoticeCard( - title: laneSnapshots.isEmpty ? "Host disconnected" : "Showing cached lanes", - message: laneSnapshots.isEmpty - ? (syncService.activeHostProfile == nil - ? "Pair with a host to load the current lane graph." - : "Reconnect to load the current lane graph from the host.") - : (needsRepairing - ? "Cached lane data is still visible, but the previous host trust was cleared. Pair again before trusting the lane graph." - : "Cached lane data is available. Reconnect to refresh."), - icon: "bolt.horizontal.circle", - tint: ADEColor.warning, - actionTitle: syncService.activeHostProfile == nil ? (needsRepairing ? "Pair again" : "Pair with host") : "Reconnect", - action: { - if syncService.activeHostProfile == nil { - syncService.settingsPresented = true - } else { - Task { - await syncService.reconnectIfPossible() - await reload(refreshRemote: true) - } - } - } - ) - case .hydrating: - return ADENoticeCard( - title: "Hydrating lane graph", - message: "Pulling lane snapshots from the host.", - icon: "arrow.trianglehead.2.clockwise.rotate.90", - tint: ADEColor.accent, - actionTitle: nil, - action: nil - ) - case .syncingInitialData: - return ADENoticeCard( - title: "Syncing initial data", - message: "Waiting for the host to finish syncing project data before the lane graph hydrates.", - icon: "arrow.trianglehead.2.clockwise.rotate.90", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - case .failed: - return ADENoticeCard( - title: "Lane hydration failed", - message: laneStatus.lastError ?? "Lane hydration did not complete.", - icon: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - actionTitle: "Retry", - action: { Task { await reload(refreshRemote: true) } } - ) - case .ready: - return nil - } - } -} - -// MARK: - Lane list row - -private struct LaneListRow: View { - let snapshot: LaneListSnapshot - let isPinned: Bool - let isOpen: Bool + .accessibilityLabel("Lane filters") - var body: some View { - HStack(spacing: 14) { - LaneStatusIndicator(bucket: snapshot.runtime.bucket) - - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 8) { - Text(snapshot.lane.name) - .font(.body.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - - if snapshot.lane.laneType == "primary" { - LaneTypeBadge(text: "Primary", tint: ADEColor.accent) - } else if snapshot.lane.laneType == "attached" { - LaneTypeBadge(text: "Attached", tint: ADEColor.textMuted) - } - } - - Text(snapshot.lane.branchRef) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - - HStack(spacing: 6) { - if snapshot.lane.status.ahead > 0 { - LaneMicroChip(icon: "arrow.up", text: "\(snapshot.lane.status.ahead)", tint: ADEColor.success) - } - if snapshot.lane.status.behind > 0 { - LaneMicroChip(icon: "arrow.down", text: "\(snapshot.lane.status.behind)", tint: ADEColor.warning) - } - if snapshot.runtime.sessionCount > 0 { - LaneMicroChip( - icon: runtimeSymbol(snapshot.runtime.bucket), - text: "\(snapshot.runtime.sessionCount)", - tint: runtimeTint(bucket: snapshot.runtime.bucket) - ) - } - if snapshot.lane.childCount > 0 { - LaneMicroChip(icon: "square.stack.3d.up", text: "\(snapshot.lane.childCount)", tint: ADEColor.textMuted) - } - if isPinned { - Image(systemName: "pin.fill") - .font(.system(size: 9)) - .foregroundStyle(ADEColor.accent) - } + Menu { + Button { + createPresented = true + } label: { + Label("New lane", systemImage: "plus.square") } - - if let activity = laneActivitySummary(snapshot) { - Text(activity) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) + Button { + attachPresented = true + } label: { + Label("Attach worktree", systemImage: "link") } - } - - Spacer(minLength: 8) - - VStack(alignment: .trailing, spacing: 6) { - lanePriorityBadge(snapshot: snapshot) - } - - Image(systemName: "chevron.right") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - } - .adeGlassCard(cornerRadius: 16, padding: 14) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(isOpen ? ADEColor.accent.opacity(0.35) : ADEColor.border.opacity(0.14), lineWidth: isOpen ? 1 : 0.75) - ) - .accessibilityElement(children: .combine) - .accessibilityLabel(laneRowAccessibilityLabel) - } - - private var laneRowAccessibilityLabel: String { - var parts = [snapshot.lane.name, snapshot.lane.branchRef] - if snapshot.lane.status.dirty { parts.append("dirty") } - if isPinned { parts.append("pinned") } - if isOpen { parts.append("open") } - if snapshot.lane.status.ahead > 0 { parts.append("\(snapshot.lane.status.ahead) ahead") } - if snapshot.lane.status.behind > 0 { parts.append("\(snapshot.lane.status.behind) behind") } - return parts.joined(separator: ", ") - } -} - -// MARK: - Lane detail screen - -private struct LaneDetailScreen: View { - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @EnvironmentObject private var syncService: SyncService - - let laneId: String - let initialSnapshot: LaneListSnapshot - let allLaneSnapshots: [LaneListSnapshot] - let onRefreshRoot: @MainActor () async -> Void - - @State private var detail: LaneDetailPayload? - @State private var errorMessage: String? - @State private var section: LaneDetailSection - @State private var busyAction: String? - @State private var renameText = "" - @State private var selectedParentLaneId = "" - @State private var colorText = "" - @State private var iconText = "" - @State private var tagsText = "" - @State private var commitMessage = "" - @State private var amendCommit = false - @State private var stashMessage = "" - @State private var deleteMode: LaneDeleteMode = .worktree - @State private var deleteRemoteName = "origin" - @State private var deleteForce = false - @State private var deleteConfirmText = "" - @State private var selectedDiffRequest: LaneDiffRequest? - @State private var trackedLaunch = true - @State private var showStackGraph = false - @State private var chatLaunchTarget: LaneChatLaunchTarget? - @State private var lanePullRequests: [PullRequestListItem] = [] - - init( - laneId: String, - initialSnapshot: LaneListSnapshot, - allLaneSnapshots: [LaneListSnapshot], - initialSection: LaneDetailSection = .git, - onRefreshRoot: @escaping @MainActor () async -> Void - ) { - self.laneId = laneId - self.initialSnapshot = initialSnapshot - self.allLaneSnapshots = allLaneSnapshots - self.onRefreshRoot = onRefreshRoot - _section = State(initialValue: initialSection) - } - - private var currentSnapshot: LaneListSnapshot { - allLaneSnapshots.first(where: { $0.lane.id == laneId }) ?? initialSnapshot - } - - private var reparentCandidates: [LaneSummary] { - allLaneSnapshots - .map(\.lane) - .filter { $0.id != laneId && $0.archivedAt == nil } - .sorted { lhs, rhs in - if lhs.laneType == "primary" && rhs.laneType != "primary" { return true } - if lhs.laneType != "primary" && rhs.laneType == "primary" { return false } - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } - } - - var body: some View { - ScrollView { - LazyVStack(spacing: 14, pinnedViews: [.sectionHeaders]) { - // Connection banner - if let banner = connectionBanner { - banner - } - - // Busy indicator - if let busyAction { - HStack(spacing: 10) { - ProgressView() - .tint(ADEColor.accent) - Text(busyAction.capitalized) - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - Spacer() - } - .adeGlassCard(cornerRadius: 12, padding: 12) - } - - // Error - if let errorMessage { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(ADEColor.danger) - Text(errorMessage) - .font(.caption) - .foregroundStyle(ADEColor.danger) - Spacer() - } - .padding(12) - .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - - // Header card - detailHeader - - // Quick actions - quickActionBar - - // Section picker + content - Section { - selectedSectionContent - .id(section) - .transition(.opacity.animation(.smooth)) - } header: { - sectionPicker - .padding(.bottom, 6) - .background(ADEColor.pageBackground.opacity(0.96)) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - } - .animation(ADEMotion.emphasis(reduceMotion: reduceMotion), value: section) - .adeNavigationGlass() - .navigationTitle(detail?.lane.name ?? initialSnapshot.lane.name) - .navigationBarTitleDisplayMode(.inline) - .task { - await loadDetail(refreshRemote: true) - } - .refreshable { - await loadDetail(refreshRemote: true) - } - .sheet(item: $selectedDiffRequest) { request in - LaneDiffScreen(request: request) - } - .sheet(isPresented: $showStackGraph) { - LaneStackGraphSheet(snapshots: allLaneSnapshots, selectedLaneId: laneId) - } - .sheet(item: $chatLaunchTarget) { target in - LaneChatLaunchSheet(laneId: laneId, provider: target.provider) { _ in - await loadDetail(refreshRemote: true) - } - } - } - - // MARK: Detail header - - @ViewBuilder - private var detailHeader: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 10) { - LaneStatusIndicator(bucket: currentSnapshot.runtime.bucket, size: 12) - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Text(detail?.lane.branchRef ?? currentSnapshot.lane.branchRef) - .font(.system(.headline, design: .monospaced)) - .foregroundStyle(ADEColor.textPrimary) - lanePriorityBadge(snapshot: currentSnapshot) - } - - if currentSnapshot.lane.baseRef != currentSnapshot.lane.branchRef { - Text("from \(detail?.lane.baseRef ?? currentSnapshot.lane.baseRef)") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - } - - Spacer(minLength: 10) - - VStack(alignment: .trailing, spacing: 6) { - if currentSnapshot.lane.laneType == "primary" { - LaneTypeBadge(text: "Primary", tint: ADEColor.accent) - } else if currentSnapshot.lane.laneType == "attached" { - LaneTypeBadge(text: "Attached", tint: ADEColor.textSecondary) - } - if currentSnapshot.runtime.sessionCount > 0 { - LaneTypeBadge( - text: "\(currentSnapshot.runtime.sessionCount) live", - tint: runtimeTint(bucket: currentSnapshot.runtime.bucket) - ) - } - } - } - - // Meta chips - HStack(spacing: 6) { - if currentSnapshot.lane.status.ahead > 0 { - LaneMicroChip(icon: "arrow.up", text: "\(currentSnapshot.lane.status.ahead) ahead", tint: ADEColor.success) - } - if currentSnapshot.lane.status.behind > 0 { - LaneMicroChip(icon: "arrow.down", text: "\(currentSnapshot.lane.status.behind) behind", tint: ADEColor.warning) - } - if currentSnapshot.lane.status.dirty { - LaneMicroChip(icon: "pencil.line", text: "Dirty", tint: ADEColor.warning) - } - if currentSnapshot.lane.childCount > 0 { - LaneMicroChip(icon: "square.stack.3d.up", text: "\(currentSnapshot.lane.childCount) child", tint: ADEColor.textMuted) - } - } - } - .adeGlassCard(cornerRadius: 16, padding: 14) - } - - // MARK: Quick actions - - @ViewBuilder - private var quickActionBar: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - LaneQuickAction(title: "Files", symbol: "folder", tint: ADEColor.accent) { - Task { await openFiles() } - } - if !lanePullRequests.isEmpty { - LaneQuickAction(title: lanePullRequests.count == 1 ? "PR" : "PRs", symbol: "arrow.triangle.pull", tint: ADEColor.warning) { - openPullRequest(lanePullRequests[0].id) - } - } - LaneQuickAction(title: "Copy path", symbol: "doc.on.doc", tint: ADEColor.textSecondary) { - UIPasteboard.general.string = detail?.lane.worktreePath ?? currentSnapshot.lane.worktreePath - } - LaneQuickAction(title: "Stack", symbol: "list.number", tint: ADEColor.textSecondary) { - showStackGraph = true - } - if canRunLiveActions { - LaneQuickAction(title: "Shell", symbol: "terminal", tint: ADEColor.success) { - Task { - await performAction("launch shell") { - try await syncService.runQuickCommand(laneId: laneId, title: "Shell", toolType: "shell", tracked: trackedLaunch) - } - } - } - } - } - } - } - - // MARK: Section picker - - @ViewBuilder - private var sectionPicker: some View { - Picker("Section", selection: $section) { - ForEach(LaneDetailSection.allCases) { item in - Label(item.title, systemImage: item.symbol) - .tag(item) - } - } - .pickerStyle(.segmented) - .sensoryFeedback(.selection, trigger: section) - } - - // MARK: Section content - - @ViewBuilder - private var selectedSectionContent: some View { - if detail == nil && errorMessage == nil { - HStack(spacing: 12) { - ProgressView() - .tint(ADEColor.accent) - Text("Loading lane detail...") - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - Spacer() - } - .adeGlassCard(cornerRadius: 14, padding: 14) - } else { - switch section { - case .overview: - overviewSections - case .git: - gitSections - case .work: - workSections - case .manage: - manageSections - } - } - } - - // MARK: - Overview section - - @ViewBuilder - private var overviewSections: some View { - if let detail { - VStack(spacing: 14) { - GlassSection(title: "Lane summary") { - VStack(alignment: .leading, spacing: 10) { - LaneInfoRow(label: "Type", value: detail.lane.laneType.capitalized) - LaneInfoRow(label: "Base", value: detail.lane.baseRef) - LaneInfoRow(label: "Path", value: detail.lane.worktreePath, isMonospaced: true) - if let parentLaneId = detail.lane.parentLaneId, - let parent = allLaneSnapshots.first(where: { $0.lane.id == parentLaneId })?.lane { - LaneInfoRow(label: "Parent", value: "\(parent.name) (\(parent.branchRef))") - } - } - } - - if !lanePullRequests.isEmpty { - GlassSection(title: lanePullRequests.count == 1 ? "Linked PR" : "Linked PRs") { - VStack(alignment: .leading, spacing: 10) { - ForEach(lanePullRequests.prefix(3)) { pr in - Button { - openPullRequest(pr.id) - } label: { - HStack(spacing: 10) { - VStack(alignment: .leading, spacing: 4) { - Text(pr.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text("#\(pr.githubPrNumber) · \(pr.state.uppercased())") - .font(.caption.monospaced()) - .foregroundStyle(ADEColor.textSecondary) - } - Spacer(minLength: 8) - ADEStatusPill(text: pr.state.uppercased(), tint: lanePullRequestTint(pr.state)) - } - } - .buttonStyle(.glass) - .accessibilityLabel("Open pull request number \(pr.githubPrNumber), \(pr.title)") - } - } - } - } - - if detail.autoRebaseStatus != nil || detail.rebaseSuggestion != nil { - GlassSection(title: "Rebase status") { - VStack(alignment: .leading, spacing: 10) { - if let autoRebaseStatus = detail.autoRebaseStatus, autoRebaseStatus.state != "autoRebased" { - Text(autoRebaseStatus.message ?? "This lane needs manual rebase attention.") - .font(.subheadline) - .foregroundStyle(ADEColor.textPrimary) - if autoRebaseStatus.conflictCount > 0 { - Text("\(autoRebaseStatus.conflictCount) conflict file(s) blocking auto-rebase.") - .font(.caption) - .foregroundStyle(ADEColor.danger) - } - } - - if let rebaseSuggestion = detail.rebaseSuggestion { - Text("Behind parent by \(rebaseSuggestion.behindCount) commit(s).") - .font(.subheadline) - .foregroundStyle(ADEColor.textPrimary) - } - - HStack(spacing: 8) { - if detail.rebaseSuggestion != nil { - LaneActionButton(title: "Defer", symbol: "clock.badge.pause") { - Task { await performAction("defer rebase") { try await syncService.deferRebaseSuggestion(laneId: laneId) } } - } - LaneActionButton(title: "Dismiss", symbol: "xmark.circle") { - Task { await performAction("dismiss rebase") { try await syncService.dismissRebaseSuggestion(laneId: laneId) } } - } - } - Spacer(minLength: 8) - LaneActionButton(title: "Rebase", symbol: "arrow.triangle.2.circlepath", tint: ADEColor.accent) { - Task { await performAction("rebase lane") { try await syncService.startLaneRebase(laneId: laneId) } } - } - .disabled(!canRunLiveActions) - } - } - } - } - - if let conflictStatus = detail.conflictStatus { - GlassSection(title: "Conflicts") { - VStack(alignment: .leading, spacing: 10) { - Text(conflictSummary(conflictStatus)) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - - ForEach(detail.overlaps) { overlap in - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(overlap.peerName) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Spacer() - LaneTypeBadge( - text: overlap.riskLevel.uppercased(), - tint: overlap.riskLevel == "high" ? ADEColor.danger : ADEColor.warning - ) - } - - ForEach(overlap.files.prefix(4)) { file in - HStack(alignment: .top, spacing: 8) { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 10)) - .foregroundStyle(ADEColor.textMuted) - .padding(.top, 3) - VStack(alignment: .leading, spacing: 2) { - Text(file.path) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textPrimary) - Text(file.conflictType) - .font(.caption2) - .foregroundStyle(ADEColor.textSecondary) - } - } - } - } - } - } - } - } - - GlassSection(title: "Stack") { - VStack(alignment: .leading, spacing: 10) { - if detail.stackChain.isEmpty { - Text("No stack chain available.") - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - } else { - ForEach(detail.stackChain) { item in - HStack(alignment: .center, spacing: 10) { - Circle() - .fill(item.laneId == laneId ? ADEColor.accent : runtimeTint(bucket: detail.runtime.bucket)) - .frame(width: 7, height: 7) - Text(String(repeating: " ", count: item.depth) + item.laneName) - .font(.subheadline.weight(item.laneId == laneId ? .semibold : .regular)) - .foregroundStyle(ADEColor.textPrimary) - Spacer() - Text(item.branchRef) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - } - } - } - - if !detail.children.isEmpty { - Divider() - VStack(alignment: .leading, spacing: 8) { - Text("Children") - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - ForEach(detail.children) { child in - HStack { - Text(child.name) - .font(.subheadline) - .foregroundStyle(ADEColor.textPrimary) - Spacer() - Text(child.branchRef) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - } - } - } - } - } - } - - if summarizeState(detail.stateSnapshot?.agentSummary) != nil || summarizeState(detail.stateSnapshot?.missionSummary) != nil { - GlassSection(title: "Live state") { - VStack(alignment: .leading, spacing: 10) { - if let stateText = summarizeState(detail.stateSnapshot?.agentSummary) { - VStack(alignment: .leading, spacing: 4) { - Text("Agent") - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - Text(stateText) - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - } - } - if let missionText = summarizeState(detail.stateSnapshot?.missionSummary) { - VStack(alignment: .leading, spacing: 4) { - Text("Mission") - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - Text(missionText) - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - } - } - } - } - } - } - } - } - - // MARK: - Git section - - @ViewBuilder - private var gitSections: some View { - if let detail { - VStack(spacing: 14) { - GlassSection(title: "Sync", subtitle: detail.syncStatus.map(syncSummary)) { - VStack(alignment: .leading, spacing: 12) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - LaneActionButton(title: "Fetch", symbol: "arrow.down.circle") { - Task { await performAction("fetch") { try await syncService.fetchGit(laneId: laneId) } } - } - Menu { - Button("Pull (merge)") { - Task { await performAction("pull merge") { try await syncService.pullGit(laneId: laneId) } } - } - Button("Pull (rebase)") { - Task { await performAction("pull rebase") { try await syncService.syncGit(laneId: laneId, mode: "rebase") } } - } - } label: { - LaneMenuLabel(title: "Pull") - } - LaneActionButton( - title: detail.syncStatus?.hasUpstream == false ? "Publish" : "Push", - symbol: "arrow.up.circle", - tint: ADEColor.accent - ) { - Task { await performAction("push") { try await syncService.pushGit(laneId: laneId) } } - } - Menu { - Button("Force push") { - Task { await performAction("force push") { try await syncService.pushGit(laneId: laneId, forceWithLease: true) } } - } - Divider() - Button("Rebase lane only") { - Task { await performAction("rebase lane") { try await syncService.startLaneRebase(laneId: laneId, scope: "lane_only") } } - } - Button("Rebase lane + descendants") { - Task { await performAction("rebase descendants") { try await syncService.startLaneRebase(laneId: laneId, scope: "lane_and_descendants") } } - } - Button("Rebase and push") { - Task { await performAction("rebase and push") { try await runRebaseAndPush() } } - } - } label: { - LaneMenuLabel(title: "More") - } - } - } - - if let upstreamRef = detail.syncStatus?.upstreamRef { - LaneInfoRow(label: "Upstream", value: upstreamRef, isMonospaced: true) - } - } - } - - GlassSection(title: "Commit") { - VStack(alignment: .leading, spacing: 12) { - TextField("Commit message", text: $commitMessage, axis: .vertical) - .textFieldStyle(.plain) - .adeInsetField() - - Toggle("Amend latest commit", isOn: $amendCommit) - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - - HStack(spacing: 8) { - LaneActionButton(title: "Generate", symbol: "sparkles") { - Task { - do { - commitMessage = try await syncService.generateCommitMessage(laneId: laneId, amend: amendCommit) - } catch { - errorMessage = error.localizedDescription - } - } - } - LaneActionButton(title: "Commit", symbol: "checkmark.circle.fill", tint: ADEColor.accent) { - Task { - await performAction("commit") { - try await syncService.commitLane(laneId: laneId, message: commitMessage, amend: amendCommit) - } - commitMessage = "" - } - } - } - } - } - - if let diffChanges = detail.diffChanges, !diffChanges.unstaged.isEmpty { - GlassSection(title: "Unstaged files (\(diffChanges.unstaged.count))") { - VStack(alignment: .leading, spacing: 10) { - if diffChanges.unstaged.count > 1 { - LaneActionButton(title: "Stage all", symbol: "plus.circle.fill", tint: ADEColor.accent) { - Task { - await performAction("stage all") { - try await syncService.stageAll(laneId: laneId, paths: diffChanges.unstaged.map(\.path)) - } - } - } - } - ForEach(diffChanges.unstaged) { file in - fileRow(file: file, mode: "unstaged") - } - } - } - } - - if let diffChanges = detail.diffChanges, !diffChanges.staged.isEmpty { - GlassSection(title: "Staged files (\(diffChanges.staged.count))") { - VStack(alignment: .leading, spacing: 10) { - if diffChanges.staged.count > 1 { - LaneActionButton(title: "Unstage all", symbol: "minus.circle", tint: ADEColor.warning) { - Task { - await performAction("unstage all") { - try await syncService.unstageAll(laneId: laneId, paths: diffChanges.staged.map(\.path)) - } - } - } - } - ForEach(diffChanges.staged) { file in - fileRow(file: file, mode: "staged") - } - } - } - } - - if !detail.stashes.isEmpty || canRunLiveActions { - GlassSection(title: "Stashes") { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 8) { - TextField("Stash message", text: $stashMessage) - .textFieldStyle(.plain) - .adeInsetField(cornerRadius: 10, padding: 10) - LaneActionButton(title: "Stash", symbol: "tray.and.arrow.down", tint: ADEColor.accent) { - Task { await performAction("stash") { try await syncService.stashPush(laneId: laneId, message: stashMessage, includeUntracked: true) } } - } - } - - ForEach(detail.stashes) { stash in - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(stash.subject) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Spacer() - if let createdAt = stash.createdAt { - Text(relativeTimestamp(createdAt)) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - } - } - HStack(spacing: 8) { - LaneActionButton(title: "Apply", symbol: "tray.and.arrow.up") { - Task { await performAction("stash apply") { try await syncService.stashApply(laneId: laneId, stashRef: stash.ref) } } - } - LaneActionButton(title: "Pop", symbol: "arrow.up.right.square") { - Task { await performAction("stash pop") { try await syncService.stashPop(laneId: laneId, stashRef: stash.ref) } } - } - LaneActionButton(title: "Drop", symbol: "trash", tint: ADEColor.danger) { - Task { await performAction("stash drop") { try await syncService.stashDrop(laneId: laneId, stashRef: stash.ref) } } - } - } - } - if stash.id != detail.stashes.last?.id { - Divider() - } - } - } - } - } - - if !detail.recentCommits.isEmpty { - GlassSection(title: "Recent commits") { - VStack(alignment: .leading, spacing: 12) { - ForEach(detail.recentCommits) { commit in - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(commit.subject) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(2) - Spacer() - Text(commit.shortSha) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - } - Text("\(commit.authorName) \u{2022} \(relativeTimestamp(commit.authoredAt))") - .font(.caption2) - .foregroundStyle(ADEColor.textSecondary) - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - LaneActionButton(title: "Diff", symbol: "doc.text.magnifyingglass") { - Task { - do { - let files = try await syncService.listCommitFiles(laneId: laneId, commitSha: commit.sha) - guard let path = files.first else { - errorMessage = "This commit has no file diffs." - return - } - selectedDiffRequest = LaneDiffRequest( - laneId: laneId, - path: path, - mode: "commit", - compareRef: commit.sha, - compareTo: "parent", - title: commit.subject - ) - } catch { - errorMessage = error.localizedDescription - } - } - } - LaneActionButton(title: "Message", symbol: "text.alignleft") { - Task { - do { - commitMessage = try await syncService.getCommitMessage(laneId: laneId, commitSha: commit.sha) - } catch { - errorMessage = error.localizedDescription - } - } - } - LaneActionButton(title: "Revert", symbol: "arrow.uturn.backward", tint: ADEColor.warning) { - Task { await performAction("revert commit") { try await syncService.revertCommit(laneId: laneId, commitSha: commit.sha) } } - } - LaneActionButton(title: "Cherry-pick", symbol: "arrow.triangle.merge") { - Task { await performAction("cherry pick") { try await syncService.cherryPickCommit(laneId: laneId, commitSha: commit.sha) } } - } - } - } - } - - if commit.id != detail.recentCommits.last?.id { - Divider() - } - } - } - } - } - - if let conflictState = detail.conflictState, conflictState.inProgress { - GlassSection(title: "Rebase conflict") { - VStack(alignment: .leading, spacing: 12) { - Text("Git reports a \(conflictState.kind ?? "merge") in progress.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - - if !conflictState.conflictedFiles.isEmpty { - ForEach(conflictState.conflictedFiles, id: \.self) { path in - Text(path) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - } - } - HStack(spacing: 8) { - LaneActionButton(title: "Continue", symbol: "play.fill", tint: ADEColor.accent) { - Task { await performAction("rebase continue") { try await syncService.rebaseContinueGit(laneId: laneId) } } - } - .disabled(!conflictState.canContinue) - LaneActionButton(title: "Abort", symbol: "xmark.circle", tint: ADEColor.danger) { - Task { await performAction("rebase abort") { try await syncService.rebaseAbortGit(laneId: laneId) } } - } - .disabled(!conflictState.canAbort) - } - } - } - } - } - } - } - - // MARK: - Work section - - @ViewBuilder - private var workSections: some View { - if let detail { - VStack(spacing: 14) { - GlassSection(title: "Launch") { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 10) { - LaneLaunchTile(title: "Shell", symbol: "terminal", tint: ADEColor.textSecondary) { - Task { - await performAction("launch shell") { - try await syncService.runQuickCommand(laneId: laneId, title: "Shell", toolType: "shell", tracked: trackedLaunch) - } - } - } - LaneLaunchTile(title: "Codex", symbol: "sparkle", tint: ADEColor.accent) { - chatLaunchTarget = LaneChatLaunchTarget(provider: "codex") - } - LaneLaunchTile(title: "Claude", symbol: "brain.head.profile", tint: ADEColor.warning) { - chatLaunchTarget = LaneChatLaunchTarget(provider: "claude") - } - } - - Toggle("Track sessions", isOn: $trackedLaunch) - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - - LaneActionButton(title: "Open in Files", symbol: "folder", tint: ADEColor.accent) { - Task { await openFiles() } - } - } - } - - if !detail.sessions.isEmpty { - GlassSection(title: "Workspace sessions (\(detail.sessions.count))") { - VStack(alignment: .leading, spacing: 10) { - ForEach(detail.sessions) { session in - NavigationLink { - LaneSessionTranscriptView(session: session) - } label: { - LaneSessionCard(session: session) - } - .buttonStyle(.plain) - .swipeActions(edge: .trailing) { - Button("Close", role: .destructive) { - Task { await performAction("close session") { try await syncService.closeWorkSession(sessionId: session.id) } } - } - } - } - } - } - } - - if !detail.chatSessions.isEmpty { - GlassSection(title: "AI chats (\(detail.chatSessions.count))") { - VStack(alignment: .leading, spacing: 10) { - ForEach(detail.chatSessions) { chat in - NavigationLink { - LaneChatSessionView(summary: chat) - } label: { - LaneChatCard(chat: chat) - } - .buttonStyle(.plain) - } - } - } - } - } - } - } - - // MARK: - Manage section - - @ViewBuilder - private var manageSections: some View { - if let detail { - VStack(spacing: 14) { - GlassSection(title: "Identity") { - VStack(alignment: .leading, spacing: 12) { - LaneTextField("Lane name", text: $renameText) - LaneActionButton(title: "Save name", symbol: "checkmark.circle.fill", tint: ADEColor.accent) { - Task { await performAction("rename lane") { try await syncService.renameLane(laneId, name: renameText) } } - } - .disabled(renameText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || renameText == detail.lane.name) - } - } - - GlassSection(title: "Appearance") { - VStack(alignment: .leading, spacing: 12) { - LaneTextField("Color token or hex", text: $colorText) - .textInputAutocapitalization(.never) - LaneTextField("Icon (star, flag, bolt, shield, tag)", text: $iconText) - .textInputAutocapitalization(.never) - LaneTextField("Tags (comma separated)", text: $tagsText) - LaneActionButton(title: "Save appearance", symbol: "paintpalette", tint: ADEColor.accent) { - Task { - let tags = tagsText - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - await performAction("save appearance") { - try await syncService.updateLaneAppearance( - laneId, - color: colorText, - icon: iconText, - tags: tags - ) - } - } - } - } - } - - if detail.lane.laneType != "primary" { - GlassSection(title: "Reparent") { - VStack(alignment: .leading, spacing: 12) { - Picker("Parent lane", selection: $selectedParentLaneId) { - Text("Select parent").tag("") - ForEach(reparentCandidates) { lane in - Text("\(lane.name) (\(lane.branchRef))").tag(lane.id) - } - } - .pickerStyle(.menu) - - LaneActionButton(title: "Save parent", symbol: "arrow.triangle.swap", tint: ADEColor.accent) { - Task { await performAction("reparent lane") { try await syncService.reparentLane(laneId, newParentLaneId: selectedParentLaneId) } } - } - .disabled(selectedParentLaneId.isEmpty) - } - } - } - - if detail.lane.laneType == "attached" && detail.lane.archivedAt == nil { - GlassSection(title: "Attached lane") { - VStack(alignment: .leading, spacing: 8) { - Text("Adopt this worktree into .ade/worktrees so ADE manages it end-to-end.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - LaneActionButton(title: "Move to ADE-managed worktree", symbol: "arrow.down.doc", tint: ADEColor.accent) { - Task { await performAction("adopt attached lane") { _ = try await syncService.adoptAttachedLane(laneId) } } - } - } - } - } - - GlassSection(title: detail.lane.archivedAt == nil ? "Archive" : "Restore") { - if detail.lane.archivedAt == nil { - LaneActionButton(title: "Archive lane", symbol: "archivebox", tint: ADEColor.warning) { - Task { await performAction("archive lane") { try await syncService.archiveLane(laneId) } } - } - .disabled(detail.lane.laneType == "primary") - } else { - LaneActionButton(title: "Restore lane", symbol: "tray.and.arrow.up", tint: ADEColor.accent) { - Task { await performAction("restore lane") { try await syncService.unarchiveLane(laneId) } } - } - } - } - - if detail.lane.laneType != "primary" { - GlassSection(title: "Danger zone") { - DisclosureGroup("Delete lane") { - VStack(alignment: .leading, spacing: 12) { - Picker("Delete mode", selection: $deleteMode) { - ForEach(LaneDeleteMode.allCases) { mode in - Text(mode.title).tag(mode) - } - } - .pickerStyle(.menu) - - if deleteMode == .remoteBranch { - LaneTextField("Remote name", text: $deleteRemoteName) - } - - Toggle("Force delete", isOn: $deleteForce) - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - - LaneTextField("Type delete \(detail.lane.name) to confirm", text: $deleteConfirmText) - - LaneActionButton(title: "Delete lane", symbol: "trash", tint: ADEColor.danger) { - Task { - await performAction("delete lane") { - try await syncService.deleteLane( - laneId, - deleteBranch: deleteMode != .worktree, - deleteRemoteBranch: deleteMode == .remoteBranch, - remoteName: deleteRemoteName, - force: deleteForce - ) - } - } - } - .disabled(deleteConfirmText.lowercased() != "delete \(detail.lane.name)".lowercased()) - } - .padding(.top, 12) - } - .tint(ADEColor.danger) - } - } - } - } - } - - // MARK: - Detail helpers - - private var connectionBanner: ADENoticeCard? { - guard !canRunLiveActions else { return nil } - return ADENoticeCard( - title: "Showing cached lane detail", - message: "Reconnect to refresh git state, sessions, and lane actions.", - icon: "icloud.slash", - tint: ADEColor.warning, - actionTitle: syncService.activeHostProfile == nil ? "Pair again" : "Reconnect", - action: { - if syncService.activeHostProfile == nil { - syncService.settingsPresented = true - } else { - Task { - await syncService.reconnectIfPossible() - await loadDetail(refreshRemote: true) - } - } - } - ) - } - - private var canRunLiveActions: Bool { - syncService.connectionState == .connected || syncService.connectionState == .syncing - } - - @MainActor - private func loadDetail(refreshRemote: Bool) async { - do { - if let cached = try await syncService.fetchLaneDetail(laneId: laneId) { - detail = cached - seedForms(from: cached) - } - if refreshRemote { - let refreshed = try await syncService.refreshLaneDetail(laneId: laneId) - detail = refreshed - seedForms(from: refreshed) - await onRefreshRoot() - } - lanePullRequests = (try? await syncService.fetchPullRequestListItems().filter { $0.laneId == laneId }) ?? [] - errorMessage = nil - } catch { - errorMessage = error.localizedDescription - } - } - - @MainActor - private func performAction(_ label: String, operation: () async throws -> Void) async { - do { - busyAction = label - try await operation() - await loadDetail(refreshRemote: true) - errorMessage = nil - } catch { - errorMessage = error.localizedDescription - } - busyAction = nil - } - - private func runRebaseAndPush() async throws { - try await syncService.startLaneRebase(laneId: laneId, scope: "lane_only", pushMode: "none") - try? await syncService.fetchGit(laneId: laneId) - let syncStatus = try await syncService.fetchSyncStatus(laneId: laneId) - if syncStatus.hasUpstream == false { - try await syncService.pushGit(laneId: laneId) - return - } - if syncStatus.diverged && syncStatus.ahead > 0 { - try await syncService.pushGit(laneId: laneId, forceWithLease: true) - return - } - if syncStatus.ahead > 0 { - try await syncService.pushGit(laneId: laneId) - } - } - - @MainActor - private func openFiles(path: String? = nil) async { - do { - let workspaces = try await syncService.listWorkspaces() - guard let workspace = workspaces.first(where: { $0.laneId == laneId }) else { - errorMessage = "No Files workspace for this lane." - return - } - syncService.requestedFilesNavigation = FilesNavigationRequest( - workspaceId: workspace.id, - relativePath: path - ) - } catch { - errorMessage = error.localizedDescription - } - } - - private func openPullRequest(_ prId: String) { - syncService.requestedPrNavigation = PrNavigationRequest(prId: prId) - } - - private func seedForms(from detail: LaneDetailPayload) { - renameText = detail.lane.name - colorText = detail.lane.color ?? "" - iconText = detail.lane.icon?.rawValue ?? "" - tagsText = detail.lane.tags.joined(separator: ", ") - selectedParentLaneId = detail.lane.parentLaneId ?? "" - } - - @ViewBuilder - private func fileRow(file: FileChange, mode: String) -> some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 10) { - Circle() - .fill(file.kind == "modified" ? ADEColor.warning : file.kind == "added" ? ADEColor.success : ADEColor.danger) - .frame(width: 6, height: 6) - .padding(.top, 7) - VStack(alignment: .leading, spacing: 2) { - Text(file.path) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(2) - Text(file.kind.capitalized) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - } - Spacer() - } - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - LaneActionButton(title: "Diff", symbol: "doc.text.magnifyingglass") { - selectedDiffRequest = LaneDiffRequest( - laneId: laneId, - path: file.path, - mode: mode, - compareRef: nil, - compareTo: nil, - title: file.path - ) - } - LaneActionButton(title: "Files", symbol: "folder") { - Task { await openFiles(path: file.path) } - } - if mode == "unstaged" { - LaneActionButton(title: "Stage", symbol: "plus.circle.fill", tint: ADEColor.accent) { - Task { await performAction("stage file") { try await syncService.stageFile(laneId: laneId, path: file.path) } } - } - LaneActionButton(title: "Discard", symbol: "trash", tint: ADEColor.danger) { - Task { await performAction("discard file") { try await syncService.discardFile(laneId: laneId, path: file.path) } } - } - } else { - LaneActionButton(title: "Unstage", symbol: "minus.circle", tint: ADEColor.warning) { - Task { await performAction("unstage file") { try await syncService.unstageFile(laneId: laneId, path: file.path) } } - } - LaneActionButton(title: "Restore", symbol: "trash", tint: ADEColor.danger) { - Task { await performAction("restore staged file") { try await syncService.restoreStagedFile(laneId: laneId, path: file.path) } } - } - } - } - } - } - .adeGlassCard(cornerRadius: 10, padding: 10) - } -} - -// MARK: - Sheets - -private struct LaneDetailSheetTarget: Identifiable { - var id: String { "\(laneId):\(initialSection.rawValue)" } - let laneId: String - let snapshot: LaneListSnapshot - let initialSection: LaneDetailSection -} - -private struct LaneDiffRequest: Identifiable { - var id: String { "\(laneId):\(mode):\(path ?? "none"):\(compareRef ?? "none")" } - let laneId: String - let path: String? - let mode: String - let compareRef: String? - let compareTo: String? - let title: String -} - -private struct LaneChatLaunchTarget: Identifiable { - var id: String { provider } - let provider: String -} - -private struct LaneCreateSheet: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var syncService: SyncService - - let primaryLane: LaneSummary? - let lanes: [LaneSummary] - let onComplete: @MainActor (String) async -> Void - - @State private var name = "" - @State private var description = "" - @State private var createAsChild = false - @State private var selectedParentLaneId = "" - @State private var selectedBaseBranch = "" - @State private var templates: [LaneTemplate] = [] - @State private var selectedTemplateId = "" - @State private var branches: [GitBranchSummary] = [] - @State private var errorMessage: String? - @State private var busy = false - @State private var envProgress: LaneEnvInitProgress? - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 14) { - GlassSection(title: "Create lane", subtitle: createAsChild ? "Branches from another ADE lane." : "Branches from the selected base.") { - VStack(alignment: .leading, spacing: 12) { - LaneTextField("Lane name", text: $name) - LaneTextField("Description", text: $description) - } - } - - GlassSection(title: "Branching") { - VStack(alignment: .leading, spacing: 12) { - Toggle("Create as child lane", isOn: $createAsChild) - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - - if createAsChild { - Picker("Parent lane", selection: $selectedParentLaneId) { - Text("Select parent").tag("") - ForEach(lanes.filter { $0.archivedAt == nil }) { lane in - Text("\(lane.name) (\(lane.branchRef))").tag(lane.id) - } - } - .pickerStyle(.menu) - } else { - Picker("Base branch", selection: $selectedBaseBranch) { - ForEach(branches.filter { !$0.isRemote }) { branch in - Text(branch.name).tag(branch.name) - } - } - .pickerStyle(.menu) - } - } - } - - GlassSection(title: "Template") { - Picker("Template", selection: $selectedTemplateId) { - Text("No template").tag("") - ForEach(templates) { template in - Text(template.name).tag(template.id) - } - } - .pickerStyle(.menu) - } - - if let envProgress { - GlassSection(title: "Environment setup") { - VStack(alignment: .leading, spacing: 10) { - ForEach(envProgress.steps) { step in - HStack { - Text(step.label) - .font(.subheadline) - .foregroundStyle(ADEColor.textPrimary) - Spacer() - Text(step.status) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - } - } - } - } - } - - if let errorMessage { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(ADEColor.danger) - Text(errorMessage) - .font(.caption) - .foregroundStyle(ADEColor.danger) - Spacer() - } - .padding(12) - .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - } - .padding(16) - } - .adeScreenBackground() - .adeNavigationGlass() - .navigationTitle("Create lane") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - .disabled(busy) - } - ToolbarItem(placement: .confirmationAction) { - Button("Create") { - Task { await submit() } - } - .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (createAsChild && selectedParentLaneId.isEmpty) || busy) - } - } - .task { - await loadOptions() - } - } - } - - @MainActor - private func loadOptions() async { - do { - templates = try await syncService.fetchLaneTemplates() - selectedTemplateId = try await syncService.fetchDefaultLaneTemplateId() ?? "" - if let primaryLane { - branches = try await syncService.listBranches(laneId: primaryLane.id) - selectedBaseBranch = branches.first(where: { $0.isCurrent })?.name ?? branches.first?.name ?? primaryLane.branchRef - } - } catch { - errorMessage = error.localizedDescription - } - } - - @MainActor - private func submit() async { - do { - busy = true - errorMessage = nil - let created: LaneSummary - if createAsChild { - created = try await syncService.createChildLane(name: name, parentLaneId: selectedParentLaneId, description: description) - } else { - created = try await syncService.createLane( - name: name, - description: description, - parentLaneId: nil, - baseBranch: selectedBaseBranch - ) - } - envProgress = selectedTemplateId.isEmpty - ? try await syncService.initializeLaneEnvironment(laneId: created.id) - : try await syncService.applyLaneTemplate(laneId: created.id, templateId: selectedTemplateId) - await onComplete(created.id) - dismiss() - } catch { - errorMessage = error.localizedDescription - } - busy = false - } -} - -private struct LaneAttachSheet: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var syncService: SyncService - - let onComplete: @MainActor (String) async -> Void - - @State private var name = "" - @State private var attachedPath = "" - @State private var description = "" - @State private var busy = false - @State private var errorMessage: String? - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 14) { - GlassSection(title: "Attach worktree", subtitle: "Register an existing worktree as a lane.") { - VStack(alignment: .leading, spacing: 12) { - LaneTextField("Lane name", text: $name) - LaneTextField("Worktree path", text: $attachedPath) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - LaneTextField("Description", text: $description) - } - } - - if let errorMessage { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(ADEColor.danger) - Text(errorMessage) - .font(.caption) - .foregroundStyle(ADEColor.danger) - Spacer() - } - .padding(12) - .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - } - .padding(16) - } - .adeScreenBackground() - .adeNavigationGlass() - .navigationTitle("Attach worktree") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - .disabled(busy) - } - ToolbarItem(placement: .confirmationAction) { - Button("Attach") { - Task { await submit() } - } - .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || attachedPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || busy) - } - } - } - } - - @MainActor - private func submit() async { - do { - busy = true - errorMessage = nil - let lane = try await syncService.attachLane(name: name, attachedPath: attachedPath, description: description) - await onComplete(lane.id) - dismiss() - } catch { - errorMessage = error.localizedDescription - } - busy = false - } -} - -private struct LaneBatchManageSheet: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var syncService: SyncService - - let snapshots: [LaneListSnapshot] - let onComplete: @MainActor () async -> Void - - @State private var deleteMode: LaneDeleteMode = .worktree - @State private var deleteRemoteName = "origin" - @State private var deleteForce = false - @State private var confirmText = "" - @State private var errorMessage: String? - @State private var busy = false - - private var laneIds: [String] { - snapshots.map(\.lane.id) - } - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 14) { - GlassSection(title: "Selected lanes (\(laneIds.count))") { - VStack(alignment: .leading, spacing: 8) { - ForEach(snapshots) { snapshot in - HStack(alignment: .center, spacing: 10) { - LaneStatusIndicator(bucket: snapshot.runtime.bucket, size: 8) - VStack(alignment: .leading, spacing: 2) { - Text(snapshot.lane.name) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text(snapshot.lane.branchRef) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - } - Spacer() - if snapshot.lane.status.dirty { - LaneTypeBadge(text: "Dirty", tint: ADEColor.warning) - } - } - } - } - } - - GlassSection(title: "Archive") { - Button { - Task { await archiveSelected() } - } label: { - HStack { - Image(systemName: "archivebox.fill") - Text("Archive selected lanes") - .font(.subheadline.weight(.semibold)) - Spacer() - } - .foregroundStyle(ADEColor.warning) - .padding(12) - .background(ADEColor.warning.opacity(0.1), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - .buttonStyle(.plain) - .disabled(busy || laneIds.isEmpty) - } - - GlassSection(title: "Delete") { - VStack(alignment: .leading, spacing: 12) { - Picker("Delete mode", selection: $deleteMode) { - ForEach(LaneDeleteMode.allCases) { mode in - Text(mode.title).tag(mode) - } - } - .pickerStyle(.menu) - - if deleteMode == .remoteBranch { - LaneTextField("Remote name", text: $deleteRemoteName) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - } - - Toggle("Force delete", isOn: $deleteForce) - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - - LaneTextField("Type delete open lanes to confirm", text: $confirmText) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - Button(role: .destructive) { - Task { await deleteSelected() } - } label: { - HStack { - Image(systemName: "trash.fill") - Text("Delete selected lanes") - .font(.subheadline.weight(.semibold)) - Spacer() - } - .padding(12) - .background(ADEColor.danger.opacity(0.1), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - .buttonStyle(.plain) - .disabled(confirmText.lowercased() != "delete open lanes" || busy || laneIds.isEmpty) - } - } - - if let errorMessage { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(ADEColor.danger) - Text(errorMessage) - .font(.caption) - .foregroundStyle(ADEColor.danger) - Spacer() - } - .padding(12) - .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - } - .padding(16) - } - .adeScreenBackground() - .adeNavigationGlass() - .navigationTitle("Manage lanes") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { dismiss() } - .disabled(busy) - } - } - } - } - - @MainActor - private func archiveSelected() async { - do { - busy = true - for laneId in laneIds { - try await syncService.archiveLane(laneId) - } - await onComplete() - dismiss() - } catch { - errorMessage = error.localizedDescription - } - busy = false - } - - @MainActor - private func deleteSelected() async { - do { - busy = true - for laneId in laneIds { - try await syncService.deleteLane( - laneId, - deleteBranch: deleteMode != .worktree, - deleteRemoteBranch: deleteMode == .remoteBranch, - remoteName: deleteRemoteName, - force: deleteForce - ) - } - await onComplete() - dismiss() - } catch { - errorMessage = error.localizedDescription - } - busy = false - } -} - -private struct LaneStackGraphSheet: View { - @Environment(\.dismiss) private var dismiss - - let snapshots: [LaneListSnapshot] - let selectedLaneId: String - - private var orderedSnapshots: [LaneListSnapshot] { - let childrenByParent = Dictionary(grouping: snapshots) { snapshot in - snapshot.lane.parentLaneId ?? "__root__" - } - let primaryId = snapshots.first(where: { $0.lane.laneType == "primary" })?.lane.id - - func visit(parentId: String?) -> [LaneListSnapshot] { - let key = parentId ?? "__root__" - let children = (childrenByParent[key] ?? []).sorted { lhs, rhs in - lhs.lane.createdAt < rhs.lane.createdAt - } - return children.flatMap { child in - [child] + visit(parentId: child.lane.id) - } - } - - let primaryBranch = primaryId.flatMap { id in snapshots.first(where: { $0.lane.id == id }) }.map { [$0] + visit(parentId: $0.lane.id) } ?? [] - let seen = Set(primaryBranch.map(\.lane.id)) - let remaining = snapshots.filter { !seen.contains($0.lane.id) }.sorted { $0.lane.createdAt < $1.lane.createdAt } - return primaryBranch + remaining - } - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 14) { - GlassSection(title: "Stack graph") { - VStack(alignment: .leading, spacing: 8) { - ForEach(orderedSnapshots) { snapshot in - HStack(alignment: .top, spacing: 12) { - HStack(spacing: 0) { - if snapshot.lane.stackDepth > 0 { - Rectangle() - .fill(ADEColor.border.opacity(0.4)) - .frame(width: CGFloat(snapshot.lane.stackDepth) * 12, height: 1) - .padding(.top, 10) - } - Circle() - .fill(snapshot.lane.id == selectedLaneId ? ADEColor.accent : runtimeTint(bucket: snapshot.runtime.bucket)) - .frame(width: 8, height: 8) - .padding(.top, 6) - } - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 8) { - Text(snapshot.lane.name) - .font(.subheadline.weight(snapshot.lane.id == selectedLaneId ? .semibold : .regular)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - if snapshot.lane.id == selectedLaneId { - LaneTypeBadge(text: "Current", tint: ADEColor.accent) - } - } - Text(snapshot.lane.branchRef) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - } - Spacer() - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(snapshot.lane.id == selectedLaneId ? ADEColor.accent.opacity(0.1) : ADEColor.surfaceBackground.opacity(0.6)) - ) - } - } - } - } - .padding(16) - } - .adeScreenBackground() - .adeNavigationGlass() - .navigationTitle("Stack graph") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { dismiss() } - } - } - } - } -} - -private struct LaneDiffScreen: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var syncService: SyncService - - let request: LaneDiffRequest - - @State private var diff: FileDiff? - @State private var editedText = "" - @State private var errorMessage: String? - @State private var side = "modified" - - var body: some View { - NavigationStack { - VStack(spacing: 0) { - ScrollView { - VStack(spacing: 14) { - GlassSection(title: request.title) { - VStack(alignment: .leading, spacing: 8) { - if let path = request.path { - Text(path) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - } - if let compareRef = request.compareRef, !compareRef.isEmpty { - LaneInfoRow(label: "Base", value: compareRef, isMonospaced: true) - } - if let compareTo = request.compareTo, !compareTo.isEmpty { - LaneInfoRow(label: "Against", value: compareTo, isMonospaced: true) - } - } - } - - if let errorMessage { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(ADEColor.danger) - Text(errorMessage) - .font(.caption) - .foregroundStyle(ADEColor.danger) - Spacer() - } - .padding(12) - .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - - if diff != nil { - Picker("Side", selection: $side) { - Text("Original").tag("original") - Text("Modified").tag("modified") - } - .pickerStyle(.segmented) - } - } - .padding(16) - } - - if let diff { - if diff.isBinary == true { - GlassSection(title: "Binary diff") { - Text("Binary content is view-only on iPhone.") - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - } - .padding(.horizontal, 16) - } else { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(side == "original" ? "Original" : "Modified") - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - Spacer() - if request.mode == "unstaged" && side == "modified" { - Text("Editable") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.accent) - } - } - TextEditor(text: Binding( - get: { - side == "original" ? diff.original.text : editedText - }, - set: { newValue in - editedText = newValue - } - )) - .font(.system(.footnote, design: .monospaced)) - .scrollContentBackground(.hidden) - .adeInsetField(cornerRadius: 14, padding: 12) - .disabled(side == "original") - } - .padding(.horizontal, 16) - .padding(.bottom, 16) - } - } else { - Spacer() - ProgressView() - .tint(ADEColor.accent) - Spacer() - } - } - .adeScreenBackground() - .adeNavigationGlass() - .navigationTitle(request.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - if request.mode == "unstaged", let path = request.path, side == "modified" { - Button("Save") { - Task { - do { - try await syncService.writeLaneFileText(laneId: request.laneId, path: path, text: editedText) - try await load() - } catch { - errorMessage = error.localizedDescription - } - } - } - } - } - ToolbarItem(placement: .topBarTrailing) { - if let path = request.path { - Button("Files") { - Task { - do { - let workspaces = try await syncService.listWorkspaces() - guard let workspace = workspaces.first(where: { $0.laneId == request.laneId }) else { return } - syncService.requestedFilesNavigation = FilesNavigationRequest( - workspaceId: workspace.id, - relativePath: path - ) - dismiss() - } catch { - errorMessage = error.localizedDescription - } - } - } - } - } - } - .task { - try? await load() - } - } - } - - @MainActor - private func load() async throws { - guard let path = request.path else { return } - let loaded = try await syncService.fetchFileDiff( - laneId: request.laneId, - path: path, - mode: request.mode, - compareRef: request.compareRef, - compareTo: request.compareTo - ) - diff = loaded - editedText = loaded.modified.text - } -} - -private struct LaneChatLaunchSheet: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var syncService: SyncService - - let laneId: String - let provider: String - let onComplete: @MainActor (AgentChatSessionSummary) async -> Void - - @State private var models: [AgentChatModelInfo] = [] - @State private var selectedModelId = "" - @State private var selectedReasoningEffort = "" - @State private var busy = false - @State private var errorMessage: String? - - private var selectedModel: AgentChatModelInfo? { - models.first(where: { $0.id == selectedModelId }) - } - - private var providerTitle: String { - provider == "claude" ? "Claude" : "Codex" - } - - var body: some View { - NavigationStack { - ScrollView { - VStack(spacing: 14) { - GlassSection(title: providerTitle) { - HStack(alignment: .center, spacing: 12) { - Image(systemName: provider == "claude" ? "brain.head.profile" : "sparkle") - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(ADEColor.accent) - VStack(alignment: .leading, spacing: 3) { - Text(selectedModel?.displayName ?? "Choose a model") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text("Session stays lane-scoped.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - Spacer() - } - } - - if !models.isEmpty { - GlassSection(title: "Model") { - VStack(alignment: .leading, spacing: 12) { - Picker("Model", selection: $selectedModelId) { - ForEach(models) { model in - Text(model.displayName).tag(model.id) - } - } - .pickerStyle(.menu) - - if let selectedModel { - VStack(alignment: .leading, spacing: 8) { - if let description = selectedModel.description, !description.isEmpty { - Text(description) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - HStack(spacing: 6) { - if let family = selectedModel.family, !family.isEmpty { - LaneMicroChip(icon: "circle.grid.2x2.fill", text: family, tint: ADEColor.textSecondary) - } - if selectedModel.supportsReasoning == true { - LaneMicroChip(icon: "brain", text: "Reasoning", tint: ADEColor.accent) - } - if selectedModel.supportsTools == true { - LaneMicroChip(icon: "hammer.fill", text: "Tools", tint: ADEColor.success) - } - } - } - } - } - } - } - - if let reasoningEfforts = selectedModel?.reasoningEfforts, !reasoningEfforts.isEmpty { - GlassSection(title: "Reasoning") { - VStack(alignment: .leading, spacing: 12) { - Picker("Reasoning", selection: $selectedReasoningEffort) { - Text("Default").tag("") - ForEach(reasoningEfforts) { effort in - Text(effort.effort.capitalized).tag(effort.effort) - } - } - .pickerStyle(.segmented) - - if let effort = reasoningEfforts.first(where: { $0.effort == selectedReasoningEffort }) { - Text(effort.description) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - } - } - } - - if let errorMessage { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(ADEColor.danger) - Text(errorMessage) - .font(.caption) - .foregroundStyle(ADEColor.danger) - Spacer() - } - .padding(12) - .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - - if busy { - HStack(spacing: 10) { - ProgressView() - .tint(ADEColor.accent) - Text("Creating \(providerTitle) chat...") - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - Spacer() - } - .adeGlassCard(cornerRadius: 12, padding: 12) - } - } - .padding(16) - } - .adeScreenBackground() - .adeNavigationGlass() - .navigationTitle("New \(providerTitle) chat") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } - .disabled(busy) - } - ToolbarItem(placement: .confirmationAction) { - Button("Launch") { - Task { await submit() } - } - .disabled(busy || (models.isEmpty == false && selectedModelId.isEmpty)) - } - } - .task { - await loadModels() - } - } - } - - @MainActor - private func loadModels() async { - do { - models = try await syncService.listChatModels(provider: provider) - if let preferred = models.first(where: \.isDefault) ?? models.first { - selectedModelId = preferred.id - selectedReasoningEffort = preferred.reasoningEfforts?.first?.effort ?? "" - } - errorMessage = nil - } catch { - errorMessage = error.localizedDescription - } - } - - @MainActor - private func submit() async { - do { - busy = true - let session = try await syncService.createChatSession( - laneId: laneId, - provider: provider, - model: selectedModelId, - reasoningEffort: selectedReasoningEffort.isEmpty ? nil : selectedReasoningEffort - ) - await onComplete(session) - dismiss() - } catch { - errorMessage = error.localizedDescription - } - busy = false - } -} - -// MARK: - Sub-views - -private struct LaneSessionTranscriptView: View { - @EnvironmentObject private var syncService: SyncService - let session: TerminalSessionSummary - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - GlassSection(title: session.title) { - HStack(spacing: 8) { - LaneTypeBadge(text: session.status.uppercased(), tint: session.status == "running" ? ADEColor.success : ADEColor.textSecondary) - if let goal = session.goal, !goal.isEmpty { - Text(goal) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - } - } - } - - GlassSection(title: "Transcript") { - Text(syncService.terminalBuffers[session.id] ?? session.lastOutputPreview ?? "No output yet.") - .frame(maxWidth: .infinity, alignment: .leading) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - .textSelection(.enabled) - .adeInsetField(cornerRadius: 12, padding: 12) - } - } - .padding(16) - } - .adeScreenBackground() - .adeNavigationGlass() - .navigationTitle(session.title) - .navigationBarTitleDisplayMode(.inline) - .task { - try? await syncService.subscribeTerminal(sessionId: session.id) - } - } -} - -private struct LaneChatSessionView: View { - @EnvironmentObject private var syncService: SyncService - let summary: AgentChatSessionSummary - - @State private var transcript: [AgentChatTranscriptEntry] = [] - @State private var composer = "" - @State private var errorMessage: String? - @State private var sending = false - - var body: some View { - ScrollViewReader { proxy in - ScrollView { - VStack(spacing: 14) { - GlassSection(title: summary.title ?? summary.provider.uppercased()) { - HStack(spacing: 8) { - LaneTypeBadge(text: summary.status.uppercased(), tint: summary.status == "active" ? ADEColor.success : ADEColor.textSecondary) - Text(summary.model) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - } - - if let errorMessage { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(ADEColor.danger) - Text(errorMessage) - .font(.caption) - .foregroundStyle(ADEColor.danger) - Spacer() - } - .padding(12) - .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - - if transcript.isEmpty { - GlassSection(title: "Transcript") { - Text("No chat messages yet.") - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - } - } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(transcript) { entry in - VStack(alignment: .leading, spacing: 4) { - Text(entry.role.uppercased()) - .font(.caption2.weight(.bold)) - .foregroundStyle(entry.role == "assistant" ? ADEColor.accent : ADEColor.textMuted) - Text(entry.text) - .font(.body) - .foregroundStyle(ADEColor.textPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - Text(relativeTimestamp(entry.timestamp)) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(entry.role == "assistant" ? ADEColor.accent.opacity(0.08) : ADEColor.surfaceBackground.opacity(0.6)) - ) - } - } - } - - Color.clear - .frame(height: 1) - .id("lane-chat-end") - } - .padding(16) - } - .safeAreaInset(edge: .bottom) { - VStack(spacing: 10) { - HStack(spacing: 10) { - TextField("Send a message", text: $composer, axis: .vertical) - .textFieldStyle(.plain) - .adeInsetField(cornerRadius: 12, padding: 10) - - Button { - Task { - await sendMessage() - withAnimation(.snappy) { - proxy.scrollTo("lane-chat-end", anchor: .bottom) - } - } - } label: { - Image(systemName: sending ? "ellipsis.circle" : "paperplane.fill") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(ADEColor.accent) - .frame(width: 40, height: 40) - .background(ADEColor.accent.opacity(0.15), in: Circle()) - } - .disabled(composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || sending) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(ADEColor.surfaceBackground.opacity(0.08)) - .glassEffect() - } - .onChange(of: transcript.count) { _, _ in - withAnimation(.snappy) { - proxy.scrollTo("lane-chat-end", anchor: .bottom) - } - } - } - .adeScreenBackground() - .adeNavigationGlass() - .navigationTitle(summary.title ?? summary.provider.uppercased()) - .navigationBarTitleDisplayMode(.inline) - .task { - await loadTranscript() - } - } - - @MainActor - private func loadTranscript() async { - do { - transcript = try await syncService.fetchChatTranscript(sessionId: summary.sessionId) - errorMessage = nil - } catch { - errorMessage = error.localizedDescription - } - } - - @MainActor - private func sendMessage() async { - do { - sending = true - let text = composer.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { - sending = false - return - } - try await syncService.sendChatMessage(sessionId: summary.sessionId, text: text) - composer = "" - transcript = try await syncService.fetchChatTranscript(sessionId: summary.sessionId) - errorMessage = nil - } catch { - errorMessage = error.localizedDescription - } - sending = false - } -} - -// MARK: - Design system components - -private struct GlassSection: View { - let title: String - let subtitle: String? - let content: Content - - init(title: String, subtitle: String? = nil, @ViewBuilder content: () -> Content) { - self.title = title - self.subtitle = subtitle - self.content = content() - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 3) { - Text(title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - if let subtitle { - Text(subtitle) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - } - - content - } - .adeGlassCard(cornerRadius: 16, padding: 14) - } -} - -private struct LaneStatusIndicator: View { - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - let bucket: String - var size: CGFloat = 10 - - @State private var isPulsing = false - - var body: some View { - Circle() - .fill(runtimeTint(bucket: bucket)) - .frame(width: size, height: size) - .shadow(color: runtimeTint(bucket: bucket).opacity(isAnimating ? 0.5 : 0), radius: isAnimating ? 6 : 0) - .scaleEffect(isPulsing && isAnimating && !reduceMotion ? 1.3 : 1.0) - .animation(ADEMotion.pulse(reduceMotion: reduceMotion), value: isPulsing) - .onAppear { - if isAnimating && !reduceMotion { - isPulsing = true - } - } - } - - private var isAnimating: Bool { - (bucket == "running" || bucket == "awaiting-input") && !reduceMotion - } -} - -private struct LaneFilterPill: View { - let title: String - let count: Int - let isActive: Bool - let tint: Color - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 5) { - Text(title) - .font(.caption.weight(.medium)) - Text("\(count)") - .font(.caption2.weight(.semibold)) - .foregroundStyle(isActive ? tint : ADEColor.textMuted) - } - .foregroundStyle(isActive ? tint : ADEColor.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background( - isActive - ? AnyShapeStyle(tint.opacity(0.12)) - : AnyShapeStyle(ADEColor.surfaceBackground.opacity(0.55)), - in: Capsule() - ) - .glassEffect() - .overlay( - Capsule() - .stroke(isActive ? tint.opacity(0.2) : ADEColor.border.opacity(0.16), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - .sensoryFeedback(.selection, trigger: isActive) - .accessibilityLabel("\(title), \(count) items") - .accessibilityAddTraits(isActive ? .isSelected : []) - } -} - -private struct LaneTypeBadge: View { - let text: String - let tint: Color - - var body: some View { - Text(text) - .font(.caption2.weight(.semibold)) - .foregroundStyle(tint) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background(tint.opacity(0.12), in: Capsule()) - .glassEffect() - } -} - -private struct LaneMicroChip: View { - let icon: String - let text: String? - let tint: Color - - var body: some View { - HStack(spacing: 3) { - Image(systemName: icon) - .font(.system(size: 8, weight: .semibold)) - if let text { - Text(text) - .font(.system(.caption2).weight(.medium)) - } - } - .foregroundStyle(tint) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(tint.opacity(0.1), in: Capsule()) - .glassEffect() - } -} - -private struct LaneActionButton: View { - let title: String - let symbol: String - let tint: Color - let action: () -> Void - - init(title: String, symbol: String, tint: Color = ADEColor.textSecondary, action: @escaping () -> Void) { - self.title = title - self.symbol = symbol - self.tint = tint - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(spacing: 5) { - Image(systemName: symbol) - .font(.system(size: 11, weight: .semibold)) - Text(title) - .font(.caption.weight(.medium)) - } - .foregroundStyle(tint) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background(tint.opacity(0.1), in: Capsule()) - .glassEffect() - } - .buttonStyle(.plain) - } -} - -private struct LaneQuickAction: View { - let title: String - let symbol: String - let tint: Color - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: 4) { - Image(systemName: symbol) - .font(.system(size: 16, weight: .medium)) - .symbolRenderingMode(.hierarchical) - Text(title) - .font(.caption2.weight(.medium)) - } - .foregroundStyle(tint) - .frame(width: 64, height: 54) - .background(ADEColor.surfaceBackground.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .glassEffect(in: .rect(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(ADEColor.border.opacity(0.16), lineWidth: 0.5) - ) - } - .buttonStyle(ADEScaleButtonStyle()) - } -} - -private struct LaneMenuLabel: View { - let title: String - - var body: some View { - HStack(spacing: 4) { - Text(title) - .font(.caption.weight(.medium)) - Image(systemName: "chevron.down") - .font(.system(size: 8, weight: .bold)) - } - .foregroundStyle(ADEColor.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background(ADEColor.surfaceBackground.opacity(0.55), in: Capsule()) - .glassEffect() - .overlay( - Capsule() - .stroke(ADEColor.border.opacity(0.16), lineWidth: 0.5) - ) - } -} - -private struct LaneOpenChip: View { - let snapshot: LaneListSnapshot - let isPinned: Bool - - var body: some View { - HStack(spacing: 6) { - Circle() - .fill(runtimeTint(bucket: snapshot.runtime.bucket)) - .frame(width: 6, height: 6) - Text(snapshot.lane.name) - .font(.caption.weight(.medium)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - if isPinned { - Image(systemName: "pin.fill") - .font(.system(size: 8)) - .foregroundStyle(ADEColor.accent) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background(ADEColor.surfaceBackground.opacity(0.55), in: Capsule()) - .glassEffect() - .overlay( - Capsule() - .stroke(ADEColor.border.opacity(0.16), lineWidth: 0.5) - ) - .accessibilityLabel("\(snapshot.lane.name)\(isPinned ? ", pinned" : "")") - } -} - -private struct LaneLaunchTile: View { - let title: String - let symbol: String - let tint: Color - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: 8) { - Image(systemName: symbol) - .font(.system(size: 18, weight: .semibold)) + } label: { + Image(systemName: "plus.circle.fill") .symbolRenderingMode(.hierarchical) - Text(title) - .font(.caption.weight(.medium)) - } - .foregroundStyle(tint) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .background(ADEColor.surfaceBackground.opacity(0.08), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .glassEffect(in: .rect(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(tint.opacity(0.14), lineWidth: 0.5) - ) - } - .buttonStyle(ADEScaleButtonStyle()) - .accessibilityLabel("Launch \(title)") - } -} - -private struct LaneSessionCard: View { - let session: TerminalSessionSummary - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(session.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Spacer() - LaneTypeBadge(text: session.status.uppercased(), tint: session.status == "running" ? ADEColor.success : ADEColor.textSecondary) - } - if let preview = session.lastOutputPreview { - Text(preview) - .font(.caption) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(2) - } - } - .adeGlassCard(cornerRadius: 10, padding: 10) - } -} - -private struct LaneChatCard: View { - let chat: AgentChatSessionSummary - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(chat.title ?? chat.provider.uppercased()) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Spacer() - LaneTypeBadge(text: chat.status.uppercased(), tint: chat.status == "active" ? ADEColor.success : ADEColor.textSecondary) - } - Text(chat.model) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - if let preview = chat.lastOutputPreview { - Text(preview) - .font(.caption) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(2) - } - } - .adeGlassCard(cornerRadius: 10, padding: 10) - } -} - -private struct LaneInfoRow: View { - let label: String - let value: String - var isMonospaced = false - - var body: some View { - HStack(alignment: .top, spacing: 12) { - Text(label) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - .frame(width: 54, alignment: .leading) - Text(value) - .font(isMonospaced ? .system(.caption, design: .monospaced) : .subheadline) - .foregroundStyle(ADEColor.textPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - } - } -} - -private struct LaneTextField: View { - let title: String - @Binding var text: String - - init(_ title: String, text: Binding) { - self.title = title - self._text = text - } - - var body: some View { - TextField(title, text: $text, axis: .vertical) - .textFieldStyle(.plain) - .foregroundStyle(ADEColor.textPrimary) - .adeInsetField() - } -} - -private struct ADEScaleButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .scaleEffect(configuration.isPressed ? 0.97 : 1.0) - .opacity(configuration.isPressed ? 0.85 : 1.0) - .animation(.snappy(duration: 0.2), value: configuration.isPressed) - } -} - -// MARK: - Utility functions - -@ViewBuilder -private func lanePriorityBadge(snapshot: LaneListSnapshot) -> some View { - if snapshot.autoRebaseStatus?.state == "rebaseConflict" { - LaneTypeBadge(text: "Conflict", tint: ADEColor.danger) - } else if snapshot.lane.status.dirty { - LaneTypeBadge(text: "Dirty", tint: ADEColor.warning) - } else if snapshot.runtime.bucket == "running" { - LaneTypeBadge(text: "Running", tint: ADEColor.success) - } else if snapshot.runtime.bucket == "awaiting-input" { - LaneTypeBadge(text: "Attention", tint: ADEColor.warning) - } else if snapshot.lane.archivedAt != nil { - LaneTypeBadge(text: "Archived", tint: ADEColor.textMuted) - } else if let rebaseSuggestion = snapshot.rebaseSuggestion { - LaneTypeBadge(text: "\(rebaseSuggestion.behindCount)\u{2193}", tint: ADEColor.warning) - } else { - EmptyView() - } -} - -private func laneActivitySummary(_ snapshot: LaneListSnapshot) -> String? { - if let agentText = summarizeState(snapshot.stateSnapshot?.agentSummary) { - return agentText - } - if let missionText = summarizeState(snapshot.stateSnapshot?.missionSummary) { - return missionText - } - return nil -} - -private func laneMatchesSearch(snapshot: LaneListSnapshot, isPinned: Bool, query: String) -> Bool { - let tokens = query - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .split(whereSeparator: \.isWhitespace) - .map(String.init) - guard !tokens.isEmpty else { return true } - return tokens.allSatisfy { token in - matchesLaneToken(snapshot: snapshot, isPinned: isPinned, token: token) - } -} - -private func matchesLaneToken(snapshot: LaneListSnapshot, isPinned: Bool, token: String) -> Bool { - if token.hasPrefix("is:") { - switch String(token.dropFirst(3)) { - case "dirty": return snapshot.lane.status.dirty - case "clean": return !snapshot.lane.status.dirty - case "pinned": return isPinned - case "primary": return snapshot.lane.laneType == "primary" - case "worktree": return snapshot.lane.laneType == "worktree" - case "attached": return snapshot.lane.laneType == "attached" - default: return false - } - } - if token.hasPrefix("type:") { - return snapshot.lane.laneType.lowercased() == String(token.dropFirst(5)) - } - let indexed = [ - snapshot.lane.name, - snapshot.lane.branchRef, - snapshot.lane.baseRef, - snapshot.lane.laneType, - snapshot.lane.description ?? "", - snapshot.lane.worktreePath, - snapshot.lane.status.dirty ? "dirty modified changed" : "clean", - snapshot.lane.status.ahead > 0 ? "ahead ahead:\(snapshot.lane.status.ahead)" : "ahead:0", - snapshot.lane.status.behind > 0 ? "behind behind:\(snapshot.lane.status.behind)" : "behind:0", - snapshot.runtime.bucket, - summarizeState(snapshot.stateSnapshot?.agentSummary) ?? "", - summarizeState(snapshot.stateSnapshot?.missionSummary) ?? "", - isPinned ? "pinned" : "", - ].joined(separator: " ").lowercased() - return indexed.contains(token) -} - -private func summarizeState(_ summary: [String: RemoteJSONValue]?) -> String? { - guard let summary else { return nil } - let preferredKeys = [ - "summary", "status", "state", "label", "title", "objective", - "stepLabel", "step", "name", "agent", "agentName", "assignee", - ] - for key in preferredKeys { - if let value = flattenedString(summary[key]) { - return value - } - } - for value in summary.values { - if let flattened = flattenedString(value) { - return flattened - } - } - return nil -} - -private func flattenedString(_ value: RemoteJSONValue?) -> String? { - guard let value else { return nil } - switch value { - case .string(let string): - return string - case .number(let number): - return String(number) - case .bool(let bool): - return bool ? "true" : "false" - case .array(let values): - return values.compactMap(flattenedString).first - case .object(let object): - return summarizeState(object) - case .null: - return nil - } -} - -private func runtimeTint(bucket: String) -> Color { - switch bucket { - case "running": - return ADEColor.success - case "awaiting-input": - return ADEColor.warning - case "ended": - return ADEColor.textMuted - default: - return ADEColor.textSecondary - } -} - -private func lanePullRequestTint(_ state: String) -> Color { - switch state { - case "open": - return ADEColor.success - case "draft": - return ADEColor.warning - case "closed": - return ADEColor.danger - case "merged": - return ADEColor.accent - default: - return ADEColor.textSecondary - } -} - -private func runtimeSymbol(_ bucket: String) -> String { - switch bucket { - case "running": - return "waveform.path.ecg" - case "awaiting-input": - return "exclamationmark.bubble" - case "ended": - return "stop.circle" - default: - return "circle" - } -} - -private func relativeTimestamp(_ timestamp: String?) -> String { - guard let timestamp, let date = ISO8601DateFormatter().date(from: timestamp) else { - return timestamp ?? "Unknown" - } - return RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date()) -} - -private func syncSummary(_ status: GitUpstreamSyncStatus) -> String { - if !status.hasUpstream { - return "No upstream. Publish to create a remote branch." - } - if status.diverged { - return "Diverged. Rebase or pull before pushing." - } - if status.ahead > 0 && status.behind == 0 { - return "Ahead by \(status.ahead). Push to publish." - } - if status.behind > 0 && status.ahead == 0 { - return "Behind by \(status.behind). Pull to catch up." - } - return "In sync with remote." -} - -private func conflictSummary(_ status: ConflictStatus) -> String { - switch status.status { - case "conflict-active": - return "\(status.overlappingFileCount) overlapping file(s) in active conflict." - case "conflict-predicted": - return "\(status.overlappingFileCount) overlapping file(s) predicted across \(status.peerConflictCount) peer(s)." - case "behind-base": - return "Behind base. Rebase before merging." - case "merge-ready": - return "Conflict prediction clear. Merge-ready." - default: - return "Conflict status available from host." - } -} - -// MARK: - Previews - -#Preview("Lane list rows") { - let mockLanes: [LaneListSnapshot] = [ - LaneListSnapshot( - lane: LaneSummary( - id: "1", name: "main", description: "Primary branch", laneType: "primary", - baseRef: "main", branchRef: "main", worktreePath: "/project", - attachedRootPath: nil, parentLaneId: nil, childCount: 3, stackDepth: 0, - parentStatus: nil, isEditProtected: false, - status: LaneStatus(dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false), - color: nil, icon: nil, tags: [], folder: nil, - createdAt: "2026-01-01T00:00:00Z", archivedAt: nil - ), - runtime: LaneRuntimeSummary(bucket: "running", runningCount: 2, awaitingInputCount: 0, endedCount: 1, sessionCount: 3), - rebaseSuggestion: nil, autoRebaseStatus: nil, conflictStatus: nil, stateSnapshot: nil, adoptableAttached: false - ), - LaneListSnapshot( - lane: LaneSummary( - id: "2", name: "feature/auth-flow", description: "OAuth integration", laneType: "worktree", - baseRef: "main", branchRef: "feat/auth-flow", worktreePath: "/project/.ade/worktrees/auth", - attachedRootPath: nil, parentLaneId: "1", childCount: 0, stackDepth: 1, - parentStatus: nil, isEditProtected: false, - status: LaneStatus(dirty: true, ahead: 3, behind: 1, remoteBehind: 0, rebaseInProgress: false), - color: nil, icon: nil, tags: [], folder: nil, - createdAt: "2026-03-01T00:00:00Z", archivedAt: nil - ), - runtime: LaneRuntimeSummary(bucket: "awaiting-input", runningCount: 0, awaitingInputCount: 1, endedCount: 0, sessionCount: 1), - rebaseSuggestion: nil, autoRebaseStatus: nil, conflictStatus: nil, - stateSnapshot: LaneStateSnapshotSummary(laneId: "2", agentSummary: ["summary": .string("Codex waiting for approval")], missionSummary: nil, updatedAt: nil), - adoptableAttached: false - ), - LaneListSnapshot( - lane: LaneSummary( - id: "3", name: "fix/login-redirect", description: nil, laneType: "worktree", - baseRef: "main", branchRef: "fix/login-redirect", worktreePath: "/project/.ade/worktrees/fix-login", - attachedRootPath: nil, parentLaneId: "1", childCount: 1, stackDepth: 1, - parentStatus: nil, isEditProtected: false, - status: LaneStatus(dirty: false, ahead: 7, behind: 0, remoteBehind: 0, rebaseInProgress: false), - color: nil, icon: nil, tags: [], folder: nil, - createdAt: "2026-03-10T00:00:00Z", archivedAt: nil - ), - runtime: LaneRuntimeSummary(bucket: "running", runningCount: 1, awaitingInputCount: 0, endedCount: 0, sessionCount: 1), - rebaseSuggestion: nil, autoRebaseStatus: nil, conflictStatus: nil, - stateSnapshot: LaneStateSnapshotSummary(laneId: "3", agentSummary: ["summary": .string("Claude writing tests")], missionSummary: nil, updatedAt: nil), - adoptableAttached: false - ), - LaneListSnapshot( - lane: LaneSummary( - id: "4", name: "refactor/db-layer", description: "Database abstraction", laneType: "attached", - baseRef: "main", branchRef: "refactor/db-layer", worktreePath: "/other/project", - attachedRootPath: "/other/project", parentLaneId: nil, childCount: 0, stackDepth: 0, - parentStatus: nil, isEditProtected: false, - status: LaneStatus(dirty: false, ahead: 0, behind: 4, remoteBehind: 0, rebaseInProgress: false), - color: nil, icon: nil, tags: [], folder: nil, - createdAt: "2026-03-15T00:00:00Z", archivedAt: nil - ), - runtime: LaneRuntimeSummary(bucket: "ended", runningCount: 0, awaitingInputCount: 0, endedCount: 1, sessionCount: 1), - rebaseSuggestion: RebaseSuggestion(laneId: "4", parentLaneId: "1", parentHeadSha: "abc", behindCount: 4, lastSuggestedAt: "2026-03-22T00:00:00Z", deferredUntil: nil, dismissedAt: nil, hasPr: false), - autoRebaseStatus: nil, conflictStatus: nil, stateSnapshot: nil, adoptableAttached: true - ), - ] - - ScrollView { - LazyVStack(spacing: 14) { - ForEach(mockLanes) { snapshot in - LaneListRow( - snapshot: snapshot, - isPinned: snapshot.lane.id == "2", - isOpen: snapshot.lane.id == "2" || snapshot.lane.id == "3" - ) } + .accessibilityLabel("Create or attach lane") } - .padding(.horizontal, 16) - .padding(.vertical, 8) } - .background(ADEColor.pageBackground) - } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 1f0e07023..d95531b22 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -1114,6 +1114,105 @@ final class ADETests: XCTestCase { XCTAssertEqual(filterPullRequestListItems(items, query: "", state: .open).map(\.id), ["pr-1"]) } + func testLaneListFilteringMatchesSearchPrefixesAndSortOrder() { + let snapshots = [ + makeLaneListSnapshot( + id: "lane-primary", + name: "main", + laneType: "primary", + baseRef: "main", + branchRef: "main", + worktreePath: "/project", + description: "Primary lane", + status: LaneStatus(dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false), + runtime: LaneRuntimeSummary(bucket: "running", runningCount: 2, awaitingInputCount: 0, endedCount: 0, sessionCount: 2), + createdAt: "2026-03-01T00:00:00.000Z", + archivedAt: nil + ), + makeLaneListSnapshot( + id: "lane-attached-active", + name: "docs", + laneType: "attached", + baseRef: "main", + branchRef: "docs/cleanup", + worktreePath: "/project/docs", + description: "Docs cleanup lane", + status: LaneStatus(dirty: true, ahead: 3, behind: 1, remoteBehind: 0, rebaseInProgress: false), + runtime: LaneRuntimeSummary(bucket: "ended", runningCount: 0, awaitingInputCount: 0, endedCount: 1, sessionCount: 1), + stateSnapshot: LaneStateSnapshotSummary( + laneId: "lane-attached-active", + agentSummary: ["summary": .string("Agent waiting on approval")], + missionSummary: ["summary": .string("Ship the cleanup")], + updatedAt: nil + ), + createdAt: "2026-03-20T00:00:00.000Z", + archivedAt: nil, + adoptableAttached: true + ), + makeLaneListSnapshot( + id: "lane-worktree", + name: "auth-flow", + laneType: "worktree", + baseRef: "main", + branchRef: "feature/auth", + worktreePath: "/project/.ade/worktrees/auth", + description: "OAuth flow", + status: LaneStatus(dirty: false, ahead: 1, behind: 0, remoteBehind: 0, rebaseInProgress: false), + runtime: LaneRuntimeSummary(bucket: "awaiting-input", runningCount: 0, awaitingInputCount: 1, endedCount: 0, sessionCount: 1), + stateSnapshot: LaneStateSnapshotSummary( + laneId: "lane-worktree", + agentSummary: ["title": .string("Codex")], + missionSummary: ["objective": .string("Handle OAuth redirects")], + updatedAt: nil + ), + createdAt: "2026-03-10T00:00:00.000Z", + archivedAt: nil, + adoptableAttached: false + ), + makeLaneListSnapshot( + id: "lane-archived", + name: "legacy", + laneType: "attached", + baseRef: "main", + branchRef: "legacy/refactor", + worktreePath: "/legacy", + description: "Legacy lane", + status: LaneStatus(dirty: false, ahead: 0, behind: 2, remoteBehind: 0, rebaseInProgress: false), + runtime: LaneRuntimeSummary(bucket: "running", runningCount: 1, awaitingInputCount: 0, endedCount: 0, sessionCount: 4), + createdAt: "2026-02-01T00:00:00.000Z", + archivedAt: "2026-03-25T00:00:00.000Z" + ), + ] + + XCTAssertEqual(laneScopeCount(snapshots, scope: .active), 3) + XCTAssertEqual(laneScopeCount(snapshots, scope: .archived), 1) + XCTAssertEqual(laneRuntimeCount(snapshots, filter: .running), 2) + XCTAssertEqual(laneRuntimeCount(snapshots, filter: .awaitingInput), 1) + + let activeFiltered = laneListFilteredSnapshots( + snapshots, + scope: .active, + runtimeFilter: .all, + searchText: "", + pinnedLaneIds: ["lane-worktree"] + ) + XCTAssertEqual(activeFiltered.map(\.lane.id), ["lane-primary", "lane-attached-active", "lane-worktree"]) + + XCTAssertTrue(laneMatchesSearch(snapshot: snapshots[1], isPinned: false, query: "docs main")) + XCTAssertTrue(laneMatchesSearch(snapshot: snapshots[1], isPinned: false, query: "is:dirty type:attached")) + XCTAssertTrue(laneMatchesSearch(snapshot: snapshots[2], isPinned: true, query: "is:pinned awaiting")) + XCTAssertTrue(laneMatchesSearch(snapshot: snapshots[0], isPinned: false, query: "is:clean is:primary")) + XCTAssertTrue(laneMatchesSearch(snapshot: snapshots[2], isPinned: true, query: "is:worktree")) + XCTAssertFalse(laneMatchesSearch(snapshot: snapshots[0], isPinned: false, query: "is:unknown")) + XCTAssertFalse(laneMatchesSearch(snapshot: snapshots[0], isPinned: false, query: "type:attached")) + + XCTAssertEqual(laneListEmptyStateTitle(scope: .active), "No active lanes") + XCTAssertEqual( + laneListEmptyStateMessage(scope: .all, searchText: "auth", hasFilters: true), + "Try a different search or clear the filter." + ) + } + func testBuildPullRequestTimelineOrdersStateReviewsAndComments() { let pr = PullRequestListItem( id: "pr-9", @@ -1832,6 +1931,53 @@ final class ADETests: XCTestCase { return bytes } + private func makeLaneListSnapshot( + id: String, + name: String, + laneType: String, + baseRef: String, + branchRef: String, + worktreePath: String, + description: String?, + status: LaneStatus, + runtime: LaneRuntimeSummary, + stateSnapshot: LaneStateSnapshotSummary? = nil, + createdAt: String, + archivedAt: String?, + adoptableAttached: Bool = false + ) -> LaneListSnapshot { + LaneListSnapshot( + lane: LaneSummary( + id: id, + name: name, + description: description, + laneType: laneType, + baseRef: baseRef, + branchRef: branchRef, + worktreePath: worktreePath, + attachedRootPath: laneType == "attached" ? worktreePath : nil, + parentLaneId: nil, + childCount: 0, + stackDepth: 0, + parentStatus: nil, + isEditProtected: false, + status: status, + color: nil, + icon: nil, + tags: [], + folder: nil, + createdAt: createdAt, + archivedAt: archivedAt + ), + runtime: runtime, + rebaseSuggestion: nil, + autoRebaseStatus: nil, + conflictStatus: nil, + stateSnapshot: stateSnapshot, + adoptableAttached: adoptableAttached + ) + } + private func countRows(in baseURL: URL, table: String) throws -> Int { let dbURL = baseURL.appendingPathComponent("ADE", isDirectory: true).appendingPathComponent("ade.db") var handle: OpaquePointer? From ae852fbcd8b9492cdc7bba5e4a831187190e40db Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:11:58 -0400 Subject: [PATCH 3/7] Implement host pairing with resilient cached lane synchronization --- .factory/library/architecture.md | 17 +- .factory/library/environment.md | 4 +- .factory/services.yaml | 2 + .factory/skills/ios-worker/SKILL.md | 9 +- .../services/devTools/devToolsService.test.ts | 30 +- .../main/services/devTools/devToolsService.ts | 3 +- .../src/main/services/ipc/registerIpc.ts | 118 ++++ .../src/main/services/lanes/laneService.ts | 42 +- .../services/memory/embeddingService.test.ts | 93 +++ .../main/services/memory/embeddingService.ts | 15 + apps/desktop/src/preload/global.d.ts | 2 + apps/desktop/src/preload/preload.ts | 3 + apps/desktop/src/renderer/browserMock.ts | 33 + .../renderer/components/lanes/LanesPage.tsx | 205 ++----- .../onboarding/DevToolsSection.test.tsx | 10 - .../components/onboarding/DevToolsSection.tsx | 37 +- .../onboarding/EmbeddingsSection.tsx | 2 +- .../components/settings/MemoryHealthTab.tsx | 5 +- .../src/renderer/state/appStore.test.ts | 42 +- apps/desktop/src/renderer/state/appStore.ts | 13 +- apps/desktop/src/shared/ipc.ts | 1 + apps/desktop/src/shared/types/devTools.ts | 2 +- apps/ios/ADE/Services/Database.swift | 169 +++++- apps/ios/ADE/Services/SyncService.swift | 57 +- .../Views/Lanes/LaneBatchManageSheet.swift | 54 +- .../ADE/Views/Lanes/LaneChatLaunchSheet.swift | 2 +- .../ADE/Views/Lanes/LaneChatSessionView.swift | 22 +- apps/ios/ADE/Views/Lanes/LaneComponents.swift | 148 ++++- .../ios/ADE/Views/Lanes/LaneCreateSheet.swift | 143 ++++- .../Lanes/LaneDetailContentSections.swift | 2 +- .../Views/Lanes/LaneDetailGitSection.swift | 30 +- .../ADE/Views/Lanes/LaneDetailScreen.swift | 562 ++++++++++++++++-- apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift | 37 +- apps/ios/ADE/Views/Lanes/LaneHelpers.swift | 4 +- .../ADE/Views/Lanes/LaneListViewParts.swift | 59 +- .../ios/ADE/Views/Lanes/LaneManageSheet.swift | 9 +- apps/ios/ADE/Views/Lanes/LaneTypes.swift | 41 +- apps/ios/ADE/Views/LanesTabView.swift | 61 +- apps/ios/ADE/Views/PRsTabView.swift | 19 + apps/ios/ADETests/ADETests.swift | 59 ++ apps/web/public/app-icon.png | Bin 0 -> 172732 bytes apps/web/public/images/splash/left.png | Bin 0 -> 684479 bytes apps/web/public/images/splash/middle.png | Bin 0 -> 839323 bytes apps/web/public/images/splash/right.png | Bin 0 -> 786728 bytes apps/web/public/logo.png | Bin 0 -> 72949 bytes 45 files changed, 1731 insertions(+), 435 deletions(-) create mode 100644 apps/web/public/app-icon.png create mode 100644 apps/web/public/images/splash/left.png create mode 100644 apps/web/public/images/splash/middle.png create mode 100644 apps/web/public/images/splash/right.png create mode 100644 apps/web/public/logo.png diff --git a/.factory/library/architecture.md b/.factory/library/architecture.md index 7f6e2f388..f49b0bdd5 100644 --- a/.factory/library/architecture.md +++ b/.factory/library/architecture.md @@ -9,16 +9,21 @@ Architectural decisions, patterns discovered, and conventions. ## iOS App Structure - Entry: `ADEApp.swift` → creates SyncService → passes to ContentView - ContentView: TabView with 5 tabs (Lanes, Files, Work, PRs, Settings) -- SyncService: @MainActor ObservableObject, WebSocket to desktop host, CRDT sync via cr-sqlite +- 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 connects to desktop ADE host via WebSocket -2. Commands sent (e.g., `lanes.refreshSnapshots`) → responses decoded -3. Data cached in SQLite (lane_list_snapshots, lane_detail_snapshots tables) -4. Views observe `syncService.localStateRevision` via `.task(id:)` for reactive updates -5. Pull-to-refresh triggers `reload(refreshRemote: true)` +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.) diff --git a/.factory/library/environment.md b/.factory/library/environment.md index 4a4737cf9..1ee0578f5 100644 --- a/.factory/library/environment.md +++ b/.factory/library/environment.md @@ -17,9 +17,9 @@ Environment variables, external dependencies, and setup notes. ## Simulators - Required: iOS 26.3.1 simulators - Recommended: iPhone 17 Pro -- iOS 18.x simulators cannot be used (deployment target is 26.0) +- iOS 18.x simulators are older than the minimum deployment target (26.0), so they are incompatible. ## Build Notes -- Development team: VQ372F39G6 +- 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/services.yaml b/.factory/services.yaml index c14d0c299..e5d65e903 100644 --- a/.factory/services.yaml +++ b/.factory/services.yaml @@ -2,6 +2,8 @@ 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 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,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 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 index b1a6191ef..e0ec870cb 100644 --- a/.factory/skills/ios-worker/SKILL.md +++ b/.factory/skills/ios-worker/SKILL.md @@ -62,11 +62,16 @@ When creating new Swift files, you MUST add them to the Xcode project: 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 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.3.1' -quiet +xcodebuild build -project apps/ios/ADE.xcodeproj -scheme ADE -destination "$DESTINATION" -quiet # Test -xcodebuild test -project apps/ios/ADE.xcodeproj -scheme ADE -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.3.1' -quiet +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 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..715635e7e 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -199,6 +199,8 @@ import type { OnboardingDetectionResult, OnboardingExistingLaneCandidate, OnboardingStatus, + LaneListSnapshot, + LaneRuntimeSummary, LaneSummary, ListOperationsArgs, ListOverlapsArgs, @@ -691,6 +693,93 @@ 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 = runningCount > 0 + ? "running" + : awaitingInputCount > 0 + ? "awaiting-input" + : endedCount > 0 + ? "ended" + : "none"; + + return { + bucket, + runningCount, + awaitingInputCount, + endedCount, + sessionCount, + }; +} + +async function buildLaneListSnapshots( + args: Pick, + lanes: LaneSummary[], +): Promise { + const [sessions, rebaseSuggestions, autoRebaseStatuses, stateSnapshots, batchAssessment] = await Promise.all([ + Promise.resolve(args.sessionService.list({ limit: 500 })), + Promise.resolve(args.rebaseSuggestionService?.listSuggestions() ?? []), + Promise.resolve(args.autoRebaseService?.listStatuses() ?? []), + Promise.resolve(args.laneService.listStateSnapshots()), + 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 +3195,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 +5777,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. }); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 3d89eee38..873aecedd 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -1552,14 +1552,46 @@ 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 { + const 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. + parentLaneId = null; + } else { + if (explicitParentLaneId && explicitParentLaneId !== bestLaneId) { + console.warn( + `[laneService] importBranch: explicit parentLaneId '${explicitParentLaneId}' differs from ` + + `git-detected parent '${bestLaneId}' — using detected parent`, + ); + } + parentLaneId = bestLaneId; } - parentLaneId = bestLaneId; } } } catch (err) { 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..beb44e296 100644 --- a/apps/desktop/src/main/services/memory/embeddingService.ts +++ b/apps/desktop/src/main/services/memory/embeddingService.ts @@ -562,11 +562,26 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { } } + async function clearCache(): Promise { + await dispose(); + 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(); + } + return { embed: trackedEmbed, dispose, preload, probeCache, + clearCache, getModelId: () => modelId, getStatus, hashContent: hashEmbeddingContent, diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 37f2a3383..14f5110bf 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -509,6 +509,7 @@ import type { LaneEnvInitEvent, LaneOverlayOverrides, LaneTemplate, + LaneListSnapshot, GetLaneTemplateArgs, SetDefaultLaneTemplateArgs, ApplyLaneTemplateArgs, @@ -754,6 +755,7 @@ declare global { }; lanes: { list: (args?: ListLanesArgs) => Promise; + listSnapshots: (args?: ListLanesArgs) => Promise; create: (args: CreateLaneArgs) => Promise; createChild: (args: CreateChildLaneArgs) => Promise; createFromUnstaged: (args: CreateLaneFromUnstagedArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index b42c899ac..2f5b5cd92 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -257,6 +257,7 @@ import type { OnboardingDetectionResult, OnboardingExistingLaneCandidate, OnboardingStatus, + LaneListSnapshot, LaneSummary, ListOverlapsArgs, ListLanesArgs, @@ -962,6 +963,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 => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 9620e8735..d74cf654a 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"), ]; +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"; + const runtime = { + bucket: runtimeBucket, + runningCount: runtimeBucket === "running" ? 1 : 0, + awaitingInputCount: runtimeBucket === "awaiting-input" ? 1 : 0, + endedCount: runtimeBucket === "ended" ? 1 : 0, + sessionCount: runtimeBucket === "none" ? 0 : 1, + }; + return { + lane, + runtime, + 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: resolved(MOCK_LANES.map((lane) => makeLaneSnapshot(lane))), create: resolvedArg({ id: "mock", name: "mock" }), createChild: resolvedArg({ id: "mock", name: "mock" }), importBranch: resolvedArg({ id: "mock", name: "mock" }), diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index a191f0ed9..f84d7592e 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -8,8 +8,6 @@ import { buildIntegrationSourcesByLaneId } from "../../lib/integrationLanes"; import { EmptyState } from "../ui/EmptyState"; import { Button } from "../ui/Button"; import { PaneTilingLayout } from "../ui/PaneTilingLayout"; -import { listSessionsCached } from "../../lib/sessionListCache"; -import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, inlineBadge, outlineButton, primaryButton, conflictDotColor } from "./laneDesignTokens"; import { ResizeGutter } from "../ui/ResizeGutter"; import { LaneStackPane } from "./LaneStackPane"; @@ -36,37 +34,22 @@ import { type LanePaneDetailSelection, type LaneBranchOption } from "./laneUtils"; -import { sessionStatusBucket } from "../../lib/terminalAttention"; -import { isRunOwnedSession } from "../../lib/sessions"; import { buildPrsRouteSearch } from "../prs/prsRouteState"; import type { ConflictChip, - ConflictStatus, DeleteLaneArgs, GitCommitSummary, LaneEnvInitEvent, LaneEnvInitProgress, + LaneListSnapshot, LaneSummary, RebaseRun, RebaseScope, - RebaseSuggestion, - AutoRebaseLaneStatus, IntegrationProposal, - TerminalSessionSummary, LaneTemplate } from "../../../shared/types"; import { eventMatchesBinding, getEffectiveBinding } from "../../lib/keybindings"; -type LaneRuntimeBucket = "running" | "awaiting-input" | "ended" | "none"; - -type LaneRuntimeSummary = { - bucket: LaneRuntimeBucket; - runningCount: number; - awaitingInputCount: number; - endedCount: number; - sessionCount: number; -}; - type RebaseScopePromptState = { laneId: string; laneName: string; @@ -182,12 +165,9 @@ export function LanesPage() { const [laneActionBusy, setLaneActionBusy] = useState(false); const [laneActionError, setLaneActionError] = useState(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); @@ -207,57 +187,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,7 +257,7 @@ export function LanesPage() { }, []); const filteredLanes = useMemo(() => { - const bucketRank: Record = { + const bucketRank: Record = { running: 0, "awaiting-input": 1, ended: 2, @@ -323,12 +284,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( @@ -376,31 +351,6 @@ export function LanesPage() { 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 +364,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 +406,30 @@ 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(); + void refreshLanes(); pushConflictChips(event.chips); }); return unsubscribe; - }, [loadConflictStatuses, pushConflictChips]); + }, [pushConflictChips, refreshLanes]); 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 +443,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 +454,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 +484,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(); }; @@ -978,7 +902,7 @@ export function LanesPage() { } } - const results = await Promise.allSettled([refreshLanes(), refreshRebaseSuggestions(), refreshAutoRebaseStatuses()]); + const results = await Promise.allSettled([refreshLanes()]); for (const r of results) { if (r.status === "rejected") { console.error("Lane refresh partially failed:", r.reason); @@ -991,14 +915,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 +935,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 { @@ -1645,7 +1569,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 +1579,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..6c4aa742c 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 -
-
{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}
diff --git a/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx b/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx index 6c4aa742c..bf724856d 100644 --- a/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx +++ b/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx @@ -55,7 +55,7 @@ export function DevToolsSection({ onStatusChange }: Props) {
- +
+ ) : 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 */}
# run code from a lane's worktree +# +# Both instances share the same DB (lanes, missions, configs stay in sync). +# Only the MCP socket is separated to avoid bind conflicts. +# When run from a lane worktree, the new code is used but the DB comes +# from the main repo — so all your existing lanes/state are visible. + +set -euo pipefail + +MAIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SOCKET_PATH="/tmp/ade-dogfood-mcp.sock" + +if [ -n "${1:-}" ]; then + # Find the lane worktree by name/slug match + LANE_NAME="$1" + WORKTREE_DIR=$(find "$MAIN_ROOT/.ade/worktrees" -maxdepth 1 -type d -name "*${LANE_NAME}*" | head -1) + if [ -z "$WORKTREE_DIR" ]; then + echo "No worktree found matching '$LANE_NAME' in .ade/worktrees/" + echo "Available:" + ls "$MAIN_ROOT/.ade/worktrees/" 2>/dev/null || echo " (none)" + exit 1 + fi + DEV_DIR="$WORKTREE_DIR/apps/desktop" + echo "Running from lane worktree: $WORKTREE_DIR" +else + DEV_DIR="$MAIN_ROOT/apps/desktop" + echo "Running from main repo" +fi + +# Clean stale socket from prior run +rm -f "$SOCKET_PATH" + +echo "DB: $MAIN_ROOT/.ade/ade.db (shared)" +echo "Socket: $SOCKET_PATH (isolated)" +echo "" + +cd "$DEV_DIR" +ADE_PROJECT_ROOT="$MAIN_ROOT" ADE_MCP_SOCKET_PATH="$SOCKET_PATH" npm run dev From 032f5da42d46c843aa199e0e82ed4bbf189dd713 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:46:45 -0400 Subject: [PATCH 6/7] Fix embedding test deadlock and address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clearCache() was deadlocking by synchronously awaiting an in-flight pipeline load it had just invalidated — let it settle in the background instead since dispose() already marks the load as stale. Also fixes: MCP socket path scoped per project context, rev-parse prefers remote-tracking ref, busy_timeout on recentProjectSummary DB, lane refresh race on project switch, awaiting-input lanes sort first, send-button re-entrancy guard, transcript fetch race token, diff save nil guard, reasoning effort reset on model change, and misc iOS fixes. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/src/main/main.ts | 7 ++++- .../src/main/services/lanes/laneService.ts | 12 ++++++-- .../main/services/memory/embeddingService.ts | 7 +++-- .../services/projects/recentProjectSummary.ts | 1 + .../renderer/components/lanes/LanesPage.tsx | 4 +-- .../components/onboarding/DevToolsSection.tsx | 2 +- apps/desktop/src/renderer/state/appStore.ts | 10 ++++--- apps/ios/ADE/Services/SyncService.swift | 6 ++-- .../ADE/Views/Lanes/LaneChatLaunchSheet.swift | 27 ++++++++++++++++-- .../ADE/Views/Lanes/LaneChatSessionView.swift | 28 +++++++++++++------ .../ADE/Views/Lanes/LaneDetailScreen.swift | 1 + apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift | 4 +-- apps/ios/ADE/Views/Lanes/LaneHelpers.swift | 8 +++++- .../ADE/Views/Lanes/LaneListViewParts.swift | 4 +-- 14 files changed, 90 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 2d0625ea8..ccd9c11d3 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -2295,7 +2295,12 @@ app.whenReady().then(async () => { dispose: () => {} // desktop manages service lifecycle }; - const mcpSocketPath = process.env.ADE_MCP_SOCKET_PATH?.trim() || adePaths.socketPath; + // Only honour the env override for the first project context to avoid + // EADDRINUSE when multiple projects share a single socket path. + const envSocketOverride = process.env.ADE_MCP_SOCKET_PATH?.trim(); + const mcpSocketPath = (envSocketOverride && projectContexts.size === 0) + ? envSocketOverride + : adePaths.socketPath; const activeMcpConnections = new Set(); const destroyActiveMcpConnections = (): void => { diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index d3f40dd80..14c48c539 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -1563,10 +1563,18 @@ export function createLaneService({ if (bestLaneId) { let mainScore = Infinity; try { - const mainShaRes = await runGit( - ["rev-parse", defaultBaseRef], + // 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( diff --git a/apps/desktop/src/main/services/memory/embeddingService.ts b/apps/desktop/src/main/services/memory/embeddingService.ts index a69803209..9c0b801f7 100644 --- a/apps/desktop/src/main/services/memory/embeddingService.ts +++ b/apps/desktop/src/main/services/memory/embeddingService.ts @@ -566,9 +566,12 @@ export function createEmbeddingService(opts: CreateEmbeddingServiceOpts) { // Capture the pending load promise before dispose() nulls it out. const pendingLoad = extractorPromise; await dispose(); - // Await any in-flight load so we don't race against it writing files. + // 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) { - try { await pendingLoad; } catch { /* swallow – dispose already invalidated it */ } + void pendingLoad.catch(() => {}); } cache.clear(); try { 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/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 91c7a1ec8..886545adf 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -260,8 +260,8 @@ export function LanesPage() { const filteredLanes = useMemo(() => { const bucketRank: Record = { - running: 0, - "awaiting-input": 1, + "awaiting-input": 0, + running: 1, ended: 2, none: 3, }; diff --git a/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx b/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx index bf724856d..f023a7f00 100644 --- a/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx +++ b/apps/desktop/src/renderer/components/onboarding/DevToolsSection.tsx @@ -84,7 +84,7 @@ function ToolCard({ tool, platform, loading }: { tool: DevToolStatus | null; pla const installed = tool.installed; const statusColor = installed ? COLORS.success : tool.required ? COLORS.danger : COLORS.warning; const statusLabel = installed ? "Installed" : "Not found"; - const requirementLabel = "Required to continue setup."; + const requirementLabel = tool.required ? "Required to continue setup." : "Optional"; return (
diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 278c989ca..57c9e6c39 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -351,10 +351,11 @@ export const useAppStore = create((set, get) => ({ }, openRepo: async () => { + // Invalidate in-flight lane refreshes before the async open so stale + // responses from the previous project are discarded immediately. + ++laneRefreshVersion; const project = await window.ade.project.openRepo(); if (!project) return null; - // Invalidate any in-flight lane refreshes from the previous project - ++laneRefreshVersion; set({ project, projectHydrated: true, @@ -377,9 +378,10 @@ export const useAppStore = create((set, get) => ({ }, switchProjectToPath: async (rootPath: string) => { - const project = await window.ade.project.switchToPath(rootPath); - // Invalidate any in-flight lane refreshes from the previous project + // Invalidate in-flight lane refreshes before the async switch so stale + // responses from the previous project are discarded immediately. ++laneRefreshVersion; + const project = await window.ade.project.switchToPath(rootPath); set({ project, projectHydrated: true, diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 6ee564f96..9e398c3b2 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -957,9 +957,9 @@ final class SyncService: ObservableObject { func reparentLane(_ laneId: String, newParentLaneId: String?) async throws { var args: [String: Any] = ["laneId": laneId] - if let newParentLaneId, !newParentLaneId.isEmpty { - args["newParentLaneId"] = newParentLaneId - } + // Always include the key so the server receives a defined value. + // "ROOT" signals detachment from any parent lane. + args["newParentLaneId"] = (newParentLaneId?.isEmpty == false) ? newParentLaneId! : "ROOT" _ = try await sendCommand(action: "lanes.reparent", args: args) } diff --git a/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift b/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift index 9bb491486..43dabd67e 100644 --- a/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift @@ -166,7 +166,23 @@ struct LaneChatLaunchSheet: View { .disabled(busy || selectedModelId.isEmpty) } } + .onChange(of: selectedModelId) { _, _ in + // Reset reasoning effort when the model changes so a stale value + // for a model that doesn't support it is never submitted. + if let efforts = selectedModel?.reasoningEfforts, !efforts.isEmpty { + if !efforts.contains(where: { $0.effort == selectedReasoningEffort }) { + selectedReasoningEffort = "" + } + } else { + selectedReasoningEffort = "" + } + } .task(id: provider) { + // Clear state immediately so the UI doesn't show stale models + // from the previous provider while the async load is in-flight. + models = [] + selectedModelId = "" + selectedReasoningEffort = "" await loadModels(resetSelection: true) } } @@ -174,8 +190,11 @@ struct LaneChatLaunchSheet: View { @MainActor private func loadModels(resetSelection: Bool) async { + let requestedProvider = provider do { - let loadedModels = try await syncService.listChatModels(provider: provider) + let loadedModels = try await syncService.listChatModels(provider: requestedProvider) + // Ignore stale results if provider changed while loading. + guard provider == requestedProvider else { return } models = loadedModels if resetSelection || loadedModels.contains(where: { $0.id == selectedModelId }) == false { if let preferred = loadedModels.first(where: \.isDefault) ?? loadedModels.first { @@ -204,7 +223,11 @@ struct LaneChatLaunchSheet: View { laneId: laneId, provider: provider, model: selectedModelId, - reasoningEffort: selectedReasoningEffort.isEmpty ? nil : selectedReasoningEffort + reasoningEffort: { + guard !selectedReasoningEffort.isEmpty else { return nil } + guard selectedModel?.reasoningEfforts?.contains(where: { $0.effort == selectedReasoningEffort }) == true else { return nil } + return selectedReasoningEffort + }() ) await onComplete(session) dismiss() diff --git a/apps/ios/ADE/Views/Lanes/LaneChatSessionView.swift b/apps/ios/ADE/Views/Lanes/LaneChatSessionView.swift index f04a85c84..7bbd1f715 100644 --- a/apps/ios/ADE/Views/Lanes/LaneChatSessionView.swift +++ b/apps/ios/ADE/Views/Lanes/LaneChatSessionView.swift @@ -10,6 +10,7 @@ struct LaneChatSessionView: View { @State private var composer = "" @State private var errorMessage: String? @State private var sending = false + @State private var transcriptRequestId: UInt64 = 0 var body: some View { ScrollViewReader { proxy in @@ -84,8 +85,12 @@ struct LaneChatSessionView: View { .adeInsetField(cornerRadius: 12, padding: 10) Button { + let text = composer.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !sending else { return } + sending = true + composer = "" Task { - await sendMessage() + await sendMessage(text: text) withAnimation(.snappy) { proxy.scrollTo("lane-chat-end", anchor: .bottom) } @@ -123,35 +128,40 @@ struct LaneChatSessionView: View { @MainActor private func loadTranscript() async { + transcriptRequestId &+= 1 + let myId = transcriptRequestId do { - transcript = try await syncService.fetchChatTranscript(sessionId: summary.sessionId) + let result = try await syncService.fetchChatTranscript(sessionId: summary.sessionId) + guard myId == transcriptRequestId else { return } + transcript = result errorMessage = nil } catch { + guard myId == transcriptRequestId else { return } errorMessage = error.localizedDescription } } @MainActor - private func sendMessage() async { - let text = composer.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } - - sending = true + private func sendMessage(text: String) async { defer { sending = false } do { try await syncService.sendChatMessage(sessionId: summary.sessionId, text: text) - composer = "" errorMessage = nil } catch { errorMessage = "Message not sent. \(error.localizedDescription)" return } + transcriptRequestId &+= 1 + let myId = transcriptRequestId do { - transcript = try await syncService.fetchChatTranscript(sessionId: summary.sessionId) + let result = try await syncService.fetchChatTranscript(sessionId: summary.sessionId) + guard myId == transcriptRequestId else { return } + transcript = result errorMessage = nil } catch { + guard myId == transcriptRequestId else { return } errorMessage = "Message sent, but the transcript did not refresh. \(error.localizedDescription)" } } diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift index 30380abd2..995b1d1d7 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift @@ -643,6 +643,7 @@ struct LaneDetailScreen: View { func runRebaseAndPush() async throws { try await syncService.startLaneRebase(laneId: laneId, scope: "lane_only", pushMode: "none") + // Best-effort fetch — continue to push even if offline or the remote is unreachable. try? await syncService.fetchGit(laneId: laneId) let syncStatus = try await syncService.fetchSyncStatus(laneId: laneId) if syncStatus.hasUpstream == false { diff --git a/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift index 7be7cf6cf..1b8d71f03 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift @@ -113,7 +113,7 @@ struct LaneDiffScreen: View { Button("Done") { dismiss() } } ToolbarItem(placement: .confirmationAction) { - if request.mode == "unstaged", let path = request.path, side == "modified", diff?.isBinary != true { + if request.mode == "unstaged", let path = request.path, side == "modified", let d = diff, d.isBinary != true { Button { Task { await saveEditedFile(path: path) } } label: { @@ -183,7 +183,7 @@ struct LaneDiffScreen: View { @MainActor private func saveEditedFile(path: String) async { - guard diff?.isBinary != true else { return } + guard let d = diff, d.isBinary != true else { return } isSaving = true defer { isSaving = false } diff --git a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift index bbe203b82..97ec034f9 100644 --- a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift +++ b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift @@ -251,10 +251,16 @@ private let cachedRelativeDateFormatter: RelativeDateTimeFormatter = { return f }() +private let cachedISO8601FormatterNoFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f +}() + func relativeTimestamp(_ timestamp: String?) -> String { guard let timestamp else { return "Unknown" } guard let date = cachedISO8601Formatter.date(from: timestamp) - ?? ISO8601DateFormatter().date(from: timestamp) else { + ?? cachedISO8601FormatterNoFractional.date(from: timestamp) else { return "Unknown" } return cachedRelativeDateFormatter.localizedString(for: date, relativeTo: Date()) diff --git a/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift b/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift index c78f730e1..c6268fefc 100644 --- a/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift +++ b/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift @@ -59,8 +59,8 @@ extension LanesTabView { if syncService.activeHostProfile == nil { syncService.settingsPresented = true } else { - Task { - await syncService.reconnectIfPossible() + Task { [weak syncService] in + await syncService?.reconnectIfPossible() await reload(refreshRemote: true) } } From 5411ce7ace8e4120da6b1b98505e7713431b46d7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 6 Apr 2026 01:19:02 -0400 Subject: [PATCH 7/7] Address second round of review findings Per-project MCP socket derivation from env override, use shared createEmptyAutoUpdateSnapshot helper, simplify single-element Promise.allSettled, log getBatchAssessment errors, guard dogfood.sh against missing worktrees dir, explicit nil-path handling in LaneDiffScreen, awaiting-input sort priority and proper ISO8601 date parsing in iOS lane helpers, and trim whitespace-only search queries. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/desktop/src/main/main.ts | 12 ++++++++---- apps/desktop/src/main/services/ipc/registerIpc.ts | 14 ++------------ .../src/renderer/components/lanes/LanesPage.tsx | 11 +++++------ apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift | 6 +++++- apps/ios/ADE/Views/Lanes/LaneHelpers.swift | 13 ++++++++++++- scripts/dogfood.sh | 8 +++++++- 6 files changed, 39 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index ccd9c11d3..967903407 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -2295,11 +2295,15 @@ app.whenReady().then(async () => { dispose: () => {} // desktop manages service lifecycle }; - // Only honour the env override for the first project context to avoid - // EADDRINUSE when multiple projects share a single socket path. + // 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 + const mcpSocketPath = envSocketOverride + ? (projectContexts.size === 0 + ? envSocketOverride + : `${envSocketOverride}.${Buffer.from(normalizeProjectRoot(projectRoot)).toString("base64url").slice(0, 8)}`) : adePaths.socketPath; const activeMcpConnections = new Set(); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 5f3c35e39..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"; @@ -6325,17 +6325,7 @@ export function registerIpc({ }); ipcMain.handle(IPC.updateGetState, () => { - return getCtx().autoUpdateService?.getSnapshot() ?? { - status: "idle", - version: null, - progressPercent: null, - bytesPerSecond: null, - transferredBytes: null, - totalBytes: null, - releaseNotesUrl: null, - error: null, - recentlyInstalled: null, - }; + return getCtx().autoUpdateService?.getSnapshot() ?? createEmptyAutoUpdateSnapshot(); }); ipcMain.handle(IPC.updateQuitAndInstall, () => { diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 886545adf..958afeaf1 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -431,7 +431,7 @@ export function LanesPage() { : snapshot; }); useAppStore.setState({ laneSnapshots: next }); - }).catch(() => {}); + }).catch((err) => { console.error("getBatchAssessment failed:", err); }); pushConflictChips(event.chips); }); return unsubscribe; @@ -924,11 +924,10 @@ export function LanesPage() { } } - const results = await Promise.allSettled([refreshLanes()]); - 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); diff --git a/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift index 1b8d71f03..b1c1f6f39 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDiffScreen.swift @@ -162,7 +162,11 @@ struct LaneDiffScreen: View { @MainActor private func load() async throws { - guard let path = request.path else { return } + guard let path = request.path else { + diff = nil + editedText = "" + return + } isLoading = true defer { isLoading = false } do { diff --git a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift index 97ec034f9..0ee5b7d48 100644 --- a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift +++ b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift @@ -58,9 +58,20 @@ func laneListFilteredSnapshots( .sorted(by: laneListSortSnapshots) } +private func parseLaneTimestamp(_ rawValue: String) -> Date? { + cachedISO8601Formatter.date(from: rawValue) ?? cachedISO8601FormatterNoFractional.date(from: rawValue) +} + func laneListSortSnapshots(_ lhs: LaneListSnapshot, _ rhs: LaneListSnapshot) -> Bool { if lhs.lane.laneType == "primary" && rhs.lane.laneType != "primary" { return true } if lhs.lane.laneType != "primary" && rhs.lane.laneType == "primary" { return false } + let lhsAwaiting = lhs.runtime.bucket == "awaiting-input" + let rhsAwaiting = rhs.runtime.bucket == "awaiting-input" + if lhsAwaiting && !rhsAwaiting { return true } + if !lhsAwaiting && rhsAwaiting { return false } + if let ld = parseLaneTimestamp(lhs.lane.createdAt), let rd = parseLaneTimestamp(rhs.lane.createdAt), ld != rd { + return ld > rd + } if lhs.lane.createdAt != rhs.lane.createdAt { return lhs.lane.createdAt > rhs.lane.createdAt } @@ -99,7 +110,7 @@ func laneListEmptyStateTitle(scope: LaneListScope) -> String { } func laneListEmptyStateMessage(scope: LaneListScope, searchText: String, hasFilters: Bool) -> String { - if !searchText.isEmpty { + if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "Try a different search or clear the filter." } if hasFilters { diff --git a/scripts/dogfood.sh b/scripts/dogfood.sh index 8c10a725f..92d9e4a55 100755 --- a/scripts/dogfood.sh +++ b/scripts/dogfood.sh @@ -18,7 +18,13 @@ SOCKET_PATH="/tmp/ade-dogfood-mcp.sock" if [ -n "${1:-}" ]; then # Find the lane worktree by name/slug match LANE_NAME="$1" - WORKTREE_DIR=$(find "$MAIN_ROOT/.ade/worktrees" -maxdepth 1 -type d -name "*${LANE_NAME}*" | head -1) + if [ ! -d "$MAIN_ROOT/.ade/worktrees" ]; then + echo "No worktree found matching '$LANE_NAME' in .ade/worktrees/" + echo "Available:" + echo " (none)" + exit 1 + fi + WORKTREE_DIR=$(find "$MAIN_ROOT/.ade/worktrees" -maxdepth 1 -type d -name "*${LANE_NAME}*" -print -quit) if [ -z "$WORKTREE_DIR" ]; then echo "No worktree found matching '$LANE_NAME' in .ade/worktrees/" echo "Available:"