From 6c187f25c8503bb1944dae830f6b2c6c9b74dbb9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:15:18 -0400 Subject: [PATCH 1/2] Improve mobile controller parity --- .../src/services/sync/syncHostService.ts | 14 + .../services/sync/syncRemoteCommandService.ts | 4 + .../services/chat/agentChatService.test.ts | 53 +- .../main/services/chat/agentChatService.ts | 62 +- .../services/sync/syncHostService.test.ts | 105 +++ .../sync/syncRemoteCommandService.test.ts | 12 + apps/desktop/src/shared/types/sync.ts | 11 + apps/ios/ADE.xcodeproj/project.pbxproj | 44 +- .../ProviderDroid.imageset/Contents.json | 15 + .../ProviderDroid.imageset/droid.svg | 8 + apps/ios/ADE/Models/RemoteModels.swift | 12 + apps/ios/ADE/Services/SyncService.swift | 87 ++- .../Views/Files/FilesDetailComponents.swift | 151 +++- .../Files/FilesDetailScreen+Actions.swift | 5 +- .../ADE/Views/Files/FilesDetailScreen.swift | 259 +++---- .../FilesDirectoryContentsView+Actions.swift | 2 +- .../Files/FilesDirectoryContentsView.swift | 3 +- .../Views/Files/FilesDirectoryScreen.swift | 11 +- apps/ios/ADE/Views/Files/FilesModels.swift | 75 ++ .../ADE/Views/Files/FilesRootComponents.swift | 229 ++---- .../Views/Files/FilesRootScreen+Actions.swift | 8 +- .../ios/ADE/Views/Files/FilesRootScreen.swift | 52 +- .../Files/FilesWorkspacePickerDropdown.swift | 270 +++++++ .../ADE/Views/Lanes/LaneAdvancedScreen.swift | 198 ----- .../ADE/Views/Lanes/LaneColorPalette.swift | 85 ++ .../Views/Lanes/LaneCommitHistoryScreen.swift | 146 ---- .../ios/ADE/Views/Lanes/LaneCommitSheet.swift | 448 ----------- apps/ios/ADE/Views/Lanes/LaneComponents.swift | 238 +++--- .../ADE/Views/Lanes/LaneDeeplinkHelpers.swift | 17 + .../Lanes/LaneDetailContentSections.swift | 300 ------- .../Lanes/LaneDetailGitActionsPane.swift | 679 ++++++++++++++++ .../Views/Lanes/LaneDetailGitSection.swift | 301 ++----- .../ADE/Views/Lanes/LaneDetailScreen.swift | 493 ++++++++---- apps/ios/ADE/Views/Lanes/LaneHelpers.swift | 56 ++ .../ADE/Views/Lanes/LaneListViewParts.swift | 15 +- .../ios/ADE/Views/Lanes/LaneManageSheet.swift | 733 ++++++++++++------ .../ADE/Views/Lanes/LaneStashesScreen.swift | 117 --- apps/ios/ADE/Views/Lanes/LaneTreeView.swift | 54 +- apps/ios/ADE/Views/Lanes/LaneTypes.swift | 43 + apps/ios/ADE/Views/LanesTabView.swift | 1 + .../ADE/Views/PRs/CreatePrWizardView.swift | 593 +++++--------- apps/ios/ADE/Views/PRs/PrFiltersCard.swift | 35 +- .../ios/ADE/Views/PRs/PrListRowModifier.swift | 23 +- apps/ios/ADE/Views/PRs/PrRowCard.swift | 2 +- .../PRs/PrTargetBranchPickerDropdown.swift | 221 ++++++ apps/ios/ADE/Views/PRs/PrsRootScreen.swift | 91 +-- .../ADE/Views/PRs/PrsRootScreenPreviews.swift | 45 +- .../Views/Work/WorkChatAttachmentTray.swift | 263 +++++++ .../Work/WorkChatComposerAndInputViews.swift | 6 +- .../Work/WorkChatHeaderAndMessageViews.swift | 57 +- .../Views/Work/WorkChatRichCardViews.swift | 149 ++-- .../Work/WorkChatSessionView+Actions.swift | 7 +- .../Work/WorkChatSessionView+Timeline.swift | 20 +- .../ADE/Views/Work/WorkChatSessionView.swift | 248 ++---- .../Work/WorkErrorAndMessageHelpers.swift | 103 ++- .../ios/ADE/Views/Work/WorkEventMapping.swift | 11 +- .../Views/Work/WorkLanePickerDropdown.swift | 349 +++++++++ .../ios/ADE/Views/Work/WorkModelCatalog.swift | 46 ++ .../ADE/Views/Work/WorkModelPickerSheet.swift | 408 ++++++---- apps/ios/ADE/Views/Work/WorkModels.swift | 8 +- .../ADE/Views/Work/WorkNewChatScreen.swift | 457 +++-------- apps/ios/ADE/Views/Work/WorkPreviews.swift | 3 +- .../ADE/Views/Work/WorkReasoningCard.swift | 48 +- .../ADE/Views/Work/WorkRootComponents.swift | 7 +- .../WorkSessionDestinationView+Actions.swift | 76 ++ .../Work/WorkSessionDestinationView.swift | 234 ++++-- .../ADE/Views/Work/WorkSessionGrouping.swift | 32 +- .../Work/WorkStatusAndFormattingHelpers.swift | 192 ++++- .../ADE/Views/Work/WorkTimelineHelpers.swift | 7 +- .../ADE/Views/Work/WorkTranscriptParser.swift | 11 + apps/ios/ADETests/ADETests.swift | 347 ++++++++- docs/ARCHITECTURE.md | 2 +- docs/features/chat/README.md | 34 +- docs/features/conflicts/README.md | 4 + docs/features/deeplinks/README.md | 10 +- docs/features/lanes/README.md | 52 +- docs/features/sync-and-multi-device/README.md | 2 +- .../sync-and-multi-device/ios-companion.md | 59 +- .../sync-and-multi-device/remote-commands.md | 5 +- 79 files changed, 5823 insertions(+), 3860 deletions(-) create mode 100644 apps/ios/ADE/Assets.xcassets/ProviderDroid.imageset/Contents.json create mode 100644 apps/ios/ADE/Assets.xcassets/ProviderDroid.imageset/droid.svg create mode 100644 apps/ios/ADE/Views/Files/FilesWorkspacePickerDropdown.swift delete mode 100644 apps/ios/ADE/Views/Lanes/LaneAdvancedScreen.swift delete mode 100644 apps/ios/ADE/Views/Lanes/LaneCommitHistoryScreen.swift delete mode 100644 apps/ios/ADE/Views/Lanes/LaneCommitSheet.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneDeeplinkHelpers.swift delete mode 100644 apps/ios/ADE/Views/Lanes/LaneDetailContentSections.swift create mode 100644 apps/ios/ADE/Views/Lanes/LaneDetailGitActionsPane.swift delete mode 100644 apps/ios/ADE/Views/Lanes/LaneStashesScreen.swift create mode 100644 apps/ios/ADE/Views/PRs/PrTargetBranchPickerDropdown.swift create mode 100644 apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift create mode 100644 apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 14c29e3b9..9f1489e1a 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -3681,6 +3681,18 @@ export function createSyncHostService(args: SyncHostServiceArgs) { peer.subscribedChatSessionIds.add(sessionId); const session = args.sessionService.get(sessionId); + // Snapshots are byte-capped transcript tails — a long-running turn's + // `status: started` event can sit outside the tail, leaving a client + // that subscribes mid-turn unable to tell the session is streaming. + // Ship the live turn state on the ack so clients don't depend on the + // (slower) changeset pump for running/stop affordances. Resolved + // immediately before each send (getSessionSummary is microtask-only): + // computing it earlier leaves an I/O window (readTranscriptTail) where + // a terminal chat_event could overtake a stale `turnActive: true`. + const resolveLiveStatusFields = async (): Promise<{ turnActive?: boolean }> => { + const liveSummary = await args.agentChatService?.getSessionSummary(sessionId).catch(() => null); + return liveSummary ? { turnActive: liveSummary.status === "active" } : {}; + }; const resumePlan = planChatEventResume(chatEventReplayBuffers.get(sessionId), payload?.sinceSeq); if (resumePlan.mode === "replay") { // The replay buffer covers everything the peer missed: skip the @@ -3697,6 +3709,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { truncated: false, events: [], resumed: true, + ...(await resolveLiveStatusFields()), }; sendRequired(peer, "chat_subscribe", resumeAck, envelope.requestId); for (const entry of resumePlan.entries) { @@ -3733,6 +3746,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { capturedAt: nowIso(), truncated: transcriptSize > maxBytes, events, + ...(await resolveLiveStatusFields()), }; sendRequired(peer, "chat_subscribe", snapshot, envelope.requestId); break; diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 1f79224b0..868d37b51 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -2935,6 +2935,10 @@ function registerGitAndFileRemoteCommands({ args, register }: RemoteCommandRegis requireService(args.gitService, "Git service not available.").rebaseContinue(parseConflictLaneArgs(payload, "git.rebaseContinue"))); register("git.rebaseAbort", { viewerAllowed: true, queueable: true }, async (payload) => requireService(args.gitService, "Git service not available.").rebaseAbort(parseConflictLaneArgs(payload, "git.rebaseAbort"))); + register("git.mergeContinue", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").mergeContinue(parseConflictLaneArgs(payload, "git.mergeContinue"))); + register("git.mergeAbort", { viewerAllowed: true, queueable: true }, async (payload) => + requireService(args.gitService, "Git service not available.").mergeAbort(parseConflictLaneArgs(payload, "git.mergeAbort"))); register("git.listBranches", { viewerAllowed: true }, async (payload) => requireService(args.gitService, "Git service not available.").listBranches(parseGitListBranchesArgs(payload))); register("git.checkoutBranch", { viewerAllowed: true, queueable: true }, async (payload) => diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 798a8e7a0..9b579297d 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -76,6 +76,7 @@ const mockState = vi.hoisted(() => ({ cursorSdkSendCalls: [] as Array>, cursorSdkPolicyUpdates: [] as Array>, cursorSdkPooled: null as any, + cursorSdkAgentIdForNextAcquire: null as string | null, cursorSdkCloudRequests: [] as Array<{ type: string; payload: Record }>, cursorSdkCloudResponses: new Map(), cursorSendPromptGate: null as Promise | null, @@ -631,6 +632,8 @@ vi.mock("./cursorSdkPool", () => ({ ), acquireCursorSdkConnection: vi.fn(async (args: Record) => { mockState.cursorSdkAcquireCalls.push(args); + const agentId = mockState.cursorSdkAgentIdForNextAcquire ?? "cursor-sdk-agent-1"; + mockState.cursorSdkAgentIdForNextAcquire = null; const pooled: any = { process: { exitCode: null, killed: false }, bridge: { @@ -639,7 +642,7 @@ vi.mock("./cursorSdkPool", () => ({ onRunResult: null as any, onHookRequest: null as any, }, - agentId: "cursor-sdk-agent-1", + agentId, runId: null, request: vi.fn(async (type: string, payload?: unknown) => { if (type === "policy_update") { @@ -1511,6 +1514,7 @@ beforeEach(() => { mockState.cursorSdkSendCalls = []; mockState.cursorSdkPolicyUpdates = []; mockState.cursorSdkPooled = null; + mockState.cursorSdkAgentIdForNextAcquire = null; mockState.cursorSdkCloudRequests = []; mockState.cursorSdkCloudResponses = new Map(); mockState.cursorSendPromptGate = null; @@ -7014,6 +7018,53 @@ describe("createAgentChatService", () => { expect(mockState.cursorSdkPooled.sendPrompt).toHaveBeenCalledTimes(1); }); + it("injects recent ADE context when Cursor SDK resume opens a new agent", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Inspect the mobile files tab parity work.", + }); + const firstPooled = mockState.cursorSdkPooled; + firstPooled.process.exitCode = 1; + mockState.cursorSdkAgentIdForNextAcquire = "cursor-sdk-agent-2"; + + await service.runSessionTurn({ + sessionId: session.id, + text: "Did you finish the prior work?", + }); + + expect(mockState.cursorSdkAcquireCalls).toHaveLength(2); + expect(mockState.cursorSdkAcquireCalls[1]).toEqual( + expect.objectContaining({ agentId: "cursor-sdk-agent-1" }), + ); + const promptText = String(mockState.cursorSdkSendCalls.at(-1)?.promptText ?? ""); + expect(promptText).toContain("Cursor SDK continuity recovery"); + expect(promptText).toContain("cursor-sdk-agent-1"); + expect(promptText).toContain("cursor-sdk-agent-2"); + expect(promptText).toContain("Recent Conversation Tail"); + expect(promptText).toContain("User: Inspect the mobile files tab parity work."); + expect(promptText).toContain("Did you finish the prior work?"); + // Prompts are prepared before the runtime is acquired, so the rotation + // turn itself stays deduped — but the rotated agent is brand new, so the + // lane execution directive must be re-emitted on the following turn + // instead of staying suppressed by lastLaneDirectiveKey. + expect(promptText).not.toContain("[ADE launch directive]"); + await service.runSessionTurn({ + sessionId: session.id, + text: "Continue with the next step.", + }); + const postRotationPrompt = String(mockState.cursorSdkSendCalls.at(-1)?.promptText ?? ""); + expect(postRotationPrompt).toContain("[ADE launch directive]"); + }); + it("reports active Droid SDK turns so project switching does not close the chat runtime", async () => { const events: AgentChatEventEnvelope[] = []; let finishTurn = () => {}; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index f456f8f46..eef5c1e1f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -7202,6 +7202,38 @@ export function createAgentChatService(args: { managed.pendingReconstructionContext = nextContext.length ? nextContext : null; }; + const stageCursorSdkAgentRotationRecovery = ( + managed: ManagedChatSession, + previousAgentId: string, + nextAgentId: string, + ): void => { + const sections = [ + [ + "Cursor SDK continuity recovery", + `ADE attempted to resume Cursor SDK agent ${previousAgentId}, but the Cursor SDK opened agent ${nextAgentId} instead.`, + "Use this ADE transcript context to continue the user's work. Do not claim access to hidden Cursor SDK state that was not restored.", + ].join("\n"), + ]; + + if (managed.continuitySummary?.trim()) { + sections.push(["Continuity Summary", managed.continuitySummary.trim()].join("\n")); + } + + const recentConversation = buildRecentConversationContext(managed); + if (recentConversation.length) { + sections.push(["Recent Conversation Tail", recentConversation].join("\n")); + } + + const existing = managed.pendingReconstructionContext?.trim(); + if (existing) sections.push(existing); + + const nextContext = sections.map((section) => section.trim()).filter((section) => section.length > 0).join("\n\n"); + managed.pendingReconstructionContext = nextContext.length ? nextContext : null; + // The rotated agent is brand new, so re-emit the lane execution directive on + // the next turn instead of letting the dedupe key suppress it. + clearLaneDirectiveKey(managed); + }; + const detectAuth = async () => { const snapshot = projectConfigService.get(); const configured = snapshot.effective.ai?.apiKeys; @@ -9809,8 +9841,8 @@ export function createAgentChatService(args: { return; } - const preserveClaudeResumeState = - managed.runtime.kind === "claude" && reasonAllowsPreservation; + const preserveProviderResumeState = + (managed.runtime.kind === "claude" || managed.runtime.kind === "cursor") && reasonAllowsPreservation; if (managed.runtime.kind === "codex") { const runtime = managed.runtime; const interruptedTurnId = runtime.activeTurnId ?? runtime.startedTurnId ?? null; @@ -9868,7 +9900,7 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "claude") { // Mark interrupted so the streaming catch block takes the graceful path managed.runtime.interrupted = true; - if (preserveClaudeResumeState) persistChatState(managed); + if (preserveProviderResumeState) persistChatState(managed); cancelClaudeWarmup(managed, managed.runtime, "teardown"); try { managed.runtime.query?.close(); } catch { /* ignore */ } managed.runtime.inputPump?.close(); @@ -9908,6 +9940,7 @@ export function createAgentChatService(args: { cancelCursorPermissionWaiter(w, "Cursor tool approval was cancelled because the session closed."); } rt.permissionWaiters.clear(); + if (preserveProviderResumeState) persistChatState(managed); releaseCursorSdkConnection(rt.poolKey, rt.poolGeneration); managed.runtime = null; } @@ -9920,8 +9953,8 @@ export function createAgentChatService(args: { releaseDroidSdkConnection(rt.poolKey, rt.poolGeneration); managed.runtime = null; } - managed.runtimeInvalidated = !preserveClaudeResumeState; - if (!preserveClaudeResumeState) { + managed.runtimeInvalidated = !preserveProviderResumeState; + if (!preserveProviderResumeState) { clearLaneDirectiveKey(managed); } }; @@ -19740,7 +19773,7 @@ export function createAgentChatService(args: { killed: existing.sdk.process.killed, connected: existing.sdk.process.connected, }); - teardownRuntime(managed, "handle_close"); + teardownRuntime(managed, "pool_compaction"); } else { existing.sdkPolicy = policy; existing.currentModeId = displayModeId; @@ -19836,12 +19869,25 @@ export function createAgentChatService(args: { throw error; } const pooled = acquired.pooled; + const nextCursorSdkAgentId = pooled.agentId?.trim() || null; + if ( + persistedCursorSdkAgentId + && nextCursorSdkAgentId + && nextCursorSdkAgentId !== persistedCursorSdkAgentId + ) { + stageCursorSdkAgentRotationRecovery(managed, persistedCursorSdkAgentId, nextCursorSdkAgentId); + logger.warn("agent_chat.cursor_sdk_agent_rotated_after_resume", { + sessionId: managed.session.id, + previousAgentId: persistedCursorSdkAgentId, + nextAgentId: nextCursorSdkAgentId, + }); + } const rt: CursorRuntime = { kind: "cursor", poolKey, poolGeneration: acquired.generation, sdk: pooled, - sdkAgentId: pooled.agentId, + sdkAgentId: nextCursorSdkAgentId, sdkRunId: pooled.runId, sdkPolicy: policy, sdkApprovedTools: new Set(), @@ -19941,7 +19987,7 @@ export function createAgentChatService(args: { const reconstructionContext = managed.pendingReconstructionContext?.trim() ?? ""; if (reconstructionContext.length) { composed = [ - "System context (CTO reconstruction, do not echo verbatim):", + "System context (ADE continuity, do not echo verbatim):", reconstructionContext, "", composed, diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index 6b4fcc0ba..3bfa1b6bd 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -1984,6 +1984,111 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { await expect(clientB.queue.next("chat_event", 250)).rejects.toThrow(/Timed out waiting for chat_event/); }, 15_000); + it("ships live turn state on the chat_subscribe ack so mid-turn subscribers see streaming state", async () => { + const brainDb = await openKvDb(makeDbPath("ade-sync-chat-turnstate-"), createLogger() as any); + const projectRoot = makeProjectRoot("ade-sync-chat-turnstate-project-"); + const workspaceRoot = path.join(projectRoot, "workspace"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + const chatService = createStubChatService(); + // Snapshot tails can miss a long turn's `status: started` event, so the + // ack itself must carry the live "a turn is running" state. + chatService.service.getSessionSummary.mockImplementation(async (sessionId: string) => + sessionId === "session-awaiting" + ? { sessionId, status: "active", awaitingInput: true } + : { sessionId, status: "active" }); + + const host = createSyncHostService({ + db: brainDb, + logger: createLogger() as any, + projectId: "project-1", + projectRoot, + port: 0, + fileService: createStubFileService(workspaceRoot) as any, + laneService: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn(), + archive: vi.fn(), + } as any, + prService: { + listAll: vi.fn().mockResolvedValue([]), + refresh: vi.fn().mockResolvedValue([]), + listSnapshots: vi.fn().mockReturnValue([]), + getDetail: vi.fn(), + getStatus: vi.fn(), + getChecks: vi.fn(), + getReviews: vi.fn(), + getComments: vi.fn(), + getFiles: vi.fn(), + createFromLane: vi.fn(), + land: vi.fn(), + closePr: vi.fn(), + requestReviewers: vi.fn(), + } as any, + sessionService: { + list: () => [], + get: () => null, + readTranscriptTail: async () => "", + } as any, + ptyService: { + create: vi.fn(), + } as any, + agentChatService: chatService.service, + computerUseArtifactBrokerService: { + listArtifacts: () => [], + } as any, + pinStore: createStubPinStore(), + }); + activeDisposers.push(async () => { + await host.dispose(); + brainDb.close(); + }); + + const port = await host.waitUntilListening(); + const client = await connectClient({ + port, + token: host.getBootstrapToken(), + deviceId: "peer-chat-turnstate", + deviceName: "Peer Chat Turn State", + siteId: brainDb.sync.getSiteId(), + dbVersion: brainDb.sync.getDbVersion(), + }); + activeDisposers.push(client.close); + + client.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + payload: { sessionId: "session-running" }, + })); + const runningAck = await client.queue.next("chat_subscribe"); + expect(runningAck.payload).toMatchObject({ + sessionId: "session-running", + turnActive: true, + }); + + // Awaiting-input sessions still report an active turn — the turn is + // running, just paused on a prompt; clients keep their stop affordance. + client.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + payload: { sessionId: "session-awaiting" }, + })); + const awaitingAck = await client.queue.next("chat_subscribe"); + expect(awaitingAck.payload).toMatchObject({ + sessionId: "session-awaiting", + turnActive: true, + }); + + // When the chat service has no summary for the session, the ack must + // omit the field rather than fabricate state — clients treat absence as + // "no live signal" and fall back to transcript-derived streaming state. + chatService.service.getSessionSummary.mockResolvedValue(null); + client.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + payload: { sessionId: "session-unknown" }, + })); + const unknownAck = await client.queue.next("chat_subscribe"); + expect(unknownAck.payload).toMatchObject({ sessionId: "session-unknown" }); + expect(unknownAck.payload).not.toHaveProperty("turnActive"); + }, 15_000); + it("resubscribes chat listeners after reconnect and routes chat remote commands", async () => { const brainDb = await openKvDb(makeDbPath("ade-sync-chat-commands-"), createLogger() as any); const projectRoot = makeProjectRoot("ade-sync-chat-commands-project-"); diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 66a28af5c..2ca48f93a 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -82,6 +82,8 @@ const IOS_REMOTE_COMMAND_ACTIONS = [ "git.getConflictState", "git.rebaseContinue", "git.rebaseAbort", + "git.mergeContinue", + "git.mergeAbort", "chat.models", "chat.modelCatalog", "chat.listSessions", @@ -412,6 +414,8 @@ function createMockGitService() { getConflictState: vi.fn().mockResolvedValue(null), rebaseContinue: vi.fn().mockResolvedValue(undefined), rebaseAbort: vi.fn().mockResolvedValue(undefined), + mergeContinue: vi.fn().mockResolvedValue(undefined), + mergeAbort: vi.fn().mockResolvedValue(undefined), listBranches: vi.fn().mockResolvedValue([]), checkoutBranch: vi.fn().mockResolvedValue(undefined), } as any; @@ -1544,6 +1548,14 @@ describe("createSyncRemoteCommandService", () => { }); }); + it("git.mergeContinue and git.mergeAbort dispatch to the git service with the lane id", async () => { + await service.execute(makePayload("git.mergeContinue", { laneId: "lane-1" })); + expect(gitService.mergeContinue).toHaveBeenCalledWith({ laneId: "lane-1" }); + + await service.execute(makePayload("git.mergeAbort", { laneId: "lane-1" })); + expect(gitService.mergeAbort).toHaveBeenCalledWith({ laneId: "lane-1" }); + }); + it("git.checkoutBranch requires laneId and branchName", async () => { await service.execute(makePayload("git.checkoutBranch", { laneId: "lane-1", diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index f6589869c..5d1352284 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -552,6 +552,15 @@ export type SyncChatSubscribeSnapshotPayload = { * host's seq stream may have restarted (e.g. host process restart). */ resumed?: boolean; + /** + * Whether a turn is currently running for this session, taken from the live + * agent chat service at subscribe time. Snapshots are byte-capped transcript + * tails, so a long-running turn's `status: started` event can fall outside + * the tail — without this flag a client subscribing mid-turn cannot tell + * the session is streaming. Absent on hosts that predate the field and when + * the host has no live summary for the session. + */ + turnActive?: boolean; }; export type SyncChatUnsubscribePayload = { @@ -762,6 +771,8 @@ export type SyncRemoteCommandAction = | "git.getConflictState" | "git.rebaseContinue" | "git.rebaseAbort" + | "git.mergeContinue" + | "git.mergeAbort" | "git.listBranches" | "git.checkoutBranch" | "conflicts.getLaneStatus" diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 715df23e5..e098429c2 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -70,6 +70,9 @@ E1000000000000000000002B /* WorkChatSessionView+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002B /* WorkChatSessionView+Actions.swift */; }; E1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */; }; E1000000000000000000002D /* WorkChatRichCardViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002D /* WorkChatRichCardViews.swift */; }; + E10000000000000000000050 /* WorkChatAttachmentTray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000050 /* WorkChatAttachmentTray.swift */; }; + E10000000000000000000052 /* LaneDeeplinkHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000052 /* LaneDeeplinkHelpers.swift */; }; + E10000000000000000000053 /* LaneDetailGitActionsPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000053 /* LaneDetailGitActionsPane.swift */; }; E10000000000000000000047 /* ADEInspectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000047 /* ADEInspectable.swift */; }; E1000000000000000000002E /* WorkChatComposerAndInputViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002E /* WorkChatComposerAndInputViews.swift */; }; E1000000000000000000002F /* WorkArtifactTerminalViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002F /* WorkArtifactTerminalViews.swift */; }; @@ -107,6 +110,7 @@ E2000000000000000000004A /* FilesDetailComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000004A /* FilesDetailComponents.swift */; }; E2000000000000000000004B /* FilesDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000004B /* FilesDetailScreen.swift */; }; E2000000000000000000004C /* FilesDetailScreen+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000004C /* FilesDetailScreen+Actions.swift */; }; + E2000000000000000000004D /* FilesWorkspacePickerDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000004D /* FilesWorkspacePickerDropdown.swift */; }; 60F4CDDB763C0A9F0E650B40 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 31EC445F22FD38F90C16343E /* Foundation.framework */; }; 63A9C60B0E0F0E2707634B2E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8943C47805A871A4E4A4BF68 /* Assets.xcassets */; }; 6BDC22C6450AF0B3CBDB2650 /* FilesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBFB65019F4C3F8A89428CE /* FilesTabView.swift */; }; @@ -124,7 +128,6 @@ B10000000000000000000004 /* LaneChatLaunchSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000004 /* LaneChatLaunchSheet.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 */; }; @@ -137,12 +140,8 @@ B10000000000000000000012 /* LaneTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000012 /* LaneTypes.swift */; }; B10000000000000000000014 /* ConnectionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000014 /* ConnectionSettingsView.swift */; }; B10000000000000000000101 /* LaneDetailRebaseBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000101 /* LaneDetailRebaseBanner.swift */; }; - B10000000000000000000102 /* LaneCommitSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000102 /* LaneCommitSheet.swift */; }; B10000000000000000000103 /* LaneActionsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000103 /* LaneActionsCard.swift */; }; - B10000000000000000000150 /* LaneAdvancedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000150 /* LaneAdvancedScreen.swift */; }; B10000000000000000000104 /* LaneSyncDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000104 /* LaneSyncDetailScreen.swift */; }; - B10000000000000000000105 /* LaneStashesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000105 /* LaneStashesScreen.swift */; }; - B10000000000000000000106 /* LaneCommitHistoryScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000106 /* LaneCommitHistoryScreen.swift */; }; B10000000000000000000107 /* LaneEnvInitProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000107 /* LaneEnvInitProgressView.swift */; }; B10000000000000000000109 /* LaneBranchPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000109 /* LaneBranchPickerSheet.swift */; }; B10000000000000000000110 /* LaneMultiAttachSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000110 /* LaneMultiAttachSheet.swift */; }; @@ -168,6 +167,7 @@ G1000000000000000000000C /* PrWorkflowCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = G2000000000000000000000C /* PrWorkflowCards.swift */; }; G1000000000000000000000D /* PrStackSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = G2000000000000000000000D /* PrStackSheet.swift */; }; G1000000000000000000000E /* CreatePrWizardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = G2000000000000000000000E /* CreatePrWizardView.swift */; }; + G10000000000000000000027 /* PrTargetBranchPickerDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000027 /* PrTargetBranchPickerDropdown.swift */; }; G1000000000000000000000F /* PrMergeGateCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = G2000000000000000000000F /* PrMergeGateCard.swift */; }; G10000000000000000000020 /* PrAiSummaryCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000020 /* PrAiSummaryCard.swift */; }; G10000000000000000000021 /* PrCommitRailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000021 /* PrCommitRailView.swift */; }; @@ -190,6 +190,7 @@ E1000000000000000000003F /* WorkModelCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000003F /* WorkModelCatalog.swift */; }; E10000000000000000000040 /* WorkModelPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000040 /* WorkModelPickerSheet.swift */; }; E10000000000000000000041 /* WorkNewChatScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000041 /* WorkNewChatScreen.swift */; }; + E10000000000000000000051 /* WorkLanePickerDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000051 /* WorkLanePickerDropdown.swift */; }; E10000000000000000000042 /* WorkPreviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000042 /* WorkPreviews.swift */; }; E10000000000000000000043 /* WorkMentionsPickerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000043 /* WorkMentionsPickerSheet.swift */; }; E10000000000000000000044 /* WorkSlashCommandsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000044 /* WorkSlashCommandsSheet.swift */; }; @@ -277,6 +278,9 @@ D1000000000000000000002B /* WorkChatSessionView+Actions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkChatSessionView+Actions.swift"; path = "ADE/Views/Work/WorkChatSessionView+Actions.swift"; sourceTree = ""; }; D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatHeaderAndMessageViews.swift; path = ADE/Views/Work/WorkChatHeaderAndMessageViews.swift; sourceTree = ""; }; D1000000000000000000002D /* WorkChatRichCardViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatRichCardViews.swift; path = ADE/Views/Work/WorkChatRichCardViews.swift; sourceTree = ""; }; + D10000000000000000000050 /* WorkChatAttachmentTray.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatAttachmentTray.swift; path = ADE/Views/Work/WorkChatAttachmentTray.swift; sourceTree = ""; }; + D10000000000000000000052 /* LaneDeeplinkHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDeeplinkHelpers.swift; path = ADE/Views/Lanes/LaneDeeplinkHelpers.swift; sourceTree = ""; }; + D10000000000000000000053 /* LaneDetailGitActionsPane.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDetailGitActionsPane.swift; path = ADE/Views/Lanes/LaneDetailGitActionsPane.swift; sourceTree = ""; }; D10000000000000000000047 /* ADEInspectable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEInspectable.swift; path = ADE/Debug/ADEInspectorKit/ADEInspectable.swift; sourceTree = ""; }; D1000000000000000000002E /* WorkChatComposerAndInputViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatComposerAndInputViews.swift; path = ADE/Views/Work/WorkChatComposerAndInputViews.swift; sourceTree = ""; }; D1000000000000000000002F /* WorkArtifactTerminalViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkArtifactTerminalViews.swift; path = ADE/Views/Work/WorkArtifactTerminalViews.swift; sourceTree = ""; }; @@ -296,6 +300,7 @@ D1000000000000000000003F /* WorkModelCatalog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkModelCatalog.swift; path = ADE/Views/Work/WorkModelCatalog.swift; sourceTree = ""; }; D10000000000000000000040 /* WorkModelPickerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkModelPickerSheet.swift; path = ADE/Views/Work/WorkModelPickerSheet.swift; sourceTree = ""; }; D10000000000000000000041 /* WorkNewChatScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkNewChatScreen.swift; path = ADE/Views/Work/WorkNewChatScreen.swift; sourceTree = ""; }; + D10000000000000000000051 /* WorkLanePickerDropdown.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkLanePickerDropdown.swift; path = ADE/Views/Work/WorkLanePickerDropdown.swift; sourceTree = ""; }; D10000000000000000000042 /* WorkPreviews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPreviews.swift; path = ADE/Views/Work/WorkPreviews.swift; sourceTree = ""; }; D10000000000000000000043 /* WorkMentionsPickerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkMentionsPickerSheet.swift; path = ADE/Views/Work/WorkMentionsPickerSheet.swift; sourceTree = ""; }; D10000000000000000000044 /* WorkSlashCommandsSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSlashCommandsSheet.swift; path = ADE/Views/Work/WorkSlashCommandsSheet.swift; sourceTree = ""; }; @@ -326,6 +331,7 @@ D2000000000000000000004A /* FilesDetailComponents.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesDetailComponents.swift; path = ADE/Views/Files/FilesDetailComponents.swift; sourceTree = ""; }; D2000000000000000000004B /* FilesDetailScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesDetailScreen.swift; path = ADE/Views/Files/FilesDetailScreen.swift; sourceTree = ""; }; D2000000000000000000004C /* FilesDetailScreen+Actions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "FilesDetailScreen+Actions.swift"; path = "ADE/Views/Files/FilesDetailScreen+Actions.swift"; sourceTree = ""; }; + D2000000000000000000004D /* FilesWorkspacePickerDropdown.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesWorkspacePickerDropdown.swift; path = ADE/Views/Files/FilesWorkspacePickerDropdown.swift; sourceTree = ""; }; 14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADETests.swift; path = ADETests/ADETests.swift; sourceTree = ""; }; D30000000000000000000001 /* AttentionDrawerModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerModel.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerModel.swift; sourceTree = ""; }; D30000000000000000000002 /* AttentionDrawerButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerButton.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerButton.swift; sourceTree = ""; }; @@ -350,7 +356,6 @@ A10000000000000000000004 /* LaneChatLaunchSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneChatLaunchSheet.swift; path = ADE/Views/Lanes/LaneChatLaunchSheet.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 = ""; }; @@ -363,12 +368,8 @@ A10000000000000000000012 /* LaneTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneTypes.swift; path = ADE/Views/Lanes/LaneTypes.swift; sourceTree = ""; }; A10000000000000000000014 /* ConnectionSettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ConnectionSettingsView.swift; path = ADE/Views/Settings/ConnectionSettingsView.swift; sourceTree = ""; }; A10000000000000000000101 /* LaneDetailRebaseBanner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDetailRebaseBanner.swift; path = ADE/Views/Lanes/LaneDetailRebaseBanner.swift; sourceTree = ""; }; - A10000000000000000000102 /* LaneCommitSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneCommitSheet.swift; path = ADE/Views/Lanes/LaneCommitSheet.swift; sourceTree = ""; }; A10000000000000000000103 /* LaneActionsCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneActionsCard.swift; path = ADE/Views/Lanes/LaneActionsCard.swift; sourceTree = ""; }; - A10000000000000000000150 /* LaneAdvancedScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneAdvancedScreen.swift; path = ADE/Views/Lanes/LaneAdvancedScreen.swift; sourceTree = ""; }; A10000000000000000000104 /* LaneSyncDetailScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneSyncDetailScreen.swift; path = ADE/Views/Lanes/LaneSyncDetailScreen.swift; sourceTree = ""; }; - A10000000000000000000105 /* LaneStashesScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneStashesScreen.swift; path = ADE/Views/Lanes/LaneStashesScreen.swift; sourceTree = ""; }; - A10000000000000000000106 /* LaneCommitHistoryScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneCommitHistoryScreen.swift; path = ADE/Views/Lanes/LaneCommitHistoryScreen.swift; sourceTree = ""; }; A10000000000000000000107 /* LaneEnvInitProgressView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneEnvInitProgressView.swift; path = ADE/Views/Lanes/LaneEnvInitProgressView.swift; sourceTree = ""; }; A10000000000000000000109 /* LaneBranchPickerSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneBranchPickerSheet.swift; path = ADE/Views/Lanes/LaneBranchPickerSheet.swift; sourceTree = ""; }; A10000000000000000000110 /* LaneMultiAttachSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneMultiAttachSheet.swift; path = ADE/Views/Lanes/LaneMultiAttachSheet.swift; sourceTree = ""; }; @@ -394,6 +395,7 @@ G2000000000000000000000C /* PrWorkflowCards.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrWorkflowCards.swift; path = ADE/Views/PRs/PrWorkflowCards.swift; sourceTree = ""; }; G2000000000000000000000D /* PrStackSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrStackSheet.swift; path = ADE/Views/PRs/PrStackSheet.swift; sourceTree = ""; }; G2000000000000000000000E /* CreatePrWizardView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CreatePrWizardView.swift; path = ADE/Views/PRs/CreatePrWizardView.swift; sourceTree = ""; }; + G20000000000000000000027 /* PrTargetBranchPickerDropdown.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrTargetBranchPickerDropdown.swift; path = ADE/Views/PRs/PrTargetBranchPickerDropdown.swift; sourceTree = ""; }; G2000000000000000000000F /* PrMergeGateCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrMergeGateCard.swift; path = ADE/Views/PRs/PrMergeGateCard.swift; sourceTree = ""; }; G20000000000000000000020 /* PrAiSummaryCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrAiSummaryCard.swift; path = ADE/Views/PRs/PrAiSummaryCard.swift; sourceTree = ""; }; G20000000000000000000021 /* PrCommitRailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrCommitRailView.swift; path = ADE/Views/PRs/PrCommitRailView.swift; sourceTree = ""; }; @@ -479,8 +481,9 @@ A1000000000000000000001E /* LaneColorPalette.swift */, A1000000000000000000001F /* LaneColorSwatchPicker.swift */, A10000000000000000000007 /* LaneCreateSheet.swift */, - A10000000000000000000008 /* LaneDetailContentSections.swift */, A1000000000000000000001D /* LaneDetailSectionChrome.swift */, + D10000000000000000000052 /* LaneDeeplinkHelpers.swift */, + D10000000000000000000053 /* LaneDetailGitActionsPane.swift */, A10000000000000000000009 /* LaneDetailGitSection.swift */, A1000000000000000000000A /* LaneDetailScreen.swift */, A1000000000000000000000B /* LaneDiffScreen.swift */, @@ -495,12 +498,8 @@ A10000000000000000000012 /* LaneTypes.swift */, A10000000000000000000019 /* LanesOfflineEmptyState.swift */, A10000000000000000000101 /* LaneDetailRebaseBanner.swift */, - A10000000000000000000102 /* LaneCommitSheet.swift */, A10000000000000000000103 /* LaneActionsCard.swift */, - A10000000000000000000150 /* LaneAdvancedScreen.swift */, A10000000000000000000104 /* LaneSyncDetailScreen.swift */, - A10000000000000000000105 /* LaneStashesScreen.swift */, - A10000000000000000000106 /* LaneCommitHistoryScreen.swift */, A10000000000000000000107 /* LaneEnvInitProgressView.swift */, A10000000000000000000109 /* LaneBranchPickerSheet.swift */, A10000000000000000000110 /* LaneMultiAttachSheet.swift */, @@ -563,6 +562,7 @@ G2000000000000000000000C /* PrWorkflowCards.swift */, G2000000000000000000000D /* PrStackSheet.swift */, G2000000000000000000000E /* CreatePrWizardView.swift */, + G20000000000000000000027 /* PrTargetBranchPickerDropdown.swift */, G2000000000000000000000F /* PrMergeGateCard.swift */, G20000000000000000000020 /* PrAiSummaryCard.swift */, G20000000000000000000021 /* PrCommitRailView.swift */, @@ -588,6 +588,7 @@ D2000000000000000000004A /* FilesDetailComponents.swift */, D2000000000000000000004B /* FilesDetailScreen.swift */, D2000000000000000000004C /* FilesDetailScreen+Actions.swift */, + D2000000000000000000004D /* FilesWorkspacePickerDropdown.swift */, ); name = Files; sourceTree = ""; @@ -631,6 +632,7 @@ D1000000000000000000003F /* WorkModelCatalog.swift */, D10000000000000000000040 /* WorkModelPickerSheet.swift */, D10000000000000000000041 /* WorkNewChatScreen.swift */, + D10000000000000000000051 /* WorkLanePickerDropdown.swift */, D10000000000000000000042 /* WorkPreviews.swift */, D10000000000000000000043 /* WorkMentionsPickerSheet.swift */, D10000000000000000000044 /* WorkSlashCommandsSheet.swift */, @@ -643,6 +645,7 @@ D1000000000000000000003C /* WorkSessionGrouping.swift */, D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */, D1000000000000000000002D /* WorkChatRichCardViews.swift */, + D10000000000000000000050 /* WorkChatAttachmentTray.swift */, D1000000000000000000002E /* WorkChatComposerAndInputViews.swift */, D1000000000000000000002F /* WorkArtifactTerminalViews.swift */, D10000000000000000000030 /* WorkMarkdownViews.swift */, @@ -1056,14 +1059,16 @@ E2000000000000000000004A /* FilesDetailComponents.swift in Sources */, E2000000000000000000004B /* FilesDetailScreen.swift in Sources */, E2000000000000000000004C /* FilesDetailScreen+Actions.swift in Sources */, + E2000000000000000000004D /* FilesWorkspacePickerDropdown.swift in Sources */, B10000000000000000000002 /* LaneAttachSheet.swift in Sources */, B10000000000000000000003 /* LaneBatchManageSheet.swift in Sources */, B10000000000000000000004 /* LaneChatLaunchSheet.swift in Sources */, B10000000000000000000006 /* LaneComponents.swift in Sources */, B10000000000000000000007 /* LaneCreateSheet.swift in Sources */, - B10000000000000000000008 /* LaneDetailContentSections.swift in Sources */, B1000000000000000000001D /* LaneDetailSectionChrome.swift in Sources */, B10000000000000000000009 /* LaneDetailGitSection.swift in Sources */, + E10000000000000000000052 /* LaneDeeplinkHelpers.swift in Sources */, + E10000000000000000000053 /* LaneDetailGitActionsPane.swift in Sources */, B1000000000000000000000A /* LaneDetailScreen.swift in Sources */, B1000000000000000000000B /* LaneDiffScreen.swift in Sources */, B1000000000000000000000C /* LaneFileTreeComponents.swift in Sources */, @@ -1079,12 +1084,8 @@ B10000000000000000000012 /* LaneTypes.swift in Sources */, B10000000000000000000014 /* ConnectionSettingsView.swift in Sources */, B10000000000000000000101 /* LaneDetailRebaseBanner.swift in Sources */, - B10000000000000000000102 /* LaneCommitSheet.swift in Sources */, B10000000000000000000103 /* LaneActionsCard.swift in Sources */, - B10000000000000000000150 /* LaneAdvancedScreen.swift in Sources */, B10000000000000000000104 /* LaneSyncDetailScreen.swift in Sources */, - B10000000000000000000105 /* LaneStashesScreen.swift in Sources */, - B10000000000000000000106 /* LaneCommitHistoryScreen.swift in Sources */, B10000000000000000000107 /* LaneEnvInitProgressView.swift in Sources */, B10000000000000000000109 /* LaneBranchPickerSheet.swift in Sources */, B10000000000000000000110 /* LaneMultiAttachSheet.swift in Sources */, @@ -1110,6 +1111,7 @@ G1000000000000000000000C /* PrWorkflowCards.swift in Sources */, G1000000000000000000000D /* PrStackSheet.swift in Sources */, G1000000000000000000000E /* CreatePrWizardView.swift in Sources */, + G10000000000000000000027 /* PrTargetBranchPickerDropdown.swift in Sources */, G1000000000000000000000F /* PrMergeGateCard.swift in Sources */, G10000000000000000000020 /* PrAiSummaryCard.swift in Sources */, G10000000000000000000021 /* PrCommitRailView.swift in Sources */, @@ -1142,6 +1144,7 @@ E1000000000000000000003F /* WorkModelCatalog.swift in Sources */, E10000000000000000000040 /* WorkModelPickerSheet.swift in Sources */, E10000000000000000000041 /* WorkNewChatScreen.swift in Sources */, + E10000000000000000000051 /* WorkLanePickerDropdown.swift in Sources */, E10000000000000000000042 /* WorkPreviews.swift in Sources */, E10000000000000000000043 /* WorkMentionsPickerSheet.swift in Sources */, E10000000000000000000044 /* WorkSlashCommandsSheet.swift in Sources */, @@ -1153,6 +1156,7 @@ E1000000000000000000003C /* WorkSessionGrouping.swift in Sources */, E1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift in Sources */, E1000000000000000000002D /* WorkChatRichCardViews.swift in Sources */, + E10000000000000000000050 /* WorkChatAttachmentTray.swift in Sources */, E10000000000000000000047 /* ADEInspectable.swift in Sources */, E1000000000000000000002E /* WorkChatComposerAndInputViews.swift in Sources */, E1000000000000000000002F /* WorkArtifactTerminalViews.swift in Sources */, diff --git a/apps/ios/ADE/Assets.xcassets/ProviderDroid.imageset/Contents.json b/apps/ios/ADE/Assets.xcassets/ProviderDroid.imageset/Contents.json new file mode 100644 index 000000000..67182fed1 --- /dev/null +++ b/apps/ios/ADE/Assets.xcassets/ProviderDroid.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "droid.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/apps/ios/ADE/Assets.xcassets/ProviderDroid.imageset/droid.svg b/apps/ios/ADE/Assets.xcassets/ProviderDroid.imageset/droid.svg new file mode 100644 index 000000000..16b99b68b --- /dev/null +++ b/apps/ios/ADE/Assets.xcassets/ProviderDroid.imageset/droid.svg @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 4e011bd24..0f8babed9 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -1768,6 +1768,7 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { struct AgentChatFileRef: Codable, Equatable { var path: String var type: String + var url: String? = nil } enum AgentChatEvent: Decodable, Equatable { @@ -2143,6 +2144,12 @@ struct SyncChatSubscribeSnapshotPayload: Decodable, Equatable { var capturedAt: String var truncated: Bool var events: [AgentChatEventEnvelope] + /// Live turn state from the host's agent chat service at subscribe time. + /// Snapshots are byte-capped transcript tails, so a long turn's + /// `status: started` event can fall outside the tail; this flag is fresher + /// than both the snapshot tail and the changeset-synced session row. Nil on + /// older hosts and when the host has no live summary for the session. + var turnActive: Bool? } struct AgentChatSteerRequest: Codable, Equatable { @@ -2443,6 +2450,7 @@ struct FilesWorkspace: Codable, Identifiable, Equatable { var kind: String var laneId: String? var name: String + var branchRef: String? = nil var rootPath: String var isReadOnlyByDefault: Bool var mobileReadOnly: Bool @@ -2456,6 +2464,7 @@ struct FilesWorkspace: Codable, Identifiable, Equatable { kind: String, laneId: String?, name: String, + branchRef: String? = nil, rootPath: String, isReadOnlyByDefault: Bool, mobileReadOnly: Bool = true @@ -2464,6 +2473,7 @@ struct FilesWorkspace: Codable, Identifiable, Equatable { self.kind = kind self.laneId = laneId self.name = name + self.branchRef = branchRef self.rootPath = rootPath self.isReadOnlyByDefault = isReadOnlyByDefault self.mobileReadOnly = mobileReadOnly @@ -2474,6 +2484,7 @@ struct FilesWorkspace: Codable, Identifiable, Equatable { case kind case laneId case name + case branchRef case rootPath case isReadOnlyByDefault case mobileReadOnly @@ -2485,6 +2496,7 @@ struct FilesWorkspace: Codable, Identifiable, Equatable { kind = try container.decode(String.self, forKey: .kind) laneId = try container.decodeIfPresent(String.self, forKey: .laneId) name = try container.decode(String.self, forKey: .name) + branchRef = try container.decodeIfPresent(String.self, forKey: .branchRef) rootPath = try container.decode(String.self, forKey: .rootPath) isReadOnlyByDefault = try container.decode(Bool.self, forKey: .isReadOnlyByDefault) mobileReadOnly = try container.decodeIfPresent(Bool.self, forKey: .mobileReadOnly) ?? true diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 0e70ac531..3be3742b7 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -266,7 +266,14 @@ private let syncChatSubscriptionMaxBytes = 2_000_000 private let syncReducedLoadChatSubscriptionMaxBytes = 512_000 private let syncTerminalBufferMaxCharacters = 240_000 private let chatEventHistoryMaxEvents = 1_000 -private let chatEventNotificationCoalesceNanoseconds: UInt64 = 420_000_000 +/// Coalescing window for chat-event UI notifications. The coalescer fires on +/// the leading edge (first event after a quiet period surfaces immediately) +/// and then at most once per window during a sustained burst. Was a 420 ms +/// trailing-only debounce, which stacked with the 100 ms timeline rebuild +/// debounce into ~640 ms delta-to-screen — streaming read as laggy next to +/// the desktop's immediate render. +private let chatEventNotificationCoalesceSeconds: TimeInterval = 0.15 +private let chatEventNotificationCoalesceNanoseconds = UInt64((chatEventNotificationCoalesceSeconds * 1_000_000_000).rounded()) enum SyncBonjourTiming { static let searchRetryNanoseconds: UInt64 = 2_000_000_000 @@ -1204,6 +1211,13 @@ final class SyncService: ObservableObject { /// events after a replay. Events without `seq` (older hosts) bypass this /// entirely, preserving today's behavior. private(set) var chatEventLastSeqBySession: [String: Int] = [:] + /// Live "a turn is running right now" hint per chat session. Seeded from + /// the chat_subscribe ack's `turnActive` field (authoritative host state at + /// subscribe time) and kept current by live `status` / `done` chat events. + /// Bridges two gaps the synced session row can't cover: the changeset pump + /// lagging behind the chat event stream, and byte-capped snapshot tails + /// that dropped the active turn's `status: started` event. + private(set) var chatTurnActiveHintBySession: [String: Bool] = [:] /// Latest known chat summary keyed by session id. Populated by the Work /// list and chat detail screens so the LA reconcile can read `modelId` /// + a real `lastActivityAt` without round-tripping for each running chat. @@ -1295,6 +1309,9 @@ final class SyncService: ObservableObject { private var openLaneReferenceCounts: [String: Int] = [:] private var terminalBufferRevisionTask: Task? private var chatEventRevisionTask: Task? + /// When the chat-event revision last fired; drives the coalescer's + /// leading-edge check in `markChatEventsChanged`. + private var lastChatEventRevisionBumpAt = Date.distantPast private var databaseObserver: NSObjectProtocol? /// Coalesces bursty `adeDatabaseDidChange` notifications so SwiftUI `.task(id: localStateRevision)` surfaces /// do not reload on every CRDT row during host sync (was freezing the Work tab and Settings UI). @@ -4025,6 +4042,41 @@ final class SyncService: ObservableObject { chatEventRevisionsBySession[sessionId] ?? 0 } + /// True/false when the host has told us whether a turn is currently running + /// for this session (subscribe ack or live status/done events); nil when no + /// signal has arrived yet (e.g. older hosts without `turnActive`). + func chatTurnActiveHint(sessionId: String) -> Bool? { + chatTurnActiveHintBySession[sessionId] + } + + func updateChatTurnActiveHint(sessionId: String, turnActive: Bool) { + guard chatTurnActiveHintBySession[sessionId] != turnActive else { return } + chatTurnActiveHintBySession[sessionId] = turnActive + // Streaming-state flips drive the stop button and activity indicator — + // surface them immediately rather than waiting out the event coalescer. + markChatEventsChanged(immediate: true) + } + + /// Drop the hint so streaming state falls back to transcript-derived + /// signals. Used when a full subscribe ack carries no `turnActive` (older + /// host, or no live summary) — keeping a stale `true` would pin the stop + /// button and the active-poll loop on a session the host no longer runs. + private func clearChatTurnActiveHint(sessionId: String) { + guard chatTurnActiveHintBySession.removeValue(forKey: sessionId) != nil else { return } + markChatEventsChanged(immediate: true) + } + + private func updateChatTurnActiveHintFromEvent(_ envelope: AgentChatEventEnvelope) { + switch envelope.event { + case .status(let turnStatus, _, _): + updateChatTurnActiveHint(sessionId: envelope.sessionId, turnActive: turnStatus == .started) + case .done: + updateChatTurnActiveHint(sessionId: envelope.sessionId, turnActive: false) + default: + break + } + } + func chatSubscriptionPayloads() -> [[String: Any]] { subscribedChatSessionIds.sorted().map { chatSubscriptionPayload(sessionId: $0, includeSinceSeq: true) } } @@ -4485,6 +4537,8 @@ final class SyncService: ObservableObject { func fetchGitConflictState(laneId: String) async throws -> GitConflictState { try await sendDecodableCommand(action: "git.getConflictState", args: ["laneId": laneId], as: GitConflictState.self) } func rebaseContinueGit(laneId: String) async throws { _ = try await sendCommand(action: "git.rebaseContinue", args: ["laneId": laneId]) } func rebaseAbortGit(laneId: String) async throws { _ = try await sendCommand(action: "git.rebaseAbort", args: ["laneId": laneId]) } + func mergeContinueGit(laneId: String) async throws { _ = try await sendCommand(action: "git.mergeContinue", args: ["laneId": laneId]) } + func mergeAbortGit(laneId: String) async throws { _ = try await sendCommand(action: "git.mergeAbort", args: ["laneId": laneId]) } @MainActor func listChatModels(provider: String) async throws -> [AgentChatModelInfo] { @@ -7504,6 +7558,15 @@ final class SyncService: ObservableObject { chatEventLastSeqBySession.removeValue(forKey: snapshot.sessionId) } mergeChatEventHistory(sessionId: snapshot.sessionId, events: snapshot.events) + if let turnActive = snapshot.turnActive { + updateChatTurnActiveHint(sessionId: snapshot.sessionId, turnActive: turnActive) + } else if (dict["resumed"] as? Bool) != true { + // Full snapshot with no live turn state (older host, or the host + // has no summary for this session): a previously latched hint can + // never be corrected by this host, so drop it and fall back to + // transcript-derived streaming state. + clearChatTurnActiveHint(sessionId: snapshot.sessionId) + } } case "chat_event": if supportsChatStreaming, @@ -8326,6 +8389,7 @@ final class SyncService: ObservableObject { chatEventEnvelopesBySession[envelope.sessionId] = events chatEventRevisionsBySession[envelope.sessionId, default: 0] += 1 lastSyncAt = Date() + updateChatTurnActiveHintFromEvent(envelope) markChatEventsChanged() } @@ -8416,25 +8480,42 @@ final class SyncService: ObservableObject { } } + /// The single place the chat-event revision advances — keeps the + /// leading-edge timestamp and the published counter in lockstep. + private func bumpChatEventRevision() { + lastChatEventRevisionBumpAt = Date() + chatEventNotificationRevision += 1 + } + private func markChatEventsChanged(immediate: Bool = false) { if immediate { chatEventRevisionTask?.cancel() chatEventRevisionTask = nil - chatEventNotificationRevision += 1 + bumpChatEventRevision() return } guard chatEventRevisionTask == nil else { return } + // Leading edge: the first event after a quiet period surfaces now instead + // of waiting out the coalescing window — streaming starts rendering the + // moment the first delta lands. + if Date().timeIntervalSince(lastChatEventRevisionBumpAt) >= chatEventNotificationCoalesceSeconds { + bumpChatEventRevision() + return + } chatEventRevisionTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: chatEventNotificationCoalesceNanoseconds) guard let self, !Task.isCancelled else { return } - self.chatEventNotificationRevision += 1 + self.bumpChatEventRevision() self.chatEventRevisionTask = nil } } private func resetChatEventState(clearHistory: Bool) { subscribedChatSessionIds.removeAll() + // Turn-active hints are scoped to the live connection's event stream — + // a stale "running" hint must not survive a project switch or reconnect. + chatTurnActiveHintBySession.removeAll() if clearHistory { chatEventEnvelopesBySession.removeAll() chatEventRevisionsBySession.removeAll() diff --git a/apps/ios/ADE/Views/Files/FilesDetailComponents.swift b/apps/ios/ADE/Views/Files/FilesDetailComponents.swift index fd14b817a..94a3326a3 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailComponents.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailComponents.swift @@ -1,6 +1,117 @@ import SwiftUI import UIKit +struct FilesCompactSegmentedControl: View { + let items: [Item] + @Binding var selection: Item + let label: (Item) -> String + var systemImage: ((Item) -> String)? = nil + + var body: some View { + HStack(spacing: 2) { + ForEach(items) { item in + let isSelected = selection == item + Button { + guard !isSelected else { return } + withAnimation(.snappy(duration: 0.16)) { + selection = item + } + } label: { + HStack(spacing: 4) { + if let systemImage { + Image(systemName: systemImage(item)) + .font(.caption2.weight(.semibold)) + } + Text(label(item)) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + } + .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textMuted) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + isSelected ? ADEColor.accent.opacity(0.18) : Color.clear, + in: Capsule(style: .continuous) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(label(item)) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + } + .padding(2) + .background(ADEColor.recessedBackground.opacity(0.72), in: Capsule(style: .continuous)) + } +} + +struct FilesViewerControlStrip: View { + let editorModes: [FilesEditorMode] + @Binding var mode: FilesEditorMode + let showsMarkdownToggle: Bool + @Binding var markdownViewMode: FilesMarkdownViewMode + let showsLayoutToggle: Bool + @Binding var codeLayoutMode: FilesCodeLayoutMode + let showsDiffScopeToggle: Bool + @Binding var diffMode: FilesDiffMode + + private var hasControls: Bool { + editorModes.count > 1 || showsMarkdownToggle || showsLayoutToggle || showsDiffScopeToggle + } + + var body: some View { + if hasControls { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + if editorModes.count > 1 { + FilesCompactSegmentedControl( + items: editorModes, + selection: $mode, + label: { $0.title }, + systemImage: { $0.systemImage } + ) + .accessibilityElement(children: .contain) + .accessibilityLabel("View mode") + } + + if showsMarkdownToggle { + FilesCompactSegmentedControl( + items: FilesMarkdownViewMode.allCases, + selection: $markdownViewMode, + label: { $0.title }, + systemImage: { $0.systemImage } + ) + .accessibilityElement(children: .contain) + .accessibilityLabel("Markdown mode") + } + + if showsLayoutToggle { + FilesCompactSegmentedControl( + items: FilesCodeLayoutMode.allCases, + selection: $codeLayoutMode, + label: { $0.title }, + systemImage: { $0.systemImage } + ) + .accessibilityElement(children: .contain) + .accessibilityLabel("Code layout") + } + + if showsDiffScopeToggle { + FilesCompactSegmentedControl( + items: FilesDiffMode.allCases, + selection: $diffMode, + label: { $0.title }, + systemImage: { $0.systemImage } + ) + .accessibilityElement(children: .contain) + .accessibilityLabel("Diff scope") + } + } + .padding(.horizontal, 16) + } + } + } +} + struct FilesHeaderStrip: View { @EnvironmentObject private var syncService: SyncService @@ -378,6 +489,7 @@ struct SyntaxHighlightedCodeView: View { let language: FilesLanguage let focusLine: Int? let layoutMode: FilesCodeLayoutMode + var fillsContainer: Bool = false private var lines: [String] { let split = splitPreservingEmptyLines(text) @@ -395,7 +507,7 @@ struct SyntaxHighlightedCodeView: View { var body: some View { ScrollViewReader { proxy in codeScrollView - .adeInsetField(cornerRadius: 16, padding: 0) + .modifier(FilesPreviewContainerStyle(fillsContainer: fillsContainer)) .task(id: focusLine) { guard let focusLine else { return } withAnimation(.smooth) { @@ -448,24 +560,17 @@ struct SyntaxHighlightedCodeView: View { struct FilesMarkdownPreview: View { let text: String - - private var renderedText: AttributedString { - (try? AttributedString( - markdown: text, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - )) ?? AttributedString(text) - } + var fillsContainer: Bool = false var body: some View { ScrollView(.vertical) { - Text(renderedText) - .font(.body) - .foregroundStyle(ADEColor.textPrimary) - .textSelection(.enabled) + WorkMarkdownRenderer(markdown: text) .frame(maxWidth: .infinity, alignment: .leading) - .padding(14) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .textSelection(.enabled) } - .adeInsetField(cornerRadius: 16, padding: 0) + .modifier(FilesPreviewContainerStyle(fillsContainer: fillsContainer)) } } @@ -473,6 +578,7 @@ struct FilesInlineDiffView: View { let lines: [FilesInlineDiffLine] let language: FilesLanguage let layoutMode: FilesCodeLayoutMode + var fillsContainer: Bool = false private var wrapsLines: Bool { layoutMode == .wrap @@ -487,7 +593,7 @@ struct FilesInlineDiffView: View { var body: some View { diffScrollView - .adeInsetField(cornerRadius: 16, padding: 0) + .modifier(FilesPreviewContainerStyle(fillsContainer: fillsContainer)) } @ViewBuilder @@ -685,8 +791,21 @@ struct FilesProofArtifactSheet: View { } } +private struct FilesPreviewContainerStyle: ViewModifier { + let fillsContainer: Bool + + func body(content: Content) -> some View { + if fillsContainer { + content.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + content.adeInsetField(cornerRadius: 16, padding: 0) + } + } +} + struct ZoomableImageView: View { let image: UIImage + var fillsContainer: Bool = false @State private var scale: CGFloat = 1 @State private var lastScale: CGFloat = 1 @@ -704,7 +823,7 @@ struct ZoomableImageView: View { .contentShape(Rectangle()) .gesture(magnificationGesture.simultaneously(with: dragGesture)) } - .adeInsetField(cornerRadius: 16, padding: 0) + .modifier(FilesPreviewContainerStyle(fillsContainer: fillsContainer)) } private var magnificationGesture: some Gesture { diff --git a/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift b/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift index c5c6eecf9..5f0b7c39e 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift @@ -22,7 +22,8 @@ extension FilesDetailScreen { func load(refreshDiff: Bool = false, preservesVisibleHistory: Bool = false) async { var cachedImageBlob: SyncFileBlob? - if isImagePreviewable, let cachedData = ADEImageCache.shared.cachedData(for: imageCacheKey) { + if filesIsImagePreviewable(path: relativePath, blob: blob ?? SyncFileBlob(path: relativePath, size: 0, encoding: "utf8", isBinary: true, content: "")), + let cachedData = ADEImageCache.shared.cachedData(for: imageCacheKey) { let cachedBlob = SyncFileBlob( path: relativePath, size: cachedData.count, @@ -45,7 +46,7 @@ extension FilesDetailScreen { do { let loaded = try await syncService.readFile(workspaceId: workspace.id, path: relativePath) blob = loaded - if loaded.isBinary, isImagePreviewable, let data = imageData { + if filesIsImagePreviewable(path: relativePath, blob: loaded), let data = filesImageData(from: loaded) { ADEImageCache.shared.store(data, for: imageCacheKey) } await loadHistoryAndMetadata(from: loaded, preservesVisibleHistory: preservesVisibleHistory) diff --git a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift index 4bfc324ce..8fcea3233 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift @@ -14,6 +14,7 @@ struct FilesDetailScreen: View { @State var errorMessage: String? @State var metadata: FilesFileMetadata? @State var mode: FilesEditorMode = .preview + @State var markdownViewMode: FilesMarkdownViewMode = .preview @State var diffMode: FilesDiffMode = .unstaged @State var diff: FileDiff? @State var diffErrorMessage: String? @@ -31,16 +32,15 @@ struct FilesDetailScreen: View { } var isImagePreviewable: Bool { - let lowercased = relativePath.lowercased() - return ["png", "jpg", "jpeg", "gif", "webp", "heic", "bmp", "tiff"].contains((lowercased as NSString).pathExtension) + filesIsImagePreviewable( + path: relativePath, + blob: blob ?? SyncFileBlob(path: relativePath, size: 0, encoding: "utf8", isBinary: false, content: "") + ) } var imageData: Data? { guard let blob else { return nil } - if blob.encoding.lowercased() == "base64" { - return Data(base64Encoded: blob.content) - } - return Data(blob.content.utf8) + return filesImageData(from: blob) } var imageCacheKey: String { @@ -55,43 +55,83 @@ struct FilesDetailScreen: View { filesHistoryFallback(laneId: workspace.laneId, entries: historyEntries, errorMessage: historyErrorMessage) } - var readOnlyTagline: String { - if workspace.laneId != nil { - return "Read-only on iPhone. Preview, diff, and metadata are available here; edit on the machine." - } - return "Read-only on iPhone. Preview and metadata are available here; edit on the machine." + var isMarkdownFile: Bool { + guard let blob else { return false } + return filesIsMarkdown(blob: blob, path: relativePath) } var body: some View { - VStack(spacing: 0) { - topChrome - + Group { if let blob { filesContentHero(blob: blob) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.horizontal, 16) - .padding(.top, 12) } else if errorMessage == nil { ADECardSkeleton(rows: 4) - .padding(.horizontal, 16) - .padding(.top, 12) + .padding(16) Spacer(minLength: 0) } else { Spacer(minLength: 0) } - - Text(readOnlyTagline) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 10) } .adeScreenBackground() .adeNavigationGlass() .adeRootTabBarHidden() .navigationTitle(lastPathComponent(relativePath)) .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + isDetailsSheetPresented = true + } label: { + Image(systemName: "info.circle") + } + .accessibilityLabel("File details") + .disabled(blob == nil) + } + } + .safeAreaInset(edge: .top, spacing: 0) { + VStack(alignment: .leading, spacing: 6) { + FilesBreadcrumbBar( + relativePath: relativePath, + includeCurrentFile: true, + onSelectDirectory: { path in + if path.isEmpty { + navigateToDirectory("") + } else { + navigateToDirectory(path) + } + } + ) + .padding(.horizontal, 16) + + if let blob { + FilesViewerControlStrip( + editorModes: editorModes, + mode: $mode, + showsMarkdownToggle: mode == .preview && isMarkdownFile, + markdownViewMode: $markdownViewMode, + showsLayoutToggle: showsCodeLayoutControl(blob: blob), + codeLayoutMode: $codeLayoutMode, + showsDiffScopeToggle: mode == .diff && workspace.laneId != nil, + diffMode: $diffMode + ) + } + + if let errorMessage { + FilesCompactBanner( + symbol: "exclamationmark.triangle.fill", + tint: ADEColor.danger, + title: errorMessage, + actionTitle: "Retry", + onAction: { Task { await load() } } + ) + .padding(.horizontal, 16) + } + } + .padding(.top, 4) + .padding(.bottom, 6) + .background(ADEColor.pageBackground.opacity(0.94)) + } .adeNavigationZoomTransition(id: transitionNamespace == nil ? nil : "files-container-\(relativePath)", in: transitionNamespace) .sheet(isPresented: $isDetailsSheetPresented) { FilesDetailsSheet( @@ -123,87 +163,12 @@ struct FilesDetailScreen: View { } } - @ViewBuilder - private var topChrome: some View { - VStack(alignment: .leading, spacing: 10) { - FilesBreadcrumbBar( - relativePath: relativePath, - includeCurrentFile: true, - onSelectDirectory: { path in - if path.isEmpty { - navigateToDirectory("") - } else { - navigateToDirectory(path) - } - } - ) - - if let errorMessage { - FilesCompactBanner( - symbol: "exclamationmark.triangle.fill", - tint: ADEColor.danger, - title: errorMessage, - actionTitle: "Retry", - onAction: { Task { await load() } } - ) - } - - if let blob { - FilesHeaderStrip( - relativePath: relativePath, - fileKindLabel: fileKindLabel(for: blob), - fileSize: blob.size, - transitionNamespace: transitionNamespace, - onShowDetails: { isDetailsSheetPresented = true } - ) - - if editorModes.count > 1 { - filesModeControl - } - - if showsCodeLayoutControl(blob: blob) { - filesCodeLayoutControl - } - } - } - .padding(.horizontal, 16) - .padding(.top, 8) - } - - @ViewBuilder - private var filesModeControl: some View { - VStack(alignment: .leading, spacing: 8) { - FilesSegmentedControl( - title: "Mode", - items: editorModes, - selection: $mode, - label: { $0.title } - ) - - if mode == .diff, workspace.laneId != nil { - FilesSegmentedControl( - title: "Diff", - items: FilesDiffMode.allCases, - selection: $diffMode, - label: { $0.title } - ) - } - } - } - - @ViewBuilder - private var filesCodeLayoutControl: some View { - FilesSegmentedControl( - title: "Code layout", - items: FilesCodeLayoutMode.allCases, - selection: $codeLayoutMode, - label: { $0.title } - ) - } - private func showsCodeLayoutControl(blob: SyncFileBlob) -> Bool { switch mode { case .preview: + if isMarkdownFile { + return markdownViewMode == .source + } return !blob.isBinary && !filesIsMarkdown(blob: blob, path: relativePath) case .diff: return workspace.laneId != nil @@ -211,7 +176,7 @@ struct FilesDetailScreen: View { } func fileKindLabel(for blob: SyncFileBlob) -> String { - if isImagePreviewable { + if filesIsImagePreviewable(path: relativePath, blob: blob) { return "Image" } if blob.isBinary { @@ -238,22 +203,24 @@ struct FilesDetailScreen: View { title: limit.title, message: limit.message ) - } else if blob.isBinary { + .padding(16) + } else if blob.isBinary || filesIsImagePreviewable(path: relativePath, blob: blob) { if isImagePreviewable, let data = imageData, let image = UIImage(data: data) { - ZoomableImageView(image: image) - .frame(maxWidth: .infinity, maxHeight: .infinity) + ZoomableImageView(image: image, fillsContainer: true) } else if isImagePreviewable { FilesContentFallback( symbol: "photo", title: "Image preview pending", message: "The machine returned metadata only. Reconnect to stream the full bytes." ) + .padding(16) } else { FilesContentFallback( symbol: "doc.fill", title: "Binary file", message: "iPhone keeps this read-only. Use ADE on the machine to open with a local tool." ) + .padding(16) } } else { if let limit = filesTextPreviewLimit(blob: blob) { @@ -262,14 +229,26 @@ struct FilesDetailScreen: View { title: limit.title, message: limit.message ) + .padding(16) } else if filesIsMarkdown(blob: blob, path: relativePath) { - FilesMarkdownPreview(text: blob.content) + if markdownViewMode == .source { + SyntaxHighlightedCodeView( + text: blob.content, + language: .markdown, + focusLine: focusLine, + layoutMode: codeLayoutMode, + fillsContainer: true + ) + } else { + FilesMarkdownPreview(text: filesStripYamlFrontmatter(blob.content), fillsContainer: true) + } } else { SyntaxHighlightedCodeView( text: blob.content, language: language, focusLine: focusLine, - layoutMode: codeLayoutMode + layoutMode: codeLayoutMode, + fillsContainer: true ) } } @@ -283,8 +262,10 @@ struct FilesDetailScreen: View { title: "Diff needs a lane", message: "Open this file from a lane-backed workspace to compare working tree or staged changes." ) + .padding(16) } else if !hasLoadedDiff, diffErrorMessage == nil { ADECardSkeleton(rows: 5) + .padding(16) } else if let diffErrorMessage { FilesCompactBanner( symbol: "exclamationmark.triangle.fill", @@ -293,29 +274,34 @@ struct FilesDetailScreen: View { actionTitle: "Retry", onAction: { Task { await loadDiff() } } ) + .padding(16) } else if let diff, diff.isBinary == true { FilesContentFallback( symbol: "doc.badge.gearshape", title: "Binary diff", message: "The machine reported a binary diff that cannot be rendered inline." ) + .padding(16) } else if let diff, !filesDiffHasChanges(diff) { FilesContentFallback( symbol: "checkmark.circle", title: "No \(diffMode.title.lowercased()) changes", message: "This file matches the selected \(diffMode.title.lowercased()) diff scope." ) + .padding(16) } else if let diff, let limit = filesDiffPreviewLimit(diff: diff) { FilesContentFallback( symbol: "arrow.left.arrow.right", title: limit.title, message: limit.message ) + .padding(16) } else if let diff { FilesInlineDiffView( lines: buildInlineDiffLines(original: diff.original.text, modified: diff.modified.text), language: FilesLanguage.detect(languageId: diff.language, filePath: relativePath), - layoutMode: codeLayoutMode + layoutMode: codeLayoutMode, + fillsContainer: true ) } else { FilesContentFallback( @@ -323,58 +309,7 @@ struct FilesDetailScreen: View { title: "No diff available", message: "Nothing cached for \(diffMode.title.lowercased()) diff. Reconnect or refresh to try again." ) - } - } -} - -private struct FilesSegmentedControl: View { - let title: String - let items: [Item] - @Binding var selection: Item - let label: (Item) -> String - - var body: some View { - HStack(spacing: 3) { - ForEach(items) { item in - let isSelected = selection == item - Button { - guard !isSelected else { return } - withAnimation(.snappy(duration: 0.16)) { - selection = item - } - } label: { - Text(label(item)) - .font(.caption.weight(.semibold)) - .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textSecondary) - .lineLimit(1) - .minimumScaleFactor(0.85) - .frame(maxWidth: .infinity, minHeight: 34) - .padding(.horizontal, 8) - .background { - if isSelected { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(ADEColor.accent.opacity(0.18)) - } - } - .overlay { - if isSelected { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(ADEColor.accent.opacity(0.35), lineWidth: 0.75) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityElement(children: .ignore) - .accessibilityLabel("\(title): \(label(item))") - .accessibilityAddTraits(isSelected ? [.isSelected] : []) - } - } - .padding(3) - .background(ADEColor.recessedBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(ADEColor.glassBorder, lineWidth: 0.5) + .padding(16) } } } diff --git a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView+Actions.swift b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView+Actions.swift index 05478d676..4e440ae6d 100644 --- a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView+Actions.swift +++ b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView+Actions.swift @@ -8,7 +8,7 @@ extension FilesDirectoryContentsView { if nodes.isEmpty { isLoading = true } - nodes = try await syncService.listTree(workspaceId: workspace.id, parentPath: parentPath, includeIgnored: showHidden) + nodes = try await syncService.listTree(workspaceId: workspace.id, parentPath: parentPath, includeIgnored: true) errorMessage = nil } catch { errorMessage = error.localizedDescription diff --git a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift index b8f79055f..ad527a3a3 100644 --- a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift +++ b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift @@ -5,7 +5,6 @@ struct FilesDirectoryContentsView: View { let workspace: FilesWorkspace let parentPath: String - let showHidden: Bool let isLive: Bool let isTabActive: Bool let openDirectory: (String) -> Void @@ -77,7 +76,7 @@ struct FilesDirectoryContentsView: View { .task(id: DirectoryReloadKey( workspaceId: workspace.id, parentPath: parentPath, - includeHidden: showHidden, + includeHidden: true, live: isLive, active: isTabActive, manualReloadToken: manualReloadToken diff --git a/apps/ios/ADE/Views/Files/FilesDirectoryScreen.swift b/apps/ios/ADE/Views/Files/FilesDirectoryScreen.swift index d83c1ce77..bb12e7aa3 100644 --- a/apps/ios/ADE/Views/Files/FilesDirectoryScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesDirectoryScreen.swift @@ -5,7 +5,6 @@ struct FilesDirectoryScreen: View { let workspace: FilesWorkspace let parentPath: String - @Binding var showHidden: Bool let isLive: Bool let isTabActive: Bool let openDirectory: (String) -> Void @@ -41,7 +40,6 @@ struct FilesDirectoryScreen: View { FilesDirectoryContentsView( workspace: workspace, parentPath: parentPath, - showHidden: showHidden, isLive: isLive, isTabActive: isTabActive, openDirectory: openDirectory, @@ -61,14 +59,7 @@ struct FilesDirectoryScreen: View { .navigationTitle(parentPath.isEmpty ? "Root" : lastPathComponent(parentPath)) .toolbar { ADERootToolbarLeadingItems() - ToolbarItemGroup(placement: .topBarTrailing) { - Button { - showHidden.toggle() - } label: { - Image(systemName: showHidden ? "eye.slash" : "eye") - } - .accessibilityLabel(showHidden ? "Hide hidden files" : "Show hidden files") - + ToolbarItem(placement: .topBarTrailing) { Button { Task { await refreshDirectory() } } label: { diff --git a/apps/ios/ADE/Views/Files/FilesModels.swift b/apps/ios/ADE/Views/Files/FilesModels.swift index 6e04b2a6d..86ef6e0db 100644 --- a/apps/ios/ADE/Views/Files/FilesModels.swift +++ b/apps/ios/ADE/Views/Files/FilesModels.swift @@ -34,6 +34,13 @@ enum FilesEditorMode: String, CaseIterable, Identifiable { case .diff: return "Diff" } } + + var systemImage: String { + switch self { + case .preview: return "doc.text" + case .diff: return "arrow.left.arrow.right" + } + } } enum FilesCodeLayoutMode: String, CaseIterable, Identifiable { @@ -48,6 +55,13 @@ enum FilesCodeLayoutMode: String, CaseIterable, Identifiable { case .scroll: return "Scroll" } } + + var systemImage: String { + switch self { + case .wrap: return "text.alignleft" + case .scroll: return "arrow.left.and.right" + } + } } struct FilesSectionFallback: Equatable { @@ -94,6 +108,13 @@ enum FilesDiffMode: String, CaseIterable, Identifiable { case .staged: return "Staged" } } + + var systemImage: String { + switch self { + case .unstaged: return "pencil.and.outline" + case .staged: return "tray.and.arrow.down" + } + } } struct FilesFileMetadata { @@ -155,6 +176,60 @@ func filesIsMarkdown(blob: SyncFileBlob, path: String) -> Bool { return blob.languageId?.lowercased() == "markdown" } +private let filesImageExtensions: Set = [ + "png", "jpg", "jpeg", "gif", "webp", "heic", "bmp", "tiff", "ico", "avif", "svg", +] + +func filesIsImagePreviewable(path: String, blob: SyncFileBlob) -> Bool { + let ext = (path.lowercased() as NSString).pathExtension + if blob.previewKind?.lowercased() == "image" { return true } + return filesImageExtensions.contains(ext) +} + +func filesStripYamlFrontmatter(_ text: String) -> String { + let normalized = text.replacingOccurrences(of: "\r\n", with: "\n") + guard normalized.hasPrefix("---\n") else { return text } + let searchStart = normalized.index(normalized.startIndex, offsetBy: 4) + guard searchStart < normalized.endIndex, + let endRange = normalized.range(of: "\n---\n", range: searchStart.. Data? { + if let dataUrl = blob.dataUrl, let comma = dataUrl.firstIndex(of: ",") { + let base64 = String(dataUrl[dataUrl.index(after: comma)...]) + return Data(base64Encoded: base64) + } + if blob.encoding.lowercased() == "base64" { + return Data(base64Encoded: blob.content) + } + return Data(blob.content.utf8) +} + +enum FilesMarkdownViewMode: String, CaseIterable, Identifiable { + case preview + case source + + var id: String { rawValue } + + var title: String { + switch self { + case .preview: return "Rendered" + case .source: return "Source" + } + } + + var systemImage: String { + switch self { + case .preview: return "text.document" + case .source: return "chevron.left.forwardslash.chevron.right" + } + } +} + func filesDiffPreviewLimit(diff: FileDiff) -> FilesPreviewLimit? { if diff.original.isTruncated == true || diff.modified.isTruncated == true { return FilesPreviewLimit( diff --git a/apps/ios/ADE/Views/Files/FilesRootComponents.swift b/apps/ios/ADE/Views/Files/FilesRootComponents.swift index 17b6dae45..3d3ecf769 100644 --- a/apps/ios/ADE/Views/Files/FilesRootComponents.swift +++ b/apps/ios/ADE/Views/Files/FilesRootComponents.swift @@ -2,84 +2,35 @@ import SwiftUI struct FilesWorkspaceHeader: View { let workspaces: [FilesWorkspace] + let lanes: [LaneSummary] @Binding var selectedWorkspaceId: String let selectedWorkspace: FilesWorkspace - @Binding var showHidden: Bool + let isLive: Bool var body: some View { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 10) { - Text("Workspace") - .font(.headline) - .foregroundStyle(ADEColor.textPrimary) + VStack(alignment: .leading, spacing: 8) { + FilesWorkspacePickerDropdown( + workspaces: workspaces, + lanes: lanes, + selectedWorkspaceId: $selectedWorkspaceId + ) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(workspaces) { workspace in - Button { - selectedWorkspaceId = workspace.id - } label: { - HStack(spacing: 6) { - Text(workspace.name) - .font(.caption.weight(.semibold)) - .lineLimit(1) - if workspace.id == selectedWorkspaceId { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 11, weight: .semibold)) - } - } - .foregroundStyle(workspace.id == selectedWorkspaceId ? ADEColor.accent : ADEColor.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background( - (workspace.id == selectedWorkspaceId ? ADEColor.accent.opacity(0.14) : ADEColor.surfaceBackground.opacity(0.55)), - in: Capsule() - ) - .overlay( - Capsule() - .stroke(workspace.id == selectedWorkspaceId ? ADEColor.accent.opacity(0.45) : ADEColor.border.opacity(0.16), lineWidth: 0.5) - ) - .glassEffect() - } - .buttonStyle(.plain) - .accessibilityLabel("Workspace \(workspace.name)") - .accessibilityValue(workspace.id == selectedWorkspaceId ? "Selected" : "") - } - } - } - } + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(selectedWorkspace.rootPath) + .font(.caption.monospaced()) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) - Text(selectedWorkspace.rootPath) - .font(.caption.monospaced()) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(2) - .truncationMode(.middle) - .accessibilityLabel("Workspace path \(selectedWorkspace.rootPath)") - .textSelection(.enabled) - - ScrollView(.horizontal, showsIndicators: false) { - ADEGlassGroup(spacing: 8) { - ADEStatusPill(text: selectedWorkspace.kind.uppercased(), tint: ADEColor.accent) - if selectedWorkspace.laneId != nil { - ADEStatusPill(text: "LANE ROOT", tint: ADEColor.success) - } - if selectedWorkspace.readOnlyOnMobile { - ADEStatusPill(text: "READ ONLY", tint: ADEColor.warning) - } - Button { - showHidden.toggle() - } label: { - Label(showHidden ? "Hide" : "Show", systemImage: showHidden ? "eye.slash" : "eye") - .font(.caption.weight(.semibold)) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - } - .buttonStyle(.glass) - .accessibilityLabel(showHidden ? "Hide hidden files" : "Show hidden files") - } + Text(isLive ? "Live" : "Cached") + .font(.caption2.weight(.semibold)) + .foregroundStyle(isLive ? ADEColor.success : ADEColor.textMuted) } + .accessibilityElement(children: .combine) + .accessibilityLabel("Workspace path \(selectedWorkspace.rootPath), \(isLive ? "live" : "cached")") } - .adeGlassCard(cornerRadius: 18) } } @@ -271,83 +222,62 @@ struct FilesTreeNodeRow: View { let onCopyRelativePath: () -> Void var body: some View { - HStack(spacing: 12) { - Button(action: onOpen) { - HStack(spacing: 12) { - Image(systemName: node.type == "directory" ? "folder.fill" : fileIcon(for: node.name)) - .font(.headline) - .foregroundStyle(node.type == "directory" ? ADEColor.accent : fileTint(for: node.name)) - .frame(width: 22) - .adeMatchedGeometry(id: canTransition ? "files-icon-\(node.path)" : nil, in: transitionNamespace) - - VStack(alignment: .leading, spacing: 4) { - Text(node.name) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - .adeMatchedGeometry(id: canTransition ? "files-title-\(node.path)" : nil, in: transitionNamespace) - HStack(spacing: 6) { - Text(node.path.isEmpty ? (node.type == "directory" ? "Folder" : "File") : node.path) - .font(.caption.monospaced()) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - - if let changeStatus = node.changeStatus { - ADEStatusPill(text: changeStatus.uppercased(), tint: changeStatusTint(changeStatus)) - .fixedSize(horizontal: true, vertical: false) - } - } - } - .layoutPriority(1) + Button(action: onOpen) { + HStack(spacing: 10) { + Image(systemName: node.type == "directory" ? "folder.fill" : fileIcon(for: node.name)) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(node.type == "directory" ? ADEColor.accent : fileTint(for: node.name)) + .frame(width: 18) + .adeMatchedGeometry(id: canTransition ? "files-icon-\(node.path)" : nil, in: transitionNamespace) + + Text(node.name) + .font(.subheadline.weight(.medium)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .adeMatchedGeometry(id: canTransition ? "files-title-\(node.path)" : nil, in: transitionNamespace) - Spacer(minLength: 8) + if let changeStatus = node.changeStatus { + ADEStatusPill(text: changeStatus.uppercased(), tint: changeStatusTint(changeStatus)) + .fixedSize(horizontal: true, vertical: false) + } - if let size = node.size, node.type == "file" { - Text(formattedFileSize(size)) - .font(.caption2.monospaced()) - .foregroundStyle(ADEColor.textMuted) - .fixedSize(horizontal: true, vertical: false) - } + Spacer(minLength: 4) - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) + if let size = node.size, node.type == "file" { + Text(formattedFileSize(size)) + .font(.caption2.monospaced()) .foregroundStyle(ADEColor.textMuted) + .fixedSize(horizontal: true, vertical: false) } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityLabel) - .accessibilityHint(node.type == "directory" ? "Opens folder" : "Opens file") - .adeInspectable( - "Files.Directory.NodeRow", - metadata: [ - "label": accessibilityLabel, - "path": node.path, - "type": node.type, - "role": "row" - ] - ) - HStack(spacing: 6) { - Button(action: onCopyPath) { - Image(systemName: "link") - .font(.system(size: 14, weight: .semibold)) - .frame(width: 32, height: 32) - } - .buttonStyle(.glass) - .accessibilityLabel("Copy path for \(node.name)") - - Button(action: onCopyRelativePath) { - Image(systemName: "doc.on.doc") - .font(.system(size: 14, weight: .semibold)) - .frame(width: 32, height: 32) + if node.type == "directory" { + Image(systemName: "chevron.right") + .font(.caption2.weight(.bold)) + .foregroundStyle(ADEColor.textMuted.opacity(0.7)) } - .buttonStyle(.glass) - .accessibilityLabel("Copy relative path for \(node.name)") } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background(ADEColor.surfaceBackground.opacity(0.35), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .contentShape(Rectangle()) } - .adeListCard(cornerRadius: 16) + .buttonStyle(.plain) + .contextMenu { + Button("Copy Path", action: onCopyPath) + Button("Copy Relative Path", action: onCopyRelativePath) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(node.type == "directory" ? "Opens folder" : "Opens file") + .adeInspectable( + "Files.Directory.NodeRow", + metadata: [ + "label": accessibilityLabel, + "path": node.path, + "type": node.type, + "role": "row" + ] + ) .adeMatchedTransitionSource(id: canTransition ? "files-container-\(node.path)" : nil, in: transitionNamespace) } @@ -461,35 +391,34 @@ struct FilesBreadcrumbBar: View { var body: some View { ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { + HStack(spacing: 6) { Button("root") { onSelectDirectory("") } - .buttonStyle(.glass) + .font(.caption2.weight(.semibold)) + .buttonStyle(.plain) + .foregroundStyle(ADEColor.textMuted) ForEach(filesBreadcrumbItems(relativePath: relativePath, includeCurrentFile: includeCurrentFile), id: \.path) { breadcrumb in Image(systemName: "chevron.right") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(ADEColor.textMuted.opacity(0.55)) if breadcrumb.isDirectory { Button(breadcrumb.label) { onSelectDirectory(breadcrumb.path) } - .buttonStyle(.glass) + .font(.caption2.weight(.semibold)) + .buttonStyle(.plain) + .foregroundStyle(ADEColor.textSecondary) } else { Text(breadcrumb.label) - .font(.caption.weight(.semibold)) + .font(.caption2.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background(ADEColor.surfaceBackground, in: Capsule()) - .glassEffect() } } } - .padding(4) + .padding(.vertical, 2) } - .adeGlassCard(cornerRadius: 18, padding: 12) } } diff --git a/apps/ios/ADE/Views/Files/FilesRootScreen+Actions.swift b/apps/ios/ADE/Views/Files/FilesRootScreen+Actions.swift index f2a45d57a..f4edf262a 100644 --- a/apps/ios/ADE/Views/Files/FilesRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Files/FilesRootScreen+Actions.swift @@ -57,10 +57,16 @@ extension FilesRootScreen { try? await syncService.refreshLaneSnapshots() } let previousSelectedWorkspaceId = selectedWorkspaceId - let loadedWorkspaces = try await syncService.listWorkspaces() + async let loadedWorkspacesTask = syncService.listWorkspaces() + async let loadedLanesTask = syncService.fetchLanes() + let loadedWorkspaces = try await loadedWorkspacesTask + let loadedLanes = try await loadedLanesTask if workspaces != loadedWorkspaces { workspaces = loadedWorkspaces } + if lanes != loadedLanes { + lanes = loadedLanes + } let nextSelectedWorkspaceId = selectedWorkspaceId.flatMap { candidate in loadedWorkspaces.contains(where: { $0.id == candidate }) ? candidate : nil } ?? loadedWorkspaces.first?.id diff --git a/apps/ios/ADE/Views/Files/FilesRootScreen.swift b/apps/ios/ADE/Views/Files/FilesRootScreen.swift index 6808cccbf..820b25ed0 100644 --- a/apps/ios/ADE/Views/Files/FilesRootScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesRootScreen.swift @@ -9,11 +9,11 @@ struct FilesProofArtifactsReloadKey: Hashable { struct FilesRootScreen: View { @Environment(\.accessibilityReduceMotion) var reduceMotion @EnvironmentObject var syncService: SyncService - @AppStorage("ade.files.showHidden") private var showHidden = false @Namespace var fileTransitionNamespace var isTabActive = true @State var workspaces: [FilesWorkspace] = [] + @State var lanes: [LaneSummary] = [] @State var selectedWorkspaceId: String? @State var quickOpenQuery = "" @State var quickOpenResults: [FilesQuickOpenItem] = [] @@ -121,41 +121,28 @@ struct FilesRootScreen: View { if let workspace = selectedWorkspace { FilesWorkspaceHeader( workspaces: workspaces, + lanes: lanes, selectedWorkspaceId: selectedWorkspaceBinding, selectedWorkspace: workspace, - showHidden: $showHidden + isLive: canUseLiveFileActions ) - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .center, spacing: 10) { - Label("Browser", systemImage: "folder") - .font(.headline) - .foregroundStyle(ADEColor.textPrimary) - Spacer(minLength: 8) - Text(canUseLiveFileActions ? "Live" : "Cached") - .font(.caption2.weight(.semibold)) - .foregroundStyle(canUseLiveFileActions ? ADEColor.success : ADEColor.textMuted) - } - - FilesDirectoryContentsView( - workspace: workspace, - parentPath: "", - showHidden: showHidden, - isLive: canUseLiveFileActions, - isTabActive: isTabActive, - openDirectory: { path in - openDirectory(path, in: workspace) - }, - openFile: { path, line in - openFile(path, in: workspace, focusLine: line) - }, - transitionNamespace: transitionNamespace, - selectedFilePath: selectedFileTransitionPath, - manualReloadToken: 0 - ) - .environmentObject(syncService) - } - .adeGlassCard(cornerRadius: 18) + FilesDirectoryContentsView( + workspace: workspace, + parentPath: "", + isLive: canUseLiveFileActions, + isTabActive: isTabActive, + openDirectory: { path in + openDirectory(path, in: workspace) + }, + openFile: { path, line in + openFile(path, in: workspace, focusLine: line) + }, + transitionNamespace: transitionNamespace, + selectedFilePath: selectedFileTransitionPath, + manualReloadToken: 0 + ) + .environmentObject(syncService) FilesQueryCard( title: "Quick open", @@ -251,7 +238,6 @@ struct FilesRootScreen: View { FilesDirectoryScreen( workspace: workspace, parentPath: parentPath, - showHidden: $showHidden, isLive: canUseLiveFileActions, isTabActive: isTabActive, openDirectory: { path in diff --git a/apps/ios/ADE/Views/Files/FilesWorkspacePickerDropdown.swift b/apps/ios/ADE/Views/Files/FilesWorkspacePickerDropdown.swift new file mode 100644 index 000000000..3f1b4df57 --- /dev/null +++ b/apps/ios/ADE/Views/Files/FilesWorkspacePickerDropdown.swift @@ -0,0 +1,270 @@ +import SwiftUI + +/// Desktop-shaped workspace picker for the Files tab. Mirrors the Work tab lane +/// dropdown styling without the auto-create lane row. +struct FilesWorkspacePickerDropdown: View { + let workspaces: [FilesWorkspace] + let lanes: [LaneSummary] + @Binding var selectedWorkspaceId: String + + @State private var menuPresented = false + @State private var searchQuery = "" + + private var selectedWorkspace: FilesWorkspace? { + workspaces.first(where: { $0.id == selectedWorkspaceId }) + } + + private var filteredWorkspaces: [FilesWorkspace] { + let trimmed = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return workspaces } + return workspaces.filter { workspace in + workspace.name.lowercased().contains(trimmed) + || filesWorkspaceSubtitle(workspace).lowercased().contains(trimmed) + || workspace.rootPath.lowercased().contains(trimmed) + } + } + + var body: some View { + Button { + menuPresented = true + } label: { + triggerLabel + } + .buttonStyle(.plain) + .accessibilityLabel("Select workspace") + .accessibilityValue(selectedWorkspace?.name ?? "No workspace selected") + .popover(isPresented: $menuPresented, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom) { + FilesWorkspacePickerMenu( + workspaces: filteredWorkspaces, + allWorkspacesEmpty: workspaces.isEmpty, + lanes: lanes, + selectedWorkspaceId: selectedWorkspaceId, + searchQuery: $searchQuery, + onSelect: { workspaceId in + selectedWorkspaceId = workspaceId + menuPresented = false + searchQuery = "" + } + ) + .frame(width: 300) + .presentationCompactAdaptation(.popover) + } + .onChange(of: menuPresented) { _, isOpen in + if !isOpen { searchQuery = "" } + } + } + + private var triggerLabel: some View { + let workspace = selectedWorkspace + let lane = workspace.flatMap { filesWorkspaceLaneSummary($0, lanes: lanes) } + let surface = filesWorkspaceTriggerSurface(workspace: workspace, lane: lane) + let isCompact = workspace != nil + + return ZStack { + centeredTriggerContent(workspace: workspace, lane: lane) + .padding(.horizontal, 28) + HStack(spacing: 0) { + Spacer(minLength: 0) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(ADEColor.textMuted.opacity(0.6)) + } + } + .padding(.horizontal, isCompact ? 12 : 14) + .padding(.vertical, isCompact ? 5 : 6) + .background(surface.background, in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(surface.border, lineWidth: 1) + ) + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private func centeredTriggerContent(workspace: FilesWorkspace?, lane: LaneSummary?) -> some View { + let title = workspace?.name ?? "Select workspace..." + let subtitle = workspace.map(filesWorkspaceSubtitle) ?? "" + + if !subtitle.isEmpty { + VStack(spacing: 2) { + HStack(spacing: 5) { + if let lane { + WorkLaneLogoMark( + color: LaneColorPalette.displayColor(forHex: lane.color, fallback: ADEColor.textSecondary), + laneIcon: lane.icon, + size: 11 + ) + } else if workspace?.kind.lowercased() == "primary" { + Image(systemName: "house.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(ADEColor.accent) + } + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.85)) + .lineLimit(1) + } + HStack(spacing: 4) { + Image(systemName: "arrow.branch") + .font(.system(size: 9, weight: .regular)) + .foregroundStyle(ADEColor.textMuted.opacity(0.55)) + Text(subtitle) + .font(.system(size: 10)) + .foregroundStyle(ADEColor.textMuted.opacity(0.92)) + .lineLimit(1) + } + } + .multilineTextAlignment(.center) + } else { + HStack(spacing: 5) { + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.7)) + .lineLimit(1) + } + } + } +} + +private struct FilesWorkspacePickerMenu: View { + let workspaces: [FilesWorkspace] + let allWorkspacesEmpty: Bool + let lanes: [LaneSummary] + let selectedWorkspaceId: String + @Binding var searchQuery: String + let onSelect: (String) -> Void + + @FocusState private var searchFocused: Bool + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(ADEColor.textMuted.opacity(0.5)) + TextField("Search workspaces...", text: $searchQuery) + .textFieldStyle(.plain) + .font(.system(size: 11)) + .foregroundStyle(ADEColor.textPrimary) + .focused($searchFocused) + .submitLabel(.done) + } + .padding(.horizontal, 10) + .frame(height: 32) + .overlay(alignment: .bottom) { + Rectangle() + .fill(ADEColor.border.opacity(0.35)) + .frame(height: 0.5) + } + + ScrollView { + LazyVStack(spacing: 0) { + if workspaces.isEmpty { + Text(allWorkspacesEmpty ? "No workspaces available" : "No workspaces found") + .font(.system(size: 11)) + .foregroundStyle(ADEColor.textMuted) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } else { + ForEach(workspaces) { workspace in + workspaceRow(workspace) + } + } + } + .padding(4) + } + .frame(maxHeight: 280) + } + .background(ADEColor.cardBackground.opacity(0.96)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.8) + ) + .shadow(color: Color.black.opacity(0.45), radius: 16, y: 8) + .onAppear { + searchFocused = true + } + } + + private func workspaceRow(_ workspace: FilesWorkspace) -> some View { + let isSelected = workspace.id == selectedWorkspaceId + let subtitle = filesWorkspaceSubtitle(workspace) + let lane = filesWorkspaceLaneSummary(workspace, lanes: lanes) + let laneColor = lane.map { LaneColorPalette.displayColor(forHex: $0.color, fallback: ADEColor.textSecondary) } + + return Button { + onSelect(workspace.id) + } label: { + VStack(alignment: .leading, spacing: subtitle.isEmpty ? 0 : 3) { + HStack(spacing: 6) { + if let laneColor { + WorkLaneLogoMark(color: laneColor, laneIcon: lane?.icon, size: 12) + } else if workspace.kind.lowercased() == "primary" { + Image(systemName: "house.fill") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(ADEColor.accent) + } + Text(workspace.name) + .font(.system(size: 11, weight: isSelected ? .medium : .regular)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ADEColor.accent) + } + } + if !subtitle.isEmpty { + HStack(spacing: 4) { + Image(systemName: "arrow.branch") + .font(.system(size: 10, weight: .regular)) + .foregroundStyle(ADEColor.textMuted.opacity(0.6)) + Text(subtitle) + .font(.system(size: 10)) + .foregroundStyle(ADEColor.textMuted.opacity(0.92)) + .lineLimit(1) + } + .padding(.leading, laneColor == nil && workspace.kind.lowercased() != "primary" ? 0 : 18) + } + } + .padding(.horizontal, 8) + .padding(.vertical, subtitle.isEmpty ? 6 : 5) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + isSelected ? ADEColor.accent.opacity(0.12) : Color.clear, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + +func filesWorkspaceSubtitle(_ workspace: FilesWorkspace) -> String { + if let branchRef = workspace.branchRef?.trimmingCharacters(in: .whitespacesAndNewlines), !branchRef.isEmpty { + return normalizedPrBranchName(branchRef) + } + if workspace.kind.lowercased() == "primary" { + return "primary" + } + let leaf = (workspace.rootPath as NSString).lastPathComponent + return leaf.isEmpty ? workspace.kind : leaf +} + +func filesWorkspaceLaneSummary(_ workspace: FilesWorkspace, lanes: [LaneSummary]) -> LaneSummary? { + guard let laneId = workspace.laneId else { return nil } + return lanes.first(where: { $0.id == laneId }) +} + +private func filesWorkspaceTriggerSurface(workspace: FilesWorkspace?, lane: LaneSummary?) -> (background: Color, border: Color) { + if let lane, let hex = lane.color?.trimmingCharacters(in: .whitespacesAndNewlines), !hex.isEmpty, + let tint = LaneColorPalette.color(forHex: hex) { + return (tint.opacity(0.12), tint.opacity(0.35)) + } + if workspace?.kind.lowercased() == "primary" { + return (ADEColor.accent.opacity(0.08), ADEColor.accent.opacity(0.28)) + } + return (Color.white.opacity(0.04), Color.white.opacity(0.08)) +} diff --git a/apps/ios/ADE/Views/Lanes/LaneAdvancedScreen.swift b/apps/ios/ADE/Views/Lanes/LaneAdvancedScreen.swift deleted file mode 100644 index 9b874570d..000000000 --- a/apps/ios/ADE/Views/Lanes/LaneAdvancedScreen.swift +++ /dev/null @@ -1,198 +0,0 @@ -import SwiftUI - -/// One-stop "Advanced" page for a lane: settings, branch switching, stash, -/// and the destructive git escape hatches (rebase, force push). Each row -/// gets a description so the user knows what it does before they tap it. -struct LaneAdvancedScreen: View { - let snapshot: LaneListSnapshot - let canRunLiveActions: Bool - let disabledSubtitle: String? - let laneId: String - let branchRef: String? - let laneType: String? - let onOpenManageSheet: () -> Void - let onSwitchBranch: () -> Void - let onStash: () -> Void - let onRebaseLane: () -> Void - let onRebaseDescendants: () -> Void - let onRebaseAndPush: () -> Void - let onForcePush: () -> Void - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 14) { - if !canRunLiveActions, let disabledSubtitle { - HStack(spacing: 10) { - Image(systemName: "wifi.exclamationmark") - .foregroundStyle(ADEColor.warning) - Text(disabledSubtitle) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - Spacer(minLength: 0) - } - .adeGlassCard(cornerRadius: 12, padding: 12) - } - - groupCard(header: "Lane settings") { - advancedRow( - symbol: "gearshape.fill", - tint: ADEColor.textPrimary, - title: "Manage lane", - description: "Rename, archive, or change parent. Settings that don't run git on their own.", - action: onOpenManageSheet - ) - } - - groupCard(header: "Branch & working copy") { - advancedRow( - symbol: branchSwitchDisabledReason == nil ? "arrow.triangle.branch" : "lock", - tint: branchSwitchDisabledReason == nil ? ADEColor.tintLanes : ADEColor.textMuted, - title: "Switch branch", - description: branchSwitchDisabledReason - ?? (branchRef.map { "Currently on \($0). Move this lane to another branch." } - ?? "Move this lane to another branch."), - disabled: !canRunLiveActions || branchSwitchDisabledReason != nil, - action: onSwitchBranch - ) - divider - advancedRow( - symbol: "tray.and.arrow.down", - tint: ADEColor.accent, - title: "Stash changes", - description: "Move all uncommitted work aside as a stash. You can pop it back later.", - disabled: !canRunLiveActions, - action: onStash - ) - } - - groupCard(header: "Advanced git", warning: true) { - advancedRow( - symbol: "arrow.triangle.branch", - tint: ADEColor.textPrimary, - title: "Rebase lane", - description: "Replay this lane's commits on top of the latest base branch.", - disabled: !canRunLiveActions, - action: onRebaseLane - ) - divider - advancedRow( - symbol: "arrow.triangle.branch", - tint: ADEColor.textPrimary, - title: "Rebase + descendants", - description: "Rebase this lane and every child stacked on top of it.", - disabled: !canRunLiveActions, - action: onRebaseDescendants - ) - divider - advancedRow( - symbol: "arrow.up.and.down.text.horizontal", - tint: ADEColor.textPrimary, - title: "Rebase and push", - description: "Rebase, then push (force-with-lease if required) so the remote matches.", - disabled: !canRunLiveActions, - action: onRebaseAndPush - ) - divider - advancedRow( - symbol: "arrow.up.forward.circle.fill", - tint: ADEColor.warning, - title: "Force push (with lease)", - description: "Overwrite the remote branch. Safe-with-lease, but still rewrites history.", - disabled: !canRunLiveActions, - destructive: true, - action: onForcePush - ) - } - } - .padding(EdgeInsets(top: 14, leading: 16, bottom: 28, trailing: 16)) - } - .adeScreenBackground() - .adeNavigationGlass() - .navigationTitle("Advanced") - .navigationBarTitleDisplayMode(.inline) - } - - // MARK: - Components - - @ViewBuilder - private func groupCard( - header: String, - warning: Bool = false, - @ViewBuilder content: () -> Content - ) -> some View { - VStack(alignment: .leading, spacing: 10) { - Text(header.uppercased()) - .font(.caption.weight(.semibold)) - .tracking(0.6) - .foregroundStyle(warning ? ADEColor.warning : ADEColor.textMuted) - VStack(spacing: 0) { - content() - } - .background( - ADEColor.surfaceBackground.opacity(0.35), - in: RoundedRectangle(cornerRadius: 14, style: .continuous) - ) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke( - (warning ? ADEColor.warning.opacity(0.18) : ADEColor.border.opacity(0.18)), - lineWidth: 0.5 - ) - ) - } - } - - private var divider: some View { - Rectangle() - .fill(ADEColor.border.opacity(0.18)) - .frame(height: 0.5) - .padding(.leading, 44) - } - - @ViewBuilder - private func advancedRow( - symbol: String, - tint: Color, - title: String, - description: String, - disabled: Bool = false, - destructive: Bool = false, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: symbol) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(tint) - .frame(width: 24, height: 24, alignment: .center) - .padding(.top, 2) - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(destructive ? ADEColor.warning : ADEColor.textPrimary) - Text(description) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) - .padding(.top, 4) - } - .padding(EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(disabled) - .opacity(disabled ? 0.55 : 1.0) - } - - private var branchSwitchDisabledReason: String? { - if laneType == "attached" { - return "Branch switching is disabled for attached lanes." - } - return nil - } -} diff --git a/apps/ios/ADE/Views/Lanes/LaneColorPalette.swift b/apps/ios/ADE/Views/Lanes/LaneColorPalette.swift index 6c63f3e58..511434c88 100644 --- a/apps/ios/ADE/Views/Lanes/LaneColorPalette.swift +++ b/apps/ios/ADE/Views/Lanes/LaneColorPalette.swift @@ -79,4 +79,89 @@ enum LaneColorPalette { let hex = fallbacks[fallbackIndex % fallbacks.count] return color(forHex: hex) } + + /// Desktop `laneDisplayColor`: lane hex when set, otherwise a neutral secondary tone. + static func displayColor(forHex raw: String?, fallback: Color = ADEColor.textSecondary) -> Color { + color(forHex: raw) ?? fallback + } +} + +enum LaneSurfaceTintStrength { + case soft + case `default` + case pastel +} + +/// Desktop `laneSurfaceTint` — shaded fills and borders that reflect a lane's chosen color. +struct LaneSurfaceTint { + let background: Color + let border: Color + let accentBar: Color + let text: Color? + + static let neutral = LaneSurfaceTint( + background: ADEColor.surfaceBackground.opacity(0.08), + border: ADEColor.border.opacity(0.18), + accentBar: .clear, + text: nil + ) +} + +func laneSurfaceTint(forHex raw: String?, strength: LaneSurfaceTintStrength = .pastel) -> LaneSurfaceTint { + guard let color = LaneColorPalette.color(forHex: raw) else { + return .neutral + } + switch strength { + case .pastel: + return LaneSurfaceTint( + background: color.opacity(0.08), + border: color.opacity(0.14), + accentBar: color.opacity(0.40), + text: color.opacity(0.85) + ) + case .soft: + return LaneSurfaceTint( + background: color.opacity(0.10), + border: color.opacity(0.22), + accentBar: color, + text: color + ) + case .default: + return LaneSurfaceTint( + background: color.opacity(0.16), + border: color.opacity(0.28), + accentBar: color, + text: color + ) + } +} + +func laneIconSystemName(_ icon: LaneIcon) -> String { + switch icon { + case .star: return "star.fill" + case .flag: return "flag.fill" + case .bolt: return "bolt.fill" + case .shield: return "shield.fill" + case .tag: return "tag.fill" + } +} + +/// Desktop `LaneLogoMark` — git-branch lane identity or the lane's custom icon glyph. +struct WorkLaneLogoMark: View { + let color: Color + var laneIcon: LaneIcon? = nil + var size: CGFloat = 11 + + var body: some View { + Group { + if let laneIcon { + Image(systemName: laneIconSystemName(laneIcon)) + } else { + Image(systemName: "arrow.branch") + } + } + .font(.system(size: size, weight: .regular)) + .foregroundStyle(color) + .accessibilityHidden(true) + } } diff --git a/apps/ios/ADE/Views/Lanes/LaneCommitHistoryScreen.swift b/apps/ios/ADE/Views/Lanes/LaneCommitHistoryScreen.swift deleted file mode 100644 index ba8f957b7..000000000 --- a/apps/ios/ADE/Views/Lanes/LaneCommitHistoryScreen.swift +++ /dev/null @@ -1,146 +0,0 @@ -import SwiftUI - -struct LaneCommitHistoryScreen: View { - let laneName: String - let commits: [GitCommitSummary] - let canRunLiveActions: Bool - let allowsDiffInspection: (GitCommitSummary) -> Bool - let onOpenDiff: (GitCommitSummary) async -> Void - let onCopyMessage: (GitCommitSummary) async -> Void - let onRevert: (GitCommitSummary) async -> Void - let onCherryPick: (GitCommitSummary) async -> Void - - @State private var pendingConfirmation: CommitHistoryConfirmation? - - var body: some View { - ScrollView { - VStack(spacing: 14) { - if commits.isEmpty { - emptyState - } else { - ADEGlassSection(title: "History", subtitle: "\(commits.count) commit\(commits.count == 1 ? "" : "s")") { - VStack(alignment: .leading, spacing: 14) { - ForEach(Array(commits.enumerated()), id: \.element.id) { index, commit in - commitRow(commit: commit, isHead: index == 0) - if index < commits.count - 1 { - Divider().opacity(0.35) - } - } - } - } - } - } - .padding(EdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 16)) - } - .background(ADEColor.surfaceBackground.ignoresSafeArea()) - .navigationTitle("\(laneName) commits") - .navigationBarTitleDisplayMode(.inline) - .alert(item: $pendingConfirmation) { confirmation in - Alert( - title: Text(confirmation.title), - message: Text(confirmation.message), - primaryButton: .destructive(Text(confirmation.confirmTitle)) { - Task { await perform(confirmation) } - }, - secondaryButton: .cancel() - ) - } - } - - private var emptyState: some View { - ADEEmptyStateView( - symbol: "clock.arrow.circlepath", - title: "No commits yet", - message: "Commits on this lane will appear here." - ) - } - - private func commitRow(commit: GitCommitSummary, isHead: Bool) -> some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(commit.subject) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(2) - .minimumScaleFactor(0.92) - if isHead { - LaneMicroChip(icon: "bookmark.fill", text: "HEAD", tint: ADEColor.accent) - } - if commit.parents.count > 1 { - LaneMicroChip(icon: "arrow.triangle.merge", 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) - HStack(spacing: 8) { - LaneActionButton(title: "Files", symbol: "doc.text.magnifyingglass") { - Task { await onOpenDiff(commit) } - } - .disabled(!allowsDiffInspection(commit)) - LaneActionButton(title: "Copy", symbol: "doc.on.doc") { - Task { await onCopyMessage(commit) } - } - .disabled(!canRunLiveActions) - Spacer(minLength: 0) - LaneActionButton(title: "Revert", symbol: "arrow.uturn.backward") { - pendingConfirmation = CommitHistoryConfirmation(kind: .revert, commit: commit) - } - .disabled(!canRunLiveActions) - LaneActionButton(title: "Pick", symbol: "arrow.triangle.merge") { - pendingConfirmation = CommitHistoryConfirmation(kind: .cherryPick, commit: commit) - } - .disabled(!canRunLiveActions) - } - } - } - - private func perform(_ confirmation: CommitHistoryConfirmation) async { - pendingConfirmation = nil - switch confirmation.kind { - case .revert: - await onRevert(confirmation.commit) - case .cherryPick: - await onCherryPick(confirmation.commit) - } - } -} - -private struct CommitHistoryConfirmation: Identifiable { - enum Kind: String { - case revert - case cherryPick - } - - let kind: Kind - let commit: GitCommitSummary - - var id: String { "\(kind.rawValue):\(commit.sha)" } - - var title: String { - switch kind { - case .revert: return "Revert this commit?" - case .cherryPick: return "Cherry-pick this commit?" - } - } - - var message: String { - switch kind { - case .revert: - return "ADE will create a new commit that reverses \(commit.shortSha)." - case .cherryPick: - return "ADE will apply \(commit.shortSha) onto the current lane." - } - } - - var confirmTitle: String { - switch kind { - case .revert: return "Revert" - case .cherryPick: return "Cherry-pick" - } - } -} diff --git a/apps/ios/ADE/Views/Lanes/LaneCommitSheet.swift b/apps/ios/ADE/Views/Lanes/LaneCommitSheet.swift deleted file mode 100644 index b14ac8134..000000000 --- a/apps/ios/ADE/Views/Lanes/LaneCommitSheet.swift +++ /dev/null @@ -1,448 +0,0 @@ -import SwiftUI - -private struct LaneCommitPendingDestructiveAction: Identifiable { - let id = UUID() - let title: String - let message: String - let confirmTitle: String - let perform: () -> Void -} - -struct LaneCommitSheet: View { - @Binding var commitMessage: String - @Binding var amendCommit: Bool - let stagedCount: Int - let unstagedCount: Int - let canRunLiveActions: Bool - let stagedFiles: [FileChange] - let unstagedFiles: [FileChange] - /// Returns the suggested commit message, or throws. The sheet owns the - /// loading + setup-hint state so the desktop's specific "AI commit messages - /// are off" error can be detected and surfaced inline. - let onGenerateMessage: () async throws -> String - let onCommit: () -> Void - let onDismiss: () -> Void - let onStageFile: (FileChange) -> Void - let onUnstageFile: (FileChange) -> Void - let onDiscardFile: (FileChange) -> Void - let onRestoreStaged: (FileChange) -> Void - let onStageAll: () -> Void - let onUnstageAll: () -> Void - let onDiscardAllUnstaged: () -> Void - let onRestoreAllStaged: () -> Void - let onOpenDiff: (FileChange, _ staged: Bool) -> Void - let onOpenFiles: (FileChange) -> Void - - @FocusState private var messageFieldFocused: Bool - @Environment(\.dismiss) private var dismissEnv - @State private var isGenerating = false - /// When set, the desktop reported AI commit messages as not configured; - /// we lock the Suggest button for the remainder of this sheet session - /// and show the user how to enable it. - @State private var aiSetupHint: String? - @State private var aiTransientError: String? - @State private var pendingDestructiveAction: LaneCommitPendingDestructiveAction? - - var body: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 18) { - if !unstagedFiles.isEmpty || !stagedFiles.isEmpty { - VStack(alignment: .leading, spacing: 12) { - sectionHeader(title: "Files", subtitle: filesSubtitle) - if !unstagedFiles.isEmpty { - unstagedSection - } - if !stagedFiles.isEmpty { - stagedSection - } - } - } - - VStack(alignment: .leading, spacing: 10) { - sectionHeader(title: "Commit message", subtitle: nil, trailing: { suggestButton }) - messageField - if let aiSetupHint { - setupHintCard(aiSetupHint) - } else if let aiTransientError { - transientErrorCard(aiTransientError) - } - } - - amendRow - commitActionSection - } - .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 24) - } - .background(ADEColor.surfaceBackground.ignoresSafeArea()) - .navigationTitle(amendCommit ? "Amend commit" : "Review & commit") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Close") { - onDismiss() - dismissEnv() - } - } - } - .onAppear { - // Auto-focus the commit message field when the sheet appears so the - // user can start typing immediately without an extra tap. - messageFieldFocused = true - } - .alert(item: $pendingDestructiveAction) { action in - Alert( - title: Text(action.title), - message: Text(action.message), - primaryButton: .destructive(Text(action.confirmTitle)) { - action.perform() - pendingDestructiveAction = nil - }, - secondaryButton: .cancel { - pendingDestructiveAction = nil - } - ) - } - } - } - - // MARK: - Layout helpers - - private var filesSubtitle: String? { - let parts: [String] = [ - stagedCount > 0 ? "\(stagedCount) staged" : nil, - unstagedCount > 0 ? "\(unstagedCount) unstaged" : nil - ].compactMap { $0 } - return parts.isEmpty ? nil : parts.joined(separator: " · ") - } - - @ViewBuilder - private func sectionHeader( - title: String, - subtitle: String?, - @ViewBuilder trailing: () -> Trailing = { EmptyView() } - ) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 8) { - VStack(alignment: .leading, spacing: 2) { - Text(title.uppercased()) - .font(.caption.weight(.semibold)) - .tracking(0.6) - .foregroundStyle(ADEColor.textMuted) - if let subtitle { - Text(subtitle) - .font(.caption2) - .foregroundStyle(ADEColor.textSecondary) - } - } - Spacer(minLength: 8) - trailing() - } - } - - // MARK: - Suggest button - - @ViewBuilder - private var suggestButton: some View { - let disabled = !canRunLiveActions || aiSetupHint != nil || isGenerating - Button(action: triggerSuggest) { - HStack(spacing: 5) { - if isGenerating { - ProgressView() - .controlSize(.mini) - .tint(ADEColor.accent) - } else { - Image(systemName: "sparkles") - .font(.system(size: 11, weight: .semibold)) - } - Text(suggestButtonLabel) - .font(.caption.weight(.semibold)) - } - .foregroundStyle(aiSetupHint == nil ? ADEColor.accent : ADEColor.textMuted) - .padding(EdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10)) - .background( - (aiSetupHint == nil ? ADEColor.accent : ADEColor.textMuted).opacity(0.12), - in: Capsule() - ) - } - .buttonStyle(.plain) - .disabled(disabled) - .opacity(disabled ? 0.6 : 1) - .accessibilityLabel(isGenerating ? "Generating commit message" : "Suggest commit message") - } - - private var suggestButtonLabel: String { - if isGenerating { return "Generating…" } - if aiSetupHint != nil { return "Setup needed" } - return "Suggest" - } - - private func triggerSuggest() { - guard !isGenerating, aiSetupHint == nil, canRunLiveActions else { return } - Task { @MainActor in - isGenerating = true - aiTransientError = nil - defer { isGenerating = false } - do { - let suggestion = try await onGenerateMessage() - commitMessage = suggestion - messageFieldFocused = true - } catch { - let text = error.localizedDescription - if isAiSetupError(text) { - aiSetupHint = aiSetupHintFor(text) - } else { - aiTransientError = text - } - } - } - } - - // TODO(review #3146132904): drop substring matching once the desktop's - // git.generateCommitMessage RPC returns a structured error code (e.g. - // `errorCode: "ai_commit_messages_off" | "no_commit_messages_model"`). - // Until then we fall back to keyword detection on the localized - // description, which is brittle if the desktop copy ever changes. See - // https://github.com/anthropic-experimental/ADE/pull/212#discussion_r3146132904 - private func isAiSetupError(_ text: String) -> Bool { - let lower = text - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - let needles = [ - "ai commit messages are off", - "ai commit messages are turned off", - "commit messages model", - "choose a commit messages", - "pick a commit messages", - "not currently available", - ] - return needles.contains(where: lower.contains) - } - - private func aiSetupHintFor(_ text: String) -> String { - let lower = text - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - if lower.contains("are off") || lower.contains("turned off") { - return "AI commit messages are turned off on your ADE machine. Open ADE Settings → AI → Commit Messages to enable it." - } - return "Pick a Commit Messages model in ADE Settings → AI → Commit Messages." - } - - @ViewBuilder - private func setupHintCard(_ message: String) -> some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: "wand.and.stars") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.warning) - .padding(.top, 2) - VStack(alignment: .leading, spacing: 4) { - Text("Suggest needs setup") - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text(message) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 8) - } - .padding(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12)) - .background(ADEColor.warning.opacity(0.10), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(ADEColor.warning.opacity(0.25), lineWidth: 0.5) - ) - } - - @ViewBuilder - private func transientErrorCard(_ message: String) -> some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.danger) - .padding(.top, 2) - Text(message) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 8) - } - .padding(EdgeInsets(top: 10, leading: 12, bottom: 10, trailing: 12)) - .background(ADEColor.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(ADEColor.danger.opacity(0.22), lineWidth: 0.5) - ) - } - - // MARK: - File sections - - @ViewBuilder - private var unstagedSection: some View { - LaneFileTreeSection( - title: "Unstaged files", - subtitle: "\(unstagedFiles.count) file\(unstagedFiles.count == 1 ? "" : "s")", - changes: unstagedFiles, - allowsLiveActions: canRunLiveActions, - allowsDiffInspection: true, - bulkActionTitle: unstagedFiles.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, - extraBulkActions: [ - LaneFileTreeBulkAction( - title: "Discard unstaged", - symbol: "trash", - tint: ADEColor.danger, - isDestructive: true - ) { - requestDestructiveConfirmation(.discardAllUnstaged(unstagedFiles), perform: onDiscardAllUnstaged) - } - ], - onBulkAction: onStageAll, - onDiff: { file in onOpenDiff(file, false) }, - onPrimaryAction: onStageFile, - onSecondaryAction: { file in - requestDestructiveConfirmation(.discardUnstaged(file)) { - onDiscardFile(file) - } - }, - onOpenFiles: onOpenFiles - ) - } - - @ViewBuilder - private var stagedSection: some View { - LaneFileTreeSection( - title: "Staged files", - subtitle: "\(stagedFiles.count) file\(stagedFiles.count == 1 ? "" : "s")", - changes: stagedFiles, - allowsLiveActions: canRunLiveActions, - allowsDiffInspection: true, - bulkActionTitle: stagedFiles.count > 1 ? "Unstage all" : nil, - bulkActionSymbol: "minus.circle", - bulkActionTint: ADEColor.warning, - primaryActionTitle: "Unstage", - primaryActionSymbol: "minus.circle", - primaryActionTint: ADEColor.warning, - secondaryActionTitle: "Discard", - secondaryActionSymbol: "trash", - secondaryActionTint: ADEColor.danger, - extraBulkActions: [ - LaneFileTreeBulkAction( - title: "Discard staged", - symbol: "trash", - tint: ADEColor.danger, - isDestructive: true - ) { - requestDestructiveConfirmation(.restoreAllStaged(stagedFiles), perform: onRestoreAllStaged) - } - ], - onBulkAction: onUnstageAll, - onDiff: { file in onOpenDiff(file, true) }, - onPrimaryAction: onUnstageFile, - onSecondaryAction: { file in - requestDestructiveConfirmation(.restoreStaged(file)) { - onRestoreStaged(file) - } - }, - onOpenFiles: onOpenFiles - ) - } - - private func requestDestructiveConfirmation(_ confirmation: LaneFileConfirmation, perform: @escaping () -> Void) { - pendingDestructiveAction = LaneCommitPendingDestructiveAction( - title: confirmation.title, - message: confirmation.message, - confirmTitle: confirmation.confirmTitle, - perform: perform - ) - } - - // MARK: - Message + amend + commit - - private var messageField: some View { - TextField( - amendCommit ? "Update the previous commit message" : "Describe what this change does", - text: $commitMessage, - axis: .vertical - ) - .textFieldStyle(.plain) - .font(.subheadline) - .lineLimit(4...10) - .focused($messageFieldFocused) - .frame(minHeight: 130, alignment: .topLeading) - .adeInsetField(cornerRadius: 12, padding: 12) - } - - private var amendRow: some View { - Toggle(isOn: $amendCommit) { - VStack(alignment: .leading, spacing: 2) { - Text("Amend last commit") - .font(.subheadline.weight(.medium)) - .foregroundStyle(ADEColor.textPrimary) - Text("Replace the previous commit with these changes.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - } - .tint(ADEColor.accent) - .adeGlassCard(cornerRadius: 12, padding: 12) - } - - private var commitActionSection: some View { - VStack(alignment: .leading, spacing: 8) { - Button(action: onCommit) { - HStack(spacing: 6) { - Image(systemName: amendCommit ? "arrow.counterclockwise" : "checkmark.circle.fill") - .font(.system(size: 14, weight: .semibold)) - Text(commitButtonLabel) - .font(.subheadline.weight(.semibold)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - } - .buttonStyle(.glassProminent) - .tint(ADEColor.accent) - .disabled(!canCommit) - Text(commitActionHint) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - .adeGlassCard(cornerRadius: 12, padding: 12) - } - - private var trimmedMessage: String { - commitMessage.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var canCommit: Bool { - guard canRunLiveActions else { return false } - if amendCommit { - return !trimmedMessage.isEmpty - } - return stagedCount > 0 && !trimmedMessage.isEmpty - } - - private var commitButtonLabel: String { - if amendCommit { return "Amend commit" } - if stagedCount == 0 { return "Stage files first" } - if trimmedMessage.isEmpty { return "Write a message" } - return "Commit \(stagedCount) file\(stagedCount == 1 ? "" : "s")" - } - - private var commitActionHint: String { - if !canRunLiveActions { return "Reconnect to commit changes." } - if amendCommit { return "This replaces the last commit on this lane." } - if stagedCount == 0 { return "Stage files before committing." } - if trimmedMessage.isEmpty { return "Write a commit message before continuing." } - return "Creates a commit from the staged files." - } -} diff --git a/apps/ios/ADE/Views/Lanes/LaneComponents.swift b/apps/ios/ADE/Views/Lanes/LaneComponents.swift index b42e41d8f..193e26630 100644 --- a/apps/ios/ADE/Views/Lanes/LaneComponents.swift +++ b/apps/ios/ADE/Views/Lanes/LaneComponents.swift @@ -39,19 +39,13 @@ struct LaneOpenChip: View { let isPinned: Bool var body: some View { - let laneAccent = LaneColorPalette.color(forHex: snapshot.lane.color) + let laneTint = laneSurfaceTint(forHex: snapshot.lane.color) + let laneAccent = laneTint.text ?? ADEColor.textPrimary HStack(spacing: 6) { - Circle() - .fill(runtimeTint(bucket: snapshot.runtime.bucket)) - .frame(width: 6, height: 6) - if let laneAccent { - Circle() - .fill(laneAccent) - .frame(width: 7, height: 7) - } + WorkLaneLogoMark(color: laneAccent, laneIcon: snapshot.lane.icon, size: 10) Text(snapshot.lane.name) .font(.caption.weight(.medium)) - .foregroundStyle(laneAccent ?? ADEColor.textPrimary) + .foregroundStyle(laneAccent) .lineLimit(1) if isPinned { Image(systemName: "pin.fill") @@ -60,11 +54,11 @@ struct LaneOpenChip: View { } } .padding(EdgeInsets(top: 7, leading: 10, bottom: 7, trailing: 10)) - .background(ADEColor.surfaceBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .background(laneTint.background, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .glassEffect(in: .rect(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke((laneAccent ?? ADEColor.border).opacity(laneAccent == nil ? 0.16 : 0.45), lineWidth: 0.5) + .stroke(laneTint.border, lineWidth: 0.5) ) .accessibilityLabel("\(snapshot.lane.name)\(isPinned ? ", pinned" : "")") } @@ -511,19 +505,18 @@ func laneStackCardAccessibilityLabel( snapshot: LaneListSnapshot, isPinned: Bool, isOpen: Bool, - rebaseWarning: LaneCardRebaseWarningPresentation? + rebaseWarning: LaneCardRebaseWarningPresentation?, + pullRequest: PullRequestListItem? = nil ) -> String { - var parts = [snapshot.lane.name, snapshot.lane.branchRef] + var parts = [snapshot.lane.name, normalizedPrBranchName(snapshot.lane.branchRef)] if snapshot.lane.laneType == "primary" { parts.append("primary") } if snapshot.lane.archivedAt != nil { parts.append("archived") } - if snapshot.runtime.bucket == "running" { parts.append("running") } - if snapshot.runtime.bucket == "awaiting-input" { parts.append("awaiting input") } 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") } - if snapshot.runtime.sessionCount > 0 { parts.append("\(snapshot.runtime.sessionCount) sessions") } + if let pullRequest { parts.append(formatLanePrBadgeLabel(pullRequest)) } if let warning = rebaseWarning { parts.append(warning.accessibilitySummary) } return parts.joined(separator: ", ") } @@ -563,6 +556,32 @@ struct LaneCardRebaseWarning: View { } } +// MARK: - PR tag + +struct LanePrTagChip: View { + let pullRequest: PullRequestListItem + + var body: some View { + let tint = lanePullRequestTint(pullRequest.state) + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.pull") + .font(.system(size: 9, weight: .bold)) + Text(formatLanePrBadgeLabel(pullRequest)) + .font(.caption2.monospaced().weight(.bold)) + .lineLimit(1) + } + .foregroundStyle(tint) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(tint.opacity(0.28), lineWidth: 0.6) + ) + .accessibilityLabel(formatLanePrBadgeLabel(pullRequest)) + } +} + // MARK: - Stack card struct LaneStackCard: View, Equatable { @@ -570,6 +589,7 @@ struct LaneStackCard: View, Equatable { let isPinned: Bool let isOpen: Bool let depth: Int + var pullRequest: PullRequestListItem? = nil var transitionNamespace: Namespace.ID? = nil var isSelectedTransitionSource = false @@ -578,127 +598,134 @@ struct LaneStackCard: View, Equatable { && lhs.isPinned == rhs.isPinned && lhs.isOpen == rhs.isOpen && lhs.depth == rhs.depth + && lhs.pullRequest == rhs.pullRequest && lhs.isSelectedTransitionSource == rhs.isSelectedTransitionSource } var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .center, spacing: 10) { - LaneStatusIndicator(bucket: snapshot.runtime.bucket, size: 10) - .adeMatchedGeometry(id: isSelectedTransitionSource ? "lane-icon-\(snapshot.lane.id)" : nil, in: transitionNamespace) - - if let laneAccent = LaneColorPalette.color(forHex: snapshot.lane.color) { - Circle() - .fill(laneAccent) - .frame(width: 7, height: 7) - } - Text(snapshot.lane.name) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(LaneColorPalette.color(forHex: snapshot.lane.color) ?? ADEColor.textPrimary) - .lineLimit(1) - .adeMatchedGeometry(id: isSelectedTransitionSource ? "lane-title-\(snapshot.lane.id)" : nil, in: transitionNamespace) + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(laneTint.accentBar) + .frame(width: 3) + .padding(.vertical, 10) + + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 8) { + WorkLaneLogoMark(color: laneLabelColor, laneIcon: snapshot.lane.icon, size: 12) + .frame(width: 14, height: 14) + .adeMatchedGeometry(id: isSelectedTransitionSource ? "lane-icon-\(snapshot.lane.id)" : nil, in: transitionNamespace) - laneTypeBadge + Text(snapshot.lane.name) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(laneLabelColor) + .lineLimit(1) + .adeMatchedGeometry(id: isSelectedTransitionSource ? "lane-title-\(snapshot.lane.id)" : nil, in: transitionNamespace) - Spacer(minLength: 4) + laneTypeBadge - if let devices = snapshot.lane.devicesOpen, !devices.isEmpty { - Image(systemName: devicePresenceSymbol(for: devices)) + if let pullRequest { + LanePrTagChip(pullRequest: pullRequest) + } + + Spacer(minLength: 4) + + if let devices = snapshot.lane.devicesOpen, !devices.isEmpty { + Image(systemName: devicePresenceSymbol(for: devices)) + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .accessibilityLabel("Open on \(devices.count) other device\(devices.count == 1 ? "" : "s")") + } + + Image(systemName: "chevron.right") .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.accent) - .accessibilityLabel("Open on \(devices.count) other device\(devices.count == 1 ? "" : "s")") + .foregroundStyle(ADEColor.textMuted) } - Image(systemName: "chevron.right") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - } - - HStack(spacing: 5) { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(ADEColor.textMuted) - Text(snapshot.lane.branchRef) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - .truncationMode(.middle) - } + HStack(spacing: 5) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 10, weight: .regular)) + .foregroundStyle(ADEColor.textMuted.opacity(0.7)) + Text(normalizedPrBranchName(snapshot.lane.branchRef)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + .truncationMode(.middle) + } - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - if snapshot.lane.status.dirty { - LaneMicroChip(icon: "circle.fill", text: "dirty", tint: ADEColor.warning) - } - 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) running", - 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 let issue = primaryLaneLinearIssue(for: snapshot.lane) { - LaneMicroChip(icon: "link", text: issue.identifier, tint: ADEColor.accent) - } else if laneLinearIssueLinkCount(for: snapshot.lane) > 0 { - LaneMicroChip(icon: "link", text: "\(laneLinearIssueLinkCount(for: snapshot.lane))", tint: ADEColor.accent) - } - if isPinned { - LaneMicroChip(icon: "pin.fill", text: nil, tint: ADEColor.accent) + if hasStatusChips { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + if snapshot.lane.status.dirty { + LaneMicroChip(icon: "circle.fill", text: "dirty", tint: ADEColor.warning) + } + 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.lane.childCount > 0 { + LaneMicroChip(icon: "square.stack.3d.up", text: "\(snapshot.lane.childCount)", tint: ADEColor.textMuted) + } + if let issue = primaryLaneLinearIssue(for: snapshot.lane) { + LaneMicroChip(icon: "link", text: issue.identifier, tint: ADEColor.accent) + } else if laneLinearIssueLinkCount(for: snapshot.lane) > 0 { + LaneMicroChip(icon: "link", text: "\(laneLinearIssueLinkCount(for: snapshot.lane))", tint: ADEColor.accent) + } + if isPinned { + LaneMicroChip(icon: "pin.fill", text: nil, tint: ADEColor.accent) + } + } } + .scrollClipDisabled() } - } - .scrollClipDisabled() - if let activity = laneActivitySummary(snapshot) { - Text(activity) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - } - - if let warning = rebaseWarning { - LaneCardRebaseWarning(presentation: warning) + if let warning = rebaseWarning { + LaneCardRebaseWarning(presentation: warning) + } } + .padding(.leading, 11) + .padding(.trailing, 14) + .padding(.vertical, 12) } - .padding(14) .frame(maxWidth: .infinity, alignment: .leading) - .background(cardBackgroundTint.opacity(isPrimary ? 0.12 : 0.08), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .background(laneTint.background, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .glassEffect(in: .rect(cornerRadius: 14)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(cardStrokeTint, lineWidth: isOpen ? 1.5 : (isPrimary ? 1.0 : 0.75)) + .stroke(cardStrokeTint, lineWidth: isOpen ? 1.25 : 0.75) ) - .shadow(color: isOpen ? ADEColor.accent.opacity(0.08) : .clear, radius: 8, y: 2) + .shadow(color: isOpen ? laneTint.accentBar.opacity(0.14) : .clear, radius: 8, y: 2) .adeMatchedTransitionSource(id: isSelectedTransitionSource ? "lane-container-\(snapshot.lane.id)" : nil, in: transitionNamespace) .accessibilityElement(children: .combine) .accessibilityLabel(stackCardAccessibilityLabel) } - private var isPrimary: Bool { - snapshot.lane.laneType == "primary" + private var laneTint: LaneSurfaceTint { + laneSurfaceTint(forHex: snapshot.lane.color) + } + + private var laneLabelColor: Color { + laneTint.text ?? ADEColor.textPrimary } private var rebaseWarning: LaneCardRebaseWarningPresentation? { laneCardRebaseWarningPresentation(for: snapshot) } - private var cardBackgroundTint: Color { - isPrimary ? ADEColor.accent : ADEColor.surfaceBackground + private var cardStrokeTint: Color { + if isOpen { return laneTint.accentBar.opacity(0.55) } + return laneTint.border } - private var cardStrokeTint: Color { - if isOpen { return ADEColor.accent.opacity(0.4) } - if isPrimary { return ADEColor.accent.opacity(0.32) } - return ADEColor.border.opacity(0.18) + private var hasStatusChips: Bool { + snapshot.lane.status.dirty + || snapshot.lane.status.ahead > 0 + || snapshot.lane.status.behind > 0 + || snapshot.lane.childCount > 0 + || primaryLaneLinearIssue(for: snapshot.lane) != nil + || laneLinearIssueLinkCount(for: snapshot.lane) > 0 + || isPinned } @ViewBuilder @@ -717,7 +744,8 @@ struct LaneStackCard: View, Equatable { snapshot: snapshot, isPinned: isPinned, isOpen: isOpen, - rebaseWarning: rebaseWarning + rebaseWarning: rebaseWarning, + pullRequest: pullRequest ) } } diff --git a/apps/ios/ADE/Views/Lanes/LaneDeeplinkHelpers.swift b/apps/ios/ADE/Views/Lanes/LaneDeeplinkHelpers.swift new file mode 100644 index 000000000..0cb9d3cd2 --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneDeeplinkHelpers.swift @@ -0,0 +1,17 @@ +import Foundation + +enum LaneDeeplinkHelpers { + static func laneLink(laneId: String) -> String { + "ade://lane/\(laneId)" + } + + static func branchLink(repoOwner: String, repoName: String, branch: String) -> String { + let encodedBranch = branch + .split(separator: "/") + .map { segment in + segment.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment) + } + .joined(separator: "/") + return "ade://repo/\(repoOwner)/\(repoName)/branch/\(encodedBranch)" + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailContentSections.swift b/apps/ios/ADE/Views/Lanes/LaneDetailContentSections.swift deleted file mode 100644 index 9f752bdcf..000000000 --- a/apps/ios/ADE/Views/Lanes/LaneDetailContentSections.swift +++ /dev/null @@ -1,300 +0,0 @@ -import SwiftUI - -struct LaneDetailHeaderCard: View { - let snapshot: LaneListSnapshot - let detail: LaneDetailPayload? - let linkedPullRequests: [PullRequestListItem] - let transitionNamespace: Namespace.ID? - let transitionLaneId: String? - let canRunLiveActions: Bool - let onStackTapped: () -> Void - let onOpenLinkedPullRequest: (PullRequestListItem) -> Void - let onPush: () -> Void - let onPull: () -> Void - let onFetch: () -> Void - @ViewBuilder let footer: () -> Footer - - // transitionNamespace / transitionLaneId are retained on the init for - // caller compatibility but intentionally unused in body: - // navigationTransition(.zoom(sourceID:)) on the container already - // interpolates child layouts during the push, so this destination must - // NOT emit per-element matchedGeometryEffect — the list row is the sole - // isSource=true view in each lane-icon/title/status group. - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - headerTopRow - detailMetadataRow - if let issue = primaryLaneLinearIssue(for: detail?.lane ?? snapshot.lane) { - LaneLinearIssueBadge(issue: issue) - } - statusRow - if let summary = headerSummaryText { - Text(summary) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - activeSessionsRow - syncActionsRow - footer() - } - .adeGlassCard(cornerRadius: 18, padding: 16) - .accessibilityElement(children: .contain) - } - - 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) - .minimumScaleFactor(0.85) - - laneTypeBadge - } - } - - Spacer(minLength: 8) - } - } - - /// Inline Pull / Push / Fetch row pinned to the bottom of the header card. - /// Sync details + the underlying full sync screen live in Advanced now — - /// this is the everyday "I'm done, push it" affordance. - @ViewBuilder - private var syncActionsRow: some View { - let syncStatus = detail?.syncStatus - let remoteAhead = syncStatus?.ahead ?? 0 - let remoteBehind = syncStatus?.behind ?? 0 - let hasUpstream = syncStatus?.hasUpstream ?? true - let diverged = syncStatus?.diverged ?? false - let shouldPull = hasUpstream && remoteBehind > 0 && !diverged - // While detail is still loading (syncStatus nil) we don't know the ahead count yet, so - // keep Push enabled instead of disabling an already-ahead lane until the fetch lands. - let syncStatusLoaded = syncStatus != nil - let shouldPush = !syncStatusLoaded || !hasUpstream || remoteAhead > 0 - - HStack(spacing: 8) { - Image(systemName: "arrow.triangle.2.circlepath") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(ADEColor.textSecondary) - Text(compactSyncSummary(syncStatus)) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 8) - - syncActionButton( - symbol: "arrow.down.to.line.compact", - // Action flips between Pull (when there are commits to integrate) - // and Fetch (when we just want to refresh remote state). The label - // and accessibility text must follow the action so VoiceOver users - // hear what the button is actually about to do. - label: shouldPull ? "Pull" : "Fetch", - tint: shouldPull ? ADEColor.warning : ADEColor.textPrimary, - emphasize: shouldPull, - isEnabled: canRunLiveActions, - action: shouldPull ? onPull : onFetch - ) - syncActionButton( - symbol: "arrow.up.to.line.compact", - label: "Push", - tint: shouldPush ? ADEColor.success : ADEColor.textPrimary, - emphasize: shouldPush, - isEnabled: canRunLiveActions && shouldPush && !diverged, - action: onPush - ) - } - .padding(.top, 2) - } - - @ViewBuilder - private func syncActionButton( - symbol: String, - label: String, - tint: Color, - emphasize: Bool, - isEnabled: Bool, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - HStack(spacing: 5) { - Image(systemName: symbol) - .font(.system(size: 11, weight: .bold)) - Text(label) - .font(.caption.weight(.semibold)) - } - .foregroundStyle(tint) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - (emphasize ? tint.opacity(0.16) : ADEColor.surfaceBackground.opacity(0.45)), - in: Capsule() - ) - .overlay( - Capsule().stroke(tint.opacity(emphasize ? 0.32 : 0.16), lineWidth: 0.6) - ) - } - .buttonStyle(.plain) - .disabled(!isEnabled) - .opacity(isEnabled ? 1 : 0.5) - .accessibilityLabel(label) - } - - @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 - if let detail, !detail.stackChain.isEmpty { - Button(action: onStackTapped) { - LaneMicroChip(icon: "list.number", text: "Stack \(detail.stackChain.count)", tint: ADEColor.accent) - } - .buttonStyle(.plain) - .accessibilityLabel("View stack graph") - } - } - } - } - - @ViewBuilder - private var activeSessionsRow: some View { - let activeSessions = (detail?.sessions ?? []).filter { $0.status == "running" || $0.status == "active" } - let activeChats = (detail?.chatSessions ?? []).filter { $0.status == "running" || $0.status == "active" } - let totalActive = activeSessions.count + activeChats.count - - if totalActive > 0 { - VStack(alignment: .leading, spacing: 6) { - ForEach(activeSessions.prefix(2)) { session in - HStack(spacing: 8) { - LaneStatusIndicator(bucket: "running", size: 7) - Text(session.title) - .font(.caption) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - Spacer() - Text("Terminal") - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - } - } - ForEach(activeChats.prefix(2)) { chat in - HStack(spacing: 8) { - LaneStatusIndicator(bucket: "running", size: 7) - Text(chat.title ?? chat.provider.capitalized) - .font(.caption) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - Spacer() - Text(chat.provider.capitalized) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - } - } - if totalActive > 4 { - Text("+ \(totalActive - 4) more") - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - } - } - .padding(10) - .background(ADEColor.surfaceBackground.opacity(0.4), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - } - - @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 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 { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - ForEach(Array(linkedPullRequests.enumerated()), id: \.offset) { _, pr in - Button { - onOpenLinkedPullRequest(pr) - } label: { - LaneTypeBadge( - text: "#\(pr.githubPrNumber)", - tint: lanePullRequestTint(pr.state) - ) - } - .buttonStyle(.plain) - .accessibilityLabel(pr.title.isEmpty ? "Open PR \(pr.githubPrNumber)" : "Open \(pr.title)") - } - } - } - .accessibilityLabel("\(linkedPullRequests.count) linked pull requests") - } - } - - private var headerSummaryText: String? { - guard let detail else { return nil } - if let conflictStatus = detail.conflictStatus { - return conflictSummary(conflictStatus) - } - return nil - } -} diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailGitActionsPane.swift b/apps/ios/ADE/Views/Lanes/LaneDetailGitActionsPane.swift new file mode 100644 index 000000000..bbd1bc001 --- /dev/null +++ b/apps/ios/ADE/Views/Lanes/LaneDetailGitActionsPane.swift @@ -0,0 +1,679 @@ +import SwiftUI +import UIKit + +/// Slim git-actions surface ported from desktop `LaneGitActionsPane` / Work git drawer. +struct LaneDetailGitActionsPane: View { + let snapshot: LaneListSnapshot + let detail: LaneDetailPayload + let linkedPullRequests: [PullRequestListItem] + let canRunLiveActions: Bool + let busyAction: String? + @Binding var commitMessage: String + @Binding var amendCommit: Bool + @Binding var stashMessage: String + + let onRefresh: () -> Void + let onCommit: () -> Void + let onGenerateMessage: () async throws -> String + let onPull: (_ mode: String) -> Void + let onPush: (_ forceWithLease: Bool) -> Void + let onFetch: () -> Void + let onStageFile: (FileChange) -> Void + let onUnstageFile: (FileChange) -> Void + let onDiscardFile: (FileChange) -> Void + let onRestoreStaged: (FileChange) -> Void + let onStageAll: () -> Void + let onUnstageAll: () -> Void + let onDiscardAllUnstaged: () -> Void + let onRestoreAllStaged: () -> Void + let onOpenDiff: (FileChange, _ staged: Bool) -> Void + let onOpenFiles: (FileChange) -> Void + let onStashPush: (_ message: String) -> Void + let onStashApply: (_ ref: String) -> Void + let onStashPop: (_ ref: String) -> Void + let onStashDrop: (_ ref: String) -> Void + let onOpenCommitDiff: (GitCommitSummary) async -> Void + let onCopyCommitMessage: (GitCommitSummary) async -> Void + let onRevertCommit: (GitCommitSummary) -> Void + let onCherryPickCommit: (GitCommitSummary) -> Void + let onSwitchBranch: () -> Void + let onRebaseLane: () -> Void + let onRebaseDescendants: () -> Void + let onRebaseAndPush: () -> Void + let onForcePush: () -> Void + let onOpenLinkedPullRequest: (PullRequestListItem) -> Void + let onCreateLaneFromChanges: () -> Void + + @State private var pullMode: String = "rebase" + @State private var showMoreActions = false + @State private var isGeneratingMessage = false + @State private var aiSetupHint: String? + @State private var pendingCommitConfirmation: CommitHistoryConfirmation? + @FocusState private var commitFieldFocused: Bool + + private var stagedFiles: [FileChange] { detail.diffChanges?.staged ?? [] } + private var unstagedFiles: [FileChange] { detail.diffChanges?.unstaged ?? [] } + private var syncStatus: GitUpstreamSyncStatus? { detail.syncStatus } + private var canRescueUnstaged: Bool { + !unstagedFiles.isEmpty && stagedFiles.isEmpty && canRunLiveActions && busyAction == nil + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + headerSection + actionToolbar + if showMoreActions { + moreActionsSection + } + ScrollView { + LazyVStack(alignment: .leading, spacing: 14) { + filesSection + stashesSection + historySection + } + .padding(EdgeInsets(top: 12, leading: 0, bottom: 20, trailing: 0)) + } + } + .alert(item: $pendingCommitConfirmation) { confirmation in + Alert( + title: Text(confirmation.title), + message: Text(confirmation.message), + primaryButton: .destructive(Text(confirmation.confirmTitle)) { + switch confirmation.kind { + case .revert: onRevertCommit(confirmation.commit) + case .cherryPick: onCherryPickCommit(confirmation.commit) + } + }, + secondaryButton: .cancel() + ) + } + } + + // MARK: - Header + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Circle() + .fill(laneAccentColor) + .frame(width: 8, height: 8) + Text(snapshot.lane.name) + .font(.headline.weight(.bold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(2) + if let issue = primaryLaneLinearIssue(for: snapshot.lane) { + LaneLinearIssueBadge(issue: issue) + } + Spacer(minLength: 0) + if let syncStatus { + Text(compactSyncSummary(syncStatus)) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + branchBadge + cleanBadge + if snapshot.lane.status.ahead > 0 || snapshot.lane.status.behind > 0 { + LaneMicroChip( + icon: "arrow.up.arrow.down", + text: "base ↑\(snapshot.lane.status.ahead) ↓\(snapshot.lane.status.behind)", + tint: ADEColor.textMuted + ) + } + linkedPullRequestBadge + if let origin = originLabel { + Text(origin) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + } + } + + if let conflictStatus = detail.conflictStatus { + Text(conflictSummary(conflictStatus)) + .font(.caption2) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(2) + } + } + .padding(EdgeInsets(top: 4, leading: 0, bottom: 10, trailing: 0)) + } + + private var laneAccentColor: Color { + laneSurfaceTint(forHex: snapshot.lane.color).text ?? ADEColor.accent + } + + private var branchBadge: some View { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9, weight: .bold)) + Text(normalizedPrBranchName(snapshot.lane.branchRef)) + .font(.system(.caption2, design: .monospaced)) + .lineLimit(1) + } + .foregroundStyle(ADEColor.accent) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(ADEColor.accent.opacity(0.14), in: Capsule()) + } + + private var cleanBadge: some View { + let dirty = snapshot.lane.status.dirty || !stagedFiles.isEmpty || !unstagedFiles.isEmpty + return Text(dirty ? "DIRTY" : "CLEAN") + .font(.system(.caption2, design: .monospaced).weight(.bold)) + .foregroundStyle(dirty ? ADEColor.warning : ADEColor.success) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background((dirty ? ADEColor.warning : ADEColor.success).opacity(0.14), in: Capsule()) + } + + @ViewBuilder + private var linkedPullRequestBadge: some View { + if linkedPullRequests.count == 1, let pr = linkedPullRequests.first { + Button { onOpenLinkedPullRequest(pr) } label: { + LaneTypeBadge(text: "PR #\(pr.githubPrNumber)", tint: lanePullRequestTint(pr.state)) + } + .buttonStyle(.plain) + } else if linkedPullRequests.count > 1 { + ForEach(linkedPullRequests.prefix(3)) { pr in + Button { onOpenLinkedPullRequest(pr) } label: { + LaneTypeBadge(text: "#\(pr.githubPrNumber)", tint: lanePullRequestTint(pr.state)) + } + .buttonStyle(.plain) + } + } + } + + private var originLabel: String? { + if snapshot.lane.laneType == "primary" { return nil } + if let base = detail.lane.baseRef.nilIfEmpty, base != snapshot.lane.branchRef { + return "from \(normalizedPrBranchName(base))" + } + return nil + } + + // MARK: - Toolbar + + private var actionToolbar: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + TextField("Commit message", text: $commitMessage, axis: .vertical) + .lineLimit(1...3) + .font(.system(.subheadline, design: .monospaced)) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(ADEColor.surfaceBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.5) + ) + .focused($commitFieldFocused) + .disabled(!canRunLiveActions || busyAction != nil) + + gitToolbarButton( + title: amendCommit ? "Amend on" : "Amend", + tint: amendCommit ? ADEColor.warning : ADEColor.textSecondary, + emphasize: amendCommit + ) { + amendCommit.toggle() + } + .disabled(!canRunLiveActions || busyAction != nil) + + gitToolbarButton(title: "Commit", tint: ADEColor.accent, emphasize: true) { + onCommit() + } + .disabled(!canRunLiveActions || busyAction != nil || (!amendCommit && stagedFiles.isEmpty)) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + gitToolbarButton(title: pullMode == "merge" ? "Merge" : "Rebase", tint: ADEColor.textSecondary) { + pullMode = pullMode == "merge" ? "rebase" : "merge" + } + gitToolbarButton(title: "Pull", tint: shouldPull ? ADEColor.warning : ADEColor.textPrimary, emphasize: shouldPull) { + onPull(pullMode) + } + .disabled(!canRunLiveActions || busyAction != nil || !shouldPull) + gitToolbarButton(title: pushTitle, tint: shouldPush ? ADEColor.success : ADEColor.textPrimary, emphasize: shouldPush) { + onPush(false) + } + .disabled(!canRunLiveActions || busyAction != nil || !shouldPush || (syncStatus?.diverged ?? false)) + gitToolbarButton(title: showMoreActions ? "More ▴" : "More ▾", tint: showMoreActions ? ADEColor.accent : ADEColor.textMuted) { + withAnimation(.smooth(duration: 0.2)) { showMoreActions.toggle() } + } + Button(action: onRefresh) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + .frame(width: 34, height: 34) + .background(ADEColor.surfaceBackground.opacity(0.45), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(busyAction != nil) + .accessibilityLabel("Refresh git state") + } + } + + HStack(spacing: 8) { + Button(action: triggerSuggest) { + Label("Suggest message", systemImage: "sparkles") + .font(.caption.weight(.semibold)) + } + .buttonStyle(.borderless) + .disabled(!canRunLiveActions || isGeneratingMessage || aiSetupHint != nil) + Spacer(minLength: 0) + Text(compactSyncSummary(syncStatus)) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } + + if let aiSetupHint { + Text(aiSetupHint) + .font(.caption2) + .foregroundStyle(ADEColor.warning) + } + } + .padding(.vertical, 10) + .padding(.horizontal, 10) + .background(ADEColor.surfaceBackground.opacity(0.35), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.border.opacity(0.16), lineWidth: 0.5) + ) + .padding(.bottom, 10) + } + + private var shouldPull: Bool { + guard let syncStatus else { return true } + return syncStatus.hasUpstream && syncStatus.behind > 0 && !syncStatus.diverged + } + + private var shouldPush: Bool { + guard let syncStatus else { return true } + return !syncStatus.hasUpstream || syncStatus.ahead > 0 + } + + private var pushTitle: String { + syncStatus?.hasUpstream == false ? "Publish" : "Push" + } + + @ViewBuilder + private func gitToolbarButton( + title: String, + tint: Color, + emphasize: Bool = false, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(title.uppercased()) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundStyle(tint) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + (emphasize ? tint.opacity(0.16) : ADEColor.surfaceBackground.opacity(0.45)), + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(tint.opacity(emphasize ? 0.35 : 0.14), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + } + + private func triggerSuggest() { + guard !isGeneratingMessage else { return } + isGeneratingMessage = true + Task { + defer { isGeneratingMessage = false } + do { + let message = try await onGenerateMessage() + commitMessage = message + ADEHaptics.success() + } catch { + let text = error.localizedDescription + if text.localizedCaseInsensitiveContains("not configured") || text.localizedCaseInsensitiveContains("disabled") { + aiSetupHint = "Enable AI commit messages on desktop in Settings." + } else { + ADEHaptics.error() + } + } + } + } + + // MARK: - More actions + + private var moreActionsSection: some View { + VStack(alignment: .leading, spacing: 8) { + moreActionRow("Fetch only", symbol: "arrow.down.circle") { onFetch() } + moreActionRow("Switch branch", symbol: "arrow.triangle.branch") { onSwitchBranch() } + moreActionRow("Stash changes", symbol: "tray.and.arrow.down") { + onStashPush("") + } + moreActionRow("Rebase lane", symbol: "arrow.triangle.branch") { onRebaseLane() } + moreActionRow("Rebase + descendants", symbol: "arrow.triangle.branch") { onRebaseDescendants() } + moreActionRow("Rebase and push", symbol: "arrow.up.and.down.text.horizontal") { onRebaseAndPush() } + moreActionRow("Force push (lease)", symbol: "arrow.up.forward.circle.fill", tint: ADEColor.warning) { + onForcePush() + } + } + .padding(10) + .background(ADEColor.surfaceBackground.opacity(0.28), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .padding(.bottom, 10) + } + + private func moreActionRow(_ title: String, symbol: String, tint: Color = ADEColor.textPrimary, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: symbol) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 20) + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Spacer(minLength: 0) + } + .padding(.vertical, 6) + } + .buttonStyle(.plain) + .disabled(!canRunLiveActions || busyAction != nil) + .opacity(canRunLiveActions ? 1 : 0.55) + } + + // MARK: - Files + + private var filesSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("FILES") + .font(.caption.weight(.bold)) + .tracking(0.7) + .foregroundStyle(ADEColor.textMuted) + let fileCount = stagedFiles.count + unstagedFiles.count + if fileCount > 0 { + Text("\(fileCount)") + .font(.caption2.weight(.bold)) + .foregroundStyle(ADEColor.accent) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ADEColor.accent.opacity(0.14), in: Capsule()) + } + Spacer(minLength: 0) + if canRescueUnstaged { + Button("New lane with changes") { + onCreateLaneFromChanges() + } + .font(.caption2.weight(.bold)) + .foregroundStyle(ADEColor.accent) + } + } + if stagedFiles.isEmpty && unstagedFiles.isEmpty { + Text("No changed files.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .padding(.horizontal, 2) + } else { + if !unstagedFiles.isEmpty { + LaneFileTreeSection( + title: "Unstaged", + subtitle: "\(unstagedFiles.count) file\(unstagedFiles.count == 1 ? "" : "s")", + changes: unstagedFiles, + allowsLiveActions: canRunLiveActions, + allowsDiffInspection: true, + bulkActionTitle: unstagedFiles.count > 1 ? "Stage all" : nil, + bulkActionSymbol: "plus.circle", + bulkActionTint: ADEColor.success, + primaryActionTitle: "Stage", + primaryActionSymbol: "plus.circle", + primaryActionTint: ADEColor.success, + secondaryActionTitle: "Discard", + secondaryActionSymbol: "arrow.uturn.backward", + secondaryActionTint: ADEColor.danger, + extraBulkActions: unstagedFiles.count > 1 ? [ + LaneFileTreeBulkAction(title: "Discard unstaged", symbol: "trash", tint: ADEColor.danger, isDestructive: true, action: onDiscardAllUnstaged) + ] : [], + onBulkAction: unstagedFiles.count > 1 ? onStageAll : nil, + onDiff: { onOpenDiff($0, false) }, + onPrimaryAction: onStageFile, + onSecondaryAction: onDiscardFile, + onOpenFiles: onOpenFiles + ) + } + if !stagedFiles.isEmpty { + LaneFileTreeSection( + title: "Staged", + subtitle: "\(stagedFiles.count) file\(stagedFiles.count == 1 ? "" : "s")", + changes: stagedFiles, + allowsLiveActions: canRunLiveActions, + allowsDiffInspection: true, + bulkActionTitle: stagedFiles.count > 1 ? "Unstage all" : nil, + bulkActionSymbol: "minus.circle", + bulkActionTint: ADEColor.textSecondary, + primaryActionTitle: "Unstage", + primaryActionSymbol: "minus.circle", + primaryActionTint: ADEColor.textSecondary, + secondaryActionTitle: "Restore", + secondaryActionSymbol: "arrow.uturn.backward.circle", + secondaryActionTint: ADEColor.warning, + extraBulkActions: stagedFiles.count > 1 ? [ + LaneFileTreeBulkAction(title: "Restore all staged", symbol: "arrow.uturn.backward.circle.fill", tint: ADEColor.warning, isDestructive: true, action: onRestoreAllStaged) + ] : [], + onBulkAction: stagedFiles.count > 1 ? onUnstageAll : nil, + onDiff: { onOpenDiff($0, true) }, + onPrimaryAction: onUnstageFile, + onSecondaryAction: onRestoreStaged, + onOpenFiles: onOpenFiles + ) + } + } + } + } + + // MARK: - Stashes + + private var stashesSection: some View { + VStack(alignment: .leading, spacing: 10) { + sectionTitle("Branch stashes", badge: detail.stashes.count) + if detail.stashes.isEmpty { + HStack { + Text("None saved") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + Spacer() + Button("Save changes") { + onStashPush(stashMessage) + } + .font(.caption.weight(.semibold)) + .disabled(!canRunLiveActions || busyAction != nil) + } + } else { + ForEach(detail.stashes) { stash in + VStack(alignment: .leading, spacing: 6) { + Text(stash.subject.isEmpty ? stash.ref : stash.subject) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(2) + HStack(spacing: 8) { + LaneActionButton(title: "Apply", symbol: "tray.and.arrow.down") { onStashApply(stash.ref) } + .disabled(!canRunLiveActions || busyAction != nil) + LaneActionButton(title: "Pop", symbol: "tray.and.arrow.up") { onStashPop(stash.ref) } + .disabled(!canRunLiveActions || busyAction != nil) + LaneActionButton(title: "Drop", symbol: "trash", tint: ADEColor.danger) { onStashDrop(stash.ref) } + .disabled(!canRunLiveActions || busyAction != nil) + } + } + .padding(10) + .background(ADEColor.surfaceBackground.opacity(0.28), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } + } + } + + // MARK: - History + + private var historySection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionTitle("History", badge: detail.recentCommits.count) + if detail.recentCommits.isEmpty { + Text("No commits yet.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } else { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(detail.recentCommits.enumerated()), id: \.element.id) { index, commit in + historyRow( + commit: commit, + isHead: index == 0, + isLast: index == detail.recentCommits.count - 1 + ) + } + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(ADEColor.surfaceBackground.opacity(0.28), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + } + } + + private func historyRow(commit: GitCommitSummary, isHead: Bool, isLast: Bool) -> some View { + let isMerge = commit.parents.count > 1 + let dotColor: Color = isHead ? ADEColor.success : (isMerge ? ADEColor.accent : ADEColor.textMuted) + + return HStack(alignment: .top, spacing: 8) { + VStack(spacing: 0) { + Circle() + .strokeBorder(dotColor, lineWidth: isHead || isMerge ? 2 : 1.5) + .background(Circle().fill(isHead ? ADEColor.success : (isMerge ? ADEColor.accent.opacity(0.35) : ADEColor.pageBackground))) + .frame(width: 9, height: 9) + if !isLast { + Rectangle() + .fill(ADEColor.border.opacity(0.45)) + .frame(width: 1) + .frame(maxHeight: .infinity) + .padding(.vertical, 2) + } + } + .frame(width: 10) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(commit.shortSha) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(isHead ? ADEColor.success : ADEColor.textMuted) + if isHead { + historyBadge("HEAD", tint: ADEColor.success) + } + if isMerge { + historyBadge("MERGE", tint: ADEColor.accent) + } + if commit.pushed { + historyBadge("REMOTE", tint: ADEColor.accent) + } + Spacer(minLength: 0) + Text(relativeTimestampCompact(commit.authoredAt)) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + Text(commit.subject) + .font(.caption) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 5) + .contentShape(Rectangle()) + .contextMenu { + Button { + Task { await onOpenCommitDiff(commit) } + } label: { + Label("View files", systemImage: "doc.text.magnifyingglass") + } + Button { + Task { await onCopyCommitMessage(commit) } + } label: { + Label("Copy message", systemImage: "doc.on.doc") + } + .disabled(!canRunLiveActions) + Button { + pendingCommitConfirmation = CommitHistoryConfirmation(kind: .revert, commit: commit) + } label: { + Label("Revert", systemImage: "arrow.uturn.backward") + } + .disabled(!canRunLiveActions) + Button { + pendingCommitConfirmation = CommitHistoryConfirmation(kind: .cherryPick, commit: commit) + } label: { + Label("Cherry-pick", systemImage: "arrow.triangle.merge") + } + .disabled(!canRunLiveActions) + } + } + + private func historyBadge(_ text: String, tint: Color) -> some View { + Text(text) + .font(.system(size: 8, weight: .bold, design: .monospaced)) + .foregroundStyle(tint) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(tint.opacity(0.14), in: Capsule()) + } + + private func sectionTitle(_ title: String, badge: Int) -> some View { + HStack(spacing: 8) { + Text(title.uppercased()) + .font(.caption.weight(.bold)) + .tracking(0.7) + .foregroundStyle(ADEColor.textMuted) + if badge > 0 { + Text("\(badge)") + .font(.caption2.weight(.bold)) + .foregroundStyle(ADEColor.accent) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ADEColor.accent.opacity(0.14), in: Capsule()) + } + Spacer(minLength: 0) + } + } +} + +private struct CommitHistoryConfirmation: Identifiable { + enum Kind { case revert, cherryPick } + let kind: Kind + let commit: GitCommitSummary + var id: String { "\(kind)-\(commit.sha)" } + var title: String { + switch kind { + case .revert: return "Revert this commit?" + case .cherryPick: return "Cherry-pick this commit?" + } + } + var message: String { + switch kind { + case .revert: return "ADE will create a new commit that reverses \(commit.shortSha)." + case .cherryPick: return "ADE will apply \(commit.shortSha) onto the current lane." + } + } + var confirmTitle: String { + switch kind { + case .revert: return "Revert" + case .cherryPick: return "Cherry-pick" + } + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift b/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift index 2b9990d68..f4ff60450 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailGitSection.swift @@ -1,131 +1,6 @@ import SwiftUI -import UIKit extension LaneDetailScreen { - @ViewBuilder - var gitSections: some View { - if let detail { - VStack(spacing: 14) { - if let conflictState = detail.conflictState, conflictState.inProgress { - conflictSection(conflictState: conflictState) - } - - NavigationLink { - LaneStashesScreen( - laneName: detail.lane.name, - stashes: detail.stashes, - canRunLiveActions: canRunLiveActions, - onCreateStash: { message in - await performAction("stash") { - try await syncService.stashPush(laneId: laneId, message: message, includeUntracked: true) - } - return errorMessage == nil - }, - onApply: { ref in - await performAction("stash apply") { try await syncService.stashApply(laneId: laneId, stashRef: ref) } - }, - onPop: { ref in - await performAction("stash pop") { try await syncService.stashPop(laneId: laneId, stashRef: ref) } - }, - onDrop: { ref in - await performAction("stash drop") { try await syncService.stashDrop(laneId: laneId, stashRef: ref) } - }, - onClearAll: { - await performAction("clear stashes") { - for stash in detail.stashes.reversed() { - try await syncService.stashDrop(laneId: laneId, stashRef: stash.ref) - } - } - } - ) - } label: { - summaryRow( - symbol: "tray.2", - title: "Stashes", - detail: detail.stashes.isEmpty ? "No stashes" : "\(detail.stashes.count) stash\(detail.stashes.count == 1 ? "" : "es")" - ) - } - .buttonStyle(.plain) - - NavigationLink { - LaneCommitHistoryScreen( - laneName: detail.lane.name, - commits: detail.recentCommits, - canRunLiveActions: canRunLiveActions, - allowsDiffInspection: { commit in - let cached = cachedCommitDiffFilesBySha[commit.sha] ?? [] - return laneAllowsDiffInspection( - connectionState: syncService.connectionState, - laneStatus: syncService.status(for: .lanes), - hasCachedTargets: !cached.isEmpty - ) - }, - onOpenDiff: { commit in await openCommitDiffs(for: commit) }, - onCopyMessage: { commit in - do { - UIPasteboard.general.string = try await syncService.getCommitMessage(laneId: laneId, commitSha: commit.sha) - } catch { - ADEHaptics.error() - errorMessage = error.localizedDescription - } - }, - onRevert: { commit in - await performAction("revert commit") { try await syncService.revertCommit(laneId: laneId, commitSha: commit.sha) } - }, - onCherryPick: { commit in - await performAction("cherry pick") { try await syncService.cherryPickCommit(laneId: laneId, commitSha: commit.sha) } - } - ) - } label: { - summaryRow( - symbol: "clock.arrow.circlepath", - title: "Recent commits", - detail: detail.recentCommits.isEmpty ? "No commits yet" : "\(detail.recentCommits.count) commit\(detail.recentCommits.count == 1 ? "" : "s")" - ) - } - .buttonStyle(.plain) - - advancedRow(detail: detail) - } - } - } - - // MARK: - Advanced entry - - @ViewBuilder - func advancedRow(detail: LaneDetailPayload) -> some View { - NavigationLink { - LaneAdvancedScreen( - snapshot: currentSnapshot, - canRunLiveActions: canRunLiveActions, - disabledSubtitle: liveActionDisabledSubtitle, - laneId: laneId, - branchRef: detail.lane.branchRef, - laneType: detail.lane.laneType, - onOpenManageSheet: { managePresented = true }, - onSwitchBranch: { showBranchPicker = true }, - onStash: { - Task { - await performAction("stash") { - try await syncService.stashPush(laneId: laneId, message: "", includeUntracked: true) - } - } - }, - onRebaseLane: { requestGitConfirmation(.rebaseLane) }, - onRebaseDescendants: { requestGitConfirmation(.rebaseDescendants) }, - onRebaseAndPush: { requestGitConfirmation(.rebaseAndPush) }, - onForcePush: { requestGitConfirmation(.forcePush) } - ) - } label: { - summaryRow( - symbol: "slider.horizontal.3", - title: "Advanced", - detail: "Settings, branch tools, rebase & push" - ) - } - .buttonStyle(.plain) - } - // MARK: - Rebase banner @ViewBuilder @@ -147,133 +22,7 @@ extension LaneDetailScreen { } } - // MARK: - Commit CTA - - @ViewBuilder - func commitCTAButton(detail: LaneDetailPayload) -> some View { - let stagedCount = detail.diffChanges?.staged.count ?? 0 - let unstagedCount = detail.diffChanges?.unstaged.count ?? 0 - Button { - showCommitSheet = true - } label: { - HStack(spacing: 10) { - Image(systemName: amendCommit ? "arrow.counterclockwise" : "square.and.pencil") - .font(.system(size: 15, weight: .semibold)) - Text(amendCommit ? "Amend last commit" : commitCTALabel(stagedCount: stagedCount, unstagedCount: unstagedCount)) - .font(.subheadline.weight(.semibold)) - Spacer(minLength: 8) - Image(systemName: "chevron.up") - .font(.system(size: 12, weight: .bold)) - .opacity(0.85) - } - .foregroundStyle(ADEColor.textPrimary) - .frame(maxWidth: .infinity) - .padding(EdgeInsets(top: 12, leading: 14, bottom: 12, trailing: 14)) - .background(ADEColor.accent.opacity(0.22), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(ADEColor.accent.opacity(0.45), lineWidth: 0.6) - ) - } - .buttonStyle(.plain) - .disabled(!canRunLiveActions) - .accessibilityHint("Opens the review and commit drawer.") - } - - private func commitCTALabel(stagedCount: Int, unstagedCount: Int) -> String { - if stagedCount > 0 { - return "Commit \(stagedCount) staged file\(stagedCount == 1 ? "" : "s")" - } - if unstagedCount > 0 { - return "Review & commit \(unstagedCount) change\(unstagedCount == 1 ? "" : "s")" - } - return "Commit changes" - } - - // MARK: - Summary row - - @ViewBuilder - func summaryRow(symbol: String, title: String, detail: String) -> some View { - HStack(spacing: 12) { - Image(systemName: symbol) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.textSecondary) - .frame(width: 24) - Text(title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Spacer(minLength: 8) - Text(detail) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - .truncationMode(.tail) - Image(systemName: "chevron.right") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .adeGlassCard(cornerRadius: 14, padding: 0) - } - - // MARK: - Status banner (inline chips, no glass card — sits inside header) - - @ViewBuilder - func gitStatusBanner(detail: LaneDetailPayload) -> some View { - let unstaged = detail.diffChanges?.unstaged.count ?? 0 - let staged = detail.diffChanges?.staged.count ?? 0 - let stashCount = detail.stashes.count - - if unstaged > 0 || staged > 0 || stashCount > 0 { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - if unstaged > 0 { - LaneMicroChip(icon: "doc.badge.plus", text: "\(unstaged) unstaged", tint: ADEColor.warning) - } - if staged > 0 { - LaneMicroChip(icon: "checkmark.circle", text: "\(staged) staged", tint: ADEColor.success) - } - if stashCount > 0 { - LaneMicroChip(icon: "tray.2", text: "\(stashCount) stash\(stashCount == 1 ? "" : "es")", tint: ADEColor.textMuted) - } - } - } - } - } - - @ViewBuilder - private func conflictContinueButton(conflictState: GitConflictState) -> some View { - Button { - Task { await performAction("rebase continue") { try await syncService.rebaseContinueGit(laneId: laneId) } } - } label: { - Label("Continue", systemImage: "play.fill") - .font(.subheadline.weight(.semibold)) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - .buttonStyle(.borderedProminent) - .tint(ADEColor.accent) - .disabled(!canRunLiveActions || !conflictState.canContinue) - } - - @ViewBuilder - private func conflictAbortButton(conflictState: GitConflictState) -> some View { - Button { - Task { await performAction("rebase abort") { try await syncService.rebaseAbortGit(laneId: laneId) } } - } label: { - Label("Abort", systemImage: "xmark.circle") - .font(.subheadline.weight(.semibold)) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - .buttonStyle(.bordered) - .tint(ADEColor.danger) - .disabled(!canRunLiveActions || !conflictState.canAbort) - } - - // MARK: - Conflict section (always visible when active) + // MARK: - Conflict section @ViewBuilder func conflictSection(conflictState: GitConflictState) -> some View { @@ -281,7 +30,7 @@ extension LaneDetailScreen { HStack(spacing: 10) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(ADEColor.danger) - Text("Rebase conflict") + Text(conflictState.kind == "merge" ? "Merge conflict" : "Rebase conflict") .font(.subheadline.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) Spacer() @@ -322,6 +71,52 @@ extension LaneDetailScreen { ) } + @ViewBuilder + private func conflictContinueButton(conflictState: GitConflictState) -> some View { + Button { + Task { + await performAction(conflictState.kind == "merge" ? "merge continue" : "rebase continue") { + if conflictState.kind == "merge" { + try await syncService.mergeContinueGit(laneId: laneId) + } else { + try await syncService.rebaseContinueGit(laneId: laneId) + } + } + } + } label: { + Label("Continue", systemImage: "play.fill") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .tint(ADEColor.accent) + .disabled(!canRunLiveActions || !conflictState.canContinue) + } + + @ViewBuilder + private func conflictAbortButton(conflictState: GitConflictState) -> some View { + Button { + Task { + await performAction(conflictState.kind == "merge" ? "merge abort" : "rebase abort") { + if conflictState.kind == "merge" { + try await syncService.mergeAbortGit(laneId: laneId) + } else { + try await syncService.rebaseAbortGit(laneId: laneId) + } + } + } + } label: { + Label("Abort", systemImage: "xmark.circle") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.bordered) + .tint(ADEColor.danger) + .disabled(!canRunLiveActions || !conflictState.canAbort) + } + @MainActor func openCommitDiffs(for commit: GitCommitSummary) async { do { diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift index 5a6eae3bb..9c284c5fd 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift @@ -1,7 +1,9 @@ import SwiftUI +import UIKit struct LaneDetailScreen: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dismiss) private var dismiss @EnvironmentObject var syncService: SyncService let laneId: String @@ -23,7 +25,6 @@ struct LaneDetailScreen: View { @State var stashMessage = "" @State var pendingFileConfirmation: LaneFileConfirmation? @State private var filesWorkspaceId: String? - @State var showCommitSheet = false @State var rebaseSuggestionDismissed = false @State var showCommitDiffPicker = false @State var commitDiffFiles: [String] = [] @@ -32,6 +33,9 @@ struct LaneDetailScreen: View { @State var cachedCommitDiffFilesBySha: [String: [String]] = [:] @State var pendingGitConfirmation: LaneGitConfirmation? @State private var lastLaneDetailLocalReload = Date.distantPast + @State private var copiedLinkNotice: String? + @State private var showRescueSheet = false + @State private var rescueLaneName = "" init( laneId: String, @@ -64,51 +68,39 @@ struct LaneDetailScreen: View { } var body: some View { - ScrollView { - LazyVStack(spacing: 14) { - if let busyAction { - HStack(spacing: 10) { - ProgressView() - .tint(ADEColor.accent) - Text(busyAction.capitalized) - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - Spacer() + Group { + if let detail { + VStack(spacing: 10) { + detailBannerStack + if let conflictState = detail.conflictState, conflictState.inProgress { + conflictSection(conflictState: conflictState) + .padding(.horizontal, 16) } - .adeGlassCard(cornerRadius: 12, padding: 12) + gitActionsPane(detail: detail) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } - - if let errorMessage { - HStack(spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(ADEColor.danger) - Text(errorMessage) - .font(.footnote) - .foregroundStyle(ADEColor.danger) - Spacer() + .padding(.bottom, 8) + } else { + ScrollView { + VStack(spacing: 12) { + detailBannerStack + if let detailEmptyStatePresentation { + detailEmptyStateCard(detailEmptyStatePresentation) + } } - .adeGlassCard(cornerRadius: 12, padding: 12) - } - - rebaseBannerSection - - detailHeader - - if detail != nil { - gitSections - } else if let detailEmptyStatePresentation { - detailEmptyStateCard(detailEmptyStatePresentation) + .padding(EdgeInsets(top: 4, leading: 16, bottom: 16, trailing: 16)) } + .scrollBounceBehavior(.basedOnSize) } - .padding(EdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 16)) } .adeScreenBackground() - .adeNavigationGlass() - .scrollBounceBehavior(.basedOnSize) - .navigationTitle(detail?.lane.name ?? initialSnapshot.lane.name) + .navigationTitle("") .navigationBarTitleDisplayMode(.inline) - .toolbar { - ADERootToolbarLeadingItems() + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .adeRootTabBarHidden() + .safeAreaInset(edge: .top, spacing: 0) { + laneDetailTopBar } .adeNavigationZoomTransition(id: transitionNamespace == nil ? nil : "lane-container-\(laneId)", in: transitionNamespace) .task { @@ -166,82 +158,6 @@ struct LaneDetailScreen: View { .sheet(isPresented: $showCommitDiffPicker) { commitDiffPickerSheet } - .sheet(isPresented: $showCommitSheet) { - LaneCommitSheet( - commitMessage: $commitMessage, - amendCommit: $amendCommit, - stagedCount: detail?.diffChanges?.staged.count ?? 0, - unstagedCount: detail?.diffChanges?.unstaged.count ?? 0, - canRunLiveActions: canRunLiveActions, - stagedFiles: detail?.diffChanges?.staged ?? [], - unstagedFiles: detail?.diffChanges?.unstaged ?? [], - onGenerateMessage: { - let shouldAmend = amendCommit - return try await syncService.generateCommitMessage(laneId: laneId, amend: shouldAmend) - }, - onCommit: { - Task { - await performAction("commit") { - try await syncService.commitLane(laneId: laneId, message: commitMessage, amend: amendCommit) - } - if errorMessage == nil { - commitMessage = "" - amendCommit = false - showCommitSheet = false - } - } - }, - onDismiss: { - showCommitSheet = false - }, - onStageFile: { file in - Task { await performAction("stage file") { try await syncService.stageFile(laneId: laneId, path: file.path) } } - }, - onUnstageFile: { file in - Task { await performAction("unstage file") { try await syncService.unstageFile(laneId: laneId, path: file.path) } } - }, - onDiscardFile: { file in - Task { await performConfirmedFileAction(.discardUnstaged(file)) } - }, - onRestoreStaged: { file in - Task { await performConfirmedFileAction(.restoreStaged(file)) } - }, - onStageAll: { - let paths = (detail?.diffChanges?.unstaged ?? []).map(\.path) - guard !paths.isEmpty else { return } - Task { await performAction("stage all") { try await syncService.stageAll(laneId: laneId, paths: paths) } } - }, - onUnstageAll: { - let paths = (detail?.diffChanges?.staged ?? []).map(\.path) - guard !paths.isEmpty else { return } - Task { await performAction("unstage all") { try await syncService.unstageAll(laneId: laneId, paths: paths) } } - }, - onDiscardAllUnstaged: { - let files = detail?.diffChanges?.unstaged ?? [] - guard !files.isEmpty else { return } - Task { await performConfirmedFileAction(.discardAllUnstaged(files)) } - }, - onRestoreAllStaged: { - let files = detail?.diffChanges?.staged ?? [] - guard !files.isEmpty else { return } - Task { await performConfirmedFileAction(.restoreAllStaged(files)) } - }, - onOpenDiff: { file, isStaged in - selectedDiffRequest = LaneDiffRequest( - laneId: laneId, - path: file.path, - mode: isStaged ? "staged" : "unstaged", - compareRef: nil, - compareTo: nil, - title: (file.path as NSString).lastPathComponent - ) - }, - onOpenFiles: { file in - Task { await openFiles(path: file.path) } - } - ) - .presentationDetents([.medium, .large]) - } .sheet(isPresented: $showBranchPicker) { if let detail { LaneBranchPickerSheet( @@ -251,61 +167,330 @@ struct LaneDetailScreen: View { ) } } + .sheet(isPresented: $showRescueSheet) { + rescueLaneSheet + } .onDisappear { syncService.releaseLaneOpen(laneId: laneId) } } @ViewBuilder - var detailHeader: some View { - LaneDetailHeaderCard( + private var detailBannerStack: some View { + VStack(spacing: 8) { + if let busyAction { + busyBanner(busyAction) + } + if let errorMessage { + errorBanner(errorMessage) + } + if let copiedLinkNotice { + copiedBanner(copiedLinkNotice) + } + rebaseBannerSection + } + .padding(.horizontal, 16) + .padding(.top, 4) + } + + private var laneDetailTopBar: some View { + HStack(spacing: 0) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(ADEColor.textPrimary) + .frame(width: 36, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel("Back to lanes") + + Spacer(minLength: 0) + + Menu { + Button { + copyLaneLink() + } label: { + Label("Copy ADE lane link", systemImage: "link") + } + Button { + copyBranchLink() + } label: { + Label("Copy branch link", systemImage: "arrow.triangle.branch") + } + Divider() + Button { + managePresented = true + } label: { + Label("Manage lane", systemImage: "slider.horizontal.3") + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(ADEColor.textPrimary) + .frame(width: 36, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel("Lane options") + } + .padding(.horizontal, 10) + .padding(.bottom, 2) + .background { + ADEColor.pageBackground.opacity(0.98) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) + } + } + + @ViewBuilder + private func gitActionsPane(detail: LaneDetailPayload) -> some View { + LaneDetailGitActionsPane( snapshot: currentSnapshot, detail: detail, linkedPullRequests: lanePullRequests, - transitionNamespace: transitionNamespace, - transitionLaneId: laneId, canRunLiveActions: canRunLiveActions, - onStackTapped: { showStackGraph = true }, - onOpenLinkedPullRequest: { pr in openPullRequest(pr) }, - onPush: { - Task { await performAction("push") { try await syncService.pushGit(laneId: laneId) } } + busyAction: busyAction, + commitMessage: $commitMessage, + amendCommit: $amendCommit, + stashMessage: $stashMessage, + onRefresh: { + Task { await loadDetail(refreshRemote: true) } + }, + onCommit: { + Task { + await performAction("commit") { + try await syncService.commitLane(laneId: laneId, message: commitMessage, amend: amendCommit) + } + if errorMessage == nil { + commitMessage = "" + amendCommit = false + } + } }, - onPull: { - Task { await performAction("pull rebase") { try await syncService.syncGit(laneId: laneId, mode: "rebase") } } + onGenerateMessage: { + try await syncService.generateCommitMessage(laneId: laneId, amend: amendCommit) + }, + onPull: { mode in + Task { await performAction("pull \(mode)") { try await syncService.syncGit(laneId: laneId, mode: mode) } } + }, + onPush: { force in + Task { await performAction(force ? "force push" : "push") { try await syncService.pushGit(laneId: laneId, forceWithLease: force) } } }, onFetch: { Task { await performAction("fetch") { try await syncService.fetchGit(laneId: laneId) } } }, - footer: { - if let detail { - // Compute whether there's any actual footer content before - // emitting the divider. `gitStatusBanner` only renders chips when - // there are unstaged/staged/stashed changes; the commit CTA only - // shows when the lane is dirty or has staged files. If neither - // produces content, drawing the divider leaves a stray hairline - // under the section header. - let unstagedCount = detail.diffChanges?.unstaged.count ?? 0 - let stagedCount = detail.diffChanges?.staged.count ?? 0 - let stashCount = detail.stashes.count - let hasStatusBanner = unstagedCount > 0 || stagedCount > 0 || stashCount > 0 - let hasCommitCTA = detail.lane.status.dirty || stagedCount > 0 - if hasStatusBanner || hasCommitCTA { - VStack(spacing: 10) { - Rectangle() - .fill(ADEColor.border.opacity(0.18)) - .frame(height: 0.5) - .padding(.top, 2) - if hasStatusBanner { - gitStatusBanner(detail: detail) - } - if hasCommitCTA { - commitCTAButton(detail: detail) - } - } + onStageFile: { file in + Task { await performAction("stage file") { try await syncService.stageFile(laneId: laneId, path: file.path) } } + }, + onUnstageFile: { file in + Task { await performAction("unstage file") { try await syncService.unstageFile(laneId: laneId, path: file.path) } } + }, + onDiscardFile: { file in + Task { await performConfirmedFileAction(.discardUnstaged(file)) } + }, + onRestoreStaged: { file in + Task { await performConfirmedFileAction(.restoreStaged(file)) } + }, + onStageAll: { + let paths = (detail.diffChanges?.unstaged ?? []).map(\.path) + guard !paths.isEmpty else { return } + Task { await performAction("stage all") { try await syncService.stageAll(laneId: laneId, paths: paths) } } + }, + onUnstageAll: { + let paths = (detail.diffChanges?.staged ?? []).map(\.path) + guard !paths.isEmpty else { return } + Task { await performAction("unstage all") { try await syncService.unstageAll(laneId: laneId, paths: paths) } } + }, + onDiscardAllUnstaged: { + let files = detail.diffChanges?.unstaged ?? [] + guard !files.isEmpty else { return } + Task { await performConfirmedFileAction(.discardAllUnstaged(files)) } + }, + onRestoreAllStaged: { + let files = detail.diffChanges?.staged ?? [] + guard !files.isEmpty else { return } + Task { await performConfirmedFileAction(.restoreAllStaged(files)) } + }, + onOpenDiff: { file, staged in + selectedDiffRequest = LaneDiffRequest( + laneId: laneId, + path: file.path, + mode: staged ? "staged" : "unstaged", + compareRef: nil, + compareTo: nil, + title: (file.path as NSString).lastPathComponent + ) + }, + onOpenFiles: { file in + Task { await openFiles(path: file.path) } + }, + onStashPush: { message in + Task { + await performAction("stash") { + try await syncService.stashPush(laneId: laneId, message: message, includeUntracked: true) } } + }, + onStashApply: { ref in + Task { await performAction("stash apply") { try await syncService.stashApply(laneId: laneId, stashRef: ref) } } + }, + onStashPop: { ref in + Task { await performAction("stash pop") { try await syncService.stashPop(laneId: laneId, stashRef: ref) } } + }, + onStashDrop: { ref in + Task { await performAction("stash drop") { try await syncService.stashDrop(laneId: laneId, stashRef: ref) } } + }, + onOpenCommitDiff: { commit in await openCommitDiffs(for: commit) }, + onCopyCommitMessage: { commit in + do { + UIPasteboard.general.string = try await syncService.getCommitMessage(laneId: laneId, commitSha: commit.sha) + ADEHaptics.success() + } catch { + ADEHaptics.error() + errorMessage = error.localizedDescription + } + }, + onRevertCommit: { commit in + Task { await performAction("revert commit") { try await syncService.revertCommit(laneId: laneId, commitSha: commit.sha) } } + }, + onCherryPickCommit: { commit in + Task { await performAction("cherry pick") { try await syncService.cherryPickCommit(laneId: laneId, commitSha: commit.sha) } } + }, + onSwitchBranch: { showBranchPicker = true }, + onRebaseLane: { requestGitConfirmation(.rebaseLane) }, + onRebaseDescendants: { requestGitConfirmation(.rebaseDescendants) }, + onRebaseAndPush: { requestGitConfirmation(.rebaseAndPush) }, + onForcePush: { requestGitConfirmation(.forcePush) }, + onOpenLinkedPullRequest: { pr in openPullRequest(pr) }, + onCreateLaneFromChanges: { + rescueLaneName = suggestedRescueLaneName + showRescueSheet = true } ) + .padding(.horizontal, 16) + } + + private var suggestedRescueLaneName: String { + let base = currentSnapshot.lane.name + return base.isEmpty ? "Rescue lane" : "\(base) changes" + } + + @ViewBuilder + private var rescueLaneSheet: some View { + NavigationStack { + Form { + Section { + TextField("Lane name", text: $rescueLaneName) + .textInputAutocapitalization(.words) + } footer: { + Text("Moves unstaged changes into a new child lane on this stack.") + .font(.caption) + } + } + .navigationTitle("New lane") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { showRescueSheet = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Create") { + Task { await createLaneFromUnstagedChanges() } + } + .disabled(rescueLaneName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + .presentationDetents([.medium]) + } + + @MainActor + private func createLaneFromUnstagedChanges() async { + let name = rescueLaneName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + await performAction("create rescue lane", refreshRoot: true) { + _ = try await syncService.createFromUnstaged(sourceLaneId: laneId, name: name) + } + if errorMessage == nil { + showRescueSheet = false + rescueLaneName = "" + } + } + + @MainActor + private func copyLaneLink() { + let url = LaneDeeplinkHelpers.laneLink(laneId: laneId) + UIPasteboard.general.string = url + ADEHaptics.success() + copiedLinkNotice = "Copied lane link" + Task { + try? await Task.sleep(for: .seconds(2)) + if copiedLinkNotice == "Copied lane link" { copiedLinkNotice = nil } + } + } + + @MainActor + private func copyBranchLink() { + let branch = normalizedPrBranchName(currentSnapshot.lane.branchRef) + guard !branch.isEmpty else { + copyLaneLink() + return + } + if let pr = lanePullRequests.first { + let url = LaneDeeplinkHelpers.branchLink(repoOwner: pr.repoOwner, repoName: pr.repoName, branch: branch) + UIPasteboard.general.string = url + ADEHaptics.success() + copiedLinkNotice = "Copied branch link" + } else { + UIPasteboard.general.string = LaneDeeplinkHelpers.laneLink(laneId: laneId) + ADEHaptics.success() + copiedLinkNotice = "No GitHub remote — copied lane link instead" + } + Task { + try? await Task.sleep(for: .seconds(2.5)) + if copiedLinkNotice?.hasPrefix("Copied") == true || copiedLinkNotice?.hasPrefix("No GitHub") == true { + copiedLinkNotice = nil + } + } + } + + @ViewBuilder + private func busyBanner(_ label: String) -> some View { + HStack(spacing: 10) { + ProgressView().tint(ADEColor.accent) + Text(label.capitalized) + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + Spacer() + } + .adeGlassCard(cornerRadius: 12, padding: 12) + } + + @ViewBuilder + private func errorBanner(_ message: String) -> some View { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ADEColor.danger) + Text(message) + .font(.footnote) + .foregroundStyle(ADEColor.danger) + Spacer() + } + .adeGlassCard(cornerRadius: 12, padding: 12) + } + + @ViewBuilder + private func copiedBanner(_ message: String) -> some View { + HStack(spacing: 10) { + Image(systemName: "doc.on.doc.fill") + .foregroundStyle(ADEColor.success) + Text(message) + .font(.footnote) + .foregroundStyle(ADEColor.textSecondary) + Spacer() + } + .adeGlassCard(cornerRadius: 12, padding: 12) } @MainActor @@ -322,14 +507,6 @@ struct LaneDetailScreen: View { } } - private var sessions: [TerminalSessionSummary] { - detail?.sessions ?? [] - } - - private var chatSessions: [AgentChatSessionSummary] { - detail?.chatSessions ?? [] - } - var canRunLiveActions: Bool { laneAllowsLiveActions(connectionState: syncService.connectionState, laneStatus: syncService.status(for: .lanes)) } diff --git a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift index bf46006c5..c2c3c7dfe 100644 --- a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift +++ b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift @@ -289,6 +289,62 @@ func lanePullRequestTint(_ state: String) -> Color { } } +func lanePrStateRank(_ state: String) -> Int { + switch state { + case "open", "draft": return 0 + case "merged": return 1 + default: return 2 + } +} + +func lanePrMatchesCurrentBranch(lane: LaneSummary, pr: PullRequestListItem) -> Bool { + guard pr.laneId == lane.id else { return false } + let laneBranch = normalizedPrBranchName(lane.branchRef) + let prHeadBranch = normalizedPrBranchName(pr.headBranch) + guard !laneBranch.isEmpty, !prHeadBranch.isEmpty, laneBranch == prHeadBranch else { return false } + if lane.laneType == "primary" { + let baseBranch = normalizedPrBranchName(lane.baseRef) + if !laneBranch.isEmpty, !baseBranch.isEmpty, laneBranch == baseBranch { return false } + } + return true +} + +func selectLanePrTag(lane: LaneSummary, pullRequests: [PullRequestListItem]) -> PullRequestListItem? { + pullRequests + .filter { lanePrMatchesCurrentBranch(lane: lane, pr: $0) } + .sorted { lhs, rhs in + let byState = lanePrStateRank(lhs.state) - lanePrStateRank(rhs.state) + if byState != 0 { return byState < 0 } + if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } + return lhs.githubPrNumber > rhs.githubPrNumber + } + .first +} + +func lanePrTagByLaneId( + snapshots: [LaneListSnapshot], + pullRequests: [PullRequestListItem] +) -> [String: PullRequestListItem] { + var result: [String: PullRequestListItem] = [:] + for snapshot in snapshots { + if let pr = selectLanePrTag(lane: snapshot.lane, pullRequests: pullRequests) { + result[snapshot.lane.id] = pr + } + } + return result +} + +func formatLanePrBadgeLabel(_ pr: PullRequestListItem) -> String { + let prefix: String + switch pr.state { + case "merged": prefix = "MERGED" + case "closed": prefix = "CLOSED" + case "draft": prefix = "DRAFT" + default: prefix = "PR" + } + return "\(prefix) #\(pr.githubPrNumber)" +} + func runtimeSymbol(_ bucket: String) -> String { switch bucket { case "running": diff --git a/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift b/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift index daf3d0ef9..a6189de33 100644 --- a/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift +++ b/apps/ios/ADE/Views/Lanes/LaneListViewParts.swift @@ -122,6 +122,10 @@ extension LanesTabView { treeSnapshots } + var lanePrTagsByLaneId: [String: PullRequestListItem] { + lanePrTagByLaneId(snapshots: laneSnapshots, pullRequests: pullRequests) + } + @ViewBuilder var laneList: some View { if laneSnapshots.isEmpty { @@ -170,6 +174,7 @@ extension LanesTabView { isPinned: pinnedLaneIds.contains(primarySnapshot.lane.id), isOpen: openLaneIds.contains(primarySnapshot.lane.id), depth: 0, + pullRequest: lanePrTagsByLaneId[primarySnapshot.lane.id], transitionNamespace: transitionNamespace, isSelectedTransitionSource: selectedLaneTransitionId == primarySnapshot.lane.id ) @@ -180,7 +185,10 @@ extension LanesTabView { }) .buttonStyle(ADEScaleButtonStyle()) .contextMenu { laneContextMenu(snapshot: primarySnapshot) } preview: { - LanePeekPreview(snapshot: primarySnapshot) + LanePeekPreview( + snapshot: primarySnapshot, + pullRequest: lanePrTagsByLaneId[primarySnapshot.lane.id] + ) } .swipeActions(edge: .leading, allowsFullSwipe: false) { Button { @@ -199,6 +207,7 @@ extension LanesTabView { pinnedLaneIds: pinnedLaneIds, openLaneIds: openLaneIds, allLaneSnapshots: laneSnapshots, + lanePrTagsByLaneId: lanePrTagsByLaneId, transitionNamespace: transitionNamespace, selectedLaneId: selectedLaneTransitionId, onRefreshRoot: { await reload(refreshRemote: true) }, @@ -384,9 +393,13 @@ extension LanesTabView { try await syncService.refreshLaneSnapshots() } let loadedSnapshots = try await syncService.fetchLaneListSnapshots(includeArchived: true) + let loadedPullRequests = try await syncService.fetchPullRequestListItems() if laneSnapshots != loadedSnapshots { laneSnapshots = loadedSnapshots } + if pullRequests != loadedPullRequests { + pullRequests = loadedPullRequests + } let visibleIds = Set(loadedSnapshots.map(\.lane.id)) let nextOpenLaneIds = openLaneIds.filter { visibleIds.contains($0) } if nextOpenLaneIds != openLaneIds { diff --git a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift index 30f11b725..3797292ee 100644 --- a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift @@ -8,14 +8,14 @@ struct LaneManageSheet: View { let allLaneSnapshots: [LaneListSnapshot] let onComplete: @MainActor () async -> Void + @State private var activeTab: ManageLaneTab = .delete @State private var renameText: String @State private var selectedParentLaneId: String @State private var baseBranchOverride: 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 deleteSelection = LaneDeleteSelection() @State private var deleteForce = false @State private var busyAction: String? @State private var errorMessage: String? @@ -34,7 +34,16 @@ struct LaneManageSheet: View { _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 isPrimary: Bool { snapshot.lane.laneType == "primary" } + + private var availableTabs: [ManageLaneTab] { + var tabs: [ManageLaneTab] = [.delete] + tabs.append(.appearance) + if !isPrimary { tabs.append(.stack) } + tabs.append(.archive) + return tabs } private var descendantIds: Set { @@ -62,9 +71,7 @@ struct LaneManageSheet: View { } } - private var canArchive: Bool { - snapshot.lane.laneType != "primary" - } + private var canArchive: Bool { !isPrimary } private var primaryLaneId: String { allLaneSnapshots.first(where: { $0.lane.laneType == "primary" })?.lane.id ?? "" @@ -74,8 +81,6 @@ struct LaneManageSheet: View { snapshot.lane.parentLaneId ?? primaryLaneId } - /// Branch ref the host will stack onto when the override field is empty — - /// matches desktop placeholder copy and mirrors the lanes.reparent fallback. private var defaultStackBaseBranch: String { reparentCandidates.first(where: { $0.id == selectedParentLaneId })?.branchRef ?? "" } @@ -89,14 +94,6 @@ struct LaneManageSheet: View { } private var reparentBaseChanged: Bool { - // Empty input means "use the selected parent's current branch". That is - // only a real change when the lane's stored base actually diverges from - // the selected parent's effective branch — `snapshot.lane.baseRef` is a - // non-optional string and is essentially always populated, so gating on - // its mere presence enables Apply on initial sheet open and risks an - // unintended rebase. Compare normalized values against the parent's - // default to mirror the backend's normalizeBranchName (strips - // refs/heads/ and origin/ prefixes). let normalizedOverride = LaneManageSheet.normalizeBranchRefForCompare(trimmedBaseOverride) let normalizedExisting = LaneManageSheet.normalizeBranchRefForCompare(snapshot.lane.baseRef) let normalizedDefault = LaneManageSheet.normalizeBranchRefForCompare(defaultStackBaseBranch) @@ -136,6 +133,10 @@ struct LaneManageSheet: View { ) } + private var branchLabel: String { + normalizedPrBranchName(snapshot.lane.branchRef) + } + var body: some View { NavigationStack { ScrollView { @@ -156,254 +157,451 @@ struct LaneManageSheet: View { } if let errorMessage { - HStack(alignment: .top, spacing: 10) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(ADEColor.danger) - Text(errorMessage) - .font(.caption) - .foregroundStyle(ADEColor.danger) - .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 0) - } - .adeGlassCard(cornerRadius: 12, padding: 12) + manageErrorBanner(errorMessage) } - 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(!canRunLiveActions || renameText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || renameText == snapshot.lane.name) - } + laneInfoHeader + + if isPrimary { + Text("Primary lane cannot be archived or deleted.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(ADEColor.surfaceBackground.opacity(0.35), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + appearanceTab + } else { + manageTabBar + tabContent } + } + .padding(16) + .allowsHitTesting(busyAction == nil) + } + .adeScreenBackground() + .overlay { busyOverlay } + .adeNavigationGlass() + .navigationTitle("Manage lane") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { dismiss() } + .disabled(busyAction != nil) + } + } + .onAppear { + if !availableTabs.contains(activeTab) { + activeTab = availableTabs.first ?? .appearance + } + } + } + } - GlassSection(title: "Appearance") { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 6) { - Text("Color") - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textSecondary) - if let name = LaneColorPalette.name(forHex: colorText) { - Text(name) - .font(.caption) - .foregroundStyle(ADEColor.textMuted) - } - LaneColorSwatchPicker( - selectedHex: colorText.isEmpty ? nil : colorText, - usedColors: LaneColorPalette.colorsInUse( - amongLanes: allLaneSnapshots.map(\.lane), - excluding: snapshot.lane.id - ) - ) { next in - colorText = next ?? "" - } - } - 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) - } - } - } - .disabled(!canRunLiveActions) - } + @ViewBuilder + private var tabContent: some View { + switch activeTab { + case .delete: + deleteTab + case .appearance: + appearanceTab + case .stack: + stackTab + case .archive: + archiveTab + } + } + + private var laneInfoHeader: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + WorkLaneLogoMark( + color: laneSurfaceTint(forHex: snapshot.lane.color).text ?? ADEColor.accent, + laneIcon: snapshot.lane.icon, + size: 13 + ) + Text(snapshot.lane.name) + .font(.headline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + if snapshot.lane.status.dirty { + Text("DIRTY") + .font(.caption2.weight(.bold)) + .foregroundStyle(ADEColor.warning) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ADEColor.warning.opacity(0.14), in: Capsule()) + } + } + LabeledContent("Branch") { + Text(branchLabel) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + } + .font(.caption) + LabeledContent("Path") { + Text(snapshot.lane.worktreePath) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .multilineTextAlignment(.trailing) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(ADEColor.surfaceBackground.opacity(0.35), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + private var manageTabBar: some View { + HStack(spacing: 4) { + ForEach(availableTabs) { tab in + Button { + withAnimation(.smooth(duration: 0.2)) { activeTab = tab } + } label: { + HStack(spacing: 4) { + Image(systemName: tab.symbol) + .font(.system(size: 11, weight: .semibold)) + Text(tab.title) + .font(.caption.weight(.semibold)) + .lineLimit(1) } + .foregroundStyle(activeTab == tab ? tabForeground(tab) : ADEColor.textMuted) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(activeTab == tab ? tabBackground(tab) : Color.clear, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + } + } + .padding(4) + .background(ADEColor.surfaceBackground.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } - if snapshot.lane.laneType != "primary" { - GlassSection(title: "Stack position") { - VStack(alignment: .leading, spacing: 12) { - Text("Parent lane is where this lane sits in the stack; the primary lane is the root. Base branch is the ref ADE uses for ahead/behind. Leave it blank to use the parent lane's current branch.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .fixedSize(horizontal: false, vertical: true) - - HStack(alignment: .top, spacing: 10) { - Image(systemName: "exclamationmark.bubble.fill") - .foregroundStyle(ADEColor.warning) - .accessibilityHidden(true) - Text("Runs git rebase. Applying updates ADE then runs git rebase in this lane's worktree onto the resolved base commit. If rebase fails, ADE aborts and restores the previous parent and base.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 0) - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(ADEColor.warning.opacity(0.08)) - ) - - if snapshot.lane.status.dirty { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(ADEColor.warning) - .accessibilityHidden(true) - Text("Commit or stash changes before changing stack position.") - .font(.caption) - .foregroundStyle(ADEColor.warning) - } - } - - if snapshot.lane.status.rebaseInProgress { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(ADEColor.warning) - .accessibilityHidden(true) - Text("Finish or abort the in-progress rebase before changing stack position.") - .font(.caption) - .foregroundStyle(ADEColor.warning) - } - } - - if reparentCandidates.isEmpty { - Text("No valid parent") - .font(.caption) - .foregroundStyle(ADEColor.textMuted) - } else if reparentCandidates.count > 4 { - ScrollView { - reparentCandidateStack - } - .frame(maxHeight: 320) - .scrollBounceBehavior(.basedOnSize) - } else { - reparentCandidateStack - } - - VStack(alignment: .leading, spacing: 4) { - LaneTextField( - defaultStackBaseBranch.isEmpty - ? "Base branch (optional)" - : "Base branch (default: \(defaultStackBaseBranch))", - text: $baseBranchOverride - ) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .accessibilityLabel("Base branch override") - if !defaultStackBaseBranch.isEmpty { - Text("Selected parent is on \(defaultStackBaseBranch) right now; that is used when this is empty.") - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - } - } - - LaneActionButton(title: "Apply stack change", symbol: "arrow.triangle.swap", tint: ADEColor.accent) { - Task { - await performAction("reparent lane") { - try await syncService.reparentLane( - snapshot.lane.id, - newParentLaneId: selectedParentLaneId, - stackBaseBranchRef: trimmedBaseOverride.isEmpty ? nil : trimmedBaseOverride - ) - } - } - } - .disabled(!canApplyReparent) - } - } + private func tabForeground(_ tab: ManageLaneTab) -> Color { + tab == .delete ? ADEColor.danger : ADEColor.accent + } + + private func tabBackground(_ tab: ManageLaneTab) -> Color { + tab == .delete ? ADEColor.danger.opacity(0.16) : ADEColor.accent.opacity(0.16) + } + + private var deleteTab: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Stops lane activity and removes what you pick below. Cannot be undone.") + .font(.caption) + .foregroundStyle(ADEColor.danger.opacity(0.85)) + + if snapshot.lane.status.dirty { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ADEColor.warning) + Text("Uncommitted changes on this lane.") + .font(.caption) + .foregroundStyle(ADEColor.warning) + } + .padding(10) + .background(ADEColor.warning.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + deleteChecklist + + Toggle("Force delete", isOn: $deleteForce) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .tint(ADEColor.danger) + + Button { + Task { await performDelete() } + } label: { + Label("Delete lane", systemImage: "trash") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.borderedProminent) + .tint(ADEColor.danger) + .disabled(!canRunLiveActions || !deleteSelection.hasAny || busyAction != nil) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(ADEColor.danger.opacity(0.06)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(ADEColor.danger.opacity(0.25), lineWidth: 1) + ) + ) + } + + private var deleteChecklist: some View { + VStack(spacing: 0) { + deleteChecklistRow( + title: "Select everything", + subtitle: "Worktree, local & remote branch", + symbol: "checkmark.circle", + isSelected: deleteSelection.allSelected, + isIndeterminate: deleteSelection.hasAny && !deleteSelection.allSelected + ) { + deleteSelection = deleteSelection.allSelected ? .empty : LaneDeleteSelection(worktree: true, localBranch: true, remoteBranch: true) + } + + Divider().opacity(0.2) + + deleteChecklistRow( + title: snapshot.lane.laneType == "attached" ? "Unlink from ADE" : "Worktree", + subtitle: snapshot.lane.laneType == "attached" + ? "Stops ADE managing this lane. Keeps the folder + branch." + : "Removes the working folder and ADE registration.", + symbol: "shippingbox", + isSelected: deleteSelection.worktree + ) { + toggleDeleteTarget(.worktree, !deleteSelection.worktree) + } + + Divider().opacity(0.2) + + deleteChecklistRow( + title: "Local branch", + subtitle: branchLabel, + symbol: "arrow.triangle.branch", + isSelected: deleteSelection.localBranch, + monoSubtitle: true + ) { + toggleDeleteTarget(.localBranch, !deleteSelection.localBranch) + } + + Divider().opacity(0.2) + + deleteChecklistRow( + title: "Remote branch", + subtitle: "origin · \(branchLabel)", + symbol: "cloud", + isSelected: deleteSelection.remoteBranch, + monoSubtitle: true + ) { + toggleDeleteTarget(.remoteBranch, !deleteSelection.remoteBranch) + } + } + .background(ADEColor.surfaceBackground.opacity(0.35), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.border.opacity(0.16), lineWidth: 0.5) + ) + } + + private enum DeleteTarget { + case worktree, localBranch, remoteBranch + } + + private func toggleDeleteTarget(_ key: DeleteTarget, _ next: Bool) { + switch key { + case .worktree: + deleteSelection = next + ? LaneDeleteSelection(worktree: true, localBranch: deleteSelection.localBranch, remoteBranch: deleteSelection.remoteBranch) + : .empty + case .localBranch: + deleteSelection.localBranch = next + if next { deleteSelection.worktree = true } + case .remoteBranch: + deleteSelection.remoteBranch = next + if next { deleteSelection.worktree = true } + } + } + + private func deleteChecklistRow( + title: String, + subtitle: String, + symbol: String, + isSelected: Bool, + isIndeterminate: Bool = false, + monoSubtitle: Bool = false, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: isSelected ? "checkmark.square.fill" : (isIndeterminate ? "minus.square.fill" : "square")) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(isSelected || isIndeterminate ? ADEColor.danger : ADEColor.textMuted) + Image(systemName: symbol) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(isSelected ? ADEColor.danger : ADEColor.textMuted) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text(subtitle) + .font(monoSubtitle ? .system(.caption2, design: .monospaced) : .caption2) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(2) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(isSelected ? ADEColor.danger.opacity(0.08) : Color.clear) + } + .buttonStyle(.plain) + .disabled(!canRunLiveActions || busyAction != nil) + } + + private var appearanceTab: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Lane color in tabs and stack. No git changes.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + + 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(!canRunLiveActions || renameText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || renameText == snapshot.lane.name) + + VStack(alignment: .leading, spacing: 6) { + Text("Color") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + if let name = LaneColorPalette.name(forHex: colorText) { + Text(name) + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + } + LaneColorSwatchPicker( + selectedHex: colorText.isEmpty ? nil : colorText, + usedColors: LaneColorPalette.colorsInUse( + amongLanes: allLaneSnapshots.map(\.lane), + excluding: snapshot.lane.id + ) + ) { next in + colorText = next ?? "" + } + } + + 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) } + } + } + .disabled(!canRunLiveActions) + } + .padding(14) + .background(ADEColor.accent.opacity(0.06), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(ADEColor.accent.opacity(0.22), lineWidth: 1) + ) + } - 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(!canRunLiveActions || !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) } } - } - .disabled(!canRunLiveActions) - } + @ViewBuilder + private var stackTab: some View { + if isPrimary { + EmptyView() + } else { + VStack(alignment: .leading, spacing: 12) { + Text("Parent lane is where this lane sits in the stack. Base branch is the ref ADE uses for ahead/behind. Leave it blank to use the parent lane's current branch.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + + Text("Runs git rebase. If rebase fails, ADE aborts and restores the previous parent and base.") + .font(.caption) + .foregroundStyle(ADEColor.warning) + .padding(10) + .background(ADEColor.warning.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + + if snapshot.lane.status.dirty { + Text("Commit or stash changes before changing stack position.") + .font(.caption) + .foregroundStyle(ADEColor.warning) + } + + if snapshot.lane.status.rebaseInProgress { + Text("Finish or abort the in-progress rebase before changing stack position.") + .font(.caption) + .foregroundStyle(ADEColor.warning) + } + + if reparentCandidates.isEmpty { + Text("No valid parent") + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + } else if reparentCandidates.count > 4 { + ScrollView { + reparentCandidateStack } + .frame(maxHeight: 280) + } else { + reparentCandidateStack + } - if snapshot.lane.laneType != "primary" { - GlassSection(title: "Danger zone") { - VStack(alignment: .leading, spacing: 12) { - LazyVStack(spacing: 8) { - ForEach(LaneDeleteMode.allCases) { mode in - LaneOptionButton( - title: mode.title, - subtitle: mode.detail, - systemImage: mode.symbol, - isSelected: deleteMode == mode, - tint: ADEColor.danger - ) { - deleteMode = mode - } - } - } - - if deleteMode == .remoteBranch { - LaneTextField("Remote name", text: $deleteRemoteName) - } - - Toggle("Force delete", isOn: $deleteForce) - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - - Text("Hold to confirm deletion of \(snapshot.lane.name).") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - - LaneHoldToConfirmButton(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(!canRunLiveActions) - } + LaneTextField( + defaultStackBaseBranch.isEmpty + ? "Base branch (optional)" + : "Base branch (default: \(defaultStackBaseBranch))", + text: $baseBranchOverride + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + LaneActionButton(title: "Apply stack change", symbol: "arrow.triangle.swap", tint: ADEColor.accent) { + Task { + await performAction("reparent lane") { + try await syncService.reparentLane( + snapshot.lane.id, + newParentLaneId: selectedParentLaneId, + stackBaseBranchRef: trimmedBaseOverride.isEmpty ? nil : trimmedBaseOverride + ) } - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(ADEColor.danger.opacity(0.4), lineWidth: 1) - .allowsHitTesting(false) - ) } } - .padding(16) - .allowsHitTesting(busyAction == nil) + .disabled(!canApplyReparent) } - .adeScreenBackground() - .overlay { - if busyAction != nil { - ZStack { - ADEColor.pageBackground.opacity(0.55) - .ignoresSafeArea() - VStack(spacing: 10) { - ProgressView() - .tint(ADEColor.accent) - Text(busyAction?.capitalized ?? "Working...") - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - } - .adeGlassCard(cornerRadius: 14, padding: 18) - .fixedSize() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(14) + .background(Color.purple.opacity(0.06), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.purple.opacity(0.22), lineWidth: 1) + ) + } + } + + private var archiveTab: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "archivebox") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(ADEColor.accent) + VStack(alignment: .leading, spacing: 4) { + Text("Hide this lane from ADE") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("Files stay on disk until you delete them.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) } } - .adeNavigationGlass() - .navigationTitle("Manage lane") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Close") { dismiss() } - .disabled(busyAction != nil) + + 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(!canRunLiveActions || !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) } } + } + .disabled(!canRunLiveActions) } } + .padding(14) + .background(ADEColor.surfaceBackground.opacity(0.35), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(ADEColor.border.opacity(0.16), lineWidth: 0.5) + ) } private var reparentCandidateStack: some View { @@ -416,14 +614,55 @@ struct LaneManageSheet: View { isSelected: selectedParentLaneId == lane.id ) { selectedParentLaneId = lane.id - // Clear the override so the new parent's branch is used as - // the default — matches desktop behavior on parent change. baseBranchOverride = "" } } } } + @ViewBuilder + private var busyOverlay: some View { + if busyAction != nil { + ZStack { + ADEColor.pageBackground.opacity(0.55).ignoresSafeArea() + VStack(spacing: 10) { + ProgressView().tint(ADEColor.accent) + Text(busyAction?.capitalized ?? "Working...") + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + } + .adeGlassCard(cornerRadius: 14, padding: 18) + .fixedSize() + } + } + } + + private func manageErrorBanner(_ message: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ADEColor.danger) + Text(message) + .font(.caption) + .foregroundStyle(ADEColor.danger) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .adeGlassCard(cornerRadius: 12, padding: 12) + } + + @MainActor + private func performDelete() async { + guard deleteSelection.hasAny else { return } + await performAction("delete lane") { + try await syncService.deleteLane( + snapshot.lane.id, + deleteBranch: deleteSelection.localBranch, + deleteRemoteBranch: deleteSelection.remoteBranch, + force: deleteForce + ) + } + } + @MainActor private func performAction(_ label: String, operation: () async throws -> Void) async { guard canRunLiveActions else { diff --git a/apps/ios/ADE/Views/Lanes/LaneStashesScreen.swift b/apps/ios/ADE/Views/Lanes/LaneStashesScreen.swift deleted file mode 100644 index 398f33f8d..000000000 --- a/apps/ios/ADE/Views/Lanes/LaneStashesScreen.swift +++ /dev/null @@ -1,117 +0,0 @@ -import SwiftUI - -struct LaneStashesScreen: View { - let laneName: String - let stashes: [GitStashSummary] - let canRunLiveActions: Bool - let onCreateStash: (String) async -> Bool - let onApply: (String) async -> Void - let onPop: (String) async -> Void - let onDrop: (String) async -> Void - let onClearAll: () async -> Void - - @State private var stashMessage = "" - - var body: some View { - ScrollView { - VStack(spacing: 14) { - composerCard - if stashes.count > 1 { - ADEGlassHoldActionButton(title: "Clear all stashes", symbol: "trash", tint: ADEColor.danger) { - Task { await onClearAll() } - } - .disabled(!canRunLiveActions) - .frame(maxWidth: .infinity, alignment: .center) - } - if stashes.isEmpty { - emptyState - } else { - ADEGlassSection(title: "Stashes", subtitle: "\(stashes.count) stash\(stashes.count == 1 ? "" : "es")") { - VStack(alignment: .leading, spacing: 12) { - ForEach(Array(stashes.enumerated()), id: \.element.id) { index, stash in - stashRow(stash: stash) - if index < stashes.count - 1 { - Divider().opacity(0.35) - } - } - } - } - } - } - .padding(EdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 16)) - } - .background(ADEColor.surfaceBackground.ignoresSafeArea()) - .navigationTitle("\(laneName) stashes") - .navigationBarTitleDisplayMode(.inline) - } - - private var composerCard: some View { - ADEGlassSection(title: "New stash", subtitle: "Move unsaved changes aside without losing them.") { - VStack(spacing: 10) { - TextField("Optional message", text: $stashMessage) - .textFieldStyle(.plain) - .frame(maxWidth: .infinity, alignment: .leading) - .adeInsetField(cornerRadius: 10, padding: 10) - .disabled(!canRunLiveActions) - Button { - let msg = stashMessage - Task { - if await onCreateStash(msg) { - stashMessage = "" - } - } - } label: { - HStack(spacing: 6) { - Image(systemName: "tray.and.arrow.down") - .font(.system(size: 13, weight: .semibold)) - Text("Stash") - .font(.subheadline.weight(.semibold)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 11) - } - .buttonStyle(.glassProminent) - .tint(ADEColor.accent) - .disabled(!canRunLiveActions) - } - } - } - - private var emptyState: some View { - ADEEmptyStateView( - symbol: "tray", - title: "No stashes", - message: "Stashes you create will appear here." - ) - } - - private func stashRow(stash: GitStashSummary) -> some View { - 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: 10) { - LaneActionButton(title: "Apply", symbol: "tray.and.arrow.up") { - Task { await onApply(stash.ref) } - } - .disabled(!canRunLiveActions) - LaneActionButton(title: "Pop", symbol: "arrow.up.right.square") { - Task { await onPop(stash.ref) } - } - .disabled(!canRunLiveActions) - LaneHoldToConfirmButton(title: "Delete", symbol: "trash", tint: ADEColor.danger) { - Task { await onDrop(stash.ref) } - } - .disabled(!canRunLiveActions) - } - } - } -} diff --git a/apps/ios/ADE/Views/Lanes/LaneTreeView.swift b/apps/ios/ADE/Views/Lanes/LaneTreeView.swift index a07e01cd9..4e7d550a0 100644 --- a/apps/ios/ADE/Views/Lanes/LaneTreeView.swift +++ b/apps/ios/ADE/Views/Lanes/LaneTreeView.swift @@ -1,7 +1,7 @@ import SwiftUI private enum LaneTreeMetrics { - static let indent: CGFloat = 18 + static let indent: CGFloat = 20 static let elbowWidth: CGFloat = 14 static let elbowHeight: CGFloat = 22 static let rowSpacing: CGFloat = 8 @@ -26,6 +26,7 @@ struct LaneTreeView: View { let pinnedLaneIds: Set let openLaneIds: [String] let allLaneSnapshots: [LaneListSnapshot] + let lanePrTagsByLaneId: [String: PullRequestListItem] let transitionNamespace: Namespace.ID? let selectedLaneId: String? let onRefreshRoot: () async -> Void @@ -76,6 +77,7 @@ struct LaneTreeView: View { snapshot: snapshot, depth: depths[snapshot.lane.id] ?? 0, allLaneSnapshots: allLaneSnapshots, + pullRequest: lanePrTagsByLaneId[snapshot.lane.id], isPinned: pinnedLaneIds.contains(snapshot.lane.id), isOpen: openLaneIds.contains(snapshot.lane.id), transitionNamespace: transitionNamespace, @@ -94,6 +96,7 @@ struct LaneTreeRow: View { let snapshot: LaneListSnapshot let depth: Int let allLaneSnapshots: [LaneListSnapshot] + let pullRequest: PullRequestListItem? let isPinned: Bool let isOpen: Bool let transitionNamespace: Namespace.ID? @@ -132,6 +135,7 @@ struct LaneTreeRow: View { isPinned: isPinned, isOpen: isOpen, depth: depth, + pullRequest: pullRequest, transitionNamespace: transitionNamespace, isSelectedTransitionSource: isSelectedTransitionSource ) @@ -144,7 +148,7 @@ struct LaneTreeRow: View { .contextMenu { onContextMenu(snapshot) } preview: { - LanePeekPreview(snapshot: snapshot) + LanePeekPreview(snapshot: snapshot, pullRequest: pullRequest) } .swipeActions(edge: .leading, allowsFullSwipe: false) { Button { @@ -161,24 +165,31 @@ struct LaneTreeRow: View { struct LanePeekPreview: View { let snapshot: LaneListSnapshot + var pullRequest: PullRequestListItem? = nil var body: some View { - let laneAccent = LaneColorPalette.color(forHex: snapshot.lane.color) + let laneTint = laneSurfaceTint(forHex: snapshot.lane.color) + let laneAccent = laneTint.text ?? ADEColor.textPrimary VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline, spacing: 8) { - LaneStatusIndicator(bucket: snapshot.runtime.bucket, size: 9) - if let laneAccent { - Circle().fill(laneAccent).frame(width: 7, height: 7) - } + WorkLaneLogoMark(color: laneAccent, laneIcon: snapshot.lane.icon, size: 12) Text(snapshot.lane.name) .font(.headline) - .foregroundStyle(laneAccent ?? ADEColor.textPrimary) + .foregroundStyle(laneAccent) + if let pullRequest { + LanePrTagChip(pullRequest: pullRequest) + } Spacer(minLength: 0) } - Text(snapshot.lane.branchRef) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) + HStack(spacing: 5) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 10, weight: .regular)) + .foregroundStyle(ADEColor.textMuted.opacity(0.7)) + Text(normalizedPrBranchName(snapshot.lane.branchRef)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } Divider().opacity(0.2) @@ -200,22 +211,13 @@ struct LanePeekPreview: View { } Spacer(minLength: 0) } - - if snapshot.runtime.sessionCount > 0 { - Label("\(snapshot.runtime.sessionCount) running session\(snapshot.runtime.sessionCount == 1 ? "" : "s")", systemImage: "waveform.path.ecg") - .font(.caption) - .foregroundStyle(ADEColor.success) - } - - if let activity = laneActivitySummary(snapshot) { - Text(activity) - .font(.caption) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(3) - } } .padding(16) .frame(width: 280) - .background(ADEColor.surfaceBackground) + .background(laneTint.background) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(laneTint.border, lineWidth: 0.75) + ) } } diff --git a/apps/ios/ADE/Views/Lanes/LaneTypes.swift b/apps/ios/ADE/Views/Lanes/LaneTypes.swift index 7f0e6ef16..134fe275f 100644 --- a/apps/ios/ADE/Views/Lanes/LaneTypes.swift +++ b/apps/ios/ADE/Views/Lanes/LaneTypes.swift @@ -219,6 +219,49 @@ enum LaneCreateMode: String, CaseIterable, Identifiable { } } +struct LaneDeleteSelection: Equatable { + var worktree = false + var localBranch = false + var remoteBranch = false + + static let empty = LaneDeleteSelection() + + var hasAny: Bool { + worktree || localBranch || remoteBranch + } + + var allSelected: Bool { + worktree && localBranch && remoteBranch + } +} + +enum ManageLaneTab: String, CaseIterable, Identifiable { + case delete + case appearance + case stack + case archive + + var id: String { rawValue } + + var title: String { + switch self { + case .delete: return "Delete" + case .appearance: return "Appearance" + case .stack: return "Restack" + case .archive: return "Archive" + } + } + + var symbol: String { + switch self { + case .delete: return "trash" + case .appearance: return "paintbrush" + case .stack: return "arrow.triangle.branch" + case .archive: return "archivebox" + } + } +} + enum LaneDeleteMode: String, CaseIterable, Identifiable { case worktree case localBranch = "local_branch" diff --git a/apps/ios/ADE/Views/LanesTabView.swift b/apps/ios/ADE/Views/LanesTabView.swift index 27c3bb8a6..f0a5bcbd5 100644 --- a/apps/ios/ADE/Views/LanesTabView.swift +++ b/apps/ios/ADE/Views/LanesTabView.swift @@ -7,6 +7,7 @@ struct LanesTabView: View { var isActive = true @State var laneSnapshots: [LaneListSnapshot] = [] + @State var pullRequests: [PullRequestListItem] = [] @State var errorMessage: String? @State var searchText = "" @State var scope: LaneListScope = .active diff --git a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift index aa8d31204..925eca0f7 100644 --- a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift +++ b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift @@ -46,16 +46,22 @@ struct CreatePrWizardView: View { let onCreateQueue: (CreateQueuePrsRequest) async -> Bool /// Integration PR submit (caller runs simulateIntegration → commitIntegration). let onCreateIntegration: (CreateIntegrationRequest) async -> Bool + let initialLaneId: String? + let singleModeOnly: Bool init( lanes: [LaneSummary], createCapabilities: PrCreateCapabilities? = nil, + initialLaneId: String? = nil, + singleModeOnly: Bool = false, onCreateSingle: @escaping (String, String, String, Bool, String, [String], [String], String?) async -> Bool, onCreateQueue: @escaping (CreateQueuePrsRequest) async -> Bool, onCreateIntegration: @escaping (CreateIntegrationRequest) async -> Bool ) { self.lanes = lanes self.createCapabilities = createCapabilities + self.initialLaneId = initialLaneId + self.singleModeOnly = singleModeOnly self.onCreateSingle = onCreateSingle self.onCreateQueue = onCreateQueue self.onCreateIntegration = onCreateIntegration @@ -84,11 +90,7 @@ struct CreatePrWizardView: View { // Cached eligible lane options — recomputed only when the source-of-truth // (capabilities / lanes) shifts, not on every keystroke. @State private var cachedLaneOptions: [CreatePrLaneOption] = [] - @State private var cachedBlockedLaneOptions: [PrCreateLaneEligibility] = [] @State private var didCacheLaneOptions = false - @State private var showAllBlockedLanes = false - - private static let collapsedBlockedLaneLimit = 3 private var fallbackCreateLanes: [LaneSummary] { lanes.filter { $0.archivedAt == nil && $0.laneType != "primary" } @@ -125,37 +127,26 @@ struct CreatePrWizardView: View { } } - private var blockedLaneOptions: [PrCreateLaneEligibility] { - didCacheLaneOptions ? cachedBlockedLaneOptions : sourceBlockedLaneOptions - } - - private var sourceBlockedLaneOptions: [PrCreateLaneEligibility] { - guard let capabilities = createCapabilities else { return [] } - return capabilities.lanes.filter { !Self.canOpenPr(from: $0) } - } - - private var visibleBlockedLaneOptions: [PrCreateLaneEligibility] { - guard !showAllBlockedLanes else { return blockedLaneOptions } - return Array(blockedLaneOptions.prefix(Self.collapsedBlockedLaneLimit)) - } - - private var canToggleBlockedLanes: Bool { - blockedLaneOptions.count > Self.collapsedBlockedLaneLimit - } - - private var blockedLaneToggleTitle: String { - showAllBlockedLanes - ? "Show fewer" - : "Show \(blockedLaneOptions.count - Self.collapsedBlockedLaneLimit) more" - } - private var selectedOption: CreatePrLaneOption? { - eligibleLaneOptions.first(where: { $0.id == selectedLaneId }) ?? eligibleLaneOptions.first + guard !selectedLaneId.isEmpty else { return nil } + if let match = eligibleLaneOptions.first(where: { $0.id == selectedLaneId }) { + return match + } + guard let lane = selectedLane else { return nil } + let eligibility = eligibility(for: lane.id) + return CreatePrLaneOption( + id: lane.id, + title: lane.name, + branchRef: lane.branchRef, + defaultBaseBranch: eligibility?.defaultBaseBranch ?? lane.baseRef, + defaultTitle: eligibility?.defaultTitle ?? lane.name, + subtitle: eligibility.map { Self.laneProgressSubtitle(for: $0) } ?? nil + ) } private var selectedLane: LaneSummary? { - guard let id = selectedOption?.id else { return nil } - return lanes.first(where: { $0.id == id }) + guard !selectedLaneId.isEmpty else { return nil } + return lanes.first(where: { $0.id == selectedLaneId }) } /// Integration branches derived from other lanes of type "integration". @@ -185,21 +176,18 @@ struct CreatePrWizardView: View { ?? "main" } - private var availableTargets: [TargetOption] { - var targets: [TargetOption] = [] - targets.append( - TargetOption( + private var branchTargetOptions: [PrBranchTargetOption] { + var targets = [ + PrBranchTargetOption( id: defaultTargetBranch, - icon: "∙", label: defaultTargetBranch, subtitle: "origin · default branch" ) - ) + ] for integration in integrationTargets { targets.append( - TargetOption( + PrBranchTargetOption( id: integration.id, - icon: "↯", label: integration.branchRef, subtitle: integration.subtitle ) @@ -208,6 +196,32 @@ struct CreatePrWizardView: View { return targets } + private func eligibility(for laneId: String) -> PrCreateLaneEligibility? { + createCapabilities?.lanes.first(where: { $0.laneId == laneId }) + } + + private func laneEligibilitySubtitle(for lane: LaneSummary) -> String? { + guard let eligibility = eligibility(for: lane.id) else { return nil } + return Self.laneProgressSubtitle(for: eligibility) + } + + private func isSourceLaneDisabled(_ lane: LaneSummary) -> Bool { + guard let eligibility = eligibility(for: lane.id) else { return false } + return !Self.canOpenPr(from: eligibility) + } + + private var selectedLaneBlockedReason: String? { + guard let lane = selectedLane, let eligibility = eligibility(for: lane.id) else { return nil } + guard !Self.canOpenPr(from: eligibility) else { return nil } + return Self.blockedCreateReason(for: eligibility) + } + + private var selectedLaneCanCreate: Bool { + guard let lane = selectedLane else { return false } + guard let eligibility = eligibility(for: lane.id) else { return true } + return Self.canOpenPr(from: eligibility) + } + private static func laneProgressSubtitle(for eligibility: PrCreateLaneEligibility) -> String? { guard let ahead = eligibility.commitsAheadOfBase else { return eligibility.dirty ? "Uncommitted edits present" : nil @@ -244,7 +258,7 @@ struct CreatePrWizardView: View { if isSubmitting { return false } switch createMode { case .single: - guard selectedOption != nil else { return false } + guard selectedLane != nil, selectedLaneCanCreate else { return false } let hasTitle = !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let hasBase = !baseBranch.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty return hasTitle && hasBase @@ -257,125 +271,15 @@ struct CreatePrWizardView: View { } } - private var branchRefForHeader: String { - let raw = selectedOption?.branchRef ?? selectedLane?.branchRef ?? "lane" - return abbreviateBranchRef(raw).uppercased() - } - - /// Long refs like `cursor/-bc-1763e942-e33d-49c1-9cb6-fa4101a980d4-aafb` - /// dominate the wizard hero subtitle. Keep the prefix + last segment so - /// the ref still looks real without wrapping across three lines. - private func abbreviateBranchRef(_ ref: String) -> String { - let trimmed = ref.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count > 28 else { return trimmed } - if let slash = trimmed.firstIndex(of: "/") { - let prefix = trimmed[.. WizardStep.mode.rawValue) - stepPill(label: "Source", step: .source) - stepConnector(filled: currentStep.rawValue > WizardStep.source.rawValue) - stepPill(label: "Details", step: .details) - stepConnector(filled: currentStep.rawValue > WizardStep.details.rawValue) - stepPill(label: "Review", step: .review) - } - .padding(.horizontal, 16) - } - .padding(.bottom, 10) - } - - @ViewBuilder - private func stepPill(label: String, step: WizardStep) -> some View { - let isActive = step == currentStep - let isComplete = step.rawValue < currentStep.rawValue - HStack(spacing: 5) { - if isComplete { - Image(systemName: "checkmark") - .font(.system(size: 9, weight: .heavy)) - .foregroundStyle(PrGlassPalette.success) - } else { - Circle() - .fill(isActive ? Color.white : Color.white.opacity(0.3)) - .frame(width: 6, height: 6) - } - Text(label) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(isActive ? .white : (isComplete ? PrGlassPalette.success : ADEColor.textMuted)) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - ZStack { - if isActive { - Capsule().fill(PrGlassPalette.accentGradient) - } else if isComplete { - Capsule().fill(PrGlassPalette.success.opacity(0.14)) - } else { - Capsule().fill(Color.white.opacity(0.04)) - } - } - ) - .overlay( - Capsule() - .strokeBorder( - isActive ? Color.white.opacity(0.28) : (isComplete ? PrGlassPalette.success.opacity(0.35) : Color.white.opacity(0.10)), - lineWidth: 0.5 - ) - ) - .overlay( - Capsule() - .inset(by: 1) - .stroke(Color.white.opacity(isActive ? 0.22 : 0), lineWidth: 0.5) - .blendMode(.plusLighter) - ) - .shadow(color: isActive ? PrGlassPalette.purpleDeep.opacity(0.55) : .clear, radius: 12, y: 3) - } - - private func stepConnector(filled: Bool) -> some View { - Rectangle() - .fill(filled ? PrGlassPalette.success.opacity(0.55) : Color.white.opacity(0.08)) - .frame(height: 1) - .frame(maxWidth: 20) - .shadow(color: filled ? PrGlassPalette.success.opacity(0.45) : .clear, radius: 4) - } - var body: some View { NavigationStack { ScrollView { VStack(spacing: 0) { - heroHeader if let errorMessage, !syncService.connectionState.isHostUnreachable { ADENoticeCard( title: "Create PR failed", @@ -386,21 +290,24 @@ struct CreatePrWizardView: View { action: nil ) .padding(.horizontal, 16) + .padding(.top, 8) .padding(.bottom, 12) } - modeSelectorSection + if !singleModeOnly { + modeSelectorSection + } switch createMode { case .single: - laneSection + branchesSection aiTitleSection strategySection - targetSection stanceSection reviewersSection labelsSection finalReviewSection case .queue: multiLaneSection(mode: .queue) + targetBranchSection(title: "Target branch") queueSettingsSection stanceSection reviewersSection @@ -408,9 +315,9 @@ struct CreatePrWizardView: View { queueReviewSection case .integration: multiLaneSection(mode: .integration) + targetBranchSection(title: "Target branch") integrationSettingsSection aiTitleSection - targetSection stanceSection reviewersSection labelsSection @@ -434,14 +341,24 @@ struct CreatePrWizardView: View { } } .onAppear { + if singleModeOnly { + createMode = .single + } refreshCachedLaneOptions() if selectedLaneId.isEmpty { - selectedLaneId = selectedOption?.id ?? "" + if let initialLaneId, + fallbackCreateLanes.contains(where: { $0.id == initialLaneId }) { + selectedLaneId = initialLaneId + } else if let firstEligible = eligibleLaneOptions.first { + selectedLaneId = firstEligible.id + } else if let firstSource = fallbackCreateLanes.first { + selectedLaneId = firstSource.id + } } if baseBranch.isEmpty { baseBranch = defaultTargetBranch } - if !draftLoadedOnce, selectedOption != nil { + if !draftLoadedOnce, selectedLane != nil, selectedLaneCanCreate { draftLoadedOnce = true Task { await generateDraft(initial: true) } } @@ -455,7 +372,7 @@ struct CreatePrWizardView: View { labelsInput = "" reviewersInput = "" errorMessage = nil - if selectedOption != nil { + if selectedLaneCanCreate { Task { await generateDraft(initial: false) } } } @@ -486,104 +403,121 @@ struct CreatePrWizardView: View { } } - // MARK: - Hero chrome + // MARK: - Branches (source + target) - private var heroHeader: some View { - VStack(alignment: .leading, spacing: 6) { - PrEyebrow(text: "NEW PR · \(branchRefForHeader)") - Text("Open pull request") - .font(.system(size: 28, weight: .heavy, design: .default)) - .tracking(-0.7) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(2) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 22) - .padding(.top, 4) - .padding(.bottom, 14) - } + private var branchesSection: some View { + VStack(spacing: 0) { + PrSectionHdr(title: "Branches") + VStack(alignment: .leading, spacing: 14) { + branchPickerField(label: "Source branch") { + if fallbackCreateLanes.isEmpty { + Text("No lanes are available to open a PR.") + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + } else { + WorkLanePickerDropdown( + lanes: fallbackCreateLanes, + selectedLaneId: $selectedLaneId, + showsAutoCreateOption: false, + laneSubtitle: laneEligibilitySubtitle(for:), + isLaneDisabled: isSourceLaneDisabled(_:) + ) + } + } - // MARK: - Lane picker (only shown when multiple lanes are eligible) + branchPickerField(label: "Target branch") { + PrTargetBranchPickerDropdown( + targets: branchTargetOptions, + selectedBranch: $baseBranch + ) + } - @ViewBuilder - private var laneSection: some View { - if eligibleLaneOptions.count > 1 { - PrSectionHdr(title: "Lane") - VStack(spacing: 0) { - ForEach(Array(eligibleLaneOptions.enumerated()), id: \.element.id) { index, option in - if index > 0 { PrRowSeparator() } - Button { - selectedLaneId = option.id - } label: { - LaneRow(option: option, selected: option.id == selectedOption?.id) - } - .buttonStyle(.plain) + if let lane = selectedLane { + comparisonStats(for: lane) + } + + if let blockedReason = selectedLaneBlockedReason { + PrWarnBanner(text: blockedReason, tint: ADEColor.warning) } } + .padding(14) .wizardCard() .padding(.horizontal, 16) .padding(.bottom, 8) + } + } - if !blockedLaneOptions.isEmpty { - blockedLanesNotice - } - } else if eligibleLaneOptions.isEmpty { - PrSectionHdr(title: "Lane") - Text("No lanes are eligible to open a PR right now.") - .font(.subheadline) + private func branchPickerField(label: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(label.uppercased()) + .font(.system(size: 10, weight: .bold)) + .tracking(1.0) .foregroundStyle(ADEColor.textSecondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(14) - .wizardCard() - .padding(.horizontal, 16) - .padding(.bottom, 8) + content() } } - private var blockedLanesNotice: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Image(systemName: "lock.fill") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.warning) - Text("Not eligible (\(blockedLaneOptions.count))") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.textSecondary) - } - ForEach(visibleBlockedLaneOptions) { entry in - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(entry.laneName) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - if let reason = Self.blockedCreateReason(for: entry), !reason.isEmpty { - Text(reason) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(2) - } - Spacer(minLength: 0) - } + private func comparisonStats(for lane: LaneSummary) -> some View { + let eligibility = eligibility(for: lane.id) + let ahead = eligibility?.commitsAheadOfBase ?? lane.status.ahead + let behind = lane.status.behind + let dirty = eligibility?.dirty ?? lane.status.dirty + + return VStack(alignment: .leading, spacing: 8) { + Text("COMPARISON") + .font(.system(size: 10, weight: .bold)) + .tracking(1.0) + .foregroundStyle(ADEColor.textSecondary) + HStack(spacing: 8) { + comparisonStat(label: "Ahead", value: "\(ahead)", tint: ADEColor.textPrimary) + comparisonStat(label: "Behind", value: "\(behind)", tint: ADEColor.textSecondary) + comparisonStat( + label: "Status", + value: dirty ? "Dirty" : "Clean", + tint: dirty ? ADEColor.warning : ADEColor.success + ) } - if canToggleBlockedLanes { - Button { - showAllBlockedLanes.toggle() - } label: { - HStack(spacing: 5) { - Text(blockedLaneToggleTitle) - Image(systemName: showAllBlockedLanes ? "chevron.up" : "chevron.down") - .font(.system(size: 9, weight: .bold)) - } - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.textSecondary) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .padding(.top, 2) + } + } + + private func comparisonStat(label: String, value: String, tint: Color) -> some View { + VStack(spacing: 4) { + Text(value) + .font(.system(size: 18, weight: .bold, design: .rounded)) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.75) + Text(label.uppercased()) + .font(.system(size: 9, weight: .bold)) + .tracking(0.8) + .foregroundStyle(ADEColor.textMuted) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(PrGlassPalette.ink.opacity(0.45)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5) + ) + } + + private func targetBranchSection(title: String) -> some View { + VStack(spacing: 0) { + PrSectionHdr(title: title) + VStack(alignment: .leading, spacing: 6) { + PrTargetBranchPickerDropdown( + targets: branchTargetOptions, + selectedBranch: $baseBranch + ) } + .padding(14) + .wizardCard() + .padding(.horizontal, 16) + .padding(.bottom, 8) } - .padding(.horizontal, 22) - .padding(.bottom, 12) } // MARK: - AI-drafted title card @@ -642,7 +576,7 @@ struct CreatePrWizardView: View { ) } .buttonStyle(.plain) - .disabled(isGenerating || selectedOption == nil) + .disabled(isGenerating || selectedLane == nil || !selectedLaneCanCreate) Button { editPresented = true @@ -720,29 +654,7 @@ struct CreatePrWizardView: View { } } - // MARK: - Target - - private var targetSection: some View { - VStack(spacing: 0) { - PrSectionHdr(title: "Target") - VStack(spacing: 0) { - ForEach(Array(availableTargets.enumerated()), id: \.element.id) { index, target in - if index > 0 { PrRowSeparator() } - Button { - baseBranch = target.id - } label: { - TargetRowView(target: target, selected: target.id == baseBranch) - } - .buttonStyle(.plain) - } - } - .wizardCard() - .padding(.horizontal, 16) - .padding(.bottom, 8) - } - } - - // MARK: - Stance + // MARK: - Strategy private var stanceSection: some View { VStack(spacing: 0) { @@ -882,16 +794,19 @@ struct CreatePrWizardView: View { private var modeSelectorSection: some View { VStack(spacing: 0) { PrSectionHdr(title: "Mode") - HStack(spacing: 8) { - ForEach(CreatePrMode.allCases) { mode in - ModeCard( - mode: mode, - selected: mode == createMode, - action: { createMode = mode } - ) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(CreatePrMode.allCases) { mode in + ModeCard( + mode: mode, + selected: mode == createMode, + action: { createMode = mode } + ) + .frame(width: 148) + } } + .padding(.horizontal, 16) } - .padding(.horizontal, 16) .padding(.bottom, 12) } } @@ -1137,7 +1052,7 @@ struct CreatePrWizardView: View { switch createMode { case .single: - guard let option = selectedOption else { + guard let option = selectedOption, selectedLaneCanCreate else { isSubmitting = false return } @@ -1206,18 +1121,14 @@ struct CreatePrWizardView: View { private func refreshCachedLaneOptions() { cachedLaneOptions = sourceEligibleLaneOptions - cachedBlockedLaneOptions = sourceBlockedLaneOptions didCacheLaneOptions = true - if sourceBlockedLaneOptions.count <= Self.collapsedBlockedLaneLimit { - showAllBlockedLanes = false - } } // MARK: - Draft generation @MainActor private func generateDraft(initial: Bool) async { - guard let option = selectedOption else { return } + guard selectedLaneCanCreate, let option = selectedOption else { return } isGenerating = true defer { isGenerating = false } @@ -1318,6 +1229,13 @@ fileprivate enum PrStrategyChoice: String { case prTarget = "pr_target" case laneBase = "lane_base" + var title: String { + switch self { + case .prTarget: return "PR target" + case .laneBase: return "Lane base" + } + } + var subtitle: String { switch self { case .prTarget: @@ -1356,8 +1274,8 @@ fileprivate struct StrategyRow: View { radio VStack(alignment: .leading, spacing: 2) { - Text(choice.rawValue) - .font(.system(size: 12, weight: .bold, design: .monospaced)) + Text(choice.title) + .font(.system(size: 12, weight: .bold)) .foregroundStyle(ADEColor.textPrimary) Text(choice.subtitle) .font(.system(size: 11.5)) @@ -1396,71 +1314,12 @@ fileprivate struct StrategyRow: View { } } -fileprivate struct TargetOption: Identifiable, Equatable { - let id: String - let icon: String - let label: String - let subtitle: String -} - fileprivate struct IntegrationTargetOption: Identifiable, Equatable { let id: String let branchRef: String let subtitle: String } -fileprivate struct TargetRowView: View { - let target: TargetOption - let selected: Bool - - var body: some View { - HStack(spacing: 10) { - RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill( - LinearGradient( - colors: [PrGlassPalette.purpleBright, PrGlassPalette.purpleDeep], - startPoint: .top, endPoint: .bottom - ) - ) - .frame(width: 3) - .opacity(selected ? 1.0 : 0.0) - .shadow(color: PrGlassPalette.purple.opacity(selected ? 0.5 : 0), radius: 6) - - ZStack { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(selected ? PrGlassPalette.purple.opacity(0.2) : PrGlassPalette.ink.opacity(0.45)) - RoundedRectangle(cornerRadius: 7, style: .continuous) - .strokeBorder(selected ? PrGlassPalette.purple.opacity(0.4) : Color.white.opacity(0.08), lineWidth: 0.5) - Text(target.icon) - .font(.system(size: 14, weight: .bold, design: .monospaced)) - .foregroundStyle(selected ? PrGlassPalette.purple : ADEColor.textSecondary) - } - .frame(width: 26, height: 26) - - VStack(alignment: .leading, spacing: 1) { - Text(target.label) - .font(.system(size: 12, weight: .semibold, design: .monospaced)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - Text(target.subtitle) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - } - Spacer(minLength: 0) - if selected { - Image(systemName: "checkmark") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(PrGlassPalette.purple) - } - } - .padding(.leading, 10) - .padding(.trailing, 14) - .padding(.vertical, 11) - .contentShape(Rectangle()) - } -} - fileprivate struct StanceSegment: View { let label: String let active: Bool @@ -1539,64 +1398,6 @@ fileprivate struct NextStepRow: View { } } -fileprivate struct LaneRow: View { - let option: CreatePrLaneOption - let selected: Bool - - var body: some View { - HStack(spacing: 10) { - RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill( - LinearGradient( - colors: [PrGlassPalette.purpleBright, PrGlassPalette.purpleDeep], - startPoint: .top, endPoint: .bottom - ) - ) - .frame(width: 3) - .opacity(selected ? 1.0 : 0.0) - .shadow(color: PrGlassPalette.purple.opacity(selected ? 0.5 : 0), radius: 6) - - ZStack { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(selected ? PrGlassPalette.purple.opacity(0.2) : PrGlassPalette.ink.opacity(0.45)) - RoundedRectangle(cornerRadius: 7, style: .continuous) - .strokeBorder(selected ? PrGlassPalette.purple.opacity(0.4) : Color.white.opacity(0.08), lineWidth: 0.5) - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(selected ? PrGlassPalette.purple : ADEColor.textSecondary) - } - .frame(width: 26, height: 26) - - VStack(alignment: .leading, spacing: 1) { - Text(option.title) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - Text(option.branchRef) - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - if let subtitle = option.subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(.system(size: 10)) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - } - } - Spacer(minLength: 0) - if selected { - Image(systemName: "checkmark") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(PrGlassPalette.purple) - } - } - .padding(.leading, 10) - .padding(.trailing, 14) - .padding(.vertical, 11) - .contentShape(Rectangle()) - } -} - // MARK: - Mode + multi-lane row views fileprivate struct ModeCard: View { diff --git a/apps/ios/ADE/Views/PRs/PrFiltersCard.swift b/apps/ios/ADE/Views/PRs/PrFiltersCard.swift index 2537a493d..d5d958b86 100644 --- a/apps/ios/ADE/Views/PRs/PrFiltersCard.swift +++ b/apps/ios/ADE/Views/PRs/PrFiltersCard.swift @@ -128,8 +128,8 @@ struct PrGitHubCategoryTabs: View { HStack(spacing: 0) { ForEach(PrGitHubCategory.allCases) { category in tab(for: category) + .frame(maxWidth: .infinity) } - Spacer(minLength: 0) } .accessibilityElement(children: .contain) .accessibilityLabel("Pull request status") @@ -145,27 +145,28 @@ struct PrGitHubCategoryTabs: View { selection = category } } label: { - VStack(spacing: 6) { - HStack(spacing: 5) { + VStack(spacing: 2) { + HStack(spacing: 3) { if let icon = category.icon { Image(systemName: icon) - .font(.system(size: 10, weight: .bold)) + .font(.system(size: 8, weight: .bold)) .foregroundStyle(isActive ? tint : PrsGlass.textMuted) } Text(category.title) - .font(.system(size: 12.5, weight: isActive ? .semibold : .regular)) + .font(.system(size: 11, weight: isActive ? .semibold : .regular)) .foregroundStyle(isActive ? tint : PrsGlass.textSecondary) Text("\(count)") - .font(.system(size: 10, weight: .bold, design: .monospaced)) + .font(.system(size: 8, weight: .bold, design: .monospaced)) .foregroundStyle(isActive ? tint.opacity(0.85) : PrsGlass.textMuted.opacity(0.8)) } - .padding(.horizontal, 12) - .padding(.top, 8) + .padding(.horizontal, 4) + .padding(.top, 1) Rectangle() .fill(isActive ? tint : Color.clear) - .frame(height: 2) + .frame(height: 1.5) } + .frame(maxWidth: .infinity) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -264,11 +265,11 @@ struct PrsSurfaceToggle: View { let workflowCount: Int var body: some View { - HStack(spacing: 4) { + HStack(spacing: 2) { segment(for: .github, count: repoPrCount) segment(for: .workflows, count: workflowCount) } - .padding(4) + .padding(2) .background { Capsule(style: .continuous) .fill(Color.white.opacity(0.04)) @@ -287,15 +288,15 @@ struct PrsSurfaceToggle: View { selection = surface } } label: { - HStack(spacing: 6) { + HStack(spacing: 4) { surfaceIcon(for: surface) Text(surface.title) - .font(.system(size: 13, weight: isActive ? .bold : .semibold)) + .font(.system(size: 11, weight: isActive ? .bold : .semibold)) if count > 0 { Text("\(count)") - .font(.system(size: 10, weight: .bold, design: .monospaced)) - .padding(.horizontal, 6) + .font(.system(size: 8, weight: .bold, design: .monospaced)) + .padding(.horizontal, 4) .padding(.vertical, 1) .background { Capsule(style: .continuous) @@ -304,8 +305,8 @@ struct PrsSurfaceToggle: View { } } .foregroundStyle(isActive ? PrsGlass.textPrimary : PrsGlass.textSecondary) - .padding(.horizontal, 14) - .padding(.vertical, 8) + .padding(.horizontal, 8) + .padding(.vertical, 4) .frame(maxWidth: .infinity) .background { if isActive { diff --git a/apps/ios/ADE/Views/PRs/PrListRowModifier.swift b/apps/ios/ADE/Views/PRs/PrListRowModifier.swift index 6ef4d6ccd..ae66e2dcb 100644 --- a/apps/ios/ADE/Views/PRs/PrListRowModifier.swift +++ b/apps/ios/ADE/Views/PRs/PrListRowModifier.swift @@ -1,15 +1,28 @@ import SwiftUI extension View { - /// App-wide gutter applied to every PRs list row so content cards, filter - /// chips, and notice banners never hug the screen edge. 16pt matches the - /// horizontal gutter used by the top-bar and the detail-screen scroll - /// padding, keeping the left edge of every surface aligned. + /// App-wide gutter applied to chrome rows (search, filters, notices). 16pt + /// matches the horizontal gutter used by the top bar and detail screens. func prListRow() -> some View { listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } + + /// Tighter vertical rhythm for stacked filter chrome (surface toggle, tabs). + func prListRowChrome() -> some View { + listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 4, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + + /// Full-bleed PR list row — no horizontal list inset so row backgrounds and + /// dividers span the entire list width. Text padding lives inside `PrRowCard`. + func prListRowCard() -> some View { + listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } } // MARK: - Liquid-glass palette shared across the PRs root + top bar restyle. @@ -519,7 +532,7 @@ struct PrsGlassSearchPill: View { } } .padding(.horizontal, 14) - .padding(.vertical, 11) + .padding(.vertical, 8) .background { ZStack { RoundedRectangle(cornerRadius: 14, style: .continuous) diff --git a/apps/ios/ADE/Views/PRs/PrRowCard.swift b/apps/ios/ADE/Views/PRs/PrRowCard.swift index 4b05dcb63..936ff5de6 100644 --- a/apps/ios/ADE/Views/PRs/PrRowCard.swift +++ b/apps/ios/ADE/Views/PRs/PrRowCard.swift @@ -51,7 +51,7 @@ struct PrRowCard: View { } } .padding(.horizontal, 14) - .padding(.vertical, 11) + .padding(.vertical, 9) .frame(maxWidth: .infinity, alignment: .leading) .background { if isSelectedTransitionSource { diff --git a/apps/ios/ADE/Views/PRs/PrTargetBranchPickerDropdown.swift b/apps/ios/ADE/Views/PRs/PrTargetBranchPickerDropdown.swift new file mode 100644 index 000000000..e601d2a26 --- /dev/null +++ b/apps/ios/ADE/Views/PRs/PrTargetBranchPickerDropdown.swift @@ -0,0 +1,221 @@ +import SwiftUI + +/// Desktop-shaped target-branch picker for the Create PR wizard. Mirrors the +/// Work tab lane dropdown styling but lists branch targets (default base + +/// integration lanes). +struct PrTargetBranchPickerDropdown: View { + let targets: [PrBranchTargetOption] + @Binding var selectedBranch: String + var emptySelectionTitle: String = "Select target..." + + @State private var menuPresented = false + @State private var searchQuery = "" + + private var selectedTarget: PrBranchTargetOption? { + targets.first(where: { $0.id == selectedBranch }) + } + + private var filteredTargets: [PrBranchTargetOption] { + let trimmed = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return targets } + return targets.filter { target in + target.label.lowercased().contains(trimmed) || target.subtitle.lowercased().contains(trimmed) + } + } + + var body: some View { + Button { + menuPresented = true + } label: { + triggerLabel + } + .buttonStyle(.plain) + .accessibilityLabel("Select target branch") + .accessibilityValue(selectedTarget?.label ?? selectedBranch) + .popover(isPresented: $menuPresented, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom) { + PrTargetBranchPickerMenu( + targets: filteredTargets, + allTargetsEmpty: targets.isEmpty, + selectedBranch: selectedBranch, + searchQuery: $searchQuery, + onSelect: { branch in + selectedBranch = branch + menuPresented = false + searchQuery = "" + } + ) + .frame(width: 280) + .presentationCompactAdaptation(.popover) + } + .onChange(of: menuPresented) { _, isOpen in + if !isOpen { searchQuery = "" } + } + } + + private var triggerLabel: some View { + let title = selectedTarget?.label ?? (selectedBranch.isEmpty ? emptySelectionTitle : selectedBranch) + let subtitle = selectedTarget?.subtitle + + return ZStack { + Group { + if let subtitle, !subtitle.isEmpty { + VStack(spacing: 2) { + triggerTitleRow(title) + HStack(spacing: 4) { + Image(systemName: "arrow.branch") + .font(.system(size: 9, weight: .regular)) + .foregroundStyle(ADEColor.textMuted.opacity(0.55)) + Text(subtitle) + .font(.system(size: 10)) + .foregroundStyle(ADEColor.textMuted.opacity(0.92)) + .lineLimit(1) + } + } + .multilineTextAlignment(.center) + } else { + triggerTitleRow(title) + } + } + .padding(.horizontal, 26) + .frame(maxWidth: .infinity, alignment: .center) + + HStack(spacing: 0) { + Spacer(minLength: 0) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(ADEColor.textMuted.opacity(0.6)) + .padding(.trailing, 10) + } + } + .padding(.leading, 12) + .padding(.trailing, 4) + .padding(.vertical, subtitle?.isEmpty == false ? 5 : 6) + .background(Color.white.opacity(0.04), in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + .frame(maxWidth: .infinity) + } + + private func triggerTitleRow(_ title: String) -> some View { + HStack(spacing: 5) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ADEColor.textSecondary) + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.85)) + .lineLimit(1) + } + } +} + +struct PrBranchTargetOption: Identifiable, Equatable { + let id: String + let label: String + let subtitle: String +} + +private struct PrTargetBranchPickerMenu: View { + let targets: [PrBranchTargetOption] + let allTargetsEmpty: Bool + let selectedBranch: String + @Binding var searchQuery: String + let onSelect: (String) -> Void + + @FocusState private var searchFocused: Bool + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(ADEColor.textMuted.opacity(0.5)) + TextField("Search branches...", text: $searchQuery) + .textFieldStyle(.plain) + .font(.system(size: 11)) + .foregroundStyle(ADEColor.textPrimary) + .focused($searchFocused) + .submitLabel(.done) + } + .padding(.horizontal, 10) + .frame(height: 32) + .overlay(alignment: .bottom) { + Rectangle() + .fill(ADEColor.border.opacity(0.35)) + .frame(height: 0.5) + } + + ScrollView { + LazyVStack(spacing: 0) { + if targets.isEmpty { + Text(allTargetsEmpty ? "No targets available" : "No branches found") + .font(.system(size: 11)) + .foregroundStyle(ADEColor.textMuted) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } else { + ForEach(targets) { target in + targetRow(target) + } + } + } + .padding(4) + } + .frame(maxHeight: 260) + } + .background(ADEColor.cardBackground.opacity(0.96)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.8) + ) + .shadow(color: Color.black.opacity(0.45), radius: 16, y: 8) + .onAppear { + searchFocused = true + } + } + + private func targetRow(_ target: PrBranchTargetOption) -> some View { + let isSelected = target.id == selectedBranch + + return Button { + onSelect(target.id) + } label: { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ADEColor.textSecondary) + Text(target.label) + .font(.system(size: 11, weight: isSelected ? .medium : .regular)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ADEColor.accent) + } + } + if !target.subtitle.isEmpty { + Text(target.subtitle) + .font(.system(size: 10)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + .padding(.leading, 18) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + isSelected ? ADEColor.accent.opacity(0.12) : Color.clear, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index c6d852d9a..5b3321b52 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -406,8 +406,7 @@ struct PRsTabView: View { repoPrCount: allGitHubPrsCount, workflowCount: workflowCards.count ) - .padding(.top, 2) - .prListRow() + .prListRowChrome() switch selectedRootSurface.wrappedValue { case .github: @@ -418,7 +417,8 @@ struct PRsTabView: View { } } .listStyle(.plain) - .listRowSpacing(10) + .listRowSpacing(0) + .contentMargins(.horizontal, 0, for: .scrollContent) .scrollContentBackground(.hidden) .adeScreenBackground() .adeNavigationGlass() @@ -727,7 +727,7 @@ struct PRsTabView: View { text: $searchText, placeholder: selectedRootSurface.wrappedValue == .github ? "Search PRs, branches, authors" : "Search workflow cards" ) - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 8, trailing: 16)) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 4, trailing: 16)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } @@ -787,7 +787,7 @@ struct PRsTabView: View { selection: selectedGitHubCategory, counts: githubCategoryCounts ) - .prListRow() + .prListRowChrome() if filtersExpanded { PrGitHubFiltersCard( @@ -842,69 +842,54 @@ struct PRsTabView: View { } if githubSnapshot == nil, !filteredPrs.isEmpty { - Section("Cached ADE pull requests") { - ForEach(filteredPrs) { pr in - NavigationLink(value: pr.id) { - PrRowCard( - pr: pr, - transitionNamespace: ADEMotion.allowsMatchedGeometry(reduceMotion: reduceMotion) ? prTransitionNamespace : nil, - isSelectedTransitionSource: selectedPrTransitionId == pr.id - ) { groupId, groupName in - stackPresentation = PrStackPresentation(id: groupId, groupName: groupName) - } + ForEach(filteredPrs) { pr in + NavigationLink(value: pr.id) { + PrRowCard( + pr: pr, + transitionNamespace: ADEMotion.allowsMatchedGeometry(reduceMotion: reduceMotion) ? prTransitionNamespace : nil, + isSelectedTransitionSource: selectedPrTransitionId == pr.id + ) { groupId, groupName in + stackPresentation = PrStackPresentation(id: groupId, groupName: groupName) } - .simultaneousGesture(TapGesture().onEnded { - selectedPrTransitionId = pr.id - }) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - rowSwipeActions(for: pr) - } - .prListRow() } + .simultaneousGesture(TapGesture().onEnded { + selectedPrTransitionId = pr.id + }) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + rowSwipeActions(for: pr) + } + .prListRowCard() } } if githubSnapshot != nil { let repoItems = githubDerived.repoItems let externalItems = githubDerived.externalItems - let repoSectionTitle: String = { - if let repo = githubSnapshot?.repo { - return "\(repo.owner)/\(repo.name)" - } - return "Repository PRs" - }() if !repoItems.isEmpty { - Section(repoSectionTitle) { - ForEach(repoItems) { item in - githubRowNavigation(for: item) - .prListRow() - } + ForEach(repoItems) { item in + githubRowNavigation(for: item) + .prListRowCard() } } if !externalItems.isEmpty { let unmappedCount = externalItems.filter { $0.adeKind == nil && $0.linkedPrId == nil && $0.linkedLaneId == nil }.count - Section { - ForEach(externalItems) { item in - githubRowNavigation(for: item) - .prListRow() - } - } header: { - HStack(spacing: 6) { - PrsEyebrowLabel( - text: unmappedCount > 0 - ? "External · \(unmappedCount) unmapped" - : "External", - tint: PrsGlass.externalTop - ) - Spacer(minLength: 0) - } - .padding(.top, 8) - .padding(.bottom, 4) - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .listRowBackground(Color.clear) - .textCase(nil) + HStack(spacing: 6) { + PrsEyebrowLabel( + text: unmappedCount > 0 + ? "External · \(unmappedCount) unmapped" + : "External", + tint: PrsGlass.externalTop + ) + Spacer(minLength: 0) + } + .padding(.top, 6) + .padding(.bottom, 2) + .prListRow() + ForEach(externalItems) { item in + githubRowNavigation(for: item) + .prListRowCard() } } } diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreenPreviews.swift b/apps/ios/ADE/Views/PRs/PrsRootScreenPreviews.swift index 00ee96ec4..c44286002 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreenPreviews.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreenPreviews.swift @@ -186,7 +186,6 @@ private struct PrsGitHubRootPreviewScreen: View { repoPrCount: 21, workflowCount: 1 ) - .padding(.top, 2) .prListRow() PrGitHubCategoryTabs( @@ -195,34 +194,32 @@ private struct PrsGitHubRootPreviewScreen: View { ) .prListRow() - Section("arul28/ADE") { - PrRowCard( - item: PrsRootPreviewData.github559, - linkedPr: PrsRootPreviewData.linkedPr559 - ) - .prListRow() + PrRowCard( + item: PrsRootPreviewData.github559, + linkedPr: PrsRootPreviewData.linkedPr559 + ) + .prListRowCard() - PrRowCard( - item: PrsRootPreviewData.github346, - onLink: {} - ) - .prListRow() + PrRowCard( + item: PrsRootPreviewData.github346, + onLink: {} + ) + .prListRowCard() - PrRowCard( - item: PrsRootPreviewData.github425, - onLink: {} - ) - .prListRow() + PrRowCard( + item: PrsRootPreviewData.github425, + onLink: {} + ) + .prListRowCard() - PrRowCard( - item: PrsRootPreviewData.github344, - onLink: {} - ) - .prListRow() - } + PrRowCard( + item: PrsRootPreviewData.github344, + onLink: {} + ) + .prListRowCard() } .listStyle(.plain) - .listRowSpacing(10) + .listRowSpacing(2) .scrollContentBackground(.hidden) .adeScreenBackground() .adeNavigationGlass() diff --git a/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift b/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift new file mode 100644 index 000000000..4422a5482 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift @@ -0,0 +1,263 @@ +import SwiftUI +import UIKit + +func workChatAttachmentIsImage(_ ref: AgentChatFileRef) -> Bool { + let type = ref.type.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return type == "image" || type == "image-url" +} + +func workChatAttachmentDisplayName(_ ref: AgentChatFileRef) -> String { + if ref.type == "image-url", let url = ref.url?.trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty { + if let host = URL(string: url)?.host, !host.isEmpty { + return host + } + return "Image link" + } + let basename = (ref.path as NSString).lastPathComponent + return basename.isEmpty ? ref.path : basename +} + +func workChatAttachmentAccessibilityLabel(_ attachments: [AgentChatFileRef]) -> String { + let names = attachments.map(workChatAttachmentDisplayName) + if names.count == 1 { + return "Attachment: \(names[0])" + } + return "\(names.count) attachments: \(names.joined(separator: ", "))" +} + +enum WorkChatAttachmentTrayStyle { + /// Standalone tray below the bubble (composer / legacy layout). + case standalone + /// Thumbnails live inside the user bubble, matching desktop `ChatAttachmentTray`. + case embeddedInBubble +} + +/// Compact attachment tray for user messages — mirrors desktop's +/// `ChatAttachmentTray` with mobile-friendly placeholders when image bytes +/// have not synced from the desktop host yet. +struct WorkChatAttachmentTray: View { + let attachments: [AgentChatFileRef] + var alignment: HorizontalAlignment = .trailing + var style: WorkChatAttachmentTrayStyle = .standalone + + @EnvironmentObject private var syncService: SyncService + @Environment(\.workChatLaneId) private var laneId + @Environment(\.workChatRequestedCwd) private var requestedCwd + + private var chipSize: CGFloat { + style == .embeddedInBubble ? 56 : 72 + } + + var body: some View { + VStack(alignment: alignment, spacing: 6) { + if style == .standalone, attachments.count > 1 { + Text("\(attachments.count) attachments") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + } + + if style == .embeddedInBubble { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: chipSize, maximum: chipSize), spacing: 8)], + alignment: alignment, + spacing: 8 + ) { + ForEach(Array(attachments.enumerated()), id: \.offset) { _, attachment in + WorkChatAttachmentChip(attachment: attachment, size: chipSize) + } + } + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Array(attachments.enumerated()), id: \.offset) { _, attachment in + WorkChatAttachmentChip(attachment: attachment, size: chipSize) + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: alignment == .trailing ? .trailing : .leading) + .accessibilityElement(children: .contain) + .accessibilityLabel(workChatAttachmentAccessibilityLabel(attachments)) + } +} + +private struct WorkChatAttachmentChip: View { + let attachment: AgentChatFileRef + var size: CGFloat = 72 + + @EnvironmentObject private var syncService: SyncService + @Environment(\.workChatLaneId) private var laneId + @Environment(\.workChatRequestedCwd) private var requestedCwd + + @State private var previewImage: UIImage? + @State private var loadFailed = false + + var body: some View { + Group { + if workChatAttachmentIsImage(attachment) { + imageChip + } else { + fileChip + } + } + .task(id: attachment.path) { + await loadPreviewIfNeeded() + } + } + + private var imageChip: some View { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.white.opacity(0.08)) + .frame(width: size, height: size) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.white.opacity(0.16), lineWidth: 0.8) + ) + + if let previewImage { + Image(uiImage: previewImage) + .resizable() + .scaledToFill() + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } else { + VStack(spacing: 4) { + Image(systemName: loadFailed ? "photo.badge.exclamationmark" : "photo") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Color.white.opacity(0.82)) + Text(loadFailed ? "On desktop" : "Image") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Color.white.opacity(0.72)) + .lineLimit(1) + } + } + } + .accessibilityLabel("Image attachment \(workChatAttachmentDisplayName(attachment))") + } + + private var fileChip: some View { + HStack(spacing: 6) { + Image(systemName: "paperclip") + .font(.system(size: 11, weight: .bold)) + Text(workChatAttachmentDisplayName(attachment)) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + .truncationMode(.middle) + } + .foregroundStyle(Color.white.opacity(0.9)) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(Color.white.opacity(0.10), in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(Color.white.opacity(0.18), lineWidth: 0.8) + ) + .frame(maxWidth: 220) + .accessibilityLabel("File attachment \(workChatAttachmentDisplayName(attachment))") + } + + @MainActor + private func loadPreviewIfNeeded() async { + guard workChatAttachmentIsImage(attachment) else { return } + if attachment.type == "image-url", let urlString = attachment.url, + let url = URL(string: urlString), let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" { + do { + let (data, _) = try await URLSession.shared.data(from: url) + if let image = UIImage(data: data) { + previewImage = image + loadFailed = false + return + } + } catch { + loadFailed = true + return + } + } + + guard let laneId, !laneId.isEmpty else { + loadFailed = true + return + } + + do { + let workspaces = try await syncService.listWorkspaces() + guard let workspace = workFilesWorkspace(for: laneId, in: workspaces) else { + loadFailed = true + return + } + let relativePath = normalizeWorkFileReference( + attachment.path, + workspaceRoot: workspace.rootPath, + requestedCwd: requestedCwd + ) + guard !relativePath.isEmpty else { + loadFailed = true + return + } + let blob = try await syncService.readFile(workspaceId: workspace.id, path: relativePath) + if let dataUrl = blob.dataUrl, let image = workChatUIImage(fromDataUrl: dataUrl) { + previewImage = image + loadFailed = false + return + } + if blob.isBinary, + !blob.content.isEmpty, + let data = Data(base64Encoded: blob.content), + let image = UIImage(data: data) { + previewImage = image + loadFailed = false + return + } + loadFailed = true + } catch { + loadFailed = true + } + } +} + +struct WorkChatTranscriptEnvironmentModifier: ViewModifier { + let provider: String? + let modelId: String? + let modelLabel: String? + let laneId: String + let requestedCwd: String? + + func body(content: Content) -> some View { + content + .environment(\.workChatProvider, provider) + .environment(\.workChatModelId, modelId) + .environment(\.workChatModelLabel, modelLabel) + .environment(\.workChatLaneId, laneId) + .environment(\.workChatRequestedCwd, requestedCwd) + } +} + +private struct WorkChatLaneIdEnvironmentKey: EnvironmentKey { + static let defaultValue: String? = nil +} + +private struct WorkChatRequestedCwdEnvironmentKey: EnvironmentKey { + static let defaultValue: String? = nil +} + +extension EnvironmentValues { + var workChatLaneId: String? { + get { self[WorkChatLaneIdEnvironmentKey.self] } + set { self[WorkChatLaneIdEnvironmentKey.self] = newValue } + } + + var workChatRequestedCwd: String? { + get { self[WorkChatRequestedCwdEnvironmentKey.self] } + set { self[WorkChatRequestedCwdEnvironmentKey.self] = newValue } + } +} + +private func workChatUIImage(fromDataUrl dataUrl: String) -> UIImage? { + guard let commaIndex = dataUrl.firstIndex(of: ",") else { return nil } + let base64 = String(dataUrl[dataUrl.index(after: commaIndex)...]) + guard let data = Data(base64Encoded: base64) else { return nil } + return UIImage(data: data) +} diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index b80e91f4e..a3d38b0d5 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -119,12 +119,11 @@ struct WorkComposerInputBanner: View { } /// Compact horizontal strip matching the desktop composer toolbar: small -/// single-line pills for access / model / reasoning, queued/pending status +/// single-line pills for access / model / reasoning, pending status /// chips, and nothing else. Runtime and reasoning choices are visible chips /// so mobile does not hide critical steering behind a native menu. struct WorkComposerChipStrip: View { let chatSummary: AgentChatSessionSummary? - let queuedSteerCount: Int let pendingInputCount: Int let onOpenModelPicker: (() -> Void)? let onSelectRuntimeMode: ((String) -> Void)? @@ -153,9 +152,6 @@ struct WorkComposerChipStrip: View { effortControl(summary: chatSummary) } - if queuedSteerCount > 0 { - statusChip(icon: "paperplane.circle.fill", label: "\(queuedSteerCount) staged", tint: ADEColor.accent) - } if pendingInputCount > 0 { statusChip(icon: "hand.raised.circle.fill", label: "\(pendingInputCount) waiting", tint: ADEColor.warning) } diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index 661dcea2d..f21119636 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -237,23 +237,41 @@ struct WorkChatMessageBubble: View { private var userRow: some View { // Desktop parity: the user message is the ONLY bubble — right-aligned, an // accent→violet 135° gradient, white text, inset top highlight + soft - // drop shadow. Capped at ~82% of the measured column width so short replies - // stay compact while long ones wrap rather than clip. - HStack(alignment: .top, spacing: 8) { + // drop shadow. Attachments render inside the same bubble (not below it). + // Capped at ~92% of the measured column width on mobile so long prompts + // use more horizontal space and less vertical scroll. + let attachments = message.attachments ?? [] + let hasAttachments = !attachments.isEmpty + let hasText = !message.markdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let maxBubbleWidth = columnWidth > 0 ? columnWidth * 0.92 : 360 + + return HStack(alignment: .top, spacing: 8) { Spacer(minLength: 0) - VStack(alignment: .trailing, spacing: 4) { + VStack(alignment: .trailing, spacing: 6) { if let deliveryBadge { // Delivery badges only render when a non-default state applies // (queued/sending/failed). Successful deliveries stay silent. WorkDeliveryBadge(state: deliveryBadge) } - Text(message.markdown) - .font(.body) - .foregroundStyle(Color.white) - .lineSpacing(5) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) + if hasText || hasAttachments { + VStack(alignment: .leading, spacing: hasText && hasAttachments ? 8 : 0) { + if hasText { + Text(message.markdown) + .font(.body) + .foregroundStyle(Color.white) + .lineSpacing(5) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + if hasAttachments { + WorkChatAttachmentTray( + attachments: attachments, + alignment: .leading, + style: .embeddedInBubble + ) + } + } .padding(.horizontal, 16) .padding(.vertical, 8) .background(userBubbleGradient, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) @@ -274,8 +292,9 @@ struct WorkChatMessageBubble: View { .stroke(userBubbleBorder, lineWidth: 0.8) ) .shadow(color: accent.opacity(0.34), radius: 12, y: 5) - .frame(maxWidth: columnWidth > 0 ? columnWidth * 0.82 : 320, alignment: .trailing) + .frame(maxWidth: maxBubbleWidth, alignment: .trailing) .fixedSize(horizontal: false, vertical: true) + } } } .frame(maxWidth: .infinity) @@ -294,7 +313,7 @@ struct WorkChatMessageBubble: View { } } .accessibilityElement(children: .combine) - .accessibilityLabel("Your message. \(workChatAccessibilityPreview(message.markdown))") + .accessibilityLabel(userMessageAccessibilityLabel) .adeInspectable( "Work.Chat.MessageBubble.User", metadata: [ @@ -306,6 +325,18 @@ struct WorkChatMessageBubble: View { ) } + private var userMessageAccessibilityLabel: String { + var parts = ["Your message."] + let preview = workChatAccessibilityPreview(message.markdown) + if !preview.isEmpty { + parts.append(preview) + } + if let attachments = message.attachments, !attachments.isEmpty { + parts.append(workChatAttachmentAccessibilityLabel(attachments)) + } + return parts.joined(separator: " ") + } + var deliveryBadge: WorkDeliveryBadge.State? { guard message.role == "user" else { return nil } if let state = message.deliveryState { diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 924fab986..9f54318d5 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -236,12 +236,8 @@ struct WorkToolCardView: View { } } -/// Minimal "Tool calls (N)" panel — the iOS counterpart to the desktop -/// `Tool calls (n)` block. Collapsed by default: just a single tappable row -/// showing the count and a `Last: ` breadcrumb. Tap the header -/// to reveal the row list; tap a row to reveal its output inline beneath it. -/// No glass card, no per-tool icons, no count pill chip — the row itself -/// carries the count. +/// Minimal "Tool calls (N)" panel — flat desktop parity. No card chrome: just a +/// tappable header row and an indented member list when expanded. struct WorkToolCallsPanelView: View { let group: WorkToolGroupModel let isExpanded: Bool @@ -253,45 +249,31 @@ struct WorkToolCallsPanelView: View { VStack(alignment: .leading, spacing: 0) { header if isExpanded { - Divider() - .background(ADEColor.glassBorder.opacity(0.4)) - .padding(.top, 8) - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 2) { ForEach(group.members) { member in memberRow(member) } } - .padding(.top, 4) + .padding(.leading, 16) + .padding(.top, 6) } } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(ADEColor.cardBackground.opacity(0.4), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(ADEColor.glassBorder, lineWidth: 1) - ) .accessibilityElement(children: .contain) .accessibilityLabel("Tool calls cluster, \(group.count) calls, \(isExpanded ? "expanded" : "collapsed")") } private var header: some View { Button(action: onToggle) { - HStack(alignment: .center, spacing: 8) { + HStack(alignment: .center, spacing: 6) { Image(systemName: isExpanded ? "chevron.down" : "chevron.right") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(ADEColor.textMuted.opacity(0.65)) Text("Tool calls") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text("\(group.count)") - .font(.caption2.weight(.bold).monospacedDigit()) + .font(.caption.weight(.medium)) .foregroundStyle(ADEColor.textMuted) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(ADEColor.textMuted.opacity(0.14), in: Capsule(style: .continuous)) - // Collapsed: a one-line peek at the latest entry — status glyph, mono - // kind-slug, then the arg text, matching desktop's work-log header. + Text("(\(group.count))") + .font(.caption2.monospacedDigit()) + .foregroundStyle(ADEColor.textMuted.opacity(0.55)) if !isExpanded, let latest = group.latest { WorkToolStatusGlyph(status: latest.status) Text(memberSlug(latest)) @@ -300,14 +282,15 @@ struct WorkToolCallsPanelView: View { .lineLimit(1) if let target = memberTarget(latest), !target.isEmpty { Text(target) - .font(.caption2.monospaced()) - .foregroundStyle(ADEColor.textMuted) + .font(.caption) + .foregroundStyle(ADEColor.textPrimary.opacity(0.88)) .lineLimit(1) - .truncationMode(.middle) + .truncationMode(.tail) } } Spacer(minLength: 0) } + .padding(.vertical, 2) } .buttonStyle(.plain) } @@ -341,10 +324,6 @@ struct WorkToolCallsPanelView: View { Spacer(minLength: 0) } if expanded, let detail = memberDetail(member) { - Divider() - .background(ADEColor.glassBorder.opacity(0.4)) - .padding(.leading, 17) - .padding(.top, 4) ScrollView { Text(detail) .frame(maxWidth: .infinity, alignment: .leading) @@ -385,11 +364,11 @@ struct WorkToolCallsPanelView: View { private func memberTarget(_ member: WorkToolGroupMember) -> String? { switch member { case .tool(let card): - return workToolResultPreview(card.argsText) + return workToolArgPreview(toolName: card.toolName, argsText: card.argsText) ?? workToolResultPreview(card.resultText) - ?? toolDisplayName(card.toolName) case .command(let card): - return card.command.isEmpty ? nil : card.command + guard !card.command.isEmpty else { return nil } + return workSummarizeInlineText(card.command, maxChars: 140) case .fileChange(let card): return workReferenceLabel(for: card.path) } @@ -398,7 +377,16 @@ struct WorkToolCallsPanelView: View { private func memberDetail(_ member: WorkToolGroupMember) -> String? { switch member { case .tool(let card): - if let result = card.resultText, !result.isEmpty { return result } + if let result = card.resultText, !result.isEmpty { + let trimmed = result.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == "{}" || trimmed == "[]" { + return workToolArgPreview(toolName: card.toolName, argsText: card.argsText) + } + return result + } + if let preview = workToolArgPreview(toolName: card.toolName, argsText: card.argsText) { + return preview + } if let args = card.argsText, !args.isEmpty { return args } return nil case .command(let card): @@ -421,62 +409,52 @@ struct WorkToolCallsPanelView: View { } } -/// Minimal "N files changed" panel. Header-only by default; tap to reveal one -/// row per file with diff stats; tap a row to reveal the full diff inline. +/// Minimal "Files changed (N)" panel — flat desktop/tool-calls parity. Collapsed +/// by default; tap to reveal one row per file; tap a row for the full diff. struct WorkChangedFilesPanelView: View { let group: WorkChangedFilesGroupModel let isExpanded: Bool let onToggle: () -> Void let onUndo: (() -> Void)? - // Desktop parity: the "N files changed" panel is OPEN by default. The cluster - // is the headline of the turn's work, so the file rows + stats show without a - // tap. Open/closed state is owned by the parent (`isExpanded`/`onToggle`) so - // it survives the row scrolling out of and back into the lazy stack — the - // call site seeds it open by default. @State private var expandedFileIds: Set = [] var body: some View { VStack(alignment: .leading, spacing: 0) { header if isExpanded { - Divider() - .background(ADEColor.glassBorder.opacity(0.4)) - .padding(.top, 8) VStack(alignment: .leading, spacing: 0) { ForEach(group.files) { file in fileRow(file) } } - .padding(.top, 4) + .padding(.leading, 16) + .padding(.top, 6) } } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(ADEColor.cardBackground.opacity(0.4), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(ADEColor.glassBorder, lineWidth: 1) - ) .accessibilityElement(children: .contain) .accessibilityLabel("Files changed cluster, \(group.count) files, \(isExpanded ? "expanded" : "collapsed")") } private var header: some View { - HStack(alignment: .center, spacing: 10) { + HStack(alignment: .center, spacing: 6) { Button(action: onToggle) { - HStack(alignment: .center, spacing: 10) { + HStack(alignment: .center, spacing: 6) { Image(systemName: isExpanded ? "chevron.down" : "chevron.right") - .font(.system(size: 10, weight: .semibold)) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(ADEColor.textMuted.opacity(0.65)) + Text("Files changed") + .font(.caption.weight(.medium)) .foregroundStyle(ADEColor.textMuted) - Circle() - .fill(headerDotTint) - .frame(width: 6, height: 6) - Text("\(group.count) \(group.count == 1 ? "file" : "files") changed") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) + Text("(\(group.count))") + .font(.caption2.monospacedDigit()) + .foregroundStyle(ADEColor.textMuted.opacity(0.55)) + if !isExpanded { + collapsedPreview + } Spacer(minLength: 0) } + .padding(.vertical, 2) } .buttonStyle(.plain) if isExpanded, let onUndo { @@ -494,6 +472,32 @@ struct WorkChangedFilesPanelView: View { } } + @ViewBuilder + private var collapsedPreview: some View { + if group.hasRunning { + Circle() + .fill(ADEColor.warning.opacity(0.85)) + .frame(width: 6, height: 6) + } + if group.totalAdditions > 0 { + Text("+\(group.totalAdditions)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(ADEColor.success.opacity(0.85)) + } + if group.totalDeletions > 0 { + Text("−\(group.totalDeletions)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(ADEColor.danger.opacity(0.85)) + } + if let latest = group.files.last { + Text(workReferenceLabel(for: latest.path)) + .font(.caption) + .foregroundStyle(ADEColor.textPrimary.opacity(0.88)) + .lineLimit(1) + .truncationMode(.middle) + } + } + @ViewBuilder private func fileRow(_ file: WorkChangedFileEntry) -> some View { let expanded = expandedFileIds.contains(file.id) @@ -539,9 +543,6 @@ struct WorkChangedFilesPanelView: View { } } if expanded { - Divider() - .background(ADEColor.glassBorder.opacity(0.4)) - .padding(.top, 4) if file.diff.isEmpty { Text("No diff payload available.") .font(.caption.monospaced()) @@ -571,12 +572,6 @@ struct WorkChangedFilesPanelView: View { // MARK: – Helpers - private var headerDotTint: Color { - if group.hasRunning { return ADEColor.warning } - if group.files.contains(where: { $0.status == .failed }) { return ADEColor.danger } - return ADEColor.textMuted.opacity(0.5) - } - private func diffLineTint(_ line: String) -> Color { if line.hasPrefix("+") && !line.hasPrefix("+++") { return ADEColor.success.opacity(0.9) } if line.hasPrefix("-") && !line.hasPrefix("---") { return ADEColor.danger.opacity(0.9) } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift index bdf6c23a0..f140b6100 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift @@ -16,8 +16,13 @@ extension WorkChatSessionView { // .userInitiated, not .utility: this rebuild feeds the visible streaming // transcript, and utility-priority tasks get starved while SwiftUI is // busy — which showed up as multi-second delta-to-screen latency. + // + // 100 ms debounce: chat-event notifications arrive at most every ~150 ms + // (SyncService coalescer), so this timer always completes between bursts + // while still folding the multiple onChange triggers (transcript, + // fallbackEntries, artifacts) from a single refresh into one rebuild. timelineRebuildTask = Task.detached(priority: .userInitiated) { - try? await Task.sleep(for: .milliseconds(220)) + try? await Task.sleep(for: .milliseconds(100)) guard !Task.isCancelled else { return } let nextSnapshot = buildWorkChatTimelineSnapshot( transcript: transcriptSnapshot, diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index a6fd545a2..3c1de254f 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -9,11 +9,7 @@ extension WorkChatSessionView { /// markdown through the tail-only streaming parser; every completed message /// keeps the whole-text block cache path. var streamingAssistantMessageId: String? { - guard workChatIsStreaming( - sessionStatus: sessionStatus, - isLive: isLive, - transcriptIndicatesActiveTurn: timelineSnapshot.transcriptIndicatesActiveTurn - ) else { return nil } + guard isStreamingTurn else { return nil } for entry in timelineSnapshot.timeline.reversed() { guard case .message(let message) = entry.payload else { continue } return message.role == "assistant" ? message.id : nil @@ -153,18 +149,10 @@ extension WorkChatSessionView { @ViewBuilder func timelineChangedFiles(_ group: WorkChangedFilesGroupModel) -> some View { - // Files panels are open by default (desktop parity); this dedicated collapse - // set keeps changed-file state distinct from ordinary tool-card expansion. WorkChangedFilesPanelView( group: group, - isExpanded: !collapsedChangedFileGroupIds.contains(group.id), - onToggle: { - if collapsedChangedFileGroupIds.contains(group.id) { - collapsedChangedFileGroupIds.remove(group.id) - } else { - collapsedChangedFileGroupIds.insert(group.id) - } - }, + isExpanded: expandedToolCardIds.contains(group.id), + onToggle: { toggleToolCard(group.id) }, onUndo: nil ) } @@ -206,7 +194,7 @@ extension WorkChatSessionView { /// Reasoning is "live" when the session is streaming AND this is the most /// recent reasoning entry in the transcript. Everything older collapses. func isReasoningLive(_ card: WorkEventCardModel) -> Bool { - guard isLive, sessionStatus == "active" else { return false } + guard isStreamingTurn else { return false } let latestReasoningId = eventCards.last(where: { $0.kind == "reasoning" })?.id return card.id == latestReasoningId } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 1e0984c79..a785fa2ab 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -14,9 +14,9 @@ struct WorkChatSessionView: View { let optimisticPendingSteers: [WorkPendingSteerModel] let localEchoMessages: [WorkLocalEchoMessage] @Binding var expandedToolCardIds: Set - @Binding var collapsedChangedFileGroupIds: Set @Binding var artifactContent: [String: WorkLoadedArtifactContent] @Binding var fullscreenImage: WorkFullscreenImage? + @Binding var artifactDrawerPresented: Bool let artifactRefreshInFlight: Bool let artifactRefreshError: String? @Binding var sending: Bool @@ -26,7 +26,6 @@ struct WorkChatSessionView: View { @State var isNearBottom = true @State var unreadBelowCount = 0 @State var lastTimelineTailId: String? - @State var artifactDrawerPresented = false @State var timelineSnapshot = WorkChatTimelineSnapshot.empty @State var timelinePresentation = WorkTimelinePresentation.empty @State var timelineRebuildTask: Task? @@ -62,6 +61,11 @@ struct WorkChatSessionView: View { // host beyond what the phone has fetched; the callback pulls the next page. var hasOlderTranscriptHistory: Bool = false var onLoadOlderTranscript: (@MainActor () async -> Void)? = nil + /// Live "turn is running" signal from the sync layer (chat_subscribe ack + + /// live status/done events). Covers the gap where the synced session row + /// still says idle while chat events are already streaming — without it + /// the chat renders output with no stop button or working indicator. + var liveTurnActiveHint: Bool = false @State var steerEditDrafts: [String: String] = [:] @State var modelPickerPresented = false @@ -71,6 +75,25 @@ struct WorkChatSessionView: View { normalizedWorkChatSessionStatus(session: session, summary: chatSummary) } + /// Combined "a turn is active" signal: transcript-derived (status/done + /// events in the local window) OR the live host hint. Either alone can + /// miss — the transcript window may have dropped the `status: started` + /// event, and the hint is absent on older hosts. + var transcriptOrHintIndicatesActiveTurn: Bool { + timelineSnapshot.transcriptIndicatesActiveTurn || liveTurnActiveHint + } + + /// Single source of truth for "the assistant is generating right now". + /// Drives the activity indicator, the composer stop button, and the + /// streaming-markdown fast path. + var isStreamingTurn: Bool { + workChatIsStreaming( + sessionStatus: sessionStatus, + isLive: isLive, + transcriptIndicatesActiveTurn: transcriptOrHintIndicatesActiveTurn + ) + } + var pendingInputs: [WorkPendingInputItem] { timelineSnapshot.pendingInputs } @@ -175,7 +198,7 @@ struct WorkChatSessionView: View { if sending && !sendWillQueue { return "Sending message to machine..." } - if sendWillQueue { + if sendWillQueue, pendingSteers.isEmpty { return sessionStatus == "active" ? "Message will stage behind the active turn." : "Machine is reconnecting. Send will queue until it is back." @@ -294,11 +317,7 @@ struct WorkChatSessionView: View { var streamingStatusSection: some View { WorkActivityIndicator( transcript: transcript, - isStreaming: workChatIsStreaming( - sessionStatus: sessionStatus, - isLive: isLive, - transcriptIndicatesActiveTurn: timelineSnapshot.transcriptIndicatesActiveTurn - ) + isStreaming: isStreamingTurn ) } @@ -371,7 +390,6 @@ struct WorkChatSessionView: View { WorkChatComposerCard( chatSummary: chatSummary, - queuedSteerCount: pendingSteers.count, pendingInputCount: pendingInputs.count, awaitingInputGate: hasPendingInputGate, canCompose: canCompose, @@ -380,8 +398,11 @@ struct WorkChatSessionView: View { // Show a Stop affordance on the Send button while the assistant is // generating. The chip strip stays usable so users can switch // access/model mid-turn; interruption replaces "Send" with a - // warning-tinted button. - showInterrupt: isLive && sessionStatus == "active", + // warning-tinted button. Gated on the combined streaming signal, not + // just the session row status — the row arrives via the (slower) + // changeset pump and a desktop-started turn would otherwise stream + // output with no way to stop it from the phone. + showInterrupt: isStreamingTurn, interruptInFlight: actionInFlight, onInterrupt: { await runSessionAction(onInterrupt) @@ -393,11 +414,6 @@ struct WorkChatSessionView: View { onSelectEffort: chatSummary == nil ? nil : { effort in Task { await onSelectEffort(effort) } }, - artifactCount: artifacts.count, - latestArtifact: artifacts.last, - artifactRefreshInFlight: artifactRefreshInFlight, - artifactRefreshError: artifactRefreshError, - onOpenProof: { artifactDrawerPresented = true }, onSend: onSend, onSent: { scrollToLatest(proxy, animated: true) @@ -431,9 +447,15 @@ struct WorkChatSessionView: View { } } .padding(16) - .environment(\.workChatProvider, chatSummary?.provider) - .environment(\.workChatModelId, chatSummary?.modelId ?? chatSummary?.model) - .environment(\.workChatModelLabel, chatSummary.map { prettyWorkChatModelName($0.model) }) + .modifier( + WorkChatTranscriptEnvironmentModifier( + provider: chatSummary?.provider, + modelId: chatSummary?.modelId ?? chatSummary?.model, + modelLabel: chatSummary.map { prettyWorkChatModelName($0.model) }, + laneId: session.laneId, + requestedCwd: chatSummary?.requestedCwd + ) + ) } .scrollIndicators(.hidden) .scrollDismissesKeyboard(.interactively) @@ -642,14 +664,14 @@ func mergeWorkPendingSteers( private struct WorkChatComposerCard: View { let chatSummary: AgentChatSessionSummary? - let queuedSteerCount: Int let pendingInputCount: Int let awaitingInputGate: Bool let canCompose: Bool let canSend: Bool let sending: Bool /// True while the assistant is streaming a response. Swaps the Send button - /// for a warning-tinted Stop button that calls `onInterrupt` — replaces the + /// Desktop parity: red bordered stop control in the composer while a turn is + /// active (`border-red-500/25 bg-red-500/[0.08] text-red-400/80`). /// old full-width yellow slab that used to sit under the header. let showInterrupt: Bool let interruptInFlight: Bool @@ -657,18 +679,12 @@ private struct WorkChatComposerCard: View { let onOpenModelPicker: (() -> Void)? let onSelectRuntimeMode: ((String) -> Void)? let onSelectEffort: ((String) -> Void)? - let artifactCount: Int - let latestArtifact: ComputerUseArtifactSummary? - let artifactRefreshInFlight: Bool - let artifactRefreshError: String? - let onOpenProof: () -> Void let onSend: @MainActor (String) async -> Bool let onSent: () -> Void var body: some View { WorkChatComposerDraftInput( chatSummary: chatSummary, - queuedSteerCount: queuedSteerCount, pendingInputCount: pendingInputCount, awaitingInputGate: awaitingInputGate, canCompose: canCompose, @@ -680,11 +696,6 @@ private struct WorkChatComposerCard: View { onOpenModelPicker: onOpenModelPicker, onSelectRuntimeMode: onSelectRuntimeMode, onSelectEffort: onSelectEffort, - artifactCount: artifactCount, - latestArtifact: latestArtifact, - artifactRefreshInFlight: artifactRefreshInFlight, - artifactRefreshError: artifactRefreshError, - onOpenProof: onOpenProof, onSend: onSend, onSent: onSent ) @@ -718,7 +729,6 @@ private struct WorkChatComposerCard: View { private struct WorkChatComposerDraftInput: View { let chatSummary: AgentChatSessionSummary? - let queuedSteerCount: Int let pendingInputCount: Int let awaitingInputGate: Bool let canCompose: Bool @@ -730,26 +740,11 @@ private struct WorkChatComposerDraftInput: View { let onOpenModelPicker: (() -> Void)? let onSelectRuntimeMode: ((String) -> Void)? let onSelectEffort: ((String) -> Void)? - let artifactCount: Int - let latestArtifact: ComputerUseArtifactSummary? - let artifactRefreshInFlight: Bool - let artifactRefreshError: String? - let onOpenProof: () -> Void let onSend: @MainActor (String) async -> Bool let onSent: () -> Void @StateObject private var draftState = WorkChatComposerDraftState() - /// Brand color for the active chat surface, used on the Send pill. Mirrors - /// desktop's provider-level chat accents: Claude amber, Codex warm white, - /// with model color only as a fallback for providers outside that map. - private var sendAccent: Color { - ADEColor.chatSurfaceAccent( - modelId: chatSummary?.modelId ?? chatSummary?.model, - provider: chatSummary?.provider - ) - } - var body: some View { VStack(alignment: .leading, spacing: 12) { WorkChatComposerTextField( @@ -764,7 +759,6 @@ private struct WorkChatComposerDraftInput: View { HStack(alignment: .center, spacing: 8) { WorkComposerChipStrip( chatSummary: chatSummary, - queuedSteerCount: queuedSteerCount, pendingInputCount: pendingInputCount, onOpenModelPicker: onOpenModelPicker, onSelectRuntimeMode: onSelectRuntimeMode, @@ -773,14 +767,6 @@ private struct WorkChatComposerDraftInput: View { Spacer(minLength: 0) - WorkProofComposerButton( - count: artifactCount, - latestArtifact: latestArtifact, - isRefreshing: artifactRefreshInFlight, - refreshError: artifactRefreshError, - onOpen: onOpenProof - ) - if showInterrupt { if draftState.hasSendableText { stopButton(compact: true) @@ -788,8 +774,6 @@ private struct WorkChatComposerDraftInput: View { draftState: draftState, canSend: canSend, sending: sending, - accent: sendAccent, - label: "Stage", accessibilityLabelText: "Stage message", onSend: onSend, onSent: onSent @@ -802,7 +786,6 @@ private struct WorkChatComposerDraftInput: View { draftState: draftState, canSend: canSend, sending: sending, - accent: sendAccent, onSend: onSend, onSent: onSent ) @@ -816,28 +799,29 @@ private struct WorkChatComposerDraftInput: View { Button { Task { await onInterrupt() } } label: { - HStack(spacing: 5) { - if interruptInFlight { - ProgressView() - .controlSize(.mini) - .tint(Color.white) + Group { + if compact { + stopButtonIcon(compact: true) } else { - Image(systemName: "stop.fill") - .font(.system(size: 12, weight: .bold)) - } - if !compact { - Text("Stop") - .font(.caption.weight(.semibold)) + HStack(spacing: 5) { + stopButtonIcon(compact: false) + Text("Stop") + .font(.caption.weight(.semibold)) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) } } - .foregroundStyle(Color.white) - .padding(.horizontal, compact ? 10 : 12) - .padding(.vertical, 8) + .foregroundStyle(ADEColor.danger.opacity(0.85)) + .frame(width: compact ? 28 : nil, height: compact ? 28 : nil) .background( - Capsule(style: .continuous) - .fill(ADEColor.warning) + RoundedRectangle(cornerRadius: compact ? 8 : 10, style: .continuous) + .fill(ADEColor.danger.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: compact ? 8 : 10, style: .continuous) + .stroke(ADEColor.danger.opacity(0.25), lineWidth: 1) ) - .shadow(color: ADEColor.warning.opacity(0.4), radius: 8, y: 2) } .buttonStyle(.plain) .accessibilityLabel(interruptInFlight ? "Interrupting turn" : "Stop turn") @@ -850,6 +834,18 @@ private struct WorkChatComposerDraftInput: View { ] ) } + + @ViewBuilder + private func stopButtonIcon(compact: Bool) -> some View { + if interruptInFlight { + ProgressView() + .controlSize(.mini) + .tint(ADEColor.danger) + } else { + Image(systemName: "stop.fill") + .font(.system(size: compact ? 10 : 12, weight: .bold)) + } + } } private final class WorkChatComposerDraftState: ObservableObject { @@ -905,8 +901,6 @@ private struct WorkChatComposerSendButton: View { @ObservedObject var draftState: WorkChatComposerDraftState let canSend: Bool let sending: Bool - let accent: Color - var label = "Send" var accessibilityLabelText = "Send message" let onSend: @MainActor (String) async -> Bool let onSent: () -> Void @@ -927,30 +921,22 @@ private struct WorkChatComposerSendButton: View { } } } label: { - HStack(spacing: 5) { + ZStack { if sending { ProgressView() .controlSize(.mini) - .tint(sendEnabled ? Color.white : ADEColor.textSecondary) + .tint(sendEnabled ? Color(red: 0.12, green: 0.12, blue: 0.14) : ADEColor.textSecondary) } else { - Image(systemName: "paperplane.fill") - .font(.system(size: 12, weight: .bold)) + Image(systemName: "arrow.up") + .font(.system(size: 14, weight: .bold)) } - Text(label) - .font(.caption.weight(.semibold)) } - .foregroundStyle(sendEnabled ? Color.white : ADEColor.textSecondary) - .padding(.horizontal, 12) - .padding(.vertical, 8) + .frame(width: 28, height: 28) + .foregroundStyle(sendEnabled ? Color(red: 0.12, green: 0.12, blue: 0.14) : ADEColor.textSecondary.opacity(0.2)) .background( - Capsule(style: .continuous) - .fill(sendEnabled ? accent : ADEColor.surfaceBackground.opacity(0.85)) - ) - .overlay( - Capsule(style: .continuous) - .stroke(sendEnabled ? Color.clear : ADEColor.border.opacity(0.35), lineWidth: 0.8) + Circle() + .fill(sendEnabled ? Color.white.opacity(0.9) : Color.white.opacity(0.06)) ) - .shadow(color: sendEnabled ? accent.opacity(0.4) : .clear, radius: 8, y: 2) } .buttonStyle(.plain) .accessibilityLabel(sending ? "Sending message" : accessibilityLabelText) @@ -964,75 +950,3 @@ private struct WorkChatComposerSendButton: View { ) } } - -private struct WorkProofComposerButton: View { - let count: Int - let latestArtifact: ComputerUseArtifactSummary? - let isRefreshing: Bool - let refreshError: String? - let onOpen: () -> Void - - private var tint: Color { - refreshError == nil ? ADEColor.accent : ADEColor.danger - } - - var body: some View { - Button(action: onOpen) { - ZStack(alignment: .topTrailing) { - ZStack { - if isRefreshing { - ProgressView() - .controlSize(.mini) - .tint(tint) - } else { - Image(systemName: "cube.transparent") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(tint) - } - } - .frame(width: 32, height: 32) - .background(ADEColor.raisedBackground.opacity(0.88), in: Circle()) - .overlay( - Circle() - .stroke(tint.opacity(refreshError == nil ? 0.28 : 0.5), lineWidth: 0.8) - ) - - if refreshError != nil { - Image(systemName: "exclamationmark") - .font(.system(size: 8, weight: .black)) - .foregroundStyle(Color.white) - .frame(width: 14, height: 14) - .background(ADEColor.danger, in: Circle()) - .offset(x: 3, y: -3) - } else if count > 0 { - Text("\(min(count, 99))") - .font(.system(size: 9, weight: .bold, design: .rounded)) - .foregroundStyle(Color.white) - .frame(minWidth: 15, minHeight: 15) - .background(tint, in: Capsule()) - .offset(x: 4, y: -4) - } - } - } - .buttonStyle(.plain) - .accessibilityLabel(accessibilityLabel) - .accessibilityHint("Opens the proof drawer") - .adeInspectable( - "Work.Chat.Composer.ProofButton", - metadata: [ - "label": accessibilityLabel, - "role": "button" - ] - ) - } - - private var accessibilityLabel: String { - if let refreshError { - return "Proof drawer, refresh failed: \(refreshError)" - } - guard let latestArtifact else { - return "Proof drawer, no artifacts" - } - return "Proof drawer, \(count) artifact\(count == 1 ? "" : "s"), latest \(workArtifactKindLabel(latestArtifact.artifactKind)) \(relativeTimestamp(latestArtifact.createdAt))" - } -} diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 31e1bfd4c..2103f2417 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -35,7 +35,7 @@ func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMess for envelope in transcript { switch envelope.event { - case .userMessage(let text, let turnId, let steerId, let deliveryState, let processed): + case .userMessage(let text, let attachments, let turnId, let steerId, let deliveryState, let processed): previousEnvelopeWasAssistantText = false // Queued steers render as inline cards above the composer, not in the message stream. if deliveryState == "queued", steerId != nil { @@ -47,6 +47,9 @@ func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMess messages[lastIndex].steerId == steerId, messages[lastIndex].timestamp == envelope.timestamp { messages[lastIndex].markdown += text + if let attachments, !attachments.isEmpty { + messages[lastIndex].attachments = attachments + } if let deliveryState { messages[lastIndex].deliveryState = deliveryState } @@ -63,7 +66,8 @@ func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMess itemId: nil, steerId: steerId, deliveryState: deliveryState, - processed: processed + processed: processed, + attachments: attachments )) } case .assistantText(let text, let turnId, let itemId): @@ -130,7 +134,7 @@ func makeWorkChatTranscript(from entries: [AgentChatTranscriptEntry], sessionId: sequence: nil, event: entry.role == "assistant" ? .assistantText(text: entry.text, turnId: entry.turnId, itemId: nil) - : .userMessage(text: entry.text, turnId: entry.turnId, steerId: nil, deliveryState: nil, processed: nil) + : .userMessage(text: entry.text, attachments: nil, turnId: entry.turnId, steerId: nil, deliveryState: nil, processed: nil) ) } } @@ -182,12 +186,27 @@ func preferredWorkTranscript( // make it into the live stream (compared by role+turnId+text). Without // this, the final assistant reply after e.g. a plan rejection vanishes // from mobile while it still shows on desktop. - return backfillMissingTextEnvelopes(into: merged, fallback: fallback) + let backfilled = backfillMissingTextEnvelopes(into: merged, fallback: fallback) + return pruneResolvedQueuedSteerEnvelopes(backfilled) } if !fallback.isEmpty { - return fallback + return pruneResolvedQueuedSteerEnvelopes(fallback) + } + return pruneResolvedQueuedSteerEnvelopes(current) +} + +/// When an idle session prefers the canonical text transcript, keep tool / +/// notice / queued-steer envelopes from the live event stream but drop the +/// plain user/assistant/status rows the fallback already owns. +func workChatEventIncludedInIdleCanonicalEventTranscript(_ event: WorkChatEvent) -> Bool { + switch event { + case .userMessage(_, _, _, let steerId, let deliveryState, _): + return deliveryState == "queued" && steerId != nil + case .assistantText, .status: + return false + default: + return true } - return current } private func backfillMissingTextEnvelopes( @@ -210,6 +229,9 @@ private func backfillMissingTextEnvelopes( workTextBackfillDedupeKeys(for: envelope).forEach { seen.insert($0) } continue } + if shouldSkipBackfillPlainUserMessage(fallback: envelope, merged: merged) { + continue + } let keys = workTextBackfillDedupeKeys(for: envelope) guard !keys.isEmpty, keys.allSatisfy({ !seen.contains($0) }) else { continue } keys.forEach { seen.insert($0) } @@ -260,7 +282,7 @@ private func replaceTruncatedTextEnvelope( /// (returns nil → skipped). private func workTextContentKey(for envelope: WorkChatEnvelope) -> String? { switch envelope.event { - case .userMessage(let text, let turnId, let steerId, _, _): + case .userMessage(let text, _, let turnId, let steerId, _, _): let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) return "user|\(turnId ?? "")|\(steerId ?? "")|\(normalized)" case .assistantText(let text, let turnId, _): @@ -274,7 +296,7 @@ private func workTextContentKey(for envelope: WorkChatEnvelope) -> String? { private func workTextBackfillDedupeKeys(for envelope: WorkChatEnvelope) -> [String] { guard let key = workTextContentKey(for: envelope) else { return [] } switch envelope.event { - case .userMessage(let text, let turnId, _, _, _): + case .userMessage(let text, _, let turnId, _, _, _): let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) return [key, "user|\(turnId ?? "")|\(normalized)"] default: @@ -284,7 +306,7 @@ private func workTextBackfillDedupeKeys(for envelope: WorkChatEnvelope) -> [Stri private func workTextRoleTurnKey(for envelope: WorkChatEnvelope) -> String? { switch envelope.event { - case .userMessage(_, let turnId, let steerId, _, _): + case .userMessage(_, _, let turnId, let steerId, _, _): return "user|\(turnId ?? "")|\(steerId ?? "")" case .assistantText(_, let turnId, _): return "assistant|\(turnId ?? "")" @@ -295,7 +317,7 @@ private func workTextRoleTurnKey(for envelope: WorkChatEnvelope) -> String? { private func workTextEnvelopeText(_ envelope: WorkChatEnvelope) -> String? { switch envelope.event { - case .userMessage(let text, _, _, _, _): + case .userMessage(let text, _, _, _, _, _): return text case .assistantText(let text, _, _): return text @@ -304,6 +326,60 @@ private func workTextEnvelopeText(_ envelope: WorkChatEnvelope) -> String? { } } +/// The host text transcript omits steer metadata, so backfilling a plain user +/// row while a queued steer envelope is already present would render the same +/// prompt twice — once in the staged strip and again as a sent bubble. +private func shouldSkipBackfillPlainUserMessage( + fallback: WorkChatEnvelope, + merged: [WorkChatEnvelope] +) -> Bool { + guard case .userMessage(let text, _, let turnId, let steerId, let deliveryState, _) = fallback.event else { + return false + } + guard steerId == nil, deliveryState != "queued" else { return false } + let normalizedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedTurnId = turnId ?? "" + for envelope in merged { + guard case .userMessage(let liveText, _, let liveTurnId, let liveSteerId, let liveDelivery, _) = envelope.event else { + continue + } + guard liveDelivery == "queued", liveSteerId != nil else { continue } + if (liveTurnId ?? "") == normalizedTurnId { return true } + if liveText.trimmingCharacters(in: .whitespacesAndNewlines) == normalizedText { return true } + } + return false +} + +/// Drop stale queued `user_message` rows once the same steerId has graduated +/// to delivered/inline/failed or been resolved by a steer system notice. +func pruneResolvedQueuedSteerEnvelopes(_ transcript: [WorkChatEnvelope]) -> [WorkChatEnvelope] { + guard !transcript.isEmpty else { return transcript } + var resolvedSteerIds = Set() + for envelope in sortedWorkChatEnvelopes(transcript) { + switch envelope.event { + case .userMessage(_, _, _, let steerId, let deliveryState, _): + if let steerId, deliveryState != "queued" { + resolvedSteerIds.insert(steerId) + } + case .systemNotice(_, let message, _, _, let steerId): + if let steerId, workSystemNoticeResolvesQueuedSteer(message) { + resolvedSteerIds.insert(steerId) + } + default: + continue + } + } + guard !resolvedSteerIds.isEmpty else { return transcript } + return transcript.filter { envelope in + guard case .userMessage(_, _, _, let steerId, let deliveryState, _) = envelope.event, + let steerId, + deliveryState == "queued", + resolvedSteerIds.contains(steerId) + else { return true } + return false + } +} + func mergeWorkChatTranscripts(base: [WorkChatEnvelope], live: [WorkChatEnvelope]) -> [WorkChatEnvelope] { guard !live.isEmpty else { return base } guard !base.isEmpty else { return live } @@ -756,7 +832,7 @@ func derivePendingWorkSteers(from transcript: [WorkChatEnvelope]) -> [WorkPendin var resolved = Set() for envelope in sortedWorkChatEnvelopes(transcript) { switch envelope.event { - case .userMessage(let text, let turnId, let steerId, let deliveryState, _): + case .userMessage(let text, _, let turnId, let steerId, let deliveryState, _): guard let steerId, !resolved.contains(steerId) else { continue } if deliveryState == "queued" { if queue[steerId] == nil { order.append(steerId) } @@ -841,13 +917,14 @@ func workChatEnvelopeMergeKey(_ envelope: WorkChatEnvelope) -> String { func workChatEventMergeKey(_ event: WorkChatEvent) -> String { switch event { - case .userMessage(let text, let turnId, let steerId, let deliveryState, let processed): + case .userMessage(let text, let attachments, let turnId, let steerId, let deliveryState, let processed): // Queued steers are uniquely identified by steerId so that editSteer replaces the existing // entry in place instead of spawning a duplicate row whose only difference is the edited text. if let steerId, deliveryState == "queued" { return ["user_message", turnId ?? "", steerId, "queued"].joined(separator: "|") } - return ["user_message", turnId ?? "", steerId ?? "", deliveryState ?? "", processed.map { $0 ? "1" : "0" } ?? "", text].joined(separator: "|") + let attachmentDigest = (attachments ?? []).map { "\($0.type):\($0.path)" }.joined(separator: ",") + return ["user_message", turnId ?? "", steerId ?? "", deliveryState ?? "", processed.map { $0 ? "1" : "0" } ?? "", attachmentDigest, text].joined(separator: "|") case .assistantText(let text, let turnId, let itemId): return ["text", turnId ?? "", itemId ?? "", text].joined(separator: "|") case .toolCall(let tool, let argsText, let itemId, let parentItemId, let turnId): diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index e577fc630..aa360e36a 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -33,8 +33,15 @@ private let workANSIAttributedStringCache: NSCache WorkChatEvent { switch event { - case .userMessage(let text, _, let turnId, let steerId, let deliveryState, let processed): - return .userMessage(text: text, turnId: turnId, steerId: steerId, deliveryState: deliveryState, processed: processed) + case .userMessage(let text, let attachments, let turnId, let steerId, let deliveryState, let processed): + return .userMessage( + text: text, + attachments: attachments, + turnId: turnId, + steerId: steerId, + deliveryState: deliveryState, + processed: processed + ) case .text(let text, let messageId, let turnId, let itemId): let normalizedMessageId = messageId?.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedItemId = itemId?.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift b/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift new file mode 100644 index 000000000..3e462ef06 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkLanePickerDropdown.swift @@ -0,0 +1,349 @@ +import SwiftUI + +/// Synthetic lane id for the draft-composer "Auto-create lane" row. Matches desktop +/// `AUTO_CREATE_LANE_OPTION_ID`. +let workAutoCreateLaneSentinelId = "__ade_auto_create_lane__" + +/// Desktop-shaped lane picker for the new-chat welcome screen. Mirrors +/// `apps/desktop/src/renderer/components/terminals/LaneCombobox.tsx`: a pill +/// trigger showing lane name + branch, and a searchable dropdown with an +/// auto-create row plus color-coded lane rows. +struct WorkLanePickerDropdown: View { + let lanes: [LaneSummary] + @Binding var selectedLaneId: String + var showsAutoCreateOption: Bool = true + var emptySelectionTitle: String = "Select lane..." + var laneSubtitle: ((LaneSummary) -> String?)? = nil + var isLaneDisabled: ((LaneSummary) -> Bool)? = nil + var onRefresh: (@MainActor () async -> Void)? = nil + + @State private var menuPresented = false + @State private var searchQuery = "" + + private var isAutoCreateSelected: Bool { + selectedLaneId == workAutoCreateLaneSentinelId + } + + private var selectedLane: LaneSummary? { + lanes.first(where: { $0.id == selectedLaneId }) + } + + private var triggerTitle: String { + if isAutoCreateSelected { return "Auto-create lane" } + return selectedLane?.name ?? emptySelectionTitle + } + + private var triggerBranchLabel: String? { + guard !isAutoCreateSelected, let lane = selectedLane else { return nil } + let label = normalizedPrBranchName(lane.branchRef) + return label.isEmpty ? nil : label + } + + private var triggerLaneColor: Color? { + guard !isAutoCreateSelected, let lane = selectedLane else { return nil } + return LaneColorPalette.displayColor(forHex: lane.color, fallback: ADEColor.textSecondary) + } + + private var filteredLanes: [LaneSummary] { + let trimmed = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return lanes } + return lanes.filter { lane in + lane.name.lowercased().contains(trimmed) + || normalizedPrBranchName(lane.branchRef).lowercased().contains(trimmed) + } + } + + var body: some View { + HStack(spacing: 8) { + Button { + menuPresented = true + } label: { + triggerLabel + } + .buttonStyle(.plain) + .accessibilityLabel("Select lane") + .accessibilityValue(triggerTitle) + .popover(isPresented: $menuPresented, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom) { + WorkLanePickerMenu( + lanes: filteredLanes, + allLanesEmpty: lanes.isEmpty, + selectedLaneId: selectedLaneId, + showsAutoCreateOption: showsAutoCreateOption, + laneSubtitle: laneSubtitle, + isLaneDisabled: isLaneDisabled, + searchQuery: $searchQuery, + onSelect: { laneId in + selectedLaneId = laneId + menuPresented = false + searchQuery = "" + } + ) + .frame(width: 280) + .presentationCompactAdaptation(.popover) + } + .onChange(of: menuPresented) { _, isOpen in + if !isOpen { searchQuery = "" } + } + + if let onRefresh { + Button { + Task { await onRefresh() } + } label: { + Image(systemName: "arrow.clockwise") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .frame(width: 34, height: 34) + .background(ADEColor.surfaceBackground.opacity(0.55), in: Circle()) + .glassEffect() + .overlay(Circle().stroke(ADEColor.accent.opacity(0.26), lineWidth: 0.6)) + } + .buttonStyle(.plain) + .accessibilityLabel("Refresh lanes") + .accessibilityHint("Reloads the lane list from your paired desktop.") + } + } + } + + private var triggerLabel: some View { + let surface = laneTriggerSurface(for: selectedLane, isAutoCreate: isAutoCreateSelected) + return ZStack { + centeredTriggerContent + .padding(.horizontal, 26) + HStack(spacing: 0) { + Spacer(minLength: 0) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(ADEColor.textMuted.opacity(0.6)) + .padding(.trailing, 10) + } + } + .padding(.leading, 12) + .padding(.trailing, 4) + .padding(.vertical, triggerBranchLabel == nil ? 6 : 5) + .background(surface.background, in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(surface.border, lineWidth: 1) + ) + .frame(minWidth: 180, maxWidth: 320) + } + + @ViewBuilder + private var centeredTriggerContent: some View { + if let branch = triggerBranchLabel { + VStack(spacing: 2) { + triggerTitleRow + HStack(spacing: 4) { + Image(systemName: "arrow.branch") + .font(.system(size: 9, weight: .regular)) + .foregroundStyle(ADEColor.textMuted.opacity(0.55)) + Text(branch) + .font(.system(size: 10)) + .foregroundStyle(ADEColor.textMuted.opacity(0.92)) + .lineLimit(1) + } + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + } else { + triggerTitleRow + .frame(maxWidth: .infinity, alignment: .center) + } + } + + @ViewBuilder + private var triggerTitleRow: some View { + HStack(spacing: 5) { + if let triggerLaneColor, !isAutoCreateSelected { + WorkLaneLogoMark(color: triggerLaneColor, laneIcon: selectedLane?.icon, size: 11) + } else if isAutoCreateSelected { + Image(systemName: "sparkles") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(ADEColor.accent) + } + Text(triggerTitle) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.white.opacity(isAutoCreateSelected ? 0.85 : 0.85)) + .lineLimit(1) + } + } + + private func laneTriggerSurface(for lane: LaneSummary?, isAutoCreate: Bool) -> (background: Color, border: Color) { + if isAutoCreate { + return (ADEColor.accent.opacity(0.06), ADEColor.accent.opacity(0.3)) + } + guard let lane, let hex = lane.color?.trimmingCharacters(in: .whitespacesAndNewlines), !hex.isEmpty, + let tint = LaneColorPalette.color(forHex: hex) else { + return (Color.white.opacity(0.04), Color.white.opacity(0.08)) + } + return (tint.opacity(0.12), tint.opacity(0.35)) + } +} + +private struct WorkLanePickerMenu: View { + let lanes: [LaneSummary] + let allLanesEmpty: Bool + let selectedLaneId: String + var showsAutoCreateOption: Bool = true + var laneSubtitle: ((LaneSummary) -> String?)? = nil + var isLaneDisabled: ((LaneSummary) -> Bool)? = nil + @Binding var searchQuery: String + let onSelect: (String) -> Void + + @FocusState private var searchFocused: Bool + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(ADEColor.textMuted.opacity(0.5)) + TextField("Search lanes...", text: $searchQuery) + .textFieldStyle(.plain) + .font(.system(size: 11)) + .foregroundStyle(ADEColor.textPrimary) + .focused($searchFocused) + .submitLabel(.done) + } + .padding(.horizontal, 10) + .frame(height: 32) + .overlay(alignment: .bottom) { + Rectangle() + .fill(ADEColor.border.opacity(0.35)) + .frame(height: 0.5) + } + + if showsAutoCreateOption { + Button { + onSelect(workAutoCreateLaneSentinelId) + } label: { + HStack(spacing: 6) { + WorkOrchestratorRainbowText(text: "Auto-create lane") + if selectedLaneId == workAutoCreateLaneSentinelId { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ADEColor.accent) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + ScrollView { + LazyVStack(spacing: 0) { + if lanes.isEmpty { + Text(allLanesEmpty ? "No lanes available" : "No lanes found") + .font(.system(size: 11)) + .foregroundStyle(ADEColor.textMuted) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } else { + ForEach(lanes) { lane in + laneRow(lane) + } + } + } + .padding(4) + } + .frame(maxHeight: 260) + } + .background(ADEColor.cardBackground.opacity(0.96)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.8) + ) + .shadow(color: Color.black.opacity(0.45), radius: 16, y: 8) + .onAppear { + searchFocused = true + } + } + + private func laneRow(_ lane: LaneSummary) -> some View { + let isSelected = lane.id == selectedLaneId + let branch = normalizedPrBranchName(lane.branchRef) + let laneColor = LaneColorPalette.displayColor(forHex: lane.color, fallback: ADEColor.textSecondary) + let disabled = isLaneDisabled?(lane) ?? false + let eligibilitySubtitle = laneSubtitle?(lane) + + return Button { + guard !disabled else { return } + onSelect(lane.id) + } label: { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + WorkLaneLogoMark(color: laneColor, laneIcon: lane.icon, size: 12) + .opacity(disabled ? 0.45 : 1) + Text(lane.name) + .font(.system(size: 11, weight: isSelected ? .medium : .regular)) + .foregroundStyle(disabled ? ADEColor.textMuted : ADEColor.textPrimary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + if disabled { + Image(systemName: "lock.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(ADEColor.warning.opacity(0.85)) + } else if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ADEColor.accent) + } + } + if !branch.isEmpty { + HStack(spacing: 4) { + Image(systemName: "arrow.branch") + .font(.system(size: 10, weight: .regular)) + .foregroundStyle(ADEColor.textMuted.opacity(0.6)) + Text(branch) + .font(.system(size: 10)) + .foregroundStyle(ADEColor.textMuted.opacity(0.92)) + .lineLimit(1) + } + .padding(.leading, 18) + } + if let eligibilitySubtitle, !eligibilitySubtitle.isEmpty { + Text(eligibilitySubtitle) + .font(.system(size: 10)) + .foregroundStyle(disabled ? ADEColor.warning.opacity(0.9) : ADEColor.textSecondary) + .lineLimit(2) + .padding(.leading, 18) + } + } + .padding(.horizontal, 8) + .padding(.vertical, branch.isEmpty && eligibilitySubtitle == nil ? 6 : 5) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + isSelected ? ADEColor.accent.opacity(0.12) : Color.clear, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + } +} + +/// Desktop `ade-orchestrator-rainbow-text` gradient label for auto-create lane. +private struct WorkOrchestratorRainbowText: View { + let text: String + + private static let colors: [Color] = [ + Color(red: 1.0, green: 0.37, blue: 0.37), + Color(red: 1.0, green: 0.61, blue: 0.25), + Color(red: 0.97, green: 0.82, blue: 0.36), + Color(red: 0.35, green: 0.85, blue: 0.50), + Color(red: 0.31, green: 0.58, blue: 1.0), + Color(red: 0.65, green: 0.40, blue: 1.0), + ] + + var body: some View { + Text(text) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle( + LinearGradient(colors: Self.colors, startPoint: .leading, endPoint: .trailing) + ) + } +} diff --git a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift index 70480a2b1..9ba54d89b 100644 --- a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift +++ b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift @@ -97,6 +97,52 @@ func workFilterChatModelsForCursorAvailability( models.filter { workAgentChatModelSupportsCursorAvailabilityMode($0, mode: mode) } } +/// Maps a picked model to the tracked CLI runtime provider, mirroring desktop +/// `resolveCliProviderForModel` + `resolveProviderGroupForModel`. +func workResolveCliProvider(for modelId: String, provider: String) -> String { + switch workModelCatalogGroupKey(for: modelId, currentProvider: provider) { + case "claude": return "claude" + case "codex": return "codex" + case "cursor": return "cursor" + case "droid": return "droid" + default: return "opencode" + } +} + +func workModelAllowedForAvailabilityMode( + modelId: String, + provider: String, + mode: WorkCursorAvailabilityMode +) -> Bool { + let groups = workFilterCatalogForCursorAvailability( + workModelCatalogGroups(currentModelId: modelId, currentProvider: provider), + mode: mode + ) + return groups.contains { group in + group.providers.contains { providerEntry in + providerEntry.models.contains { workModelIdsEquivalent($0.id, modelId) } + } + } +} + +func workDefaultModelIdForAvailabilityMode( + preferredProvider: String, + mode: WorkCursorAvailabilityMode +) -> (modelId: String, provider: String)? { + let family = providerFamilyKey(preferredProvider) + let filtered = workFilterCatalogForCursorAvailability(workCuratedModelCatalogGroups(), mode: mode) + if let match = filtered.first(where: { $0.key == family })? + .providers + .flatMap(\.models) + .first { + return (match.id, match.provider) + } + if let fallback = filtered.first?.providers.flatMap(\.models).first { + return (fallback.id, fallback.provider) + } + return nil +} + func workFilterCatalogForCursorAvailability( _ groups: [WorkModelCatalogGroup], mode: WorkCursorAvailabilityMode diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift index f694a7c8c..d83dd675f 100644 --- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift @@ -47,6 +47,8 @@ struct WorkModelPickerSheet: View { @State private var isLoadingCatalog = false @State private var didPickInitialSelection = false @State private var selectedProviderTabKey: String? + /// Model card awaiting a reasoning tier before the picker commits. + @State private var pendingModelId: String? private var catalog: [WorkModelCatalogGroup] { if let liveCatalog { @@ -93,6 +95,9 @@ struct WorkModelPickerSheet: View { var body: some View { NavigationStack { VStack(spacing: 0) { + if pendingModelId != nil { + pendingReasoningBanner + } searchBar if isLoadingCatalog && catalog.isEmpty { loadingState @@ -109,6 +114,7 @@ struct WorkModelPickerSheet: View { onSelect: { next in selection = next selectedProviderTabKey = nil + pendingModelId = nil if case .providerGroup(let key, _) = next { Task { await refreshCatalog(for: key) } } @@ -125,8 +131,10 @@ struct WorkModelPickerSheet: View { selectedProviderTabKey: selectedProviderTabKey, currentModelId: currentModelId, currentReasoningEffort: currentReasoningEffort, + pendingModelId: pendingModelId, favorites: picker.favorites, isBusy: isBusy, + onHighlight: { model in pendingModelId = model.id }, onSelect: { model, effort in commit(model: model, effort: effort) }, onSelectProviderTab: { selectedProviderTabKey = $0 }, onToggleFavorite: { picker.toggleFavorite($0, syncService: syncService) } @@ -317,6 +325,28 @@ struct WorkModelPickerSheet: View { // MARK: Subviews + @ViewBuilder + private var pendingReasoningBanner: some View { + HStack(spacing: 8) { + Image(systemName: "brain.head.profile") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + Text("Pick reasoning level") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Spacer(minLength: 0) + if let pendingModelId, let model = modelById[pendingModelId] { + Text(model.displayName) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + } + } + .padding(.horizontal, 14) + .padding(.top, 10) + .padding(.bottom, 4) + } + @ViewBuilder private var searchBar: some View { HStack(spacing: 8) { @@ -329,6 +359,11 @@ struct WorkModelPickerSheet: View { .foregroundStyle(ADEColor.textPrimary) .autocorrectionDisabled() .textInputAutocapitalization(.never) + .onChange(of: searchText) { _, newValue in + if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + pendingModelId = nil + } + } if !searchText.isEmpty { Button { searchText = "" @@ -342,15 +377,15 @@ struct WorkModelPickerSheet: View { } } .padding(.horizontal, 12) - .padding(.vertical, 9) - .background(ADEColor.recessedBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.vertical, 8) + .background(ADEColor.recessedBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) + RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(ADEColor.glassBorder, lineWidth: 0.5) ) - .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 10) + .padding(.horizontal, 14) + .padding(.top, 10) + .padding(.bottom, 8) } @ViewBuilder @@ -456,6 +491,7 @@ struct WorkModelPickerSheet: View { let effortChanged = (nextEffort ?? "") != normalizedCurrentEffort let isNoOp = workModelIdsEquivalent(model.id, currentModelId) && !effortChanged + pendingModelId = nil if isNoOp { dismiss() return @@ -515,23 +551,21 @@ struct ModelPickerRail: View { var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .center, spacing: 4) { - ForEach(entries) { entry in - VStack(spacing: 4) { - railButton(entry) - if case .recents = entry { - Divider() - .overlay(ADEColor.glassBorder) - .frame(width: 28) - .padding(.vertical, 4) - } + ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in + if index > 0, case .providerGroup = entry, case .recents = entries[index - 1] { + Divider() + .overlay(ADEColor.glassBorder.opacity(0.8)) + .frame(width: 28) + .padding(.vertical, 2) } + railButton(entry) } } .padding(.vertical, 8) - .padding(.horizontal, 6) + .padding(.horizontal, 5) } - .frame(width: 56) - .background(ADEColor.recessedBackground.opacity(0.4)) + .frame(width: 52) + .background(ADEColor.recessedBackground.opacity(0.35)) } @ViewBuilder @@ -542,20 +576,28 @@ struct ModelPickerRail: View { } label: { ZStack(alignment: .topTrailing) { railIcon(for: entry, isActive: isActive) - .frame(width: 44, height: 44) + .frame(maxWidth: .infinity, minHeight: 44) .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(isActive ? ADEColor.accent.opacity(0.16) : Color.clear) + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(isActive ? Color.white.opacity(0.07) : Color.clear) ) .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(isActive ? ADEColor.accent.opacity(0.35) : Color.clear, lineWidth: 0.8) + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(isActive ? Color.white.opacity(0.06) : Color.clear, lineWidth: 0.8) ) + .overlay(alignment: .trailing) { + if isActive { + RoundedRectangle(cornerRadius: 1, style: .continuous) + .fill(ADEColor.accent.opacity(0.85)) + .frame(width: 2, height: 20) + .offset(x: 2) + } + } if let badge = badgeCount(for: entry), badge > 0 { Text("\(badge)") - .font(.system(size: 9, weight: .bold)) + .font(.system(size: 8, weight: .bold)) .foregroundStyle(ADEColor.textPrimary) - .padding(.horizontal, 4) + .padding(.horizontal, 3) .padding(.vertical, 1) .background( Capsule(style: .continuous) @@ -565,7 +607,7 @@ struct ModelPickerRail: View { Capsule(style: .continuous) .stroke(ADEColor.glassBorder, lineWidth: 0.5) ) - .offset(x: 6, y: -4) + .offset(x: 4, y: -3) } } } @@ -587,25 +629,22 @@ struct ModelPickerRail: View { switch entry { case .favorites: Image(systemName: isActive ? "star.fill" : "star") - .font(.system(size: 17, weight: .semibold)) + .font(.system(size: 16, weight: .semibold)) .foregroundStyle(ADEColor.warning) case .recents: Image(systemName: isActive ? "clock.fill" : "clock") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(isActive ? ADEColor.accent : ADEColor.textSecondary) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(isActive ? ADEColor.textPrimary : ADEColor.textSecondary) case .providerGroup(let key, _): - WorkProviderLogo(provider: providerKeyForLogo(key), size: 30) + WorkProviderBareLogo( + provider: workRailLogoProvider(for: key), + fallbackSymbol: providerIcon(key), + tint: providerTint(key), + size: 18 + ) } } - /// The rail rows show a per-family logo. For the curated "claude"/"codex" - /// groups the brand asset key is the same as the group key, but the desktop - /// catalog uses keys like "opencode" / "cursor" / "droid" which already - /// resolve to brand assets via the existing `providerAssetName` map. - private func providerKeyForLogo(_ key: String) -> String { - key - } - private func accessibilityLabel(for entry: ModelPickerRailEntry) -> String { switch entry { case .favorites: return "Favorites (\(favoritesCount))" @@ -633,14 +672,31 @@ struct ModelPickerContentPane: View { let selectedProviderTabKey: String? let currentModelId: String let currentReasoningEffort: String + let pendingModelId: String? let favorites: [String] let isBusy: Bool + let onHighlight: (WorkModelOption) -> Void let onSelect: (WorkModelOption, String?) -> Void let onSelectProviderTab: (String) -> Void let onToggleFavorite: (String) -> Void private var favoritesSet: Set { Set(favorites) } + private var catalogGroupKey: String? { + if case .providerGroup(let key, _) = selection { return key } + return nil + } + + private var rowStyle: ModelPickerRowStyle { + if isSearching { return .compact } + switch selection { + case .favorites, .recents: + return .compact + case .providerGroup: + return .detailed + } + } + var body: some View { VStack(alignment: .leading, spacing: 0) { header @@ -650,9 +706,9 @@ struct ModelPickerContentPane: View { emptyState } else { ScrollView { - LazyVStack(alignment: .leading, spacing: 14) { + LazyVStack(alignment: .leading, spacing: rowStyle == .compact ? 4 : 5) { ForEach(groupedRows) { group in - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: rowStyle == .compact ? 4 : 5) { if let title = group.title { Text(title.uppercased()) .font(.caption2.weight(.bold)) @@ -664,10 +720,15 @@ struct ModelPickerContentPane: View { ForEach(group.models) { model in ModelPickerListRow( model: model, - isActive: workModelIdsEquivalent(model.id, currentModelId), + style: rowStyle, + catalogGroupKey: catalogGroupKey, + isSessionActive: workModelIdsEquivalent(model.id, currentModelId), + isPending: pendingModelId.map { workModelIdsEquivalent(model.id, $0) } ?? false, + hasPendingSelection: pendingModelId != nil, isFavorite: favoritesSet.contains(model.id), isBusy: isBusy, currentReasoningEffort: currentReasoningEffort, + onHighlight: { onHighlight(model) }, onSelect: { effort in onSelect(model, effort) }, onToggleFavorite: { onToggleFavorite(model.id) } ) @@ -675,8 +736,8 @@ struct ModelPickerContentPane: View { } } } - .padding(.horizontal, 14) - .padding(.vertical, 14) + .padding(.horizontal, 10) + .padding(.vertical, 8) } } } @@ -725,9 +786,7 @@ struct ModelPickerContentPane: View { @ViewBuilder private var header: some View { HStack(alignment: .center, spacing: 8) { - Image(systemName: headerSystemImage) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(headerTint) + headerLeadingIcon Text(headerTitle) .font(.subheadline.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) @@ -744,8 +803,35 @@ struct ModelPickerContentPane: View { ) } } - .padding(.horizontal, 14) - .padding(.vertical, 10) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + @ViewBuilder + private var headerLeadingIcon: some View { + if isSearching { + Image(systemName: "magnifyingglass") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + } else { + switch selection { + case .favorites: + Image(systemName: "star.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.warning) + case .recents: + Image(systemName: "clock.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + case .providerGroup(let key, _): + WorkProviderBareLogo( + provider: workRailLogoProvider(for: key), + fallbackSymbol: providerIcon(key), + tint: providerTint(key), + size: 16 + ) + } + } } private var headerTitle: String { @@ -760,24 +846,6 @@ struct ModelPickerContentPane: View { } } - private var headerSystemImage: String { - if isSearching { return "magnifyingglass" } - switch selection { - case .favorites: return "star.fill" - case .recents: return "clock.fill" - case .providerGroup: return "cpu" - } - } - - private var headerTint: Color { - if isSearching { return ADEColor.textSecondary } - switch selection { - case .favorites: return ADEColor.warning - case .recents: return ADEColor.accent - case .providerGroup: return ADEColor.textSecondary - } - } - @ViewBuilder private var emptyState: some View { VStack(spacing: 8) { @@ -833,15 +901,37 @@ struct ModelPickerContentPane: View { // MARK: - Row +enum ModelPickerRowStyle { + case compact + case detailed +} + struct ModelPickerListRow: View { let model: WorkModelOption - let isActive: Bool + let style: ModelPickerRowStyle + let catalogGroupKey: String? + let isSessionActive: Bool + let isPending: Bool + let hasPendingSelection: Bool let isFavorite: Bool let isBusy: Bool let currentReasoningEffort: String + let onHighlight: () -> Void let onSelect: (String?) -> Void let onToggleFavorite: () -> Void + private var isHighlighted: Bool { + isPending || (isSessionActive && !hasPendingSelection) + } + + private var showsSessionActiveBadge: Bool { + isSessionActive && !hasPendingSelection + } + + private var rowLogoProvider: String { + workModelRowLogoProvider(for: model, catalogGroupKey: catalogGroupKey) + } + private var supportedTiers: [String] { var seen = Set() return model.reasoningEfforts.compactMap { effort in @@ -851,91 +941,112 @@ struct ModelPickerListRow: View { } } - private var rowSelectionEffort: String? { - guard !supportedTiers.isEmpty else { return nil } - let normalizedCurrent = currentReasoningEffort - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - if supportedTiers.contains(normalizedCurrent) { - return normalizedCurrent - } - return supportedTiers.first(where: { $0 == "medium" }) - ?? supportedTiers.first(where: { $0 == "low" }) - ?? supportedTiers.first - } - var body: some View { VStack(alignment: .leading, spacing: 0) { - Button { - onSelect(rowSelectionEffort) + Button { + if supportedTiers.isEmpty { + onSelect(nil) + } else { + onHighlight() + } } label: { - headerRow - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isBusy || !model.isAvailable) + Group { + switch style { + case .compact: + compactHeaderRow + case .detailed: + detailedHeaderRow + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isBusy || !model.isAvailable) if !supportedTiers.isEmpty { reasoningPills(tiers: supportedTiers) - .padding(.top, 8) + .padding(.top, style == .detailed ? 6 : 4) } } - .padding(.horizontal, 12) - .padding(.vertical, 10) + .padding(.horizontal, style == .compact ? 10 : 11) + .padding(.vertical, style == .compact ? 7 : 8) .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(isActive ? ADEColor.accent.opacity(0.08) : ADEColor.surfaceBackground.opacity(model.isAvailable ? 0.55 : 0.32)) + RoundedRectangle(cornerRadius: style == .compact ? 10 : 11, style: .continuous) + .fill(isHighlighted ? ADEColor.accent.opacity(0.10) : ADEColor.surfaceBackground.opacity(model.isAvailable ? 0.45 : 0.28)) ) .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(isActive ? ADEColor.accent.opacity(0.35) : ADEColor.glassBorder, lineWidth: isActive ? 1 : 0.5) + RoundedRectangle(cornerRadius: style == .compact ? 10 : 11, style: .continuous) + .stroke(isHighlighted ? ADEColor.accent.opacity(isPending ? 0.45 : 0.32) : ADEColor.glassBorder.opacity(0.7), lineWidth: isHighlighted ? 1 : 0.5) ) .contentShape(Rectangle()) } @ViewBuilder - private var headerRow: some View { - HStack(alignment: .center, spacing: 10) { - WorkProviderLogo(provider: model.provider, size: 28) - VStack(alignment: .leading, spacing: 3) { + private var compactHeaderRow: some View { + HStack(alignment: .center, spacing: 8) { + favoriteButton + WorkProviderBareLogo( + provider: rowLogoProvider, + fallbackSymbol: providerIcon(rowLogoProvider), + tint: providerTint(rowLogoProvider), + size: 18 + ) + VStack(alignment: .leading, spacing: 1) { + Text(model.displayName) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(model.isAvailable ? ADEColor.textPrimary : ADEColor.textMuted) + .lineLimit(1) + Text(providerLabel(rowLogoProvider)) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted.opacity(0.85)) + .lineLimit(1) + } + Spacer(minLength: 4) + if showsSessionActiveBadge { + Image(systemName: "checkmark") + .font(.caption.weight(.bold)) + .foregroundStyle(ADEColor.accent) + } + } + .accessibilityLabel("\(model.displayName), \(providerLabel(rowLogoProvider))\(showsSessionActiveBadge ? ". Currently selected." : isPending ? ". Pick a reasoning level." : "")") + } + + @ViewBuilder + private var detailedHeaderRow: some View { + HStack(alignment: .center, spacing: 9) { + WorkProviderBareLogo( + provider: rowLogoProvider, + fallbackSymbol: providerIcon(rowLogoProvider), + tint: providerTint(rowLogoProvider), + size: 20 + ) + VStack(alignment: .leading, spacing: 1) { HStack(spacing: 6) { - Text(model.displayName) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(model.isAvailable ? ADEColor.textPrimary : ADEColor.textMuted) + Text(model.displayName) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(model.isAvailable ? ADEColor.textPrimary : ADEColor.textMuted) .lineLimit(1) - if isActive { + if showsSessionActiveBadge { Text("active") .font(.caption2.weight(.bold)) .tracking(0.3) .foregroundStyle(ADEColor.accent) - .padding(.horizontal, 6) + .padding(.horizontal, 5) .padding(.vertical, 2) .background(ADEColor.accent.opacity(0.15), in: Capsule()) } } - HStack(spacing: 6) { - Text(workModelTierLabel(model.tier)) - .font(.caption2.monospaced().weight(.bold)) - .tracking(0.3) - .foregroundStyle(workModelTierTint(model.tier)) - Text("·") - .foregroundStyle(ADEColor.textMuted) - Text(model.tagline) - .font(.caption) - .foregroundStyle(model.isAvailable ? ADEColor.textSecondary : ADEColor.textMuted) - .lineLimit(1) - } } - Spacer(minLength: 8) + Spacer(minLength: 6) favoriteButton - if isActive { + if showsSessionActiveBadge { Image(systemName: "checkmark") .font(.subheadline.weight(.bold)) .foregroundStyle(ADEColor.accent) } } - .accessibilityLabel("\(model.displayName), \(workModelTierLabel(model.tier)). \(model.tagline)\(isActive ? ". Currently selected." : "")") + .accessibilityLabel("\(model.displayName)\(showsSessionActiveBadge ? ". Currently selected." : isPending ? ". Pick a reasoning level." : "")") } @ViewBuilder @@ -958,42 +1069,35 @@ struct ModelPickerListRow: View { let normalizedCurrent = currentReasoningEffort .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() - HStack(spacing: 6) { - Text("REASONING") - .font(.system(size: 9, weight: .bold)) - .tracking(0.4) - .foregroundStyle(ADEColor.textMuted) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 5) { - ForEach(tiers, id: \.self) { tier in - let normalized = tier.lowercased() - let isActiveTier = isActive && normalized == normalizedCurrent - Button { - onSelect(normalized) - } label: { - Text(reasoningLabel(for: tier)) - .font(.caption2.weight(.semibold)) - .foregroundStyle(isActiveTier ? Color.white : ADEColor.textSecondary) - .lineLimit(1) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background( - Capsule(style: .continuous) - .fill(isActiveTier ? ADEColor.accent : ADEColor.surfaceBackground.opacity(0.6)) - ) - .overlay( - Capsule(style: .continuous) - .stroke(isActiveTier ? ADEColor.accent : ADEColor.glassBorder, lineWidth: 0.6) - ) - } - .buttonStyle(.plain) - .disabled(isBusy || !model.isAvailable) - .accessibilityLabel("\(model.displayName) · reasoning \(reasoningLabel(for: tier))") - .accessibilityAddTraits(isActiveTier ? .isSelected : []) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(tiers, id: \.self) { tier in + let normalized = tier.lowercased() + let isActiveTier = showsSessionActiveBadge && normalized == normalizedCurrent + Button { + onSelect(normalized) + } label: { + Text(reasoningLabel(for: tier)) + .font(.caption.weight(.semibold)) + .foregroundStyle(isActiveTier ? Color.white : ADEColor.textSecondary) + .lineLimit(1) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + Capsule(style: .continuous) + .fill(isActiveTier ? ADEColor.accent : ADEColor.surfaceBackground.opacity(isPending ? 0.75 : 0.5)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(isActiveTier ? ADEColor.accent : ADEColor.glassBorder, lineWidth: 0.6) + ) } + .buttonStyle(.plain) + .disabled(isBusy || !model.isAvailable || !isPending) + .accessibilityLabel("\(model.displayName) · reasoning \(reasoningLabel(for: tier))") + .accessibilityAddTraits(isActiveTier ? .isSelected : []) } } - Spacer(minLength: 0) } } diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 4cb9b2b67..92e32b124 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -35,6 +35,7 @@ struct WorkChatMessage: Identifiable, Equatable { var steerId: String? = nil var deliveryState: String? = nil var processed: Bool? = nil + var attachments: [AgentChatFileRef]? = nil } struct WorkLocalEchoMessage: Identifiable, Equatable { @@ -178,9 +179,8 @@ enum WorkTimelinePayload: Equatable { /// `Tool calls (n)` panel. case toolGroup(WorkToolGroupModel) /// Cluster of consecutive code-change entries (file_change events plus - /// write-category tool calls) collapsed into a single header-only row. - /// Tap to reveal per-file rows with diff stats; tap a row to reveal its - /// diff. Matches the desktop `N files changed` panel. + /// write-category tool calls) collapsed into a single flat header row. + /// Collapsed by default like tool calls; tap to reveal per-file rows. case changedFiles(WorkChangedFilesGroupModel) case eventCard(WorkEventCardModel) case usageSummary(WorkUsageSummary) @@ -428,7 +428,7 @@ struct WorkChatEnvelope: Identifiable, Equatable { } enum WorkChatEvent: Equatable { - case userMessage(text: String, turnId: String?, steerId: String?, deliveryState: String?, processed: Bool?) + case userMessage(text: String, attachments: [AgentChatFileRef]?, turnId: String?, steerId: String?, deliveryState: String?, processed: Bool?) case assistantText(text: String, turnId: String?, itemId: String?) case toolCall(tool: String, argsText: String, itemId: String, parentItemId: String?, turnId: String?) case toolResult(tool: String, resultText: String, itemId: String, parentItemId: String?, turnId: String?, status: WorkToolCardStatus) diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 9c63315d3..54a1b1d07 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -8,30 +8,81 @@ enum WorkNewSessionMode: String, CaseIterable, Identifiable { var title: String { switch self { - case .chat: return "ADE chat" - case .cli: return "CLI session" + case .chat: return "Chat" + case .cli: return "CLI" + } + } + + var systemImage: String { + switch self { + case .chat: return "bubble.left.and.bubble.right" + case .cli: return "chevron.left.forwardslash.chevron.right" } } -} -struct WorkCliProviderOption: Identifiable, Hashable { - let id: String - let title: String + var accessibilityDescription: String { + switch self { + case .chat: return "In-app chat agent" + case .cli: return "Terminal CLI agent" + } + } } -private let workCliProviderOptions: [WorkCliProviderOption] = [ - WorkCliProviderOption(id: "claude", title: "Claude Code"), - WorkCliProviderOption(id: "codex", title: "Codex"), - WorkCliProviderOption(id: "cursor", title: "Cursor Agent CLI"), - WorkCliProviderOption(id: "opencode", title: "OpenCode CLI"), - WorkCliProviderOption(id: "droid", title: "Factory Droid CLI"), - WorkCliProviderOption(id: "shell", title: "Shell"), -] +/// Desktop `ModeSwitcherPills` parity: compact Chat/CLI toggle for the nav bar. +struct WorkSessionTypeSwitcher: View { + @Binding var selection: WorkNewSessionMode -/// Sentinel lane id for the synthetic "Auto-create lane" picker entry. Matches -/// the desktop `AUTO_CREATE_LANE_OPTION_ID`. When selected, the host has to -/// create a fresh lane on launch before opening the session. -private let workAutoCreateLaneSentinelId = "__ade_auto_create_lane__" + var body: some View { + HStack(spacing: 4) { + ForEach(WorkNewSessionMode.allCases) { mode in + let isSelected = selection == mode + Button { + guard !isSelected else { return } + withAnimation(.snappy(duration: 0.16)) { + selection = mode + } + } label: { + HStack(spacing: 6) { + Image(systemName: mode.systemImage) + .font(.system(size: 12, weight: isSelected ? .semibold : .regular)) + .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textSecondary) + .opacity(0.85) + Text(mode.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textSecondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background { + if isSelected { + Capsule(style: .continuous) + .fill(ADEColor.surfaceBackground.opacity(0.85)) + } + } + .overlay { + if isSelected { + Capsule(style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.5) + } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel(mode.title) + .accessibilityHint(mode.accessibilityDescription) + .accessibilityAddTraits(isSelected ? [.isSelected] : []) + } + } + .padding(4) + .background(ADEColor.recessedBackground.opacity(0.72), in: Capsule(style: .continuous)) + .overlay { + Capsule(style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.5) + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Session type") + } +} /// How a new session is launched from the composer: `foreground` navigates into /// the live chat (current behaviour); `background` keeps the user on this screen @@ -95,16 +146,6 @@ struct WorkNewChatScreen: View { return lanes.first } - private var selectedLaneName: String { - if isAutoCreateLane { - return "Auto-create lane" - } - if let match = lanes.first(where: { $0.id == selectedLaneId }) { - return match.name - } - return "Choose lane" - } - var body: some View { VStack(spacing: 0) { Spacer(minLength: 24) @@ -125,10 +166,6 @@ struct WorkNewChatScreen: View { laneSelector autoCreateHelperText - modeSelector - if sessionMode == .cli { - cliProviderSelector - } } .padding(.horizontal, 20) .padding(.vertical, 16) @@ -165,11 +202,14 @@ struct WorkNewChatScreen: View { } .adeScreenBackground() .adeNavigationGlass() - .navigationTitle("New Chat") + .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbar(.hidden, for: .tabBar) .adeRootTabBarHidden() .toolbar { + ToolbarItem(placement: .principal) { + WorkSessionTypeSwitcher(selection: $sessionMode) + } ToolbarItem(placement: .topBarTrailing) { if busy { ProgressView().controlSize(.small) @@ -186,9 +226,7 @@ struct WorkNewChatScreen: View { } .onChange(of: provider) { _, newProvider in runtimeMode = workDefaultRuntimeMode(provider: newProvider) - if sessionMode == .cli { - normalizeCliSelection() - } else if !workNewChatModel(modelId, belongsTo: workNormalizedNewChatProvider(newProvider)) { + if !workNewChatModel(modelId, belongsTo: workNormalizedNewChatProvider(newProvider)) { modelId = workDefaultNewChatModelId(provider: newProvider) } if !modelSupportsReasoning(modelId: modelId, provider: newProvider) { @@ -196,11 +234,7 @@ struct WorkNewChatScreen: View { } } .onChange(of: sessionMode) { _, newMode in - if newMode == .chat { - normalizeChatSelection() - } else { - normalizeCliSelection() - } + normalizeSelection(for: newMode) } .onChange(of: modelId) { _, newModel in if !modelSupportsReasoning(modelId: newModel, provider: provider) { @@ -216,8 +250,11 @@ struct WorkNewChatScreen: View { isBusy: false, onSelect: { option, pickedReasoning, runtimeProvider in modelId = option.id - provider = runtimeProvider + provider = sessionMode == .chat + ? workNormalizedNewChatProvider(runtimeProvider) + : workResolveCliProvider(for: option.id, provider: runtimeProvider) reasoningEffort = pickedReasoning ?? "" + runtimeMode = workDefaultRuntimeMode(provider: provider) modelPickerPresented = false } ) @@ -245,121 +282,17 @@ struct WorkNewChatScreen: View { .accessibilityLabel("ADE") } - private func compactChoiceChip( - title: String, - systemImage: String?, - tint: Color, - isSelected: Bool, - accessibilityPrefix: String, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - HStack(spacing: 6) { - Circle().fill(tint).frame(width: 6, height: 6) - if let systemImage { - Image(systemName: systemImage) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(isSelected ? tint : ADEColor.textMuted) - } - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textSecondary) - .lineLimit(1) - if isSelected { - Image(systemName: "checkmark") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(tint) - } - } - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background((isSelected ? tint.opacity(0.12) : Color.clear), in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(isSelected ? tint.opacity(0.4) : ADEColor.border.opacity(0.22), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - .accessibilityLabel("\(accessibilityPrefix): \(title)") - .accessibilityValue(isSelected ? "Selected" : "") - } - @ViewBuilder private var laneSelector: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - autoCreateLaneChip - ForEach(lanes) { lane in - compactChoiceChip( - title: lane.name, - systemImage: "arrow.triangle.branch", - tint: ADEColor.accent, - isSelected: lane.id == selectedLaneId, - accessibilityPrefix: "Lane" - ) { - selectedLaneId = lane.id - } - } - if lanes.isEmpty { - Text("No lanes available") - .font(.footnote.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - .padding(.horizontal, 14) - .padding(.vertical, 9) - .background(ADEColor.surfaceBackground.opacity(0.55), in: Capsule(style: .continuous)) - } - Button { - Task { await onRefreshLanes() } - } label: { - Image(systemName: "arrow.clockwise") - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.accent) - .frame(width: 34, height: 34) - .background(ADEColor.surfaceBackground.opacity(0.55), in: Circle()) - .glassEffect() - .overlay(Circle().stroke(ADEColor.accent.opacity(0.26), lineWidth: 0.6)) - } - .buttonStyle(.plain) - .accessibilityLabel("Refresh lanes") - } - } - .frame(maxWidth: .infinity) - .accessibilityElement(children: .contain) - .accessibilityLabel("Lane selector. Current lane \(selectedLaneName).") - } - - /// Synthetic "Auto-create lane" chip rendered ahead of the real lanes. Selecting - /// it lets the user start without picking an existing lane; the host spins up a - /// fresh lane on launch. - private var autoCreateLaneChip: some View { - Button { - selectedLaneId = workAutoCreateLaneSentinelId - } label: { - HStack(spacing: 6) { - Image(systemName: "sparkles") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(isAutoCreateLane ? ADEColor.accent : ADEColor.accent.opacity(0.8)) - Text("Auto-create lane") - .font(.caption.weight(.semibold)) - .foregroundStyle(isAutoCreateLane ? ADEColor.textPrimary : ADEColor.accent) - .lineLimit(1) - if isAutoCreateLane { - Image(systemName: "checkmark") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(ADEColor.accent) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background((isAutoCreateLane ? ADEColor.accent.opacity(0.16) : ADEColor.accent.opacity(0.06)), in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(ADEColor.accent.opacity(isAutoCreateLane ? 0.5 : 0.3), style: StrokeStyle(lineWidth: 0.8, dash: isAutoCreateLane ? [] : [3, 2])) + HStack { + Spacer(minLength: 0) + WorkLanePickerDropdown( + lanes: lanes, + selectedLaneId: $selectedLaneId, + onRefresh: onRefreshLanes ) + Spacer(minLength: 0) } - .buttonStyle(.plain) - .accessibilityLabel("Auto-create lane") - .accessibilityValue(isAutoCreateLane ? "Selected" : "") } /// Helper text shown when auto-create is selected, mirroring desktop's @@ -384,96 +317,6 @@ struct WorkNewChatScreen: View { } } - @ViewBuilder - private var modeSelector: some View { - HStack(spacing: 3) { - ForEach(WorkNewSessionMode.allCases) { mode in - let isSelected = sessionMode == mode - Button { - guard !isSelected else { return } - withAnimation(.snappy(duration: 0.16)) { - sessionMode = mode - } - } label: { - Text(mode.title) - .font(.caption.weight(.semibold)) - .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textSecondary) - .lineLimit(1) - .minimumScaleFactor(0.85) - .frame(maxWidth: .infinity, minHeight: 34) - .padding(.horizontal, 8) - .background { - if isSelected { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(ADEColor.accent.opacity(0.18)) - } - } - .overlay { - if isSelected { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(ADEColor.accent.opacity(0.35), lineWidth: 0.75) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityElement(children: .ignore) - .accessibilityLabel("Session type: \(mode.title)") - .accessibilityAddTraits(isSelected ? [.isSelected] : []) - } - } - .padding(3) - .background(ADEColor.recessedBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(ADEColor.glassBorder, lineWidth: 0.5) - } - .padding(.horizontal, 8) - } - - @ViewBuilder - private var cliProviderSelector: some View { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { - ForEach(workCliProviderOptions) { option in - let isSelected = provider == option.id - Button { - provider = option.id - } label: { - HStack(spacing: 8) { - WorkProviderLogo( - provider: option.id, - fallbackSymbol: option.id == "shell" ? "terminal.fill" : providerIcon(option.id), - tint: providerTint(option.id), - size: 16 - ) - Text(option.title) - .font(.caption.weight(.semibold)) - .foregroundStyle(isSelected ? ADEColor.textPrimary : ADEColor.textSecondary) - .lineLimit(1) - .minimumScaleFactor(0.78) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, minHeight: 34, alignment: .leading) - .padding(.horizontal, 10) - .background { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(isSelected ? providerTint(option.id).opacity(0.16) : ADEColor.surfaceBackground.opacity(0.55)) - } - .overlay { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(isSelected ? providerTint(option.id).opacity(0.36) : ADEColor.border.opacity(0.22), lineWidth: 0.6) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityElement(children: .ignore) - .accessibilityLabel("CLI provider: \(option.title)") - .accessibilityAddTraits(isSelected ? [.isSelected] : []) - } - } - .padding(.horizontal, 8) - } - @ViewBuilder private var composerBar: some View { WorkNewChatComposerBar( @@ -482,7 +325,7 @@ struct WorkNewChatScreen: View { modelId: modelId, modelName: prettyNewChatModelName(modelId), busy: busy, - canStart: !busy && (isAutoCreateLane || !selectedLaneId.isEmpty) && (sessionMode == .cli || !modelId.isEmpty), + canStart: !busy && (isAutoCreateLane || !selectedLaneId.isEmpty) && !modelId.isEmpty, isAutoCreateLane: isAutoCreateLane, runtimeMode: $runtimeMode, reasoningEffort: $reasoningEffort, @@ -521,9 +364,7 @@ struct WorkNewChatScreen: View { private func submit(openingMessage: String, disposition: WorkNewChatDisposition) async -> Bool { let opener = openingMessage.trimmingCharacters(in: .whitespacesAndNewlines) guard !busy && (isAutoCreateLane || !selectedLaneId.isEmpty) else { return false } - if sessionMode == .chat { - guard !opener.isEmpty && !modelId.isEmpty else { return false } - } + guard !opener.isEmpty && !modelId.isEmpty else { return false } busy = true errorMessage = nil launchedNotice = nil @@ -559,17 +400,17 @@ struct WorkNewChatScreen: View { do { if sessionMode == .cli { - let cliModelId = workCliSupportsModelSelection(provider: provider) ? modelId : nil - let cliReasoningEffort = workCliSupportsReasoningSelection(provider: provider) && !normalizedReasoning.isEmpty + let cliProvider = workResolveCliProvider(for: modelId, provider: provider) + let cliReasoningEffort = workCliSupportsReasoningSelection(provider: cliProvider) && !normalizedReasoning.isEmpty ? normalizedReasoning : nil let result = try await syncService.startCliSession( laneId: targetLaneId, - provider: provider, - permissionMode: workCliPermissionMode(provider: provider, runtimeMode: runtimeMode), - title: workCliInitialSessionTitle(provider: provider, opener: opener), - initialInput: opener.isEmpty ? nil : opener, - modelId: cliModelId, + provider: cliProvider, + permissionMode: workCliPermissionMode(provider: cliProvider, runtimeMode: runtimeMode), + title: workCliInitialSessionTitle(provider: cliProvider, opener: opener), + initialInput: opener, + modelId: modelId, reasoningEffort: cliReasoningEffort, cols: 48, rows: 24 @@ -589,8 +430,8 @@ struct WorkNewChatScreen: View { pinned: false, manuallyNamed: nil, goal: opener.isEmpty ? nil : opener, - toolType: workCliToolType(provider: provider), - title: workCliInitialSessionTitle(provider: provider, opener: opener), + toolType: workCliToolType(provider: cliProvider), + title: workCliInitialSessionTitle(provider: cliProvider, opener: opener), status: "running", startedAt: workDateFormatter.string(from: Date()), endedAt: nil, @@ -688,31 +529,24 @@ struct WorkNewChatScreen: View { return "chat-\(stamp)" } - private func normalizeChatSelection() { - let normalizedProvider = workNormalizedNewChatProvider(provider) - if provider != normalizedProvider { - provider = normalizedProvider - } - if !workNewChatModel(modelId, belongsTo: normalizedProvider) { - modelId = workDefaultNewChatModelId(provider: normalizedProvider) - } - runtimeMode = workDefaultRuntimeMode(provider: normalizedProvider) - if !modelSupportsReasoning(modelId: modelId, provider: normalizedProvider) { - reasoningEffort = "" - } - } - - private func normalizeCliSelection() { - let family = providerFamilyKey(provider) - guard workCliSupportsModelSelection(provider: family) else { - reasoningEffort = "" - return - } - if !workNewChatModel(modelId, belongsTo: family) { - modelId = workDefaultNewChatModelId(provider: family) + private func normalizeSelection(for mode: WorkNewSessionMode) { + let availabilityMode: WorkCursorAvailabilityMode = mode == .cli ? .cli : .chat + if !workModelAllowedForAvailabilityMode(modelId: modelId, provider: provider, mode: availabilityMode), + let replacement = workDefaultModelIdForAvailabilityMode(preferredProvider: provider, mode: availabilityMode) { + modelId = replacement.modelId + provider = mode == .chat + ? workNormalizedNewChatProvider(replacement.provider) + : workResolveCliProvider(for: replacement.modelId, provider: replacement.provider) + } else if mode == .chat { + provider = workNormalizedNewChatProvider(provider) + if !workNewChatModel(modelId, belongsTo: provider) { + modelId = workDefaultNewChatModelId(provider: provider) + } + } else { + provider = workResolveCliProvider(for: modelId, provider: provider) } - if (!workCliSupportsReasoningSelection(provider: family) - || !modelSupportsReasoning(modelId: modelId, provider: family)) { + runtimeMode = workDefaultRuntimeMode(provider: provider) + if !modelSupportsReasoning(modelId: modelId, provider: provider) { reasoningEffort = "" } } @@ -742,22 +576,18 @@ private func workDefaultNewChatModelId(provider: String) -> String { } } -private func workCliSupportsModelSelection(provider: String) -> Bool { - providerFamilyKey(provider) != "shell" -} - private func workCliSupportsReasoningSelection(provider: String) -> Bool { let family = providerFamilyKey(provider) return family == "claude" || family == "codex" || family == "droid" } private func workCliInitialSessionTitle(provider: String, opener: String) -> String { - let fallback = workCliProviderOptions.first(where: { $0.id == provider })?.title ?? providerLabel(provider) + let fallback = providerLabel(provider) let seed = opener .replacingOccurrences(of: "\n", with: " ") .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) - guard !seed.isEmpty, providerFamilyKey(provider) != "shell" else { + guard !seed.isEmpty else { return fallback } let clipped: String @@ -771,9 +601,6 @@ private func workCliInitialSessionTitle(provider: String, opener: String) -> Str } func workCliPermissionMode(provider: String, runtimeMode: String) -> String? { - if provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "shell" { - return nil - } let wire = workRuntimeWireFields(provider: provider, mode: runtimeMode) return wire.permissionMode ?? (runtimeMode.isEmpty ? nil : runtimeMode) } @@ -785,7 +612,7 @@ private func workCliToolType(provider: String) -> String { case "cursor": return "cursor-cli" case "opencode": return "opencode" case "droid": return "droid" - default: return "shell" + default: return "opencode" } } @@ -810,7 +637,7 @@ private struct WorkNewChatComposerBar: View { } private var canSend: Bool { - canStart && (sessionMode == .cli || !trimmedDraft.isEmpty) + canStart && !trimmedDraft.isEmpty } private var runtimeOptions: [WorkRuntimeModeOption] { @@ -825,19 +652,12 @@ private struct WorkNewChatComposerBar: View { workRuntimeModeTint(runtimeMode) } - private var acceptsOpeningMessage: Bool { - !(sessionMode == .cli && provider == "shell") - } - private var placeholder: String { - if sessionMode == .cli && provider == "shell" { - return "Shell starts empty; type after it opens" - } - return sessionMode == .cli ? "Optional first instruction…" : "Type to vibecode…" + "Type to vibecode…" } private var sendLabel: String { - sessionMode == .cli ? "Start" : "Send" + "Send" } /// Accessibility / label for the secondary background-launch button. When the @@ -848,7 +668,7 @@ private struct WorkNewChatComposerBar: View { @MainActor private func dispatch(_ disposition: WorkNewChatDisposition) { - let text = acceptsOpeningMessage ? trimmedDraft : "" + let text = trimmedDraft draft = "" Task { let started = await onSubmit(text, disposition) @@ -870,20 +690,11 @@ private struct WorkNewChatComposerBar: View { .textInputAutocapitalization(.sentences) .focused($composerFocused) .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) - .disabled(!acceptsOpeningMessage) - .opacity(acceptsOpeningMessage ? 1 : 0.62) HStack(alignment: .center, spacing: 8) { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .center, spacing: 10) { - if sessionMode == .chat { - modelPickerButton - } else { - cliProviderChips - if workCliSupportsModelSelection(provider: provider) { - modelPickerButton - } - } + modelPickerButton if !runtimeOptions.isEmpty { HStack(spacing: 6) { @@ -935,12 +746,6 @@ private struct WorkNewChatComposerBar: View { .shadow(color: Color.black.opacity(0.32), radius: 14, y: 6) .padding(.horizontal, 16) .padding(.bottom, 0) - .onChange(of: provider) { _, _ in - if !acceptsOpeningMessage { draft = "" } - } - .onChange(of: sessionMode) { _, _ in - if !acceptsOpeningMessage { draft = "" } - } } /// Primary foreground launch button — navigates into the new live chat. @@ -975,7 +780,7 @@ private struct WorkNewChatComposerBar: View { } .buttonStyle(.plain) .disabled(!canSend || busy) - .accessibilityLabel(canSend ? (sessionMode == .cli ? "Start CLI session" : "Start chat") : "Enter a message to start") + .accessibilityLabel(canSend ? "Send" : "Enter a message to send") } /// Secondary background launch button — creates the session but stays on the @@ -1003,22 +808,6 @@ private struct WorkNewChatComposerBar: View { .accessibilityLabel(canSend ? "\(backgroundSendLabel) in background" : "Enter a message to start in background") } - private var cliProviderChips: some View { - HStack(spacing: 6) { - ForEach(workCliProviderOptions) { option in - compactChoiceChip( - title: option.title, - systemImage: option.id == "shell" ? "terminal.fill" : providerIcon(option.id), - tint: providerTint(option.id), - isSelected: option.id == provider, - accessibilityPrefix: "CLI provider" - ) { - provider = option.id - } - } - } - } - private func compactChoiceChip( title: String, systemImage: String?, diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index 965930427..03a398fd4 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -212,6 +212,7 @@ private enum WorkPreviewData { sequence: 1, event: .userMessage( text: "The iOS Work tab is lagging when I switch tabs and focus the chat input.", + attachments: nil, turnId: "turn-1", steerId: nil, deliveryState: "delivered", @@ -432,9 +433,9 @@ private enum WorkPreviewData { optimisticPendingSteers: [], localEchoMessages: [], expandedToolCardIds: Binding>.constant(["cmd-1"]), - collapsedChangedFileGroupIds: Binding>.constant([]), artifactContent: .constant([:]), fullscreenImage: Binding.constant(nil), + artifactDrawerPresented: .constant(false), artifactRefreshInFlight: false, artifactRefreshError: nil, sending: .constant(false), diff --git a/apps/ios/ADE/Views/Work/WorkReasoningCard.swift b/apps/ios/ADE/Views/Work/WorkReasoningCard.swift index 6bafe9661..c6a5b53f7 100644 --- a/apps/ios/ADE/Views/Work/WorkReasoningCard.swift +++ b/apps/ios/ADE/Views/Work/WorkReasoningCard.swift @@ -2,11 +2,9 @@ import SwiftUI /// A dedicated reasoning surface for the Work chat timeline. /// -/// Collapsed state mirrors desktop's compact "Thought" pill: a single-line -/// capsule with chevron · brain icon · "Thought" that hugs the assistant -/// column rather than spanning full width. While the turn is live the header -/// pulses ("Thinking …") but the body stays collapsed by default; the user -/// must tap the pill to reveal streaming reasoning tokens. +/// Collapsed state mirrors desktop's compact "Thought" pill: caret + label only, +/// with the reasoning body hidden until the user expands. While the turn is +/// live the header reads "Thinking"; once finished it reads "Thought". struct WorkReasoningCard: View { let card: WorkEventCardModel let isLive: Bool @@ -27,21 +25,6 @@ struct WorkReasoningCard: View { isLive ? "Thinking" : "Thought" } - private var headerTint: Color { - isLive ? ADEColor.purpleAccent : ADEColor.textSecondary - } - - /// First non-empty line of the reasoning, used as the collapsed one-liner - /// preview (desktop shows a truncated peek next to the "Thought" label). - private var previewText: String? { - guard let bodyText else { return nil } - for line in bodyText.split(separator: "\n", omittingEmptySubsequences: true) { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } - } - return nil - } - var body: some View { HStack(alignment: .top, spacing: 0) { VStack(alignment: .leading, spacing: 6) { @@ -66,37 +49,24 @@ struct WorkReasoningCard: View { } } - /// Single collapsed one-liner: caret + brain + "Thinking"/"Thought" + a - /// truncated preview. No pulsing brain, no thinking-dots loop — the lone - /// streaming animation lives in the tail WorkActivityIndicator so we don't - /// stack repeating loops across the transcript. + /// Single collapsed one-liner: caret + "Thinking"/"Thought" only. The body + /// stays hidden until expand — no preview line, no brain icon. private var compactPill: some View { Button { withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { isExpanded.toggle() } } label: { - HStack(spacing: 8) { + HStack(spacing: 6) { Image(systemName: "chevron.right") .font(.system(size: 11, weight: .bold)) .foregroundStyle(ADEColor.textMuted) .rotationEffect(isExpanded ? .degrees(90) : .degrees(0)) - Image(systemName: "brain.head.profile") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(headerTint) Text(headerTitle) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textSecondary) - if !isExpanded, let previewText { - Text(previewText) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - .truncationMode(.tail) - } + .font(.caption.weight(.medium)) + .foregroundStyle(isLive ? ADEColor.textSecondary : ADEColor.textMuted) } - .padding(.horizontal, 10) - .padding(.vertical, 5) + .padding(.vertical, 2) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index eab1f347e..3969cc15d 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -306,7 +306,7 @@ struct WorkSidebarSectionHeader: View { Text(group.label) .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) + .foregroundStyle(group.laneColor != nil ? group.tint : ADEColor.textPrimary) .lineLimit(1) Spacer(minLength: 0) @@ -335,9 +335,8 @@ struct WorkSidebarSectionHeader: View { .fill(group.tint) .frame(width: 7, height: 7) case .laneBranch: - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(group.tint) + WorkLaneLogoMark(color: group.tint, laneIcon: group.laneIcon, size: 11) + .frame(width: 12, height: 12) case .none: Color.clear.frame(width: 0, height: 0) } diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift index 6c3d50e17..0ed07fbad 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift @@ -459,4 +459,80 @@ extension WorkSessionDestinationView { laneId: pr.laneId ) } + + func openLanePrOnGitHub() { + guard let urlString = laneOpenPr?.githubUrl.trimmingCharacters(in: .whitespacesAndNewlines), + !urlString.isEmpty, + let url = URL(string: urlString) else { return } + UIApplication.shared.open(url) + } + + @MainActor + func copyLanePrLink() { + guard let urlString = laneOpenPr?.githubUrl.trimmingCharacters(in: .whitespacesAndNewlines), + !urlString.isEmpty else { return } + UIPasteboard.general.string = urlString + prLinkCopied = true + Task { + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard !Task.isCancelled else { return } + prLinkCopied = false + } + } + + func presentCreateLanePr() { + createPrPresented = true + } + + @MainActor + func loadPrCreateCapabilitiesIfNeeded() async { + guard hostReachable else { + prCreateCapabilities = nil + return + } + do { + let snapshot = try await syncService.fetchPrMobileSnapshot() + guard !Task.isCancelled else { return } + prCreateCapabilities = snapshot.createCapabilities + } catch { + guard !Task.isCancelled else { return } + prCreateCapabilities = nil + } + } + + @MainActor + func handleChatCreateSinglePr( + laneId: String, + title: String, + body: String, + draft: Bool, + baseBranch: String, + labels: [String], + reviewers: [String], + strategy: String? + ) async -> Bool { + guard hostReachable else { + errorMessage = "Connect to your desktop to create a pull request." + return false + } + do { + try await syncService.createPullRequest( + laneId: laneId, + title: title, + body: body, + draft: draft, + baseBranch: baseBranch, + labels: labels, + reviewers: reviewers, + strategy: strategy + ) + createPrPresented = false + await resolveLaneOpenPr(for: headerMenuLaneId) + await loadPrCreateCapabilitiesIfNeeded() + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } } diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 9c30ef3cc..bb1da956b 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -48,7 +48,7 @@ func latestActiveTurnId(from transcript: [WorkChatEnvelope]) -> String? { switch envelope.event { case .assistantText(_, let turnId, _), .activity(_, _, let turnId), - .userMessage(_, let turnId, _, _, _): + .userMessage(_, _, let turnId, _, _, _): if let turnId, !turnId.isEmpty { return turnId } case .status(_, _, let turnId): if let turnId, !turnId.isEmpty { return turnId } @@ -62,7 +62,7 @@ func latestActiveTurnId(from transcript: [WorkChatEnvelope]) -> String? { func transcriptContainsResolvedSteer(_ transcript: [WorkChatEnvelope], steerId: String) -> Bool { for envelope in sortedWorkChatEnvelopes(transcript).reversed() { switch envelope.event { - case .userMessage(_, _, let candidate, let deliveryState, _): + case .userMessage(_, _, _, let candidate, let deliveryState, _): guard candidate == steerId else { continue } return deliveryState != "queued" case .systemNotice(_, let message, _, _, let candidate): @@ -158,12 +158,12 @@ struct WorkSessionDestinationView: View { @State var localEchoMessages: [WorkLocalEchoMessage] = [] @State var optimisticPendingSteers: [WorkPendingSteerModel] = [] @State var expandedToolCardIds = Set() - @State private var collapsedChangedFileGroupIds = Set() @State var artifactContent: [String: WorkLoadedArtifactContent] = [:] @State var artifactContentLoadsInFlight = Set() @State var artifactRefreshInFlight = false @State var artifactRefreshError: String? @State var fullscreenImage: WorkFullscreenImage? + @State var artifactDrawerPresented = false @State var sending = false @State var errorMessage: String? @State var announcedLaneId: String? @@ -171,6 +171,9 @@ struct WorkSessionDestinationView: View { /// item. Nil until resolved (or when the lane has no cached PR), which keeps /// that menu item disabled with a "No PR yet" hint. @State var laneOpenPr: PullRequestListItem? + @State var prCreateCapabilities: PrCreateCapabilities? + @State var createPrPresented = false + @State var prLinkCopied = false @State var lastSessionRowRefreshAt = Date.distantPast @State var lastTranscriptRemoteRefreshAt = Date.distantPast @State var lastCanonicalTranscriptRefreshAt = Date.distantPast @@ -217,10 +220,22 @@ struct WorkSessionDestinationView: View { ) } + /// Deliberately row-authoritative (does NOT consult `liveTurnActiveHint`): + /// a stale-true hint must never route a fresh message into the steer queue + /// of an idle session, where it could sit undispatched. During the window + /// where the hint is true but the row hasn't flipped yet, `sendMessage`'s + /// "turn already active" error fallback retries the send as a steer. var shouldSteerActiveTurn: Bool { hostReachable && workChatShouldSteerActiveTurn(session: session, summary: chatSummary) } + /// Live host-side "turn is running" hint (chat_subscribe ack + status/done + /// events). Fresher than the synced session row, which arrives via the + /// slower changeset pump. + var liveTurnActiveHint: Bool { + syncService.chatTurnActiveHint(sessionId: sessionId) ?? false + } + var supportsManualSteerDispatch: Bool { workChatSupportsManualSteerDispatch(session: session, summary: chatSummary) } @@ -233,27 +248,27 @@ struct WorkSessionDestinationView: View { return resolvedWorkNavigationLaneId(for: session, lanes: lanes) } - /// Trailing nav-bar overflow menu scoped to the session's lane: jump to the - /// lane's PR (when one is cached) or open the lane itself. + /// Trailing nav-bar overflow menu for chat sessions: proof drawer plus lane + /// shortcuts when the session is lane-backed. @ViewBuilder var sessionHeaderTrailingControls: some View { - if let session, showsLaneActions { + if let session, isChatSession(session) { Menu { Button { - openLaneOpenPr() + artifactDrawerPresented = true } label: { - if let laneOpenPr { - Label("Open PR #\(laneOpenPr.githubPrNumber)", systemImage: "arrow.triangle.pull") + if artifacts.isEmpty { + Label("Proof", systemImage: "cube.transparent") } else { - Label("Open PR (No PR yet)", systemImage: "arrow.triangle.pull") + Label("Proof (\(artifacts.count))", systemImage: "cube.transparent") } } - .disabled(laneOpenPr == nil) + .accessibilityHint("Opens the proof drawer") - Button { - openSessionLane() - } label: { - Label("Open lane", systemImage: "arrow.triangle.branch") + if showsLaneActions { + Divider() + + chatPullRequestMenuItems } } label: { Image(systemName: "ellipsis") @@ -263,12 +278,104 @@ struct WorkSessionDestinationView: View { .contentShape(Rectangle()) } .buttonStyle(.glass) - .accessibilityLabel("Session actions for lane \(session.laneName)") + .accessibilityLabel("Chat actions") } else { EmptyView() } } + @ViewBuilder + private var chatPullRequestMenuItems: some View { + if let laneOpenPr { + Button { + openLaneOpenPr() + } label: { + Label("Open in ADE (#\(laneOpenPr.githubPrNumber))", systemImage: "arrow.triangle.pull") + } + + Button { + openLanePrOnGitHub() + } label: { + Label("Open on GitHub", systemImage: "link") + } + .disabled(laneOpenPr.githubUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + Button { + copyLanePrLink() + } label: { + if prLinkCopied { + Label("Copied link", systemImage: "checkmark") + } else { + Label("Copy link", systemImage: "doc.on.doc") + } + } + .disabled(laneOpenPr.githubUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } else { + Button { + presentCreateLanePr() + } label: { + Label("Create pull request", systemImage: "plus") + } + .disabled(!canCreatePullRequestForHeaderLane) + + if let blockedReason = createPullRequestBlockedReason { + Button {} label: { + Label(blockedReason, systemImage: "info.circle") + } + .disabled(true) + } + } + + Button { + openSessionLane() + } label: { + Label("Open lane", systemImage: "arrow.triangle.branch") + } + } + + private var canCreatePullRequestForHeaderLane: Bool { + guard hostReachable else { return false } + let laneId = headerMenuLaneId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !laneId.isEmpty else { return false } + if let eligibility = prCreateCapabilities?.lanes.first(where: { $0.laneId == laneId }) { + return eligibility.canCreate + } + if let capabilities = prCreateCapabilities { + return capabilities.canCreateAny + } + return !lanes.isEmpty + } + + private var createPullRequestBlockedReason: String? { + let laneId = headerMenuLaneId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !laneId.isEmpty else { return nil } + let reason = prCreateCapabilities? + .lanes + .first(where: { $0.laneId == laneId })? + .blockedReason? + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let reason, !reason.isEmpty else { return nil } + return reason + } + + @ViewBuilder + private var chatCreatePrWizardSheet: some View { + let laneId = headerMenuLaneId.trimmingCharacters(in: .whitespacesAndNewlines) + CreatePrWizardView( + lanes: lanes, + createCapabilities: prCreateCapabilities, + initialLaneId: laneId.isEmpty ? nil : laneId, + singleModeOnly: true, + onCreateSingle: handleChatCreateSinglePr, + onCreateQueue: { _ in false }, + onCreateIntegration: { _ in false } + ) + .environmentObject(syncService) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + .presentationContentInteraction(.scrolls) + } + var sessionDestinationZoomTransitionId: String? { transitionNamespace == nil ? nil : "work-container-\(sessionId)" } @@ -292,6 +399,9 @@ struct WorkSessionDestinationView: View { .sheet(item: $fullscreenImage) { image in WorkFullscreenImageView(image: image) } + .sheet(isPresented: $createPrPresented) { + chatCreatePrWizardSheet + } .task { session = initialSession chatSummary = initialChatSummary @@ -305,10 +415,18 @@ struct WorkSessionDestinationView: View { await reconcileIdleCanonicalTranscriptIfNeeded() } .task(id: artifactObservationKey) { - // Proof rows arrive through CRDT-backed local DB updates, not chat - // event streams, so observe the synced DB revision directly. + // Proof rows and the session row both arrive through CRDT-backed + // local DB updates, not chat event streams, so observe the synced DB + // revision directly. try? await Task.sleep(nanoseconds: 320_000_000) guard !Task.isCancelled else { return } + // The session row is the status source for the stop button and the + // poll-loop gate. Without this re-read, a turn started on desktop + // while this view is open in an idle state never updates the local + // @State row — the chat streams output but renders as frozen + // (pollIfNeeded bails on non-active status and nothing else + // observes the DB). + await refreshSessionRowFromLocalStore() // Local sync can tick rapidly while a turn is streaming. Coalesce // refreshes here so we do not refetch artifact lists for every // unrelated revision burst while the user is reading the chat. @@ -319,6 +437,7 @@ struct WorkSessionDestinationView: View { } .task(id: headerMenuLaneId) { await resolveLaneOpenPr(for: headerMenuLaneId) + await loadPrCreateCapabilitiesIfNeeded() } .task(id: pollingKey) { await pollIfNeeded() @@ -348,9 +467,9 @@ struct WorkSessionDestinationView: View { optimisticPendingSteers: optimisticPendingSteers, localEchoMessages: localEchoMessages, expandedToolCardIds: $expandedToolCardIds, - collapsedChangedFileGroupIds: $collapsedChangedFileGroupIds, artifactContent: $artifactContent, fullscreenImage: $fullscreenImage, + artifactDrawerPresented: $artifactDrawerPresented, artifactRefreshInFlight: artifactRefreshInFlight, artifactRefreshError: artifactRefreshError, sending: $sending, @@ -384,7 +503,8 @@ struct WorkSessionDestinationView: View { onSelectEffort: selectReasoningEffort, lanes: lanes, hasOlderTranscriptHistory: hasOlderTranscriptHistory, - onLoadOlderTranscript: loadOlderTranscriptEntries + onLoadOlderTranscript: loadOlderTranscriptEntries, + liveTurnActiveHint: liveTurnActiveHint ) } else { TerminalSessionScreen(session: session) @@ -402,7 +522,9 @@ struct WorkSessionDestinationView: View { var pollingKey: String { let status = normalizedWorkChatSessionStatus(session: session, summary: chatSummary) - return "\(session?.id ?? sessionId)-\(status)-\(isLiveAndReachable)" + // liveTurnActiveHint participates so a desktop-started turn (session row + // still idle locally) restarts the poll task the moment the hint flips on. + return "\(session?.id ?? sessionId)-\(status)-\(isLiveAndReachable)-\(liveTurnActiveHint)" } var liveChatObservationKey: String { @@ -457,9 +579,15 @@ struct WorkSessionDestinationView: View { let status = normalizedWorkChatSessionStatus(session: session ?? initialSession, summary: chatSummary ?? initialChatSummary) if forceRemote, let currentSession = session ?? initialSession, isChatSession(currentSession) { + let alreadySubscribed = syncService.subscribedChatSessionIds.contains(sessionId) if status == "active" { - try? await syncService.subscribeToChatEvents(sessionId: sessionId, requestSnapshot: true) - } else { + // First visit subscribes (the host answers with a snapshot or a + // sinceSeq replay). Once subscribed, live chat_event push plus the + // host's transcript pump cover continuity — re-requesting a full + // byte-capped snapshot on every 8s poll was redundant wire traffic + // and a full dedupe/sort merge on the phone mid-stream. + try? await syncService.subscribeToChatEvents(sessionId: sessionId, requestSnapshot: !alreadySubscribed) + } else if !alreadySubscribed { // Active streaming stays on reduced snapshots for performance, but an // idle detail view must reconcile against a full event snapshot. A // reduced JSONL tail can start mid-message and render as a broken @@ -514,12 +642,7 @@ struct WorkSessionDestinationView: View { let canonicalEventTranscript: [WorkChatEnvelope] if !fallbackTranscript.isEmpty, status != "active" { canonicalEventTranscript = eventTranscript.filter { envelope in - switch envelope.event { - case .userMessage, .assistantText, .status: - return false - default: - return true - } + workChatEventIncludedInIdleCanonicalEventTranscript(envelope.event) } } else { canonicalEventTranscript = eventTranscript @@ -537,7 +660,7 @@ struct WorkSessionDestinationView: View { if fetchedFallbackEntriesAvailable, fallbackEntries != fetchedFallbackEntries { fallbackEntries = fetchedFallbackEntries } - + reconcileOptimisticPendingSteers(with: mergedTranscript) reconcileLocalEchoMessages() if forceRemote { lastTranscriptRemoteRefreshAt = Date() @@ -612,6 +735,17 @@ struct WorkSessionDestinationView: View { } } + /// Re-read this session's row from the phone's local replicated DB. Cheap + /// (no network) — keeps the @State row current with changeset-synced status + /// transitions (idle → running → exited) while the view is open. + @MainActor + func refreshSessionRowFromLocalStore() async { + guard let refreshed = try? await syncService.fetchSessions().first(where: { $0.id == sessionId }) else { return } + if refreshed != session { + session = refreshed + } + } + @MainActor func refreshChatStateAfterAction(forceRemote: Bool = true) async { let preferLightweight = syncService.prefersReducedSyncLoad @@ -682,7 +816,7 @@ struct WorkSessionDestinationView: View { let promptKey = "\(sessionId)|\(prompt)" guard handledOpeningPromptKey != promptKey else { return } if transcript.contains(where: { envelope in - if case .userMessage(let text, _, _, _, _) = envelope.event { + if case .userMessage(let text, _, _, _, _, _) = envelope.event { return text.trimmingCharacters(in: .whitespacesAndNewlines) == prompt } return false @@ -768,12 +902,7 @@ struct WorkSessionDestinationView: View { let canonicalLiveTranscript: [WorkChatEnvelope] if shouldPreferFallbackTranscript { canonicalLiveTranscript = liveTranscript.filter { envelope in - switch envelope.event { - case .userMessage, .assistantText, .status: - return false - default: - return true - } + workChatEventIncludedInIdleCanonicalEventTranscript(envelope.event) } } else { canonicalLiveTranscript = liveTranscript @@ -784,7 +913,7 @@ struct WorkSessionDestinationView: View { fallback: fallbackTranscript, eventTranscript: canonicalLiveTranscript ) - if mergedTranscript != transcript { + if !mergedTranscript.isEmpty, mergedTranscript != transcript { transcript = mergedTranscript } reconcileOptimisticPendingSteers(with: mergedTranscript) @@ -841,13 +970,24 @@ struct WorkSessionDestinationView: View { @MainActor func reconcileLocalEchoMessages() { guard !localEchoMessages.isEmpty else { return } + let pendingSteerTexts = Set( + derivePendingWorkSteers(from: transcript).map { normalizedWorkLocalEchoText($0.text) } + ) localEchoMessages.removeAll { echo in - transcript.contains(where: { envelope in - if case .userMessage(let text, _, _, _, _) = envelope.event { - return text.trimmingCharacters(in: .whitespacesAndNewlines) == echo.text.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedEcho = normalizedWorkLocalEchoText(echo.text) + if pendingSteerTexts.contains(normalizedEcho) { + return true + } + return transcript.contains { envelope in + guard case .userMessage(let text, _, _, let steerId, let deliveryState, _) = envelope.event else { + return false } - return false - }) + guard normalizedWorkLocalEchoText(text) == normalizedEcho else { return false } + if deliveryState == "queued", steerId != nil { + return false + } + return true + } } } @@ -863,12 +1003,16 @@ struct WorkSessionDestinationView: View { let session, isChatSession(session) else { return } + // liveTurnActiveHint keeps the loop eligible when a desktop-started turn + // is streaming but the synced session row hasn't flipped to running yet — + // the row catches up via refreshSessionRowFromLocalStore / the loop's own + // summary refresh below. let initialStatus = normalizedWorkChatSessionStatus(session: session, summary: chatSummary) - guard initialStatus == "active" || initialStatus == "awaiting-input" else { return } + guard initialStatus == "active" || initialStatus == "awaiting-input" || liveTurnActiveHint else { return } while !Task.isCancelled, isLiveAndReachable, { let status = normalizedWorkChatSessionStatus(session: self.session, summary: self.chatSummary) - return status == "active" || status == "awaiting-input" + return status == "active" || status == "awaiting-input" || self.liveTurnActiveHint }() { syncTranscriptFromLiveEvents() let now = Date() diff --git a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift index 87c72c7af..b8fba2044 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift @@ -28,6 +28,8 @@ struct WorkSessionGroup: Identifiable, Equatable { let icon: Icon let tint: Color let sessions: [TerminalSessionSummary] + let laneColor: String? + let laneIcon: LaneIcon? enum Icon: Equatable { case statusDot @@ -35,11 +37,31 @@ struct WorkSessionGroup: Identifiable, Equatable { case none } + init( + id: String, + label: String, + icon: Icon, + tint: Color, + sessions: [TerminalSessionSummary], + laneColor: String? = nil, + laneIcon: LaneIcon? = nil + ) { + self.id = id + self.label = label + self.icon = icon + self.tint = tint + self.sessions = sessions + self.laneColor = laneColor + self.laneIcon = laneIcon + } + static func == (lhs: WorkSessionGroup, rhs: WorkSessionGroup) -> Bool { lhs.id == rhs.id && lhs.label == rhs.label && lhs.icon == rhs.icon && lhs.tint == rhs.tint + && lhs.laneColor == rhs.laneColor + && lhs.laneIcon == rhs.laneIcon && lhs.sessions.map(\.id) == rhs.sessions.map(\.id) } } @@ -234,7 +256,15 @@ func workSessionGroupsByLane( let knownLaneIds = Set(orderedLanes.map(\.id)) for lane in orderedLanes { guard let list = byLaneId[lane.id], !list.isEmpty else { continue } - groups.append(WorkSessionGroup(id: "lane:\(lane.id)", label: lane.name, icon: .laneBranch, tint: ADEColor.textSecondary, sessions: list)) + groups.append(WorkSessionGroup( + id: "lane:\(lane.id)", + label: lane.name, + icon: .laneBranch, + tint: LaneColorPalette.displayColor(forHex: lane.color), + sessions: list, + laneColor: lane.color, + laneIcon: lane.icon + )) } // Surface any sessions whose lane isn't in the ordered list (e.g., soft-deleted lanes) // as their own per-lane groups so users still recognize which branch each belongs to. diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index 7fe4bcf00..0edf91cd1 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -78,10 +78,13 @@ func toolTypeForProvider(_ provider: String) -> String { func providerLabel(_ provider: String) -> String { switch providerFamilyKey(provider) { case "codex": return "Codex" + case "openai": return "OpenAI" case "claude": return "Claude" + case "anthropic": return "Anthropic" case "opencode": return "OpenCode" - case "cursor": return "Cursor" - case "droid": return "Droid" + case "cursor": return "Cursor Composer" + case "droid", "factory": return "Droid" + case "google": return "Google" case "ollama": return "Ollama" case "lmstudio": return "LM Studio" default: return provider.capitalized @@ -109,12 +112,20 @@ func shortProviderLabel(_ toolType: String?) -> String { func providerIcon(_ provider: String) -> String { switch providerFamilyKey(provider) { - case "codex": + case "codex", "openai": return "sparkle" case "opencode": return "hammer.fill" case "cursor": return "cursorarrow" + case "droid", "factory": + return "cpu" + case "ollama": + return "hare.fill" + case "lmstudio": + return "desktopcomputer" + case "google": + return "g.circle.fill" default: return "brain.head.profile" } @@ -131,17 +142,120 @@ func providerAssetName(_ provider: String?) -> String? { switch providerFamilyKey(provider) { case "claude": return "ProviderClaude" + case "anthropic": + return "ProviderAnthropic" case "codex": return "ProviderCodex" + case "openai": + return "ProviderOpenAI" case "cursor": return "ProviderCursor" case "opencode": return "ProviderOpenCode" + case "droid", "factory": + return "ProviderDroid" default: return nil } } +/// Provider key for the model-picker rail icons. Mirrors desktop +/// `ProviderLogo` family marks (Anthropic/OpenAI company logos for Claude/Codex +/// groups, not the product marks used on model rows). +func workRailLogoProvider(for catalogGroupKey: String) -> String { + switch catalogGroupKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "claude", "anthropic": + return "anthropic" + case "codex", "openai": + return "openai" + case "cursor": + return "cursor" + case "droid", "factory": + return "droid" + case "opencode": + return "opencode" + case "ollama": + return "ollama" + case "lmstudio": + return "lmstudio" + default: + return catalogGroupKey + } +} + +/// Maps a lowercased model id to its upstream brand logo key by substring, +/// or nil when the id doesn't name a known upstream vendor model. +func workUpstreamBrand(modelId: String) -> String? { + if modelId.contains("gemini") { + return "google" + } + if modelId.contains("claude") || modelId.contains("fable") || modelId.contains("sonnet") + || modelId.contains("opus") || modelId.contains("haiku") { + return "claude" + } + if modelId.contains("gpt") || modelId.contains("codex") { + return "codex" + } + return nil +} + +/// Per-model row logo key. Mirrors desktop `ModelRowLogo` so Cursor/Droid/OpenCode +/// rows show the upstream brand (Claude, OpenAI, Gemini, etc.) instead of the +/// runtime group logo. +func workModelRowLogoProvider(for model: WorkModelOption, catalogGroupKey: String?) -> String { + let modelId = model.id.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let group = catalogGroupKey?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + + if group == "cursor" || modelId.contains("cursor/") || modelId.contains("composer") { + if modelId == "auto" || modelId.contains("composer") { + return "cursor" + } + if let brand = workUpstreamBrand(modelId: modelId) { + return brand + } + if modelId.contains("grok") { + return "xai" + } + return "cursor" + } + + if group == "droid" || group == "factory" || modelId.hasPrefix("droid/") { + if let brand = workUpstreamBrand(modelId: modelId) { + return brand + } + if modelId.hasPrefix("glm-") || modelId.hasPrefix("kimi-") || modelId.hasPrefix("minimax-") + || modelId.hasPrefix("custom:") { + return "factory" + } + return "droid" + } + + if group == "opencode" || modelId.hasPrefix("opencode/") { + if modelId.hasPrefix("opencode/") { + let parts = modelId.split(separator: "/", omittingEmptySubsequences: true) + if parts.count >= 3 { + switch String(parts[1]) { + case "anthropic": + return "claude" + case "openai": + return "codex" + case "google": + return "google" + case "xai": + return "xai" + case "deepseek": + return "deepseek" + default: + return String(parts[1]) + } + } + } + return "opencode" + } + + return model.provider +} + func providerTint(_ provider: String?) -> Color { guard let provider else { return ADEColor.accent } switch providerFamilyKey(provider) { @@ -762,6 +876,78 @@ func toolDisplayName(_ tool: String) -> String { return trimmed } +/// Collapses whitespace and truncates long inline tool/command previews. +/// Mirrors desktop `summarizeInlineText` in chatTranscriptRows.ts. +func workSummarizeInlineText(_ value: String, maxChars: Int = 120) -> String { + let collapsed = value + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + guard collapsed.count > maxChars else { return collapsed } + return String(collapsed.prefix(max(1, maxChars - 1))) + "…" +} + +/// Extracts the human-readable target from tool args JSON — file path, shell +/// command, search pattern, etc. Avoids leaking raw `{` from pretty-printed +/// JSON in collapsed work-log rows (desktop `entryArgText` parity). +func workToolArgPreview(toolName: String, argsText: String?) -> String? { + guard let object = workJSONObject(from: argsText) else { + guard let argsText else { return nil } + let trimmed = argsText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let preview = workSummarizeInlineText(trimmed, maxChars: 140) + return preview.isEmpty ? nil : preview + } + + func stringValue(_ key: String) -> String? { + guard let value = object[key] as? String else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + let suffix = toolDisplayName(toolName).lowercased() + let target: String? = { + switch suffix { + case "read", "readfile": + return stringValue("file_path") ?? stringValue("path") + case "grep", "glob": + return stringValue("pattern") ?? stringValue("path") + case "ls", "listdir": + return stringValue("path") + case "write", "edit", "multiedit", "editfile", "writefile": + return stringValue("file_path") ?? stringValue("path") + case "notebookedit": + return stringValue("notebook_path") + case "bash", "exec_command", "shell": + return stringValue("command") ?? stringValue("cmd") + case "websearch": + return stringValue("query") + case "webfetch": + return stringValue("url") + case "gitdiff": + return stringValue("path") ?? stringValue("ref") + case "gitlog": + return stringValue("ref") + case "askuser": + return stringValue("question") + case "delegate_parallel": + if let tasks = object["tasks"] as? [Any] { + return "\(tasks.count) task(s)" + } + return "0 task(s)" + default: + for key in ["file_path", "path", "command", "cmd", "query", "url", "pattern", "name"] { + if let value = stringValue(key) { return value } + } + return nil + } + }() + + guard let target else { return nil } + let preview = workSummarizeInlineText(target, maxChars: 140) + return preview.isEmpty ? nil : preview +} + private func toolNameSuffix(_ tool: String) -> String { let normalized = tool.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if let last = normalized.split(separator: ".").last { return String(last) } diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 11e85f891..5af0380db 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -178,6 +178,10 @@ func buildWorkTimeline( } : buildWorkChatMessages(from: transcript) + let pendingSteerTexts = Set( + derivePendingWorkSteers(from: transcript).map { normalizedWorkLocalEchoText($0.text) }.filter { !$0.isEmpty } + ) + var entries: [WorkTimelineEntry] = messages.enumerated().map { index, message in WorkTimelineEntry(id: "message-\(message.id)", timestamp: message.timestamp, rank: index, payload: .message(message)) } @@ -188,7 +192,8 @@ func buildWorkTimeline( .filter { !$0.isEmpty } ) let visibleLocalEchoMessages = localEchoMessages.filter { echo in - !transcriptUserMessageTexts.contains(normalizedWorkLocalEchoText(echo.text)) + let normalized = normalizedWorkLocalEchoText(echo.text) + return !transcriptUserMessageTexts.contains(normalized) && !pendingSteerTexts.contains(normalized) } entries.append(contentsOf: toolCards.enumerated().map { index, card in diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index e8d14e836..76a7188a4 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -38,6 +38,7 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { case "user_message": event = .userMessage( text: userMessageDisplayText(from: eventDict), + attachments: parseAgentChatFileRefs(from: eventDict["attachments"]), turnId: turnId, steerId: optionalString(eventDict["steerId"]), deliveryState: optionalString(eventDict["deliveryState"]), @@ -362,6 +363,16 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { } } +private func parseAgentChatFileRefs(from value: Any?) -> [AgentChatFileRef]? { + guard let array = value as? [[String: Any]], !array.isEmpty else { return nil } + let refs = array.compactMap { dict -> AgentChatFileRef? in + guard let path = optionalString(dict["path"]), !path.isEmpty else { return nil } + let type = optionalString(dict["type"]) ?? "file" + return AgentChatFileRef(path: path, type: type, url: optionalString(dict["url"])) + } + return refs.isEmpty ? nil : refs +} + private func userMessageDisplayText(from eventDict: [String: Any]) -> String { if let displayText = optionalString(eventDict["displayText"])?.trimmingCharacters(in: .whitespacesAndNewlines), !displayText.isEmpty { diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 72b9f0811..9d516c0ef 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -5660,6 +5660,92 @@ final class ADETests: XCTestCase { XCTAssertEqual(warning?.accessibilitySummary, "Auto-rebase conflict. Resolve conflicts in the Rebase/Merge tab.") } + func testSelectLanePrTagPrefersOpenPrOnMatchingBranch() { + let lane = LaneSummary( + id: "lane-audit", + name: "mobile audit", + description: nil, + laneType: "worktree", + baseRef: "main", + branchRef: "ade/mobile-audit-34b23435", + worktreePath: "/tmp/mobile-audit", + attachedRootPath: nil, + parentLaneId: "lane-primary", + childCount: 0, + stackDepth: 1, + parentStatus: nil, + isEditProtected: false, + status: LaneStatus(dirty: true, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false), + color: "#a78bfa", + icon: nil, + tags: [], + folder: nil, + createdAt: "2026-03-20T00:00:00.000Z", + archivedAt: nil + ) + let openPr = PullRequestListItem( + id: "pr-open", + laneId: "lane-audit", + laneName: "mobile audit", + projectId: "project-1", + repoOwner: "ade", + repoName: "ADE", + githubPrNumber: 561, + githubUrl: "https://github.com/ade/ADE/pull/561", + title: "Mobile audit", + state: "open", + baseBranch: "main", + headBranch: "ade/mobile-audit-34b23435", + checksStatus: "passing", + reviewStatus: "approved", + additions: 12, + deletions: 4, + lastSyncedAt: nil, + createdAt: "2026-03-20T00:00:00.000Z", + updatedAt: "2026-03-21T00:00:00.000Z", + adeKind: nil, + linkedGroupId: nil, + linkedGroupType: nil, + linkedGroupName: nil, + linkedGroupPosition: nil, + linkedGroupCount: 0, + workflowDisplayState: nil, + cleanupState: nil + ) + let mergedPr = PullRequestListItem( + id: "pr-merged", + laneId: "lane-audit", + laneName: "mobile audit", + projectId: "project-1", + repoOwner: "ade", + repoName: "ADE", + githubPrNumber: 400, + githubUrl: "https://github.com/ade/ADE/pull/400", + title: "Old audit", + state: "merged", + baseBranch: "main", + headBranch: "ade/mobile-audit-34b23435", + checksStatus: "passing", + reviewStatus: "approved", + additions: 1, + deletions: 1, + lastSyncedAt: nil, + createdAt: "2026-03-10T00:00:00.000Z", + updatedAt: "2026-03-11T00:00:00.000Z", + adeKind: nil, + linkedGroupId: nil, + linkedGroupType: nil, + linkedGroupName: nil, + linkedGroupPosition: nil, + linkedGroupCount: 0, + workflowDisplayState: nil, + cleanupState: nil + ) + + XCTAssertEqual(selectLanePrTag(lane: lane, pullRequests: [mergedPr, openPr])?.id, "pr-open") + XCTAssertEqual(formatLanePrBadgeLabel(openPr), "PR #561") + } + func testLaneStackCardAccessibilityLabelIncludesRebaseWarningSummary() { var snapshot = makeLaneListSnapshot( id: "lane-warning", @@ -7266,7 +7352,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:00:01.000Z", sequence: 1, - event: .userMessage(text: "First", turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) + event: .userMessage(text: "First", attachments: nil, turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) ), ] let live = [ @@ -7274,7 +7360,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:00:01.000Z", sequence: 1, - event: .userMessage(text: "First", turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) + event: .userMessage(text: "First", attachments: nil, turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) ), WorkChatEnvelope( sessionId: "chat-1", @@ -7339,7 +7425,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-04-20T00:00:01.000Z", sequence: nil, - event: .userMessage(text: "What model are you?", turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) + event: .userMessage(text: "What model are you?", attachments: nil, turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) ), WorkChatEnvelope( sessionId: "chat-1", @@ -7353,7 +7439,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-04-20T00:00:01.000Z", sequence: 1, - event: .userMessage(text: "What model are you?", turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) + event: .userMessage(text: "What model are you?", attachments: nil, turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) ), WorkChatEnvelope( sessionId: "chat-1", @@ -7412,7 +7498,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-04-20T00:00:01.000Z", sequence: nil, - event: .userMessage(text: "ship it", turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) + event: .userMessage(text: "ship it", attachments: nil, turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) ), ] let live = [ @@ -7420,7 +7506,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-04-20T00:00:01.000Z", sequence: 1, - event: .userMessage(text: "ship it", turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) + event: .userMessage(text: "ship it", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) ), ] @@ -7454,7 +7540,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-04-20T00:00:03.000Z", sequence: 3, - event: .userMessage(text: "keep this staged", turnId: "turn-active", steerId: "steer-1", deliveryState: "queued", processed: nil) + event: .userMessage(text: "keep this staged", attachments: nil, turnId: "turn-active", steerId: "steer-1", deliveryState: "queued", processed: nil) ), WorkChatEnvelope( sessionId: "chat-1", @@ -7520,7 +7606,7 @@ final class ADETests: XCTestCase { let transcript = parseWorkChatTranscript(raw) XCTAssertEqual(transcript.count, 2) - guard case .userMessage(let text, _, let steerId, let deliveryState, _) = transcript[0].event else { + guard case .userMessage(let text, _, _, let steerId, let deliveryState, _) = transcript[0].event else { return XCTFail("Expected user_message event.") } XCTAssertEqual(text, "ship it") @@ -7541,7 +7627,7 @@ final class ADETests: XCTestCase { let transcript = parseWorkChatTranscript(raw) XCTAssertEqual(transcript.count, 1) - guard case .userMessage(let text, let turnId, _, _, _) = transcript[0].event else { + guard case .userMessage(let text, _, let turnId, _, _, _) = transcript[0].event else { return XCTFail("Expected user_message event.") } XCTAssertEqual(text, "ADE coordinator start: initialize the session.") @@ -7685,13 +7771,13 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:00:01.000Z", sequence: 1, - event: .userMessage(text: "ship", turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) + event: .userMessage(text: "ship", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) ), WorkChatEnvelope( sessionId: "chat-1", timestamp: "2026-03-25T00:00:02.000Z", sequence: 2, - event: .userMessage(text: "ship it fast", turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) + event: .userMessage(text: "ship it fast", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) ), WorkChatEnvelope( sessionId: "chat-1", @@ -7703,7 +7789,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:00:04.000Z", sequence: 4, - event: .userMessage(text: "also run tests", turnId: "turn-1", steerId: "steer-2", deliveryState: "queued", processed: nil) + event: .userMessage(text: "also run tests", attachments: nil, turnId: "turn-1", steerId: "steer-2", deliveryState: "queued", processed: nil) ), ] @@ -7718,13 +7804,13 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:00:01.000Z", sequence: 1, - event: .userMessage(text: "first", turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) + event: .userMessage(text: "first", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) ), WorkChatEnvelope( sessionId: "chat-1", timestamp: "2026-03-25T00:00:02.000Z", sequence: 2, - event: .userMessage(text: "second", turnId: "turn-1", steerId: "steer-2", deliveryState: "queued", processed: nil) + event: .userMessage(text: "second", attachments: nil, turnId: "turn-1", steerId: "steer-2", deliveryState: "queued", processed: nil) ), WorkChatEnvelope( sessionId: "chat-1", @@ -7736,7 +7822,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:00:04.000Z", sequence: 4, - event: .userMessage(text: "second", turnId: "turn-1", steerId: "steer-2", deliveryState: "delivered", processed: nil) + event: .userMessage(text: "second", attachments: nil, turnId: "turn-1", steerId: "steer-2", deliveryState: "delivered", processed: nil) ), ] @@ -7750,7 +7836,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:00:01.000Z", sequence: 1, - event: .userMessage(text: "ship", turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) + event: .userMessage(text: "ship", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) ), ] let live = [ @@ -7758,18 +7844,101 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:00:01.000Z", sequence: 1, - event: .userMessage(text: "ship it fast", turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) + event: .userMessage(text: "ship it fast", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) ), ] let merged = mergeWorkChatTranscripts(base: base, live: live) XCTAssertEqual(merged.count, 1) - guard case .userMessage(let text, _, _, _, _) = merged[0].event else { + guard case .userMessage(let text, _, _, _, _, _) = merged[0].event else { return XCTFail("Expected user_message event.") } XCTAssertEqual(text, "ship it fast") } + func testPruneResolvedQueuedSteerEnvelopesDropsStaleQueuedRow() { + let transcript = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:01.000Z", + sequence: 1, + event: .userMessage(text: "ship it", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:02.000Z", + sequence: 2, + event: .userMessage(text: "ship it", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "delivered", processed: nil) + ), + ] + + let pruned = pruneResolvedQueuedSteerEnvelopes(transcript) + XCTAssertEqual(pruned.count, 1) + guard case .userMessage(_, _, _, let steerId, let deliveryState, _) = pruned[0].event else { + return XCTFail("Expected delivered user_message event.") + } + XCTAssertEqual(steerId, "steer-1") + XCTAssertEqual(deliveryState, "delivered") + XCTAssertTrue(derivePendingWorkSteers(from: pruned).isEmpty) + } + + func testPreferredWorkTranscriptPreservesQueuedSteerAfterPlainFallbackBackfill() { + let fallback = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:01.000Z", + sequence: nil, + event: .userMessage(text: "ship it", attachments: nil, turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) + ), + ] + let live = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:01.000Z", + sequence: 1, + event: .userMessage(text: "ship it", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) + ), + ] + + let preferred = preferredWorkTranscript( + current: [], + fallback: fallback, + eventTranscript: live + ) + + XCTAssertEqual(buildWorkChatMessages(from: preferred).map(\.markdown), []) + XCTAssertEqual(derivePendingWorkSteers(from: preferred).map(\.id), ["steer-1"]) + } + + func testWorkTimelineHidesLocalEchoWhenQueuedSteerCoversSameText() { + let transcript = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:02.000Z", + sequence: 1, + event: .userMessage(text: "Stage me", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) + ), + ] + let timeline = buildWorkTimeline( + transcript: transcript, + fallbackEntries: [], + toolCards: [], + commandCards: [], + fileChangeCards: [], + eventCards: [], + artifacts: [], + localEchoMessages: [ + WorkLocalEchoMessage(text: "Stage me", timestamp: "2026-03-25T00:00:01.000Z", deliveryState: "queued"), + ] + ) + let userMessages = timeline.compactMap { entry -> String? in + guard case .message(let message) = entry.payload, message.role == "user" else { return nil } + return message.markdown + } + + XCTAssertTrue(userMessages.isEmpty) + } + func testVisibleWorkTimelineEntriesKeepsNewestPage() { let entries = (1...6).map { index in WorkTimelineEntry( @@ -8148,6 +8317,13 @@ final class ADETests: XCTestCase { ) } + func testWorkResolveCliProviderMapsModelFamiliesLikeDesktop() { + XCTAssertEqual(workResolveCliProvider(for: "claude-sonnet-4-6", provider: "claude"), "claude") + XCTAssertEqual(workResolveCliProvider(for: "gpt-5.5", provider: "codex"), "codex") + XCTAssertEqual(workResolveCliProvider(for: "auto", provider: "cursor"), "cursor") + XCTAssertEqual(workResolveCliProvider(for: "opencode/anthropic/claude-sonnet-4-6", provider: "opencode"), "opencode") + } + func testWorkModelCatalogTreatsCodexRuntimeAndRegistryIdsAsSameModel() { let groups = workModelCatalogGroups( availableModelsByProvider: [ @@ -8431,7 +8607,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:00:02.000Z", sequence: 1, - event: .userMessage(text: prompt, turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) + event: .userMessage(text: prompt, attachments: nil, turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) ), ] let timeline = buildWorkTimeline( @@ -8487,7 +8663,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:00:01.000Z", sequence: 1, - event: .userMessage(text: "say hi", turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) + event: .userMessage(text: "say hi", attachments: nil, turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) ), WorkChatEnvelope( sessionId: "chat-1", @@ -8505,7 +8681,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:01:01.000Z", sequence: 4, - event: .userMessage(text: "say hi again", turnId: "turn-2", steerId: nil, deliveryState: nil, processed: nil) + event: .userMessage(text: "say hi again", attachments: nil, turnId: "turn-2", steerId: nil, deliveryState: nil, processed: nil) ), WorkChatEnvelope( sessionId: "chat-1", @@ -8652,6 +8828,36 @@ final class ADETests: XCTestCase { ) } + /// Regression: the chat_subscribe ack now carries live turn state so a + /// phone that subscribes mid-turn (desktop-started chat, byte-capped + /// snapshot tail without the `status: started` event) still renders the + /// stop button and working indicator. Older hosts omit the field — it + /// must decode as nil, not fail or default to a fabricated state. + func testChatSubscribeSnapshotPayloadDecodesLiveTurnState() throws { + let modernJSON = """ + { + "sessionId": "chat-1", + "capturedAt": "2026-06-12T00:00:00.000Z", + "truncated": true, + "events": [], + "turnActive": true + } + """ + let modern = try JSONDecoder().decode(SyncChatSubscribeSnapshotPayload.self, from: Data(modernJSON.utf8)) + XCTAssertEqual(modern.turnActive, true) + + let legacyJSON = """ + { + "sessionId": "chat-1", + "capturedAt": "2026-06-12T00:00:00.000Z", + "truncated": false, + "events": [] + } + """ + let legacy = try JSONDecoder().decode(SyncChatSubscribeSnapshotPayload.self, from: Data(legacyJSON.utf8)) + XCTAssertNil(legacy.turnActive) + } + func testWorkSessionEmptyStateMessagingExplainsSearchAndArchiveFallbacks() { XCTAssertEqual( workSessionEmptyStateTitle(status: .all, searchText: "deploy", hasFilters: true), @@ -8788,6 +8994,46 @@ final class ADETests: XCTestCase { ) } + func testFilesStripYamlFrontmatterRemovesLeadingBlock() { + let input = """ + --- + name: ade-autoresearch + description: perf skill + --- + # Heading + + Body text + """ + XCTAssertEqual(filesStripYamlFrontmatter(input), "# Heading\n\nBody text") + XCTAssertEqual(filesStripYamlFrontmatter("# No frontmatter"), "# No frontmatter") + } + + func testFilesIsImagePreviewableUsesPathAndPreviewKind() { + let blob = SyncFileBlob( + path: "proof/screenshot.png", + size: 1200, + encoding: "base64", + isBinary: true, + content: "", + previewKind: "image" + ) + XCTAssertTrue(filesIsImagePreviewable(path: "proof/screenshot.png", blob: blob)) + XCTAssertFalse(filesIsImagePreviewable(path: "README.md", blob: SyncFileBlob(path: "README.md", size: 10, encoding: "utf8", isBinary: false, content: "# hi"))) + } + + func testFilesImageDataPrefersDataUrl() { + let tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + let blob = SyncFileBlob( + path: "a.png", + size: 68, + encoding: "base64", + isBinary: true, + content: "", + dataUrl: "data:image/png;base64,\(tinyPngBase64)" + ) + XCTAssertNotNil(filesImageData(from: blob)) + } + func testWorkDisplayLeavesCleanRepeatedLettersAloneEvenWithManyDoubles() { // Real text with many legitimate double letters must NOT get collapsed. let natural = "Committee will assess the bookkeeping across all accounts, noting success, progress, commitment." @@ -9906,6 +10152,65 @@ final class ADETests: XCTestCase { XCTAssertEqual(workToolResultPreview(" padded line "), "padded line") } + func testMakeWorkChatEventPreservesUserMessageAttachments() { + let attachments = [AgentChatFileRef(path: ".ade/attachments/screenshot.png", type: "image")] + let mapped = makeWorkChatEvent( + from: .userMessage( + text: "see attached", + attachments: attachments, + turnId: "turn-1", + steerId: nil, + deliveryState: "delivered", + processed: true + ) + ) + guard case .userMessage(_, let preserved, _, _, _, _) = mapped else { + return XCTFail("Expected mapped user message event") + } + XCTAssertEqual(preserved, attachments) + } + + func testBuildWorkChatMessagesIncludesAttachmentMetadata() { + let transcript = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-06-12T00:00:00.000Z", + sequence: 1, + event: .userMessage( + text: "screenshots attached", + attachments: [AgentChatFileRef(path: ".ade/attachments/a.png", type: "image")], + turnId: "turn-1", + steerId: nil, + deliveryState: "delivered", + processed: true + ) + ), + ] + let messages = buildWorkChatMessages(from: transcript) + XCTAssertEqual(messages.count, 1) + XCTAssertEqual(messages.first?.attachments?.count, 1) + XCTAssertEqual(messages.first?.attachments?.first?.path, ".ade/attachments/a.png") + } + + func testWorkToolArgPreviewExtractsPathInsteadOfRawJSONBrace() { + XCTAssertEqual( + workToolArgPreview( + toolName: "Read", + argsText: """ + { + "path": "apps/ios/ADE/Views/Work/WorkReasoningCard.swift" + } + """ + ), + "apps/ios/ADE/Views/Work/WorkReasoningCard.swift" + ) + XCTAssertEqual( + workToolArgPreview(toolName: "Bash", argsText: #"{"command":"ade help ios-sim"}"#), + "ade help ios-sim" + ) + XCTAssertNil(workToolArgPreview(toolName: "Read", argsText: "{}")) + } + func testWorkToolResultTruncateShortTextIsUntouched() { let short = String(repeating: "a", count: workToolResultTruncateLimit) let (text, didTruncate) = workToolResultTruncate(short, expanded: false) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 72a83e3be..2ce3ee8ff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -886,7 +886,7 @@ The sync subsystem is **owned by the ADE runtime** (`apps/ade-cli/src/services/s - Opens WebSocket to host after racing all saved address candidates with concurrent TCP probes (happy eyeballs) — a dead LAN IP no longer delays the live Tailscale route. Sends local `db_version` plus the per-host-DB cursor map (`remoteDbVersionBySite`); host replies with its `serverDbSiteId` and sends catch-up changesets. - `hello_ok` can include the host's mobile project catalog. The iOS app shows a native project home until an active project is selected, then drives `project_switch_request` / `project_switch_result`; the port stays stable across switches. - Bidirectional sync continues; inbound processing (envelope parse, gunzip, chunk reassembly, changeset decode + apply) runs off the main actor. On disconnect: a fast exponential-backoff burst, then an indefinite ~30 s slow-heartbeat retry — the phone never permanently gives up. `reconnectIfPossible` is guarded against overlapping runs. -- Chat streaming resumes by sequence: each `chat_event` carries a host-assigned per-session `seq` backed by a replay buffer; `chat_subscribe` passes `sinceSeq` so reconnects replay only the missed events. `chat.getTranscript` pages older history via an opaque cursor. +- Chat streaming resumes by sequence: each `chat_event` carries a host-assigned per-session `seq` backed by a replay buffer; `chat_subscribe` passes `sinceSeq` so reconnects replay only the missed events. The subscribe ack also carries `turnActive` (live turn state from the agent chat service) so a phone subscribing mid-turn renders streaming/stop affordances immediately even when the byte-capped snapshot tail dropped the turn's start event. `chat.getTranscript` pages older history via an opaque cursor. - All reads are local and scoped to the active project id — the iOS tab is instant and offline-capable after the selected project's row has hydrated. - Writes from user actions: write locally, replicate to host. Execution commands (create PR, run command) are routed to the host via the `command`/`command_ack`/`command_result` message flow. - Sub-protocols: changeset sync, project catalog/switch, file access, diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 2df445947..187d6d15d 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -323,21 +323,39 @@ free the underlying server sooner). Teardown routes through `teardownRuntime` distinguishes **terminal** close reasons (`handle_close`, `ended_session`, `model_switch`) from **non-terminal** ones (`idle_ttl`, `budget_eviction`, `pool_compaction`, `paused_run`, -`project_close`, `shutdown`). For Claude runtimes only, a non-terminal -teardown preserves resume state: the service pins -`runtime.sdkSessionId` to the last known Claude SDK session id before releasing -the session, persists chat state immediately, and skips the usual +`project_close`, `shutdown`). For Claude and Cursor runtimes, a +non-terminal teardown preserves resume state: the service persists chat +state immediately (Claude additionally pins `runtime.sdkSessionId` to +the last known Claude SDK session id before releasing the session; +Cursor persists with its SDK agent id intact) and skips the usual `runtimeInvalidated = true` + `clearLaneDirectiveKey` cleanup. The next -turn on that chat can therefore rehydrate the same Claude SDK session +turn on that chat can therefore rehydrate the same provider SDK session instead of creating a fresh one, even though the SDK process was -released to reclaim budget or compact the pool. Terminal closes still -run the full invalidation path so runtime stops and explicit model -switches don't leave stale continuation pointers behind. Cursor SDK +released to reclaim budget or compact the pool (a dead pooled Cursor +worker detected during turn setup also tears down with +`pool_compaction`, keeping that path non-terminal). Terminal closes +still run the full invalidation path so runtime stops and explicit +model switches don't leave stale continuation pointers behind. Cursor SDK model switches are deferred while a turn is busy: the session model updates immediately, the active turn keeps reporting the model it started with, and runtime teardown waits until the turn finishes so approvals and stream callbacks are not orphaned mid-run. +Cursor resume is best-effort on the SDK side: acquiring the pooled +worker can come back with a **different** agent id than the one ADE +persisted (the SDK opened a fresh agent instead of resuming). When that +happens, `stageCursorSdkAgentRotationRecovery` stages a continuity +recovery block into `pendingReconstructionContext` — a note naming the +previous and rotated agent ids (with an instruction not to claim access +to Cursor-side state that was not restored), the session's continuity +summary, and a recent-conversation tail — and clears the lane-directive +dedupe key so the brand-new agent gets the lane execution directive +re-emitted on its first turn. The rotation is logged as +`agent_chat.cursor_sdk_agent_rotated_after_resume`. Pending +reconstruction context (from this path or session recovery) is injected +ahead of the next prompt under the label +`System context (ADE continuity, do not echo verbatim):`. + On app shutdown the service exposes `forceDisposeAll()` — called from `runImmediateProcessCleanup()` in `main.ts`. It stops the cleanup timer, rejects every outstanding `sessionTurnCollector` with a "closed during diff --git a/docs/features/conflicts/README.md b/docs/features/conflicts/README.md index 6a67af4e4..804e3ef07 100644 --- a/docs/features/conflicts/README.md +++ b/docs/features/conflicts/README.md @@ -208,6 +208,10 @@ Apply modes: `unstaged | staged | commit`. Apply goes through Returns per-state `canContinue` / `canAbort` / `conflictedFiles`. Surfaces: `ade.git.getConflictState`, `ade.git.rebaseContinue`, `ade.git.rebaseAbort`, `ade.git.mergeContinue`, `ade.git.mergeAbort`. +The same five operations are registered as sync remote commands +(`git.getConflictState`, `git.rebaseContinue` / `Abort`, +`git.mergeContinue` / `Abort`) so the iOS lane conflict banner can +continue or abort either flow remotely. ## IPC surface diff --git a/docs/features/deeplinks/README.md b/docs/features/deeplinks/README.md index 4d5bcea55..9625795a5 100644 --- a/docs/features/deeplinks/README.md +++ b/docs/features/deeplinks/README.md @@ -130,8 +130,14 @@ Apps/web — landing page + OG unfurl: - `apps/web/vercel.json` — adds `/open → /api/open` rewrite ahead of the catch-all SPA rewrite. -iOS — inbound deeplinks + Send-to-Mac: - +iOS — inbound deeplinks, outbound link minting, and Send-to-Mac: + +- `apps/ios/ADE/Views/Lanes/LaneDeeplinkHelpers.swift` — outbound link + minting on the phone: builds `ade://lane/` and percent-encoded + `ade://repo///branch/` strings for the lane + detail's "Copy ADE lane link" / "Copy branch link" menu actions + (branch links resolve owner/repo from a linked PR; with no GitHub + remote the lane link is copied instead, with a notice). - `apps/ios/ADE/App/DeepLinkRouter.swift` — parses inbound `ade://` URLs. `ade://session/` and `ade://pr/` (and the longer `ade://pr///` form) flip the active tab via diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 0687da222..1802dd196 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -128,32 +128,38 @@ iOS companion (`apps/ios/ADE/Views/Lanes/`): `LaneMultiAttachSheet.swift` — mobile add/attach entry points, including discovery and batch attachment of unregistered worktrees via `lanes.listUnregisteredWorktrees`. -- `LaneStackCanvasScreen.swift` and `LaneStackGraphSheet.swift` — - mobile stack graph/canvas projection for parent-child lane chains. +- `LaneStackGraphSheet.swift` — mobile stack graph projection for + parent-child lane chains. - `LaneDetailScreen.swift`, `LaneDetailGitSection.swift`, - `LaneDetailContentSections.swift`, `LaneDetailRebaseBanner.swift`, - `LaneDiffScreen.swift`, `LaneCommitSheet.swift`, - `LaneCommitHistoryScreen.swift`, `LaneStashesScreen.swift`, - `LaneSyncDetailScreen.swift`, `LaneActionsCard.swift`, - `LaneAdvancedScreen.swift` (single Advanced page that hosts Manage, - Switch branch, Stash, and the four destructive git escape hatches — - rebase lane, rebase descendants, rebase + push, force push — with a - description per row and an offline disabled banner), - `LaneManageSheet.swift`, `LaneBatchManageSheet.swift`, - `LaneChatLaunchSheet.swift`, `LaneTreeView.swift`, + `LaneDetailGitActionsPane.swift`, `LaneDetailRebaseBanner.swift`, + `LaneDiffScreen.swift`, `LaneSyncDetailScreen.swift`, + `LaneActionsCard.swift`, `LaneManageSheet.swift`, + `LaneBatchManageSheet.swift`, `LaneChatLaunchSheet.swift`, + `LaneDeeplinkHelpers.swift`, `LaneTreeView.swift`, `LaneFileTreeComponents.swift` — mobile detail, git, rebase, diff, stash, sync, manage, chat-launch, and file-tree parity surfaces. - `LaneManageSheet.swift` mirrors desktop's single-lane Stack position - section: parent-lane picker, optional base-branch override, "Runs git - rebase" disclosure, dirty/rebase-in-progress guards, and - `lanes.reparent` payloads that omit `stackBaseBranchRef` when the - override is blank. - `LaneCommitSheet.swift` is now a "review & commit" sheet: staged - and unstaged files render with per-file stage / unstage / discard / - restore / open-diff / open-files affordances, plus a "Suggest" - button that calls `aiCommitMessages.generate` and shows an inline - setup hint when the host reports AI commit messages aren't - configured. + `LaneDetailGitActionsPane.swift` is the single git surface embedded + in the lane detail (a port of desktop's `LaneGitActionsPane`): + commit message + amend with an AI "Suggest message" button (calls + `aiCommitMessages.generate` and shows an inline setup hint when the + host reports AI commit messages aren't configured), pull/push/fetch, + staged and unstaged files with per-file and bulk stage / unstage / + discard / restore / open-diff / open-files affordances, stash + push/apply/pop/drop, recent-commit history with revert / cherry-pick + context actions, and a "more actions" menu carrying switch branch + plus the destructive escape hatches (rebase lane, rebase + + descendants, rebase and push, force push). It replaced the former + `LaneAdvancedScreen`, `LaneCommitSheet`, `LaneCommitHistoryScreen`, + `LaneStashesScreen`, and `LaneDetailContentSections` files. + `LaneManageSheet.swift` is now a tabbed manage dialog (delete / + appearance / stack / archive) mirroring desktop's `ManageLaneDialog`; + its stack tab keeps the parent-lane picker, optional base-branch + override, "Runs git rebase" disclosure, dirty/rebase-in-progress + guards, and `lanes.reparent` payloads that omit `stackBaseBranchRef` + when the override is blank. `LaneDeeplinkHelpers.swift` mints the + shareable `ade://lane/` and + `ade://repo///branch/` links the lane options + menu copies to the pasteboard. Detail docs in this folder: diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 219d546d0..5b3df2357 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -589,7 +589,7 @@ payload. | Changeset sync | Bidirectional cr-sqlite row exchange | All devices | | File access | On-demand file reads, listings, writes | iOS Files, desktop remote viewing | | Terminal stream/control | Subscribe to PTY output from the runtime; send input bytes and viewport resize events back to the subscribed PTY | iOS Work tab | -| Chat stream | Agent chat transcript events. Each `chat_event` carries a host-assigned per-session monotonic `seq` backed by a capped replay buffer (500 events / 2 MB per session, 64-session LRU). `chat_subscribe` accepts `sinceSeq`: gaps the buffer covers replay as ordinary events; uncoverable gaps fall back to a snapshot, and a non-resumed ack tells the client to drop its stale seq watermark (seq epochs restart at 1 on a new host) | iOS Work tab, controller chat | +| Chat stream | Agent chat transcript events. Each `chat_event` carries a host-assigned per-session monotonic `seq` backed by a capped replay buffer (500 events / 2 MB per session, 64-session LRU). `chat_subscribe` accepts `sinceSeq`: gaps the buffer covers replay as ordinary events; uncoverable gaps fall back to a snapshot, and a non-resumed ack tells the client to drop its stale seq watermark (seq epochs restart at 1 on a new host). The ack also carries `turnActive` from the live agent chat service — snapshots are byte-capped tails, so a long turn's `status: started` event can fall outside the window and the flag is what lets a mid-turn subscriber render streaming/stop affordances without waiting on the changeset pump (a full ack without the flag tells the client to drop any latched hint) | iOS Work tab, controller chat | | Command routing | Send named actions (`chat.send`, `lanes.create`, `git.push`, `prs.getMobileSnapshot`, etc.) | Controller devices | | Project switching | `project_catalog` + `project_switch_request/result` for multi-project runtimes | iOS project home | | Runtime status | Runtime broadcasts cluster/version status (`brain_status` is the legacy envelope name) | All devices | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index fa52165f4..f9243f5e7 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -104,16 +104,23 @@ apps/ios/ │ │ │ # — `ADEStreamingShimmer.swift` was retired │ │ ├── Cto/ # CtoRootScreen, CtoSessionDestinationView │ │ ├── Lanes/ # LaneDetailScreen, LaneActionsCard, -│ │ │ # LaneAdvancedScreen (gearshape destination), -│ │ │ # LaneBatchManageSheet, LaneManageSheet, -│ │ │ # LaneMultiAttachSheet, LaneStackCanvasScreen, -│ │ │ # LaneCommitSheet (review + per-file stage/diff), +│ │ │ # LaneDetailGitActionsPane (commit / +│ │ │ # stage / stash / history / escape +│ │ │ # hatches, desktop pane parity), +│ │ │ # LaneBatchManageSheet, LaneManageSheet +│ │ │ # (tabbed manage dialog), +│ │ │ # LaneMultiAttachSheet, LaneStackGraphSheet, +│ │ │ # LaneDeeplinkHelpers (ade:// lane/branch +│ │ │ # link minting), │ │ │ # LaneEnvInitProgressView, etc. │ │ ├── Files/ # FilesRootScreen, FilesDirectoryScreen, -│ │ │ # FilesDetailScreen, *+Actions helpers +│ │ │ # FilesDetailScreen, *+Actions helpers, +│ │ │ # FilesWorkspacePickerDropdown │ │ ├── Work/ # WorkRootScreen, WorkChatSessionView, │ │ │ # Work*Helpers, WorkNewChatScreen (chat/CLI -│ │ │ # segmented launcher), WorkArtifactTerminalViews, +│ │ │ # launcher), WorkLanePickerDropdown, +│ │ │ # WorkChatAttachmentTray, +│ │ │ # WorkArtifactTerminalViews, │ │ │ # TerminalSessionScreen + SwiftTermSessionView │ │ │ # (full-screen SwiftTerm terminal, │ │ │ # offset resume/history paging + @@ -124,7 +131,8 @@ apps/ios/ │ │ │ # WorkSelectionActionBar, etc. │ │ ├── PRs/ # PrsRootScreen, PrDetailScreen and │ │ │ # per-tab views, PrWorkflowCards, -│ │ │ # PrStackSheet, CreatePrWizardView +│ │ │ # PrStackSheet, CreatePrWizardView, +│ │ │ # PrTargetBranchPickerDropdown │ │ ├── Settings/ # ConnectionSettingsView, NotificationsCenterView, │ │ │ # QuietHoursEditorView, PerSessionOverrideView, │ │ │ # SettingsPairingSection, SettingsConnectionHeader, @@ -132,7 +140,7 @@ apps/ios/ │ │ └── LanesTabView.swift │ └── Assets.xcassets/ # App icon, brand mark, provider logos │ # (Anthropic, Claude, Codex, Cursor, -│ # OpenAI, OpenCode) +│ # Droid, OpenAI, OpenCode) ├── ADENotificationService/ │ └── NotificationService.swift # UNNotificationServiceExtension: brand-prefix │ # title, set threadIdentifier, raise interruption level @@ -366,7 +374,7 @@ Implemented envelope types on iOS: | `terminal_subscribe` / `terminal_unsubscribe` / `terminal_data` | Phone ↔ runtime | Terminal streaming; `unsubscribe` is sent when a Work terminal screen disappears so the phone stops accumulating buffer for off-screen sessions. `terminal_data` carries `offset` — the transcript's end byte offset after the chunk (null when the session has no transcript or hit the size cap) — so the phone can detect dropped chunks. `terminal_subscribe` accepts `sinceOffset`; when the runtime can serve exactly `sinceOffset → end` within the byte budget it replies with a `delta: true` snapshot (append, don't replace), giving exact back-fill after reconnects/gaps. Snapshots also report `startOffset`/`endOffset`, plus `live: false` when no PTY backs the session (ended, or orphaned by a brain restart while status still says running) so the phone shows a resume bar instead of silently accepting keystrokes | | `terminal_history` | Phone → runtime | On-demand scrollback paging: `{ sessionId, beforeOffset, maxBytes? }` returns transcript bytes `[startOffset, endOffset)` ending at/before `beforeOffset` (page start scanned forward to a newline/ESC boundary; `atStart: true` at beginning of transcript). Requires an active `terminal_subscribe` | | `terminal_input` / `terminal_resize` | Phone → runtime | Raw input bytes and viewport size changes for a subscribed live PTY. Mobile resizes are non-authoritative: the runtime records the last desktop-originated size and restores it when the last subscribed phone detaches | -| `chat_subscribe` / `chat_event` | Phone → runtime / runtime → phone | Agent chat transcript streaming; `chat_subscribe` carries `sinceSeq` so the runtime can replay exactly the missed events from its per-session buffer instead of re-sending a snapshot | +| `chat_subscribe` / `chat_event` | Phone → runtime / runtime → phone | Agent chat transcript streaming; `chat_subscribe` carries `sinceSeq` so the runtime can replay exactly the missed events from its per-session buffer instead of re-sending a snapshot. The subscribe ack carries `turnActive` from the live agent chat service so a phone subscribing mid-turn renders the stop button and working indicator immediately — the byte-capped snapshot tail may have dropped the turn's `status: started` event, and the synced session row arrives via the slower changeset pump. The phone keeps the hint current from live `status` / `done` events, drops it when a full ack omits the flag (older host / no live summary), and clears it on project switch / reconnect resets. Incoming chat events bump a UI revision through a leading-edge coalescer (~150 ms window: the first event after a quiet period renders immediately, bursts batch); turn-state flips bypass the coalescer entirely so the stop button reacts instantly | | `envelope_chunk` | Runtime → phone | Slice of an oversized encoded envelope (>720 KB); the phone reassembles by `chunkId`/`index` before normal decode | | `heartbeat` | Bidirectional | Connection health (30s) | | `brain_status` | Runtime → phone | Legacy-named cluster authority broadcast | @@ -747,9 +755,9 @@ duplicate. Project list dedup runs as a final pass | Tab | Icon | Desktop equivalent | Capabilities | |---|---|---|---| -| **Lanes** | `square.stack.3d.up` | `/lanes` | Full lane surface: search/filter chips, open/create/attach/manage, multi-attach for unregistered worktrees, stack canvas, git/diff/rebase/conflicts, template-backed environment setup progress, lane-scoped sessions and AI chats. `devicesOpen` presence chips show which other devices currently have the lane open. The lane gear opens `LaneAdvancedScreen`, a single page that groups Manage / Switch branch / Stash and the destructive git escape hatches (rebase lane, rebase descendants, rebase + push, force push) with an inline description per row and an offline disabled banner. The commit sheet (`LaneCommitSheet`) renders staged + unstaged file lists with per-file stage / unstage / discard / restore / open-diff / open-files actions, a "Suggest" AI button gated by runtime capability, and a setup-hint card surfaced when the runtime returns "AI commit messages are off". | -| **Files** | `doc.text` | `/files` | Lane-backed workspace picker, live file tree/search/read, protected-workspace read-only parity. `mobileReadOnly` on the workspace payload gates mutating file actions on the phone via `ensureMobileFileMutationsAllowed`; quick-open and text-search result lists cap visible rows at 40 and ask the user to refine when more matches exist. | -| **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid / shell), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **ADE chat** and **CLI session** via a segmented picker; in CLI mode a `workCliProviderOptions` row picker exposes each supported provider explicitly. CLI mode submits `work.startCliSession` with the chosen provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. When the hosted program enables mouse reporting (Claude Code, htop), vertical pans are translated into SGR wheel events so the TUI scrolls itself; mouse-off sessions scroll native scrollback. Against pre-offset hosts (older brains, whose PTY→sync bridge never pushed terminal output) the screen detects the missing offsets and falls back to a 2s tail-refresh poll until offsets appear. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. | +| **Lanes** | `square.stack.3d.up` | `/lanes` | Full lane surface: search/filter chips, open/create/attach/manage, multi-attach for unregistered worktrees, stack canvas, git/diff/rebase/conflicts, template-backed environment setup progress, lane-scoped sessions and AI chats. `devicesOpen` presence chips show which other devices currently have the lane open. The lane detail screen (full-screen, custom tab bar hidden) embeds `LaneDetailGitActionsPane`, a port of desktop's git actions pane: commit message field with amend toggle and an AI "Suggest message" button (gated by runtime capability, with a setup-hint when the runtime reports "AI commit messages are off"), pull (rebase/merge mode) / push (with force-with-lease) / fetch, staged + unstaged file lists with per-file and bulk stage / unstage / discard / restore / open-diff / open-files, stash push/apply/pop/drop, recent-commit history with context-menu view-files / copy-message / revert / cherry-pick, and a "more actions" menu holding switch branch plus the destructive escape hatches (rebase lane, rebase + descendants, rebase and push, force push). A conflict banner offers rebase **and merge** continue/abort (`git.rebaseContinue`/`Abort`, `git.mergeContinue`/`Abort`), and a rescue sheet creates a new lane from uncommitted changes. The lane options menu copies shareable deeplinks (`LaneDeeplinkHelpers`: `ade://lane/`, `ade://repo///branch/`) and opens `LaneManageSheet`, now a tabbed manage dialog (delete / appearance / stack / archive) mirroring desktop's `ManageLaneDialog`. The previous `LaneAdvancedScreen`, `LaneCommitSheet`, `LaneStashesScreen`, and `LaneCommitHistoryScreen` destinations were deleted in favor of this single pane. | +| **Files** | `doc.text` | `/files` | Lane-backed workspace picker (`FilesWorkspacePickerDropdown`, a desktop-shaped searchable dropdown that replaced the horizontal workspace chip row), live file tree/search/read, protected-workspace read-only parity. `mobileReadOnly` on the workspace payload gates mutating file actions on the phone via `ensureMobileFileMutationsAllowed`; quick-open and text-search result lists cap visible rows at 40 and ask the user to refine when more matches exist. | +| **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **Chat** and **CLI** via a compact nav-bar pill toggle (desktop `ModeSwitcherPills` parity); the lane is chosen through `WorkLanePickerDropdown` (searchable, with an auto-create-lane row), and in CLI mode the provider is derived from the picked model via `workResolveCliProvider` instead of a separate provider row — the explicit `workCliProviderOptions` picker (and its plain "Shell" launch option) was removed. CLI mode submits `work.startCliSession` with the resolved provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. When the hosted program enables mouse reporting (Claude Code, htop), vertical pans are translated into SGR wheel events so the TUI scrolls itself; mouse-off sessions scroll native scrollback. Against pre-offset hosts (older brains, whose PTY→sync bridge never pushed terminal output) the screen detects the missing offsets and falls back to a 2s tail-refresh poll until offsets appear. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. In chat sessions, user-message attachments render through `WorkChatAttachmentTray` (image thumbnails embedded in the bubble, desktop `ChatAttachmentTray` parity, placeholder tiles when the image bytes have not synced from the host yet), and the chat header's PR menu opens the lane's open PR on GitHub, copies its link, or launches the create-PR wizard in `singleModeOnly` mode (eligibility read from `prs.getMobileSnapshot.createCapabilities`). | | **PRs** | `arrow.triangle.pull` | `/prs` | PR list/detail driven by `prs.getMobileSnapshot`: stack visibility (`PrStackSheet`), create-PR wizard (`CreatePrWizardView`) gated by per-lane eligibility, workflow cards (queue / integration / rebase) rendered from `PrWorkflowCard`, per-PR action capabilities. | | **CTO** | `brain.head.profile` | `/cto` | CTO snapshot: Chat / Team / Workflows segments, with the mobile workflows screen mirroring the desktop workflow policy/dashboard and preserving the shared glass navigation chrome. Drills into per-worker chat sessions via `CtoSessionDestinationView`. | | **Settings** | `gearshape` | `/settings` (sync subset) | PIN pairing (`SettingsPinSheet`), notification preferences (`NotificationsCenterView`), quiet hours, per-session overrides, appearance, diagnostics, connection header with QR payload and address candidates, reconnect, forget. `ConnectionSettingsView` binds to `SettingsConnectionPresentationModel`, which feeds plain `SettingsConnectionSnapshot` / `SettingsPairingSnapshot` / `SettingsDiagnosticsSnapshot` DTOs into the section views (`SettingsConnectionHeader`, `SettingsPairingSection`, `SettingsDiagnosticsSection`) instead of having them reach into `SyncService` directly. `sendTestPush` is now `async` and returns a `SyncSendTestPushResult` (`ok`, `message`); the Notifications section renders that message verbatim so APNs-not-configured / in-app-only / wire failure cases all surface to the user. | @@ -814,9 +822,13 @@ offline usage remain fast. ## PR data projection The iOS PR wizard (`CreatePrWizardView`) supports three create modes — -`single`, `queue`, and `integration` — with a shared stepper (Mode → -Source → Details → Review) and per-mode submit handlers routed through -the sync command surface: +`single`, `queue`, and `integration` — as a single scrollable form (the +earlier Mode → Source → Details → Review stepper was removed): a mode +selector (hidden when the wizard is opened with `singleModeOnly`, e.g. +from a lane that can only create one PR), a source-branches section, +and a target-branch picker rendered by `PrTargetBranchPickerDropdown` +(searchable dropdown over the lane's eligible base branches). Per-mode +submit handlers route through the sync command surface: - single → `prs.createFromLane` (via `onCreateSingle` callback) - queue → `prs.createQueue` and `prs.startQueueAutomation`, returning @@ -1005,9 +1017,13 @@ reflected in the phone's UI on the next descriptor read. `claude | codex | cursor | droid | opencode | shell`. The phone has no way to pass arbitrary `command` / `startupCommand` payloads — those come from the shared - `apps/desktop/src/shared/cliLaunch.ts`. Adding a sixth provider - means updating both the runtime registry and the phone's - `workCliProviderOptions` together. `SyncStartCliSessionArgs` also + `apps/desktop/src/shared/cliLaunch.ts`. On the phone the provider is + derived from the picked model via `workResolveCliProvider` + (`WorkModelCatalog.swift`, mirroring desktop's + `resolveCliProviderForModel`), so adding a provider means updating + both the runtime registry and the phone's model-catalog grouping + together; `shell` remains valid runtime-side but the phone no longer + offers a plain-shell launch. `SyncStartCliSessionArgs` also carries an optional `reasoningEffort` field that the runtime forwards to `buildTrackedCliLaunchCommand`, so the phone can launch a Codex / Claude CLI session at a non-default effort tier without going @@ -1017,9 +1033,10 @@ reflected in the phone's UI on the next descriptor read. into the spawned PTY (`writeBySessionId(sessionId, "${input}\\r")`), but Codex receives it as the final positional argv on `codex` via `buildTrackedCliLaunchCommand` so the model sees a clean first turn - instead of a typed shell line. Plain "Shell" launches go through - `resolveCleanShellLaunchFields` so the spawned shell never reads the - user's profile / rc / config files. + instead of a typed shell line. Runtime-side, plain `shell` launches + still go through `resolveCleanShellLaunchFields` so the spawned shell + never reads the user's profile / rc / config files (the phone UI no + longer offers that option). - **Pending-input item id flows out through chat summaries.** Both `AgentChatSessionSummary.pendingInputItemId` and `TerminalSessionSummary.pendingInputItemId` are populated by the diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index aa5665852..340ef67fd 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -192,7 +192,10 @@ both via `SyncService.dispatchChatSteer` / - `undoLastHeadChange`, `redoLastHeadChange` — paired recovery actions that re-read HEAD before acting and refuse when the lane has moved since the operation they target -- `getConflictState`, `rebaseContinue`, `rebaseAbort` +- `getConflictState`, `rebaseContinue`, `rebaseAbort`, + `mergeContinue`, `mergeAbort` — the merge variants mirror the rebase + pair so the iOS lane conflict banner can continue or abort an + in-progress merge, not just a rebase - `listBranches`, `checkoutBranch` `git.pull` accepts an optional `mode` argument From 3592b29dfc60816980eb187ba4be0d625f57a5b0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:36:59 -0400 Subject: [PATCH 2/2] ship: iteration 1 - address Greptile attachment tray review --- .../Views/Work/WorkChatAttachmentTray.swift | 70 +++++++++++++++++-- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift b/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift index 4422a5482..6581d0923 100644 --- a/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift +++ b/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift @@ -1,6 +1,22 @@ import SwiftUI import UIKit +private let workChatRemoteImageMaxBytes = 5 * 1024 * 1024 +private let workChatRemoteImageTimeoutSeconds: TimeInterval = 12 + +private let workChatRemoteImageSession: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = workChatRemoteImageTimeoutSeconds + configuration.timeoutIntervalForResource = workChatRemoteImageTimeoutSeconds + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.urlCache = nil + return URLSession(configuration: configuration) +}() + +private enum WorkChatRemoteImageError: Error { + case responseTooLarge +} + func workChatAttachmentIsImage(_ ref: AgentChatFileRef) -> Bool { let type = ref.type.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return type == "image" || type == "image-url" @@ -25,6 +41,29 @@ func workChatAttachmentAccessibilityLabel(_ attachments: [AgentChatFileRef]) -> return "\(names.count) attachments: \(names.joined(separator: ", "))" } +private func workChatAttachmentStableIdentity(_ ref: AgentChatFileRef) -> String { + let type = ref.type.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let path = ref.path.trimmingCharacters(in: .whitespacesAndNewlines) + let url = ref.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return "\(type.count):\(type)|\(path.count):\(path)|\(url.count):\(url)" +} + +private struct WorkChatAttachmentItem: Identifiable { + let id: String + let attachment: AgentChatFileRef +} + +private func workChatAttachmentItems(_ attachments: [AgentChatFileRef]) -> [WorkChatAttachmentItem] { + var seen: [String: Int] = [:] + return attachments.map { attachment in + let baseId = workChatAttachmentStableIdentity(attachment) + let occurrence = seen[baseId, default: 0] + seen[baseId] = occurrence + 1 + let id = occurrence == 0 ? baseId : "\(baseId)#\(occurrence)" + return WorkChatAttachmentItem(id: id, attachment: attachment) + } +} + enum WorkChatAttachmentTrayStyle { /// Standalone tray below the bubble (composer / legacy layout). case standalone @@ -62,15 +101,15 @@ struct WorkChatAttachmentTray: View { alignment: alignment, spacing: 8 ) { - ForEach(Array(attachments.enumerated()), id: \.offset) { _, attachment in - WorkChatAttachmentChip(attachment: attachment, size: chipSize) + ForEach(workChatAttachmentItems(attachments)) { item in + WorkChatAttachmentChip(attachment: item.attachment, size: chipSize) } } } else { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - ForEach(Array(attachments.enumerated()), id: \.offset) { _, attachment in - WorkChatAttachmentChip(attachment: attachment, size: chipSize) + ForEach(workChatAttachmentItems(attachments)) { item in + WorkChatAttachmentChip(attachment: item.attachment, size: chipSize) } } } @@ -101,7 +140,7 @@ private struct WorkChatAttachmentChip: View { fileChip } } - .task(id: attachment.path) { + .task(id: workChatAttachmentStableIdentity(attachment)) { await loadPreviewIfNeeded() } } @@ -165,7 +204,7 @@ private struct WorkChatAttachmentChip: View { let url = URL(string: urlString), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { do { - let (data, _) = try await URLSession.shared.data(from: url) + let data = try await workChatRemoteImageData(from: url) if let image = UIImage(data: data) { previewImage = image loadFailed = false @@ -218,6 +257,25 @@ private struct WorkChatAttachmentChip: View { } } +private func workChatRemoteImageData(from url: URL) async throws -> Data { + let (bytes, response) = try await workChatRemoteImageSession.bytes(from: url) + if response.expectedContentLength > Int64(workChatRemoteImageMaxBytes) { + throw WorkChatRemoteImageError.responseTooLarge + } + + var data = Data() + if response.expectedContentLength > 0 { + data.reserveCapacity(min(Int(response.expectedContentLength), workChatRemoteImageMaxBytes)) + } + for try await byte in bytes { + guard data.count < workChatRemoteImageMaxBytes else { + throw WorkChatRemoteImageError.responseTooLarge + } + data.append(byte) + } + return data +} + struct WorkChatTranscriptEnvironmentModifier: ViewModifier { let provider: String? let modelId: String?