From 12f1b3f408df378444c6351f5c27f0d072f36139 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:31:49 -0400 Subject: [PATCH 1/2] ship: checkpoint before ship Add the iOS composer overflow menu, turn-end context meter, and per-project prompt stash over optional sync commands. Co-authored-by: Cursor --- .../sync/syncRemoteCommandService.test.ts | 50 ++ .../services/sync/syncRemoteCommandService.ts | 14 + .../src/shared/syncMobileCompatibility.ts | 5 + apps/desktop/src/shared/types/sync.ts | 3 + apps/ios/ADE.xcodeproj/project.pbxproj | 12 + apps/ios/ADE/Models/RemoteModels.swift | 23 + apps/ios/ADE/Services/SyncService.swift | 121 +++++ .../Views/Components/DictationMicButton.swift | 14 +- .../ios/ADE/Views/Hub/HubComposerDrawer.swift | 18 +- .../Views/Work/WorkChatAttachmentTray.swift | 97 ++-- .../Work/WorkChatHeaderAndMessageViews.swift | 23 + .../Work/WorkChatSessionView+Actions.swift | 1 + .../Work/WorkChatSessionView+Timeline.swift | 13 +- .../ADE/Views/Work/WorkChatSessionView.swift | 307 ++--------- .../Views/Work/WorkContextUsageViews.swift | 193 +++++++ apps/ios/ADE/Views/Work/WorkModels.swift | 11 + .../ADE/Views/Work/WorkNewChatScreen.swift | 17 +- apps/ios/ADE/Views/Work/WorkPromptStash.swift | 477 ++++++++++++++++++ .../ADE/Views/Work/WorkTimelineHelpers.swift | 1 + apps/ios/ADETests/WorkPromptStashTests.swift | 78 +++ docs/features/chat/composer-and-ui.md | 10 +- .../sync-and-multi-device/ios-companion.md | 23 +- .../sync-and-multi-device/remote-commands.md | 3 +- 23 files changed, 1197 insertions(+), 317 deletions(-) create mode 100644 apps/ios/ADE/Views/Work/WorkContextUsageViews.swift create mode 100644 apps/ios/ADE/Views/Work/WorkPromptStash.swift create mode 100644 apps/ios/ADETests/WorkPromptStashTests.swift diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index e4812dc461..cf56056e96 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -2039,6 +2039,9 @@ describe("createSyncRemoteCommandService", () => { "chat.rewindFiles", "chat.getTurnFileDiff", "chat.saveTempAttachment", + "chat.listPromptStashes", + "chat.createPromptStash", + "chat.deletePromptStash", "chat.warmupModel", "chat.launch", "chat.getImageDataUrl", @@ -2074,6 +2077,9 @@ describe("createSyncRemoteCommandService", () => { "orchestration.runCreate", ])); expect(service.getDescriptor("chat.saveTempAttachment")?.scope).toBe("project"); + expect(service.getDescriptor("chat.listPromptStashes")?.scope).toBe("project"); + expect(service.getDescriptor("chat.createPromptStash")?.scope).toBe("project"); + expect(service.getDescriptor("chat.deletePromptStash")?.scope).toBe("project"); expect(service.getDescriptor("chat.resolveSmartLinkPreview")).toEqual({ action: "chat.resolveSmartLinkPreview", scope: "project", @@ -2084,6 +2090,50 @@ describe("createSyncRemoteCommandService", () => { expect(service.getDescriptor("prs.cleanupBranch")?.policy).toEqual({ viewerAllowed: false, queueable: true }); }); + it("routes per-project prompt stash create/list/delete through the project DB", async () => { + const created = { + id: "stash-1", + text: "retry the composer", + attachments: [{ path: "/tmp/shot.png", type: "image" }], + attachmentCount: 1, + attachmentsAvailable: true, + provider: "codex", + modelId: "gpt-5.6", + createdAt: "2026-08-14T00:00:00.000Z", + }; + const db = { + get: vi.fn().mockReturnValue({ id: "stash-1" }), + all: vi.fn().mockReturnValue([{ + id: created.id, + text: created.text, + attachments_json: JSON.stringify(created.attachments), + attachment_origin_site_id: "abc", + provider: created.provider, + model_id: created.modelId, + created_at: created.createdAt, + }]), + run: vi.fn(), + sync: { getSiteId: () => "abc" }, + }; + const { service } = createService({ db }); + + await expect(service.execute(makePayload("chat.createPromptStash", { + text: created.text, + attachments: created.attachments, + provider: created.provider, + modelId: created.modelId, + }))).resolves.toMatchObject({ + text: created.text, + attachments: created.attachments, + provider: created.provider, + modelId: created.modelId, + }); + await expect(service.execute(makePayload("chat.listPromptStashes"))).resolves.toEqual([ + expect.objectContaining({ id: "stash-1", text: created.text, attachmentsAvailable: true }), + ]); + await expect(service.execute(makePayload("chat.deletePromptStash", { id: "stash-1" }))).resolves.toBe(true); + }); + it("routes chat.handoff with a trimmed handoff note", async () => { const handoffSession = vi.fn().mockResolvedValue({ session: { id: "session-2" }, diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 60e0a228bd..326543fee8 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -241,6 +241,11 @@ import { buildAiSettingsStatus, getUnavailableAiStatus, isDatabaseClosedError } import type { createAiIntegrationService } from "../../../../desktop/src/main/services/ai/aiIntegrationService"; import type { createAgentChatService } from "../../../../desktop/src/main/services/chat/agentChatService"; import { resolveSmartLinkPreview } from "../../../../desktop/src/main/services/chat/smartLinkPreviewService"; +import { + createPromptStash, + deletePromptStash, + listPromptStashes, +} from "../../../../desktop/src/main/services/chat/promptStashService"; import { launchAgentChatCli } from "../../../../desktop/src/main/services/chat/agentChatCliLaunch"; import { mergeAiConfig } from "../../../../desktop/src/main/services/config/projectConfigService"; import { deleteApiKey } from "../../../../desktop/src/main/services/ai/apiKeyStore"; @@ -4403,6 +4408,15 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio register("chat.getTurnFileDiff", { viewerAllowed: true }, async (payload) => getRemoteTurnFileDiff(args, payload)); register("chat.saveTempAttachment", { viewerAllowed: true }, async (payload) => saveAgentChatTempAttachment(args, payload)); + register("chat.listPromptStashes", { viewerAllowed: true }, async () => + listPromptStashes(requireService(args.db, "Database not available."))); + register("chat.createPromptStash", { viewerAllowed: true }, async (payload) => + createPromptStash(requireService(args.db, "Database not available."), payload)); + register("chat.deletePromptStash", { viewerAllowed: true }, async (payload) => { + const id = typeof payload.id === "string" ? payload.id.trim() : ""; + if (!id) throw new Error("Missing prompt stash id."); + return deletePromptStash(requireService(args.db, "Database not available."), id); + }); register("chat.warmupModel", { viewerAllowed: true }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").warmupModel(parseWarmupModelArgs(payload))); register("chat.launch", { viewerAllowed: true, queueable: true }, async (payload) => { diff --git a/apps/desktop/src/shared/syncMobileCompatibility.ts b/apps/desktop/src/shared/syncMobileCompatibility.ts index 09dd91afe2..eb01428b11 100644 --- a/apps/desktop/src/shared/syncMobileCompatibility.ts +++ b/apps/desktop/src/shared/syncMobileCompatibility.ts @@ -43,6 +43,11 @@ export const MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS = [ // go limited, and an older host simply omits the actions. "ai.openCursorCloudChat", "ai.watchCursorCloudMirror", + // Per-project prompt stash. iOS gates the overflow-menu items on these + // descriptors so an older brain simply omits stash instead of going limited. + "chat.listPromptStashes", + "chat.createPromptStash", + "chat.deletePromptStash", ] as const satisfies readonly SyncRemoteCommandAction[]; export const MOBILE_SYNC_REQUIRED_REMOTE_COMMAND_ACTIONS = [ diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 961a415959..4a6a84e082 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1796,6 +1796,9 @@ export type SyncRemoteCommandAction = | "chat.rewindFiles" | "chat.getTurnFileDiff" | "chat.saveTempAttachment" + | "chat.listPromptStashes" + | "chat.createPromptStash" + | "chat.deletePromptStash" | "chat.warmupModel" | "chat.launch" | "chat.launchCli" diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index face70ea94..e7ffe0e660 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -106,6 +106,8 @@ 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 */; }; + E10000000000000000000F03 /* WorkPromptStash.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000F03 /* WorkPromptStash.swift */; }; + E10000000000000000000F04 /* WorkContextUsageViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000F04 /* WorkContextUsageViews.swift */; }; E1000000000000000000002F /* WorkArtifactTerminalViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002F /* WorkArtifactTerminalViews.swift */; }; E10000000000000000000030 /* WorkMarkdownViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000030 /* WorkMarkdownViews.swift */; }; E10000000000000000000031 /* WorkModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000031 /* WorkModels.swift */; }; @@ -174,6 +176,7 @@ D30000000000000000000017 /* WorkMarkdownStreamingParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */; }; D30000000000000000000018 /* PrMergeMergeStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000008 /* PrMergeMergeStateTests.swift */; }; D30000000000000000000019 /* WorkComposerTriggerDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000009 /* WorkComposerTriggerDetectorTests.swift */; }; + D31000000000000000000019 /* WorkPromptStashTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3000000000000000000000A /* WorkPromptStashTests.swift */; }; FB000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift */; }; FB000000000000000000C2C3 /* WorkAssistantRenderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000C2C3 /* WorkAssistantRenderingTests.swift */; }; FB000000000000000000C2C4 /* WorkLiveRosterHydrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000C2C4 /* WorkLiveRosterHydrationTests.swift */; }; @@ -394,6 +397,8 @@ 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 = ""; }; + D10000000000000000000F03 /* WorkPromptStash.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPromptStash.swift; path = ADE/Views/Work/WorkPromptStash.swift; sourceTree = ""; }; + D10000000000000000000F04 /* WorkContextUsageViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkContextUsageViews.swift; path = ADE/Views/Work/WorkContextUsageViews.swift; sourceTree = ""; }; D1000000000000000000002F /* WorkArtifactTerminalViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkArtifactTerminalViews.swift; path = ADE/Views/Work/WorkArtifactTerminalViews.swift; sourceTree = ""; }; D10000000000000000000030 /* WorkMarkdownViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkMarkdownViews.swift; path = ADE/Views/Work/WorkMarkdownViews.swift; sourceTree = ""; }; D10000000000000000000031 /* WorkModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkModels.swift; path = ADE/Views/Work/WorkModels.swift; sourceTree = ""; }; @@ -465,6 +470,7 @@ D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkMarkdownStreamingParsingTests.swift; path = ADETests/WorkMarkdownStreamingParsingTests.swift; sourceTree = ""; }; D30000000000000000000008 /* PrMergeMergeStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrMergeMergeStateTests.swift; path = ADETests/PrMergeMergeStateTests.swift; sourceTree = ""; }; D30000000000000000000009 /* WorkComposerTriggerDetectorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkComposerTriggerDetectorTests.swift; path = ADETests/WorkComposerTriggerDetectorTests.swift; sourceTree = ""; }; + D3000000000000000000000A /* WorkPromptStashTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPromptStashTests.swift; path = ADETests/WorkPromptStashTests.swift; sourceTree = ""; }; FA000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionCanonicalStateTests.swift; path = ADETests/WorkSessionCanonicalStateTests.swift; sourceTree = ""; }; 27A125DE2C17BA32F9291513 /* ADE.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ADE.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2856F8D2F2D630DD985B870A /* DatabaseBootstrap.sql */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = DatabaseBootstrap.sql; path = ADE/Resources/DatabaseBootstrap.sql; sourceTree = ""; }; @@ -876,6 +882,8 @@ D10000000000000000000054 /* WorkPlanComposerViews.swift */, D10000000000000000000050 /* WorkChatAttachmentTray.swift */, D1000000000000000000002E /* WorkChatComposerAndInputViews.swift */, + D10000000000000000000F03 /* WorkPromptStash.swift */, + D10000000000000000000F04 /* WorkContextUsageViews.swift */, D1000000000000000000002F /* WorkArtifactTerminalViews.swift */, D10000000000000000000030 /* WorkMarkdownViews.swift */, D10000000000000000000031 /* WorkModels.swift */, @@ -1125,6 +1133,7 @@ D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */, D30000000000000000000008 /* PrMergeMergeStateTests.swift */, D30000000000000000000009 /* WorkComposerTriggerDetectorTests.swift */, + D3000000000000000000000A /* WorkPromptStashTests.swift */, FA000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift */, FA000000000000000000C2C3 /* WorkAssistantRenderingTests.swift */, FA000000000000000000C2C4 /* WorkLiveRosterHydrationTests.swift */, @@ -1585,6 +1594,8 @@ E10000000000000000000050 /* WorkChatAttachmentTray.swift in Sources */, E10000000000000000000047 /* ADEInspectable.swift in Sources */, E1000000000000000000002E /* WorkChatComposerAndInputViews.swift in Sources */, + E10000000000000000000F03 /* WorkPromptStash.swift in Sources */, + E10000000000000000000F04 /* WorkContextUsageViews.swift in Sources */, E1000000000000000000002F /* WorkArtifactTerminalViews.swift in Sources */, E10000000000000000000030 /* WorkMarkdownViews.swift in Sources */, E10000000000000000000031 /* WorkModels.swift in Sources */, @@ -1644,6 +1655,7 @@ D30000000000000000000017 /* WorkMarkdownStreamingParsingTests.swift in Sources */, D30000000000000000000018 /* PrMergeMergeStateTests.swift in Sources */, D30000000000000000000019 /* WorkComposerTriggerDetectorTests.swift in Sources */, + D31000000000000000000019 /* WorkPromptStashTests.swift in Sources */, FB000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift in Sources */, FB000000000000000000C2C3 /* WorkAssistantRenderingTests.swift in Sources */, FB000000000000000000C2C4 /* WorkLiveRosterHydrationTests.swift in Sources */, diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 30f98994b9..b855ef3b81 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2248,6 +2248,29 @@ struct AgentChatFileRef: Codable, Equatable, Hashable { var url: String? = nil } +struct PromptStashEntry: Codable, Equatable, Identifiable { + var id: String + var text: String + var attachments: [AgentChatFileRef]? + var attachmentCount: Int? + var attachmentsAvailable: Bool? + var provider: String? + var modelId: String? + var createdAt: String + + var resolvedAttachments: [AgentChatFileRef] { + attachments ?? [] + } + + var resolvedAttachmentCount: Int { + attachmentCount ?? resolvedAttachments.count + } + + var imagesUnavailable: Bool { + attachmentsAvailable == false && resolvedAttachmentCount > 0 + } +} + private struct AgentChatSpawnCompletionPayload: Decodable { var childSessionId: String var childTitle: String diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index b201aa5968..92f56f07cc 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -13059,6 +13059,127 @@ final class SyncService: ObservableObject { ) } + func chatImageDataUrl( + path: String, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil + ) async throws -> String { + try requireInvokableRemoteAction("chat.getImageDataUrl") + let payload = try await sendDecodableCommand( + action: "chat.getImageDataUrl", + args: ["path": path], + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath, + as: PersonalChatImageData.self + ) + return payload.dataUrl + } + + func listPromptStashes( + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil + ) async throws -> [PromptStashEntry] { + try requireInvokableRemoteAction("chat.listPromptStashes") + return try await sendDecodableCommand( + action: "chat.listPromptStashes", + args: [:], + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath, + as: [PromptStashEntry].self + ) + } + + func createPromptStash( + text: String, + attachments: [AgentChatFileRef] = [], + provider: String? = nil, + modelId: String? = nil, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil + ) async throws -> PromptStashEntry { + try requireInvokableRemoteAction("chat.createPromptStash") + var args: [String: Any] = ["text": text] + if !attachments.isEmpty { + args["attachments"] = attachments.map { ref in + var entry: [String: Any] = ["path": ref.path, "type": ref.type] + if let url = ref.url, !url.isEmpty { entry["url"] = url } + return entry + } + } + if let provider, !provider.isEmpty { args["provider"] = provider } + if let modelId, !modelId.isEmpty { args["modelId"] = modelId } + return try await sendDecodableCommand( + action: "chat.createPromptStash", + args: args, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath, + as: PromptStashEntry.self + ) + } + + func deletePromptStash( + id: String, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil + ) async throws -> Bool { + try requireInvokableRemoteAction("chat.deletePromptStash") + return try await sendDecodableCommand( + action: "chat.deletePromptStash", + args: ["id": id], + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath, + as: Bool.self + ) + } + + func listPromptStashesForChat(sessionId: String) async throws -> [PromptStashEntry] { + if isPersonalChatScope(sessionId: sessionId) { return [] } + let scope = chatCommandScope(for: sessionId) + return try await listPromptStashes( + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) + } + + func createPromptStashForChat( + sessionId: String, + text: String, + attachments: [AgentChatFileRef] = [], + provider: String? = nil, + modelId: String? = nil + ) async throws -> PromptStashEntry { + let scope = chatCommandScope(for: sessionId) + return try await createPromptStash( + text: text, + attachments: attachments, + provider: provider, + modelId: modelId, + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) + } + + func deletePromptStashForChat(sessionId: String, id: String) async throws -> Bool { + let scope = chatCommandScope(for: sessionId) + return try await deletePromptStash( + id: id, + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) + } + + func chatImageDataUrlForChat(sessionId: String, path: String) async throws -> String { + if isPersonalChatScope(sessionId: sessionId) { + return try await personalChatImageDataUrl(path: path) + } + let scope = chatCommandScope(for: sessionId) + return try await chatImageDataUrl( + path: path, + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) + } + func personalChatImageDataUrl(path: String) async throws -> String { try requireInvokableRemoteAction("personalChats.getImageDataUrl") let payload = try await sendDecodableCommand( diff --git a/apps/ios/ADE/Views/Components/DictationMicButton.swift b/apps/ios/ADE/Views/Components/DictationMicButton.swift index 96db56365d..e9d782187d 100644 --- a/apps/ios/ADE/Views/Components/DictationMicButton.swift +++ b/apps/ios/ADE/Views/Components/DictationMicButton.swift @@ -39,16 +39,19 @@ struct DictationMicButton: View { /// Surfaced to the host so it can collapse its other trailing controls while /// recording. Driven by the controller's recording state for this target. var onRecordingChange: ((Bool) -> Void)? + private let showsIdleButton: Bool init( draft: Binding, coordinator: DictationInsertionCoordinator, targetId: String = UUID().uuidString, + showsIdleButton: Bool = true, onRecordingChange: ((Bool) -> Void)? = nil ) { self._draft = draft self._coordinator = ObservedObject(wrappedValue: coordinator) self.targetId = targetId + self.showsIdleButton = showsIdleButton self.onRecordingChange = onRecordingChange } @@ -101,6 +104,10 @@ struct DictationMicButton: View { .onChange(of: controller.activeTargetId) { _, _ in publishRecordingState() } + .onChange(of: coordinator.startRequestCount) { _, count in + guard count > 0 else { return } + startRecording() + } .background(visibilityReporter) } @@ -142,7 +149,7 @@ struct DictationMicButton: View { onCancel: cancelRecording, onDone: finishRecording ) - } else { + } else if showsIdleButton { micButton } } @@ -258,6 +265,7 @@ final class DictationInsertionCoordinator: ObservableObject { @Published private(set) var span: Span? @Published private(set) var showsUndoChip = false @Published private(set) var shimmerActive = false + @Published private(set) var startRequestCount = 0 private var undoChipTask: Task? private var shimmerTask: Task? @@ -266,6 +274,10 @@ final class DictationInsertionCoordinator: ObservableObject { span = Span(range: range, rawTranscript: rawTranscript, cleaned: cleaned) } + func requestStart() { + startRequestCount += 1 + } + func updateSpanContent(to newContent: String) { guard var current = span else { return } current.range = NSRange(location: current.range.location, length: newContent.utf16.count) diff --git a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift index e784622627..6661af552f 100644 --- a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift +++ b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift @@ -670,10 +670,19 @@ struct HubInlineComposer: View { if isExpanded { HStack(alignment: .center, spacing: 8) { if !isDictating { - WorkChatAttachmentAddButton( - pickerPresented: $attachmentPickerPresented, - attachmentCount: attachments.count, - disabled: busy || !attachmentsAvailable + WorkComposerOverflowButton( + attachmentPickerPresented: $attachmentPickerPresented, + draft: $draft, + attachments: $attachments, + canCompose: !busy, + attachmentsAvailable: attachmentsAvailable, + onDictate: { dictationCoordinator.requestStart() }, + stashAvailable: syncService.canInvokeRemoteAction("chat.listPromptStashes"), + scope: WorkPromptStashScope( + projectId: pickedProjectId.isEmpty ? nil : pickedProjectId + ), + provider: provider, + modelId: modelId ) ScrollView(.horizontal, showsIndicators: false) { @@ -708,6 +717,7 @@ struct HubInlineComposer: View { draft: $draft, coordinator: dictationCoordinator, targetId: dictationTargetId, + showsIdleButton: false, onRecordingChange: { isDictating = $0 } ) .frame(maxWidth: isDictating ? .infinity : nil) diff --git a/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift b/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift index bd333d701a..82fd8b7051 100644 --- a/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift +++ b/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift @@ -350,6 +350,66 @@ func workChatSaveInputAttachments( return refs } +@MainActor +func workChatInputAttachments( + from refs: [AgentChatFileRef], + syncService: SyncService, + chatSessionId: String?, + projectId: String?, + projectRootPath: String? +) async throws -> [WorkChatInputAttachment] { + var restored: [WorkChatInputAttachment] = [] + for ref in refs { + if ref.type == "image-url", let urlString = ref.url ?? Optional(ref.path), + let url = URL(string: urlString), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" { + let data = try await workChatRemoteImageData(from: url) + guard let image = UIImage(data: data), + let attachment = workChatInputAttachment( + from: image, + filename: workChatAttachmentDisplayName(ref) + ) else { + throw workChatStashImageRestoreError + } + restored.append(attachment) + continue + } + guard ref.type == "image" else { continue } + let dataUrl: String + if let chatSessionId, !chatSessionId.isEmpty { + dataUrl = try await syncService.chatImageDataUrlForChat(sessionId: chatSessionId, path: ref.path) + } else if syncService.canInvokeRemoteAction("chat.getImageDataUrl") { + dataUrl = try await syncService.chatImageDataUrl( + path: ref.path, + targetProjectId: projectId, + targetProjectRootPath: projectRootPath + ) + } else { + throw workChatStashImageRestoreError + } + guard let image = WorkChatAttachmentImagePreview.image( + fromDataUrl: dataUrl, + maxPixelSize: 2400, + maxBytes: workChatInputAttachmentMaxBytes + ), + let attachment = workChatInputAttachment( + from: image, + filename: workChatAttachmentDisplayName(ref) + ) else { + throw workChatStashImageRestoreError + } + restored.append(attachment) + } + return restored +} + +private let workChatStashImageRestoreError = NSError( + domain: "ADE", + code: 28, + userInfo: [NSLocalizedDescriptionKey: "Could not restore a stashed image. The prompt is still in your stash."] +) + private func workChatJPEGDataForUpload(_ image: UIImage) -> (image: UIImage, data: Data)? { var maxDimension = min( workChatInputAttachmentInitialMaxDimension, @@ -394,35 +454,6 @@ private func workChatRenderedJPEGImage(_ image: UIImage, maxDimension: CGFloat) } } -struct WorkChatAttachmentAddButton: View { - @Binding var pickerPresented: Bool - let attachmentCount: Int - var disabled = false - - private var isDisabled: Bool { - disabled || attachmentCount >= workChatInputAttachmentLimit - } - - var body: some View { - Button { - pickerPresented = true - } label: { - Image(systemName: "plus") - .font(.system(size: 14, weight: .bold)) - .foregroundStyle(isDisabled ? ADEColor.textMuted.opacity(0.35) : ADEColor.textPrimary) - .frame(width: 28, height: 28) - .background(ADEColor.surfaceBackground.opacity(isDisabled ? 0.18 : 0.38), in: Circle()) - .overlay(Circle().stroke(ADEColor.border.opacity(isDisabled ? 0.16 : 0.28), lineWidth: 0.6)) - .frame(width: 44, height: 44) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .disabled(isDisabled) - .accessibilityLabel("Attach from camera roll") - .accessibilityHint("Opens the photo picker.") - } -} - private struct WorkChatAttachmentPickerModifier: ViewModifier { @Binding var isPresented: Bool @Binding var attachments: [WorkChatInputAttachment] @@ -1068,10 +1099,14 @@ enum WorkChatAttachmentImagePreview { return UIImage(cgImage: cgImage) } - static func image(fromDataUrl dataUrl: String, maxPixelSize: CGFloat) -> UIImage? { + static func image( + fromDataUrl dataUrl: String, + maxPixelSize: CGFloat, + maxBytes: Int = workChatRemoteImageMaxBytes + ) -> UIImage? { guard let commaIndex = dataUrl.firstIndex(of: ",") else { return nil } let base64 = String(dataUrl[dataUrl.index(after: commaIndex)...]) - guard let data = base64DecodedImageData(base64, maxBytes: workChatRemoteImageMaxBytes) else { return nil } + guard let data = base64DecodedImageData(base64, maxBytes: maxBytes) else { return nil } return downsampledImage(data: data, maxPixelSize: maxPixelSize) } } diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index 3127f37d4a..b428047ce8 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -1066,6 +1066,10 @@ struct WorkTurnEndMarkerView: View { let marker: WorkTurnEndMarker var toolCount: Int = 0 var onOpenActivity: (() -> Void)? = nil + var usageViewModel: WorkContextUsageViewModel? = nil + var modelLabel: String? = nil + + @State private var contextUsagePresented = false private var status: String { marker.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() @@ -1122,6 +1126,25 @@ struct WorkTurnEndMarkerView: View { content } hairline + if let usageViewModel { + WorkContextUsageMeter( + usage: usageViewModel, + isPresented: $contextUsagePresented + ) + .popover( + isPresented: $contextUsagePresented, + attachmentAnchor: .rect(.bounds), + arrowEdge: .bottom + ) { + WorkContextUsagePopover( + usage: usageViewModel, + modelLabel: modelLabel ?? marker.modelLabel + ) + .presentationCompactAdaptation(.popover) + .presentationBackground(ADEColor.surfaceBackground) + } + .layoutPriority(2) + } } .frame(maxWidth: .infinity) .padding(.vertical, 8) diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift index 4b1911f3ac..44453f85c5 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift @@ -124,6 +124,7 @@ private func workSnapshotByApplyingAssistantTextTail( envelopes: candidateEnvelopes ) nextSnapshot.latestMessageAssistantId = workIncrementalLatestAssistantId(timeline) + nextSnapshot.latestTurnEndTurnId = workLatestTurnEndTurnId(in: timeline) nextSnapshot.transcriptIndicatesActiveTurn = true nextSnapshot.transcriptLatestTurnEnded = false // Keep interruptibility consistent with the full-rebuild path (which derives diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index a1ae31ff25..f31de4e04d 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -126,12 +126,23 @@ extension WorkChatSessionView { WorkTurnSeparatorView(separator: separator) case .turnEndMarker(let marker): let activity = turnToolActivity.completedByTurnId[marker.turnId] + let isLatestTurnEnd = marker.turnId == timelineSnapshot.latestTurnEndTurnId WorkTurnEndMarkerView( marker: marker, toolCount: activity?.count ?? 0, onOpenActivity: activity.map { _ in { toolActivitySheet = .completed(marker.turnId) } - } + }, + usageViewModel: isLatestTurnEnd + ? contextUsageViewModelCache.value( + sessionId: session.id, + transcript: transcript, + transcriptRenderSignature: transcriptRenderSignature, + provider: chatSummaryContext.provider, + fallbackContextWindow: chatSummaryContext.contextWindowFallback + ) + : nil, + modelLabel: chatSummaryContext.modelLabel ) case .pendingQuestion(let question): // When offline, still render the card in a disabled (busy) state so the diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 937fdd9254..ef1f458a2d 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -309,7 +309,7 @@ struct WorkChatSessionView: View { @State var latestPinTask: Task? @State var latestPinGeneration = 0 @State var assistantPreviewCache = WorkAssistantPreviewCache() - @State private var contextUsageViewModelCache = WorkContextUsageViewModelCache() + @State var contextUsageViewModelCache = WorkContextUsageViewModelCache() @State var assistantLineBudgets: [String: Int] = [:] @State var composerSettingMutationInFlight = false @State var composerSettingMutationGeneration = 0 @@ -1196,13 +1196,8 @@ struct WorkChatSessionView: View { WorkChatComposerCard( chatSummary: chatSummaryContext, - usageViewModel: contextUsageViewModelCache.value( - sessionId: session.id, - transcript: transcript, - transcriptRenderSignature: transcriptRenderSignature, - provider: chatSummaryContext.provider, - fallbackContextWindow: chatSummaryContext.contextWindowFallback - ), + sessionId: session.id, + isPersonalChat: isPersonalChat, laneId: session.laneId, dictationTargetId: "work-chat:\(session.id)", awaitingInputGate: hasPendingInputGate, @@ -2253,7 +2248,8 @@ func mergeWorkPendingSteers( private struct WorkChatComposerCard: View { let chatSummary: WorkChatSummaryRenderContext - let usageViewModel: WorkContextUsageViewModel? + let sessionId: String + let isPersonalChat: Bool let laneId: String let dictationTargetId: String let awaitingInputGate: Bool @@ -2285,7 +2281,8 @@ private struct WorkChatComposerCard: View { var body: some View { WorkChatComposerDraftInput( chatSummary: chatSummary, - usageViewModel: usageViewModel, + sessionId: sessionId, + isPersonalChat: isPersonalChat, laneId: laneId, dictationTargetId: dictationTargetId, awaitingInputGate: awaitingInputGate, @@ -2327,7 +2324,8 @@ private struct WorkChatComposerCard: View { private struct WorkChatComposerDraftInput: View { let chatSummary: WorkChatSummaryRenderContext - let usageViewModel: WorkContextUsageViewModel? + let sessionId: String + let isPersonalChat: Bool let laneId: String let dictationTargetId: String let awaitingInputGate: Bool @@ -2357,7 +2355,6 @@ private struct WorkChatComposerDraftInput: View { @EnvironmentObject private var syncService: SyncService @StateObject private var draftState = WorkChatComposerDraftState() @StateObject private var suggestionController = WorkComposerSuggestionController() - @State private var contextUsagePresented = false @StateObject private var dictationCoordinator = DictationInsertionCoordinator() @State private var isDictating = false @State private var inputAttachments: [WorkChatInputAttachment] = [] @@ -2372,6 +2369,10 @@ private struct WorkChatComposerDraftInput: View { draftState.hasSendableText || !workChatInputReadyAttachments(inputAttachments).isEmpty } + private var stashAvailable: Bool { + !isPersonalChat && syncService.canInvokeRemoteAction("chat.listPromptStashes") + } + var body: some View { VStack(alignment: .leading, spacing: 8) { if compact { @@ -2382,11 +2383,7 @@ private struct WorkChatComposerDraftInput: View { HStack(alignment: .center, spacing: 8) { if !isDictating { - WorkChatAttachmentAddButton( - pickerPresented: $attachmentPickerPresented, - attachmentCount: inputAttachments.count, - disabled: !canCompose || !attachmentsAvailable || settingsMutationInFlight - ) + composerOverflowMenu WorkChatComposerTextField( draftState: draftState, @@ -2401,13 +2398,7 @@ private struct WorkChatComposerDraftInput: View { ) } - DictationMicButton( - draft: $draftState.text, - coordinator: dictationCoordinator, - targetId: dictationTargetId, - onRecordingChange: { isDictating = $0 } - ) - .frame(maxWidth: isDictating ? .infinity : nil) + composerDictationControl if !isDictating { sendOrInterruptControls() @@ -2439,14 +2430,8 @@ private struct WorkChatComposerDraftInput: View { } HStack(alignment: .center, spacing: 8) { - // Leading controls collapse while dictating so the recording pill can - // expand into the row without a layout jump. if !isDictating { - WorkChatAttachmentAddButton( - pickerPresented: $attachmentPickerPresented, - attachmentCount: inputAttachments.count, - disabled: !canCompose || !attachmentsAvailable || settingsMutationInFlight - ) + composerOverflowMenu WorkComposerChipStrip( chatSummary: chatSummary, @@ -2459,37 +2444,9 @@ private struct WorkChatComposerDraftInput: View { DictationRawUndoChip(coordinator: dictationCoordinator, draft: $draftState.text) Spacer(minLength: 0) - - if let usageViewModel { - WorkContextUsageMeter( - usage: usageViewModel, - active: showInterrupt, - isPresented: $contextUsagePresented - ) - .popover( - isPresented: $contextUsagePresented, - attachmentAnchor: .rect(.bounds), - arrowEdge: .bottom - ) { - WorkContextUsagePopover( - usage: usageViewModel, - modelLabel: chatSummary.modelLabel - ) - .frame(maxWidth: 320, alignment: .leading) - .presentationCompactAdaptation(.popover) - } - } } - // Single mic control: renders the 28×28 mic when idle and the inline - // recording pill (full-width) while recording. - DictationMicButton( - draft: $draftState.text, - coordinator: dictationCoordinator, - targetId: dictationTargetId, - onRecordingChange: { isDictating = $0 } - ) - .frame(maxWidth: isDictating ? .infinity : nil) + composerDictationControl if !isDictating { sendOrInterruptControls() @@ -2497,11 +2454,6 @@ private struct WorkChatComposerDraftInput: View { } } } - .onChange(of: usageViewModel) { _, newValue in - if newValue == nil { - contextUsagePresented = false - } - } .onAppear { configureSuggestionController() } @@ -2544,6 +2496,32 @@ private struct WorkChatComposerDraftInput: View { ) } + private var composerOverflowMenu: some View { + WorkComposerOverflowButton( + attachmentPickerPresented: $attachmentPickerPresented, + draft: $draftState.text, + attachments: $inputAttachments, + canCompose: canCompose && !settingsMutationInFlight, + attachmentsAvailable: attachmentsAvailable, + onDictate: { dictationCoordinator.requestStart() }, + stashAvailable: stashAvailable, + scope: WorkPromptStashScope(chatSessionId: sessionId), + provider: chatSummary.provider, + modelId: chatSummary.currentModelId + ) + } + + private var composerDictationControl: some View { + DictationMicButton( + draft: $draftState.text, + coordinator: dictationCoordinator, + targetId: dictationTargetId, + showsIdleButton: false, + onRecordingChange: { isDictating = $0 } + ) + .frame(maxWidth: isDictating ? .infinity : nil) + } + @ViewBuilder private func sendOrInterruptControls() -> some View { if showInterrupt { @@ -2970,205 +2948,6 @@ private struct WorkQueueRecoveryBanner: View { } } -private struct WorkContextUsageMeter: View { - let usage: WorkContextUsageViewModel - let active: Bool - @Binding var isPresented: Bool - - private var percent: Int? { - guard usage.state == .measured else { return nil } - return usage.ratio.map { Int(($0 * 100).rounded()) } - } - - private var ringColor: Color { - guard let ratio = usage.ratio else { return ADEColor.textSecondary } - if ratio >= 0.9 { return ADEColor.danger } - if ratio >= 0.7 { return ADEColor.warning } - return Color(red: 0.22, green: 0.74, blue: 0.97) - } - - private var accessibilityLabel: String { - switch usage.state { - case .compacting: - return "Context usage: compacting" - case .recalculating: - return "Context usage: recalculating" - case .unknown: - return "Context usage unavailable" - case .measured: - return percent.map { "Context usage: \($0)% full" } ?? "Context usage" - } - } - - var body: some View { - if usage.ratio != nil || usage.usedTokens != nil { - Button { - withAnimation(.easeInOut(duration: 0.18)) { - isPresented.toggle() - } - } label: { - ZStack { - if usage.state != .measured { - Circle() - .stroke(Color.white.opacity(0.12), lineWidth: 1.5) - .frame(width: 22, height: 22) - Text(usage.state == .unknown ? "?" : "…") - .font(.system(size: 10, weight: .semibold, design: .rounded)) - .foregroundStyle(ADEColor.textSecondary) - } else if let ratio = usage.ratio, let percent { - Circle() - .stroke(Color.white.opacity(0.10), lineWidth: 2.5) - .frame(width: 22, height: 22) - - Circle() - .trim(from: 0, to: CGFloat(ratio)) - .stroke(ringColor, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .frame(width: 22, height: 22) - - Text("\(percent)") - .font(.system(size: percent >= 100 ? 7 : 8, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(ADEColor.textPrimary.opacity(0.78)) - .minimumScaleFactor(0.65) - } else if let usedTokens = usage.usedTokens { - Text(workAbbreviateCount(usedTokens)) - .font(.system(size: 10, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(ADEColor.textSecondary) - .minimumScaleFactor(0.7) - } - } - .frame(width: 28, height: 28) - .contentShape(Rectangle()) - .opacity(active ? 1 : 0.92) - } - .buttonStyle(.plain) - .accessibilityLabel(accessibilityLabel) - .accessibilityHint(isPresented ? "Dismisses context usage details" : "Shows context usage details") - .adeInspectable( - "Work.Chat.Composer.ContextUsageMeter", - metadata: [ - "label": percent.map { "Context usage: \($0)% full" } ?? "Context usage", - "role": "button" - ] - ) - } - } -} - -private struct WorkContextUsagePopover: View { - let usage: WorkContextUsageViewModel - let modelLabel: String? - - private var percent: Int? { - guard usage.state == .measured else { return nil } - return usage.ratio.map { Int(($0 * 100).rounded()) } - } - - private var windowLabel: String? { - usage.contextWindow.map { workAbbreviateCount($0) } - } - - private var usedLabel: String? { - usage.usedTokens.map { workAbbreviateCount($0) } - } - - private var description: String { - if usage.state == .compacting { - return "Claude is compacting this chat. The previous exact reading is temporarily hidden." - } - if usage.state == .recalculating { - return "Compaction finished. ADE is waiting for the next authoritative usage snapshot." - } - if usage.state == .unknown { - return "The runtime did not return an authoritative context reading." - } - let model = modelLabel?.trimmingCharacters(in: .whitespacesAndNewlines) - if let percent, let windowLabel { - let owner: String - if let model, !model.isEmpty { - owner = "\(model)'s " - } else { - owner = "the " - } - let estimated = usage.windowSource == .registry ? " (estimated)" : "" - return "Using \(percent)% of \(owner)\(windowLabel)-token context window\(estimated)." - } - let used = usedLabel ?? "--" - if let model, !model.isEmpty { - return "\(used) tokens used so far by \(model); context window unknown." - } - return "\(used) tokens used so far; context window unknown." - } - - private var breakdown: String? { - guard usage.state == .measured else { return nil } - var segments: [String] = [] - if let value = usage.inputTokens { segments.append("in \(workAbbreviateCount(value))") } - if let value = usage.outputTokens { segments.append("out \(workAbbreviateCount(value))") } - if let value = usage.cacheReadTokens { segments.append("cached \(workAbbreviateCount(value)) *") } - if let value = usage.reasoningTokens { segments.append("reasoning \(workAbbreviateCount(value))") } - return segments.isEmpty ? nil : segments.joined(separator: " · ") - } - - private var effect: String? { - guard let percent, let windowLabel else { return nil } - return "\(usedLabel ?? "--") / \(windowLabel) tokens · \(percent)% full" - } - - var body: some View { - VStack(alignment: .leading, spacing: 7) { - Text("Context usage") - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - - Text(description) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .fixedSize(horizontal: false, vertical: true) - - if breakdown != nil || effect != nil { - Rectangle() - .fill(ADEColor.border.opacity(0.35)) - .frame(height: 1) - } - - if let breakdown { - Text(breakdown) - .font(.caption.monospaced()) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } - - if let effect { - Text(effect) - .font(.caption.monospacedDigit()) - .foregroundStyle((usage.ratio ?? 0) >= 0.8 ? ADEColor.warning : ADEColor.success) - .lineLimit(1) - .minimumScaleFactor(0.7) - } - - if usage.state == .measured, let ratio = usage.ratio, ratio >= 0.8 { - Text("Nearing the limit; older context may be auto-trimmed or compacted.") - .font(.caption2) - .foregroundStyle(ADEColor.warning) - .fixedSize(horizontal: false, vertical: true) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(ADEColor.surfaceBackground.opacity(0.94), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(ADEColor.glassBorder.opacity(0.9), lineWidth: 1) - ) - .shadow(color: Color.black.opacity(0.10), radius: 3, y: 1) - .accessibilityIdentifier("Work.Chat.Composer.ContextUsagePopover") - } -} - struct WorkChatComposerDraftRestore: Equatable, Identifiable { let id: UUID let text: String diff --git a/apps/ios/ADE/Views/Work/WorkContextUsageViews.swift b/apps/ios/ADE/Views/Work/WorkContextUsageViews.swift new file mode 100644 index 0000000000..60f27ee738 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkContextUsageViews.swift @@ -0,0 +1,193 @@ +import SwiftUI + +struct WorkContextUsageMeter: View { + let usage: WorkContextUsageViewModel + @Binding var isPresented: Bool + + private var percent: Int? { + guard usage.state == .measured else { return nil } + return usage.ratio.map { Int(($0 * 100).rounded()) } + } + + private var ringColor: Color { + guard let ratio = usage.ratio else { return ADEColor.textSecondary } + if ratio >= 0.9 { return ADEColor.danger } + if ratio >= 0.7 { return ADEColor.warning } + return Color(red: 0.22, green: 0.74, blue: 0.97) + } + + private var accessibilityLabel: String { + switch usage.state { + case .compacting: + return "Context usage: compacting" + case .recalculating: + return "Context usage: recalculating" + case .unknown: + return "Context usage unavailable" + case .measured: + return percent.map { "Context usage: \($0)% full" } ?? "Context usage" + } + } + + var body: some View { + if usage.ratio != nil || usage.usedTokens != nil { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + isPresented.toggle() + } + } label: { + ZStack { + if usage.state != .measured { + Circle() + .stroke(Color.white.opacity(0.12), lineWidth: 1.5) + .frame(width: 22, height: 22) + Text(usage.state == .unknown ? "?" : "…") + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundStyle(ADEColor.textSecondary) + } else if let ratio = usage.ratio, let percent { + Circle() + .stroke(Color.white.opacity(0.10), lineWidth: 2.5) + .frame(width: 22, height: 22) + + Circle() + .trim(from: 0, to: CGFloat(ratio)) + .stroke(ringColor, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .frame(width: 22, height: 22) + + Text("\(percent)") + .font(.system(size: percent >= 100 ? 7 : 8, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(ADEColor.textPrimary.opacity(0.78)) + .minimumScaleFactor(0.65) + } else if let usedTokens = usage.usedTokens { + Text(workAbbreviateCount(usedTokens)) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(ADEColor.textSecondary) + .minimumScaleFactor(0.7) + } + } + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(isPresented ? "Dismisses context usage details" : "Shows context usage details") + .adeInspectable( + "Work.Chat.Composer.ContextUsageMeter", + metadata: [ + "label": percent.map { "Context usage: \($0)% full" } ?? "Context usage", + "role": "button" + ] + ) + } + } +} + +struct WorkContextUsagePopover: View { + let usage: WorkContextUsageViewModel + let modelLabel: String? + + private var percent: Int? { + guard usage.state == .measured else { return nil } + return usage.ratio.map { Int(($0 * 100).rounded()) } + } + + private var windowLabel: String? { + usage.contextWindow.map { workAbbreviateCount($0) } + } + + private var usedLabel: String? { + usage.usedTokens.map { workAbbreviateCount($0) } + } + + private var description: String { + if usage.state == .compacting { + return "Claude is compacting this chat. The previous exact reading is temporarily hidden." + } + if usage.state == .recalculating { + return "Compaction finished. ADE is waiting for the next authoritative usage snapshot." + } + if usage.state == .unknown { + return "The runtime did not return an authoritative context reading." + } + let model = modelLabel?.trimmingCharacters(in: .whitespacesAndNewlines) + if let percent, let windowLabel { + let owner: String + if let model, !model.isEmpty { + owner = "\(model)'s " + } else { + owner = "the " + } + let estimated = usage.windowSource == .registry ? " (estimated)" : "" + return "Using \(percent)% of \(owner)\(windowLabel)-token context window\(estimated)." + } + let used = usedLabel ?? "--" + if let model, !model.isEmpty { + return "\(used) tokens used so far by \(model); context window unknown." + } + return "\(used) tokens used so far; context window unknown." + } + + private var breakdown: String? { + guard usage.state == .measured else { return nil } + var segments: [String] = [] + if let value = usage.inputTokens { segments.append("in \(workAbbreviateCount(value))") } + if let value = usage.outputTokens { segments.append("out \(workAbbreviateCount(value))") } + if let value = usage.cacheReadTokens { segments.append("cached \(workAbbreviateCount(value)) *") } + if let value = usage.reasoningTokens { segments.append("reasoning \(workAbbreviateCount(value))") } + return segments.isEmpty ? nil : segments.joined(separator: " · ") + } + + private var effect: String? { + guard let percent, let windowLabel else { return nil } + return "\(usedLabel ?? "--") / \(windowLabel) tokens · \(percent)% full" + } + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Text("Context usage") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + + Text(description) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + + if breakdown != nil || effect != nil { + Rectangle() + .fill(ADEColor.border.opacity(0.35)) + .frame(height: 1) + } + + if let breakdown { + Text(breakdown) + .font(.caption.monospaced()) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + + if let effect { + Text(effect) + .font(.caption.monospacedDigit()) + .foregroundStyle((usage.ratio ?? 0) >= 0.8 ? ADEColor.warning : ADEColor.success) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + + if usage.state == .measured, let ratio = usage.ratio, ratio >= 0.8 { + Text("Nearing the limit; older context may be auto-trimmed or compacted.") + .font(.caption2) + .foregroundStyle(ADEColor.warning) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(14) + .frame(minWidth: 240, idealWidth: 300, maxWidth: 320, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("Work.Chat.Composer.ContextUsagePopover") + } +} diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 1da85077a5..507d13c069 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -805,6 +805,15 @@ struct WorkTurnEndMarker: Equatable { let modelId: String? } +func workLatestTurnEndTurnId(in timeline: [WorkTimelineEntry]) -> String? { + for entry in timeline.reversed() { + if case .turnEndMarker(let marker) = entry.payload { + return marker.turnId + } + } + return nil +} + struct WorkTimelineEntry: Identifiable, Equatable { let id: String let timestamp: String @@ -960,6 +969,7 @@ struct WorkChatTimelineSnapshot: Equatable { var transcriptHasInterruptibleActivity: Bool var latestTranscriptTimestamp: String? var latestMessageAssistantId: String? + var latestTurnEndTurnId: String? var timeline: [WorkTimelineEntry] static let empty = WorkChatTimelineSnapshot( @@ -977,6 +987,7 @@ struct WorkChatTimelineSnapshot: Equatable { transcriptHasInterruptibleActivity: false, latestTranscriptTimestamp: nil, latestMessageAssistantId: nil, + latestTurnEndTurnId: nil, timeline: [] ) diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 0accf9b0e2..9e25dd51be 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -1471,6 +1471,7 @@ private struct WorkNewChatComposerBar: View { let onOpenModelPicker: () -> Void let onSubmit: @MainActor (String, [WorkChatInputAttachment]) async -> Bool + @EnvironmentObject private var syncService: SyncService @State private var draft: String = "" @State private var attachments: [WorkChatInputAttachment] = [] @State private var attachmentPickerPresented = false @@ -1549,10 +1550,17 @@ private struct WorkNewChatComposerBar: View { HStack(alignment: .center, spacing: 8) { if !isDictating { - WorkChatAttachmentAddButton( - pickerPresented: $attachmentPickerPresented, - attachmentCount: attachments.count, - disabled: busy || !attachmentsAvailable + WorkComposerOverflowButton( + attachmentPickerPresented: $attachmentPickerPresented, + draft: $draft, + attachments: $attachments, + canCompose: !busy, + attachmentsAvailable: attachmentsAvailable, + onDictate: { dictationCoordinator.requestStart() }, + stashAvailable: syncService.canInvokeRemoteAction("chat.listPromptStashes"), + scope: WorkPromptStashScope(), + provider: provider, + modelId: modelId ) ScrollView(.horizontal, showsIndicators: false) { @@ -1587,6 +1595,7 @@ private struct WorkNewChatComposerBar: View { draft: $draft, coordinator: dictationCoordinator, targetId: dictationTargetId, + showsIdleButton: false, onRecordingChange: { isDictating = $0 } ) .frame(maxWidth: isDictating ? .infinity : nil) diff --git a/apps/ios/ADE/Views/Work/WorkPromptStash.swift b/apps/ios/ADE/Views/Work/WorkPromptStash.swift new file mode 100644 index 0000000000..beed2f4a75 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkPromptStash.swift @@ -0,0 +1,477 @@ +import SwiftUI + +func workComposerHasStashableContent(text: String, attachments: [WorkChatInputAttachment]) -> Bool { + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !workChatInputReadyAttachments(attachments).isEmpty +} + +func workComposerOverflowStashTitle(hasContent: Bool) -> String { + hasContent ? "Stash prompt" : "View prompt stash" +} + +func workPromptStashEntryLabel(_ entry: PromptStashEntry) -> String { + let normalized = entry.text.trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + if !normalized.isEmpty { return normalized } + let count = entry.resolvedAttachmentCount + if count == 1 { return "1 stashed image" } + if count > 1 { return "\(count) stashed images" } + return "Stashed prompt" +} + +struct WorkPromptStashScope: Equatable { + var chatSessionId: String? = nil + var projectId: String? = nil + var projectRootPath: String? = nil +} + +struct WorkComposerOverflowButton: View { + @EnvironmentObject private var syncService: SyncService + @StateObject private var promptStash = WorkPromptStashController() + @Binding var attachmentPickerPresented: Bool + @Binding var draft: String + @Binding var attachments: [WorkChatInputAttachment] + let canCompose: Bool + let attachmentsAvailable: Bool + let onDictate: () -> Void + let stashAvailable: Bool + let scope: WorkPromptStashScope + let provider: String? + let modelId: String? + + private var hasContent: Bool { + workComposerHasStashableContent(text: draft, attachments: attachments) + } + + var body: some View { + WorkChatComposerOverflowMenu( + attachmentPickerPresented: $attachmentPickerPresented, + canCompose: canCompose, + attachmentsAvailable: attachmentsAvailable, + attachmentCount: attachments.count, + dictationAvailable: SpeechDictationService.isAvailable, + onDictate: onDictate, + stashAvailable: stashAvailable, + hasComposerContent: hasContent, + stashBusy: promptStash.busy, + stashCount: promptStash.entries.count, + onStashOrView: { + Task { + await promptStash.handleMenuAction( + syncService: syncService, + text: draft, + attachments: attachments, + scope: scope, + provider: provider, + modelId: modelId, + onDraftChange: { draft = $0 }, + onAttachmentsChange: { attachments = $0 } + ) + } + } + ) + .sheet(isPresented: $promptStash.listPresented) { + WorkPromptStashListSheet( + controller: promptStash, + syncService: syncService, + currentText: draft, + currentAttachments: attachments, + scope: scope, + onDraftChange: { draft = $0 }, + onAttachmentsChange: { attachments = $0 } + ) + } + .task(id: scope) { + guard stashAvailable else { return } + await promptStash.refresh(syncService: syncService, scope: scope) + } + } +} + +struct WorkChatComposerOverflowMenu: View { + @Binding var attachmentPickerPresented: Bool + let canCompose: Bool + let attachmentsAvailable: Bool + let attachmentCount: Int + let dictationAvailable: Bool + let onDictate: () -> Void + let stashAvailable: Bool + let hasComposerContent: Bool + let stashBusy: Bool + let stashCount: Int + let onStashOrView: () -> Void + + private var attachDisabled: Bool { + !canCompose || !attachmentsAvailable || attachmentCount >= workChatInputAttachmentLimit + } + + var body: some View { + Menu { + Button { + attachmentPickerPresented = true + } label: { + Label("Attach image", systemImage: "photo") + } + .disabled(attachDisabled) + + if dictationAvailable { + Button(action: onDictate) { + Label("Dictate voice", systemImage: "mic.fill") + } + .disabled(!canCompose) + } + + if stashAvailable { + Divider() + Button(action: onStashOrView) { + Label( + workComposerOverflowStashTitle(hasContent: hasComposerContent), + systemImage: hasComposerContent ? "bookmark" : "bookmark.fill" + ) + } + .disabled(stashBusy) + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(ADEColor.textPrimary) + .frame(width: 28, height: 28) + .background(ADEColor.surfaceBackground.opacity(0.38), in: Circle()) + .overlay(Circle().stroke(ADEColor.border.opacity(0.28), lineWidth: 0.6)) + .frame(width: 44, height: 44) + .contentShape(Circle()) + .overlay(alignment: .topTrailing) { + if stashCount > 0 { + Text("\(min(stashCount, 99))") + .font(.system(size: 8, weight: .bold, design: .rounded)) + .foregroundStyle(ADEColor.textPrimary) + .padding(.horizontal, 3) + .padding(.vertical, 1) + .background(ADEColor.surfaceBackground, in: Capsule()) + .overlay(Capsule().stroke(ADEColor.border.opacity(0.35), lineWidth: 0.5)) + .offset(x: 2, y: 2) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel("Composer actions") + .accessibilityIdentifier("Work.Chat.Composer.OverflowMenu") + } +} + +@MainActor +final class WorkPromptStashController: ObservableObject { + @Published var entries: [PromptStashEntry] = [] + @Published var busy = false + @Published var errorMessage: String? + @Published var listPresented = false + + func refresh(syncService: SyncService, scope: WorkPromptStashScope) async { + guard syncService.canInvokeRemoteAction("chat.listPromptStashes") else { + entries = [] + return + } + do { + entries = try await listEntries(syncService: syncService, scope: scope) + } catch { + errorMessage = error.localizedDescription + } + } + + func handleMenuAction( + syncService: SyncService, + text: String, + attachments: [WorkChatInputAttachment], + scope: WorkPromptStashScope, + provider: String?, + modelId: String?, + onDraftChange: (String) -> Void, + onAttachmentsChange: ([WorkChatInputAttachment]) -> Void + ) async { + if workComposerHasStashableContent(text: text, attachments: attachments) { + await stash( + syncService: syncService, + text: text, + attachments: attachments, + scope: scope, + provider: provider, + modelId: modelId, + onDraftChange: onDraftChange, + onAttachmentsChange: onAttachmentsChange + ) + } else { + await refresh(syncService: syncService, scope: scope) + listPresented = true + } + } + + func stash( + syncService: SyncService, + text: String, + attachments: [WorkChatInputAttachment], + scope: WorkPromptStashScope, + provider: String?, + modelId: String?, + onDraftChange: (String) -> Void, + onAttachmentsChange: ([WorkChatInputAttachment]) -> Void + ) async { + guard !busy else { return } + if workChatInputHasLoadingAttachments(attachments) { + errorMessage = "Wait for images to finish loading before stashing." + listPresented = true + return + } + let ready = workChatInputReadyAttachments(attachments) + guard workComposerHasStashableContent(text: text, attachments: attachments) else { return } + busy = true + errorMessage = nil + defer { busy = false } + do { + let refs = try await workChatSaveInputAttachments( + ready, + syncService: syncService, + chatSessionId: scope.chatSessionId, + targetProjectId: scope.projectId, + targetProjectRootPath: scope.projectRootPath + ) + let created = try await createEntry( + syncService: syncService, + scope: scope, + text: text, + attachments: refs, + provider: provider, + modelId: modelId + ) + if !refs.isEmpty { + let confirmed = created.resolvedAttachments + let allConfirmed = refs.allSatisfy { stored in + confirmed.contains { $0.path == stored.path && $0.type == stored.type } + } + if !allConfirmed { + _ = try? await deleteEntry(created.id, syncService: syncService, scope: scope) + throw NSError( + domain: "ADE", + code: 27, + userInfo: [NSLocalizedDescriptionKey: "The connected ADE runtime could not preserve the attached images. They are still in your composer."] + ) + } + } + entries = [created] + entries.filter { $0.id != created.id } + onDraftChange("") + onAttachmentsChange([]) + } catch { + errorMessage = error.localizedDescription + listPresented = true + } + } + + func restore( + entry: PromptStashEntry, + syncService: SyncService, + currentText: String, + currentAttachments: [WorkChatInputAttachment], + scope: WorkPromptStashScope, + onDraftChange: (String) -> Void, + onAttachmentsChange: ([WorkChatInputAttachment]) -> Void + ) async { + guard !busy else { return } + if workComposerHasStashableContent(text: currentText, attachments: currentAttachments) { + listPresented = false + return + } + if entry.imagesUnavailable { + errorMessage = "These images live on the machine where this prompt was stashed. Connect to that machine to restore it." + return + } + busy = true + errorMessage = nil + defer { busy = false } + do { + let restored = try await workChatInputAttachments( + from: entry.resolvedAttachments, + syncService: syncService, + chatSessionId: scope.chatSessionId, + projectId: scope.projectId, + projectRootPath: scope.projectRootPath + ) + guard restored.count == entry.resolvedAttachments.count else { + throw NSError( + domain: "ADE", + code: 28, + userInfo: [NSLocalizedDescriptionKey: "Could not restore every stashed image. The prompt is still in your stash."] + ) + } + onDraftChange(entry.text) + onAttachmentsChange(restored) + _ = try await deleteEntry(entry.id, syncService: syncService, scope: scope) + entries.removeAll { $0.id == entry.id } + listPresented = false + } catch { + errorMessage = error.localizedDescription + } + } + + func remove( + entry: PromptStashEntry, + syncService: SyncService, + scope: WorkPromptStashScope + ) async { + guard !busy else { return } + busy = true + defer { busy = false } + do { + _ = try await deleteEntry(entry.id, syncService: syncService, scope: scope) + entries.removeAll { $0.id == entry.id } + } catch { + errorMessage = error.localizedDescription + } + } + + private func listEntries( + syncService: SyncService, + scope: WorkPromptStashScope + ) async throws -> [PromptStashEntry] { + if let chatSessionId = scope.chatSessionId, !chatSessionId.isEmpty { + return try await syncService.listPromptStashesForChat(sessionId: chatSessionId) + } + return try await syncService.listPromptStashes( + targetProjectId: scope.projectId, + targetProjectRootPath: scope.projectRootPath + ) + } + + private func createEntry( + syncService: SyncService, + scope: WorkPromptStashScope, + text: String, + attachments: [AgentChatFileRef], + provider: String?, + modelId: String? + ) async throws -> PromptStashEntry { + if let chatSessionId = scope.chatSessionId, !chatSessionId.isEmpty { + return try await syncService.createPromptStashForChat( + sessionId: chatSessionId, + text: text, + attachments: attachments, + provider: provider, + modelId: modelId + ) + } + return try await syncService.createPromptStash( + text: text, + attachments: attachments, + provider: provider, + modelId: modelId, + targetProjectId: scope.projectId, + targetProjectRootPath: scope.projectRootPath + ) + } + + private func deleteEntry( + _ id: String, + syncService: SyncService, + scope: WorkPromptStashScope + ) async throws -> Bool { + if let chatSessionId = scope.chatSessionId, !chatSessionId.isEmpty { + return try await syncService.deletePromptStashForChat(sessionId: chatSessionId, id: id) + } + return try await syncService.deletePromptStash( + id: id, + targetProjectId: scope.projectId, + targetProjectRootPath: scope.projectRootPath + ) + } +} + +struct WorkPromptStashListSheet: View { + @ObservedObject var controller: WorkPromptStashController + let syncService: SyncService + let currentText: String + let currentAttachments: [WorkChatInputAttachment] + let scope: WorkPromptStashScope + let onDraftChange: (String) -> Void + let onAttachmentsChange: ([WorkChatInputAttachment]) -> Void + + var body: some View { + NavigationStack { + Group { + if controller.entries.isEmpty { + ContentUnavailableView( + "No stashed prompts", + systemImage: "bookmark", + description: Text("Stash a prompt from the composer menu to keep it for later.") + ) + } else { + List { + ForEach(controller.entries) { entry in + Button { + Task { + await controller.restore( + entry: entry, + syncService: syncService, + currentText: currentText, + currentAttachments: currentAttachments, + scope: scope, + onDraftChange: onDraftChange, + onAttachmentsChange: onAttachmentsChange + ) + } + } label: { + VStack(alignment: .leading, spacing: 6) { + Text(workPromptStashEntryLabel(entry)) + .font(.body) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(3) + HStack(spacing: 8) { + if entry.resolvedAttachmentCount > 0 { + Label( + "\(entry.resolvedAttachmentCount)", + systemImage: "photo" + ) + .font(.caption2) + .foregroundStyle(entry.imagesUnavailable ? ADEColor.warning : ADEColor.textMuted) + } + if let provider = entry.provider, !provider.isEmpty { + Text(provider.capitalized) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } + } + } + } + .swipeActions { + Button(role: .destructive) { + Task { + await controller.remove( + entry: entry, + syncService: syncService, + scope: scope + ) + } + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + } + } + .navigationTitle("Stashed prompts") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { controller.listPresented = false } + } + } + .safeAreaInset(edge: .bottom) { + if let errorMessage = controller.errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(ADEColor.danger) + .padding() + } + } + } + .presentationDetents([.medium, .large]) + } +} diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 34bd8d4894..50a7b12dbe 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -62,6 +62,7 @@ func buildWorkChatTimelineSnapshot( transcriptHasInterruptibleActivity: transcriptHasInterruptibleActivity, latestTranscriptTimestamp: latestTranscriptTimestamp, latestMessageAssistantId: latestWorkTimelineMessageAssistantId(timeline), + latestTurnEndTurnId: workLatestTurnEndTurnId(in: timeline), timeline: timeline ) } diff --git a/apps/ios/ADETests/WorkPromptStashTests.swift b/apps/ios/ADETests/WorkPromptStashTests.swift new file mode 100644 index 0000000000..1b33c9c4a1 --- /dev/null +++ b/apps/ios/ADETests/WorkPromptStashTests.swift @@ -0,0 +1,78 @@ +import XCTest +@testable import ADE + +final class WorkPromptStashTests: XCTestCase { + func testOverflowStashTitleDependsOnComposerContent() { + XCTAssertEqual(workComposerOverflowStashTitle(hasContent: true), "Stash prompt") + XCTAssertEqual(workComposerOverflowStashTitle(hasContent: false), "View prompt stash") + } + + func testComposerHasStashableContentForTextOrReadyImages() { + XCTAssertFalse(workComposerHasStashableContent(text: " ", attachments: [])) + XCTAssertTrue(workComposerHasStashableContent(text: "retry this", attachments: [])) + } + + func testLoadingAttachmentsAreNotReadyToStash() { + let loading = WorkChatInputAttachment( + filename: "shot.png", + state: .loading + ) + XCTAssertTrue(workChatInputHasLoadingAttachments([loading])) + XCTAssertTrue(workComposerHasStashableContent(text: "retry this", attachments: [loading])) + XCTAssertTrue(workChatInputReadyAttachments([loading]).isEmpty) + } + + func testPromptStashEntryDecodesAttachmentsAndAvailability() throws { + let json = """ + { + "id": "stash-1", + "text": "with a screenshot", + "attachments": [{ "path": "/tmp/shot.png", "type": "image" }], + "attachmentCount": 1, + "attachmentsAvailable": true, + "provider": "codex", + "modelId": "gpt-5.6", + "createdAt": "2026-08-14T00:00:00.000Z" + } + """.data(using: .utf8)! + let entry = try JSONDecoder().decode(PromptStashEntry.self, from: json) + XCTAssertEqual(entry.resolvedAttachmentCount, 1) + XCTAssertFalse(entry.imagesUnavailable) + XCTAssertEqual(workPromptStashEntryLabel(entry), "with a screenshot") + } + + func testLatestTurnEndUsesTheNewestMarker() { + let older = WorkTimelineEntry( + id: "old", + timestamp: "2026-08-14T00:00:00.000Z", + rank: 1, + payload: .turnEndMarker(WorkTurnEndMarker( + turnId: "turn-1", + time: "2026-08-14T00:00:00.000Z", + workedDurationLabel: "2s", + status: "completed", + terminalReasonLabel: nil, + provider: "codex", + modelLabel: "GPT", + modelId: nil + )) + ) + let newer = WorkTimelineEntry( + id: "new", + timestamp: "2026-08-14T00:01:00.000Z", + rank: 2, + payload: .turnEndMarker(WorkTurnEndMarker( + turnId: "turn-2", + time: "2026-08-14T00:01:00.000Z", + workedDurationLabel: "4s", + status: "completed", + terminalReasonLabel: nil, + provider: "codex", + modelLabel: "GPT", + modelId: nil + )) + ) + XCTAssertEqual(workLatestTurnEndTurnId(in: [older, newer]), "turn-2") + XCTAssertNil(workLatestTurnEndTurnId(in: [])) + } +} diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 73a87daa15..dbd1123d9d 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -237,7 +237,7 @@ that could not work without it. rather than the old `ResizeObserver`-based 28 %-of-height formula; that eliminated the observer churn without changing the visible ceiling for normal tile sizes. -- **Prompt stashes (desktop only).** Cmd/Ctrl+S stores the current prompt text +- **Prompt stashes.** Cmd/Ctrl+S stores the current prompt text in the project runtime and clears the composer only after the runtime confirms the write. The bookmark control immediately left of the context-usage meter performs the same action; invoking either path with an empty composer opens @@ -253,8 +253,12 @@ that could not work without it. currently bound local or remote project runtime. File attachments and visual context are deliberately not stashed because their paths can be machine-specific. Appearance > Prompt stash button can hide the bookmark; - the keyboard shortcut remains active. Mobile and the TUI do not expose this - feature. + the keyboard shortcut remains active. iOS exposes the same per-project stash + through the composer overflow menu (`WorkPromptStash.swift`) over + `chat.listPromptStashes` / `chat.createPromptStash` / `chat.deletePromptStash`; + those actions are optional in mobile compatibility so an older host omits + stash instead of going limited. Personal chats hide stash. The TUI does not + expose this feature. - **Smart links.** Once an HTTP(S) or `ade://` URL is completed by paste, whitespace, or paragraph insertion, the rich editor replaces its visible run with an atomic violet chip. Each chip shows the provider's real brand mark — diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 8b82742db7..7a4c554923 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -421,6 +421,9 @@ apps/ios/ │ │ │ # WorkMentionsPickerSheet / │ │ │ # WorkSlashCommandsSheet modals), │ │ │ # WorkChatAttachmentTray, +│ │ │ # WorkPromptStash (composer overflow + +│ │ │ # per-project stash host), +│ │ │ # WorkContextUsageViews (turn-end meter), │ │ │ # WorkChatComposerAndInputViews (compacted │ │ │ # icon-only staged-steer strip + the │ │ │ # structured-question card: pinned provider @@ -1975,14 +1978,18 @@ multi-line prompt the available space instead of lifting the activity panel with it. Mobile image attachments use the same host-side temp attachment contract as -desktop. Hub, Work new-session, compact/in-session, Chat, and CLI composers open -the scoped `PhotosPicker` directly from the plus control rather than through a -one-item menu. Their `UITextView` inputs also advertise Paste for image-only -clipboards and stage pasted images through the same path. Up to ten images are -normalized to JPEG and retained locally; overflow and load failures stay visible -as blocking tray errors instead of being silently dropped. Staging works while -offline, while upload/send waits for reconnection. Hosts that do not advertise -`chat.saveTempAttachment` do not offer attachment entry. +desktop. Hub, Work new-session, and in-session composers open attach, dictate, +and per-project prompt stash from `WorkComposerOverflowButton` (a three-dot +menu) rather than a plus control or idle mic. Their `UITextView` inputs also +advertise Paste for image-only clipboards and stage pasted images through the +same path. Up to ten images are normalized to JPEG and retained locally; +overflow and load failures stay visible as blocking tray errors instead of +being silently dropped. Staging works while offline, while upload/send waits +for reconnection. Hosts that do not advertise `chat.saveTempAttachment` disable +Attach only; Dictate stays available. Hosts that omit the optional +`chat.listPromptStashes` / `chat.createPromptStash` / `chat.deletePromptStash` +actions hide stash instead of going limited. Context usage lives on the latest +turn-end marker, not in the composer. `WorkChatInputAttachmentTray` is a separate fixed-height shelf above the input region, so adding previews grows the composer upward without reducing the text diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 2c72e677e3..af1f47924a 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -316,7 +316,8 @@ that cannot encode a JSON null (iOS) must still be able to express "clear". `preflightCrossMachineDestination`, `fastForwardCrossMachineHandoffLane`, `acceptCrossMachineHandoff`, `markCrossMachineHandoff`, - `rewindFiles`, `getTurnFileDiff`, `saveTempAttachment`, `getImageDataUrl` + `rewindFiles`, `getTurnFileDiff`, `saveTempAttachment`, + `listPromptStashes`, `createPromptStash`, `deletePromptStash`, `getImageDataUrl` `chat.getTranscript` supports cursor pagination: responses carry an opaque `nextCursor`, and requests can pass `cursor` to page strictly-older From 92731bc9c3ec9cc07f550ae3b6831d8964d1fafd Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:49:50 -0400 Subject: [PATCH 2/2] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20fix=20t?= =?UTF-8?q?est-ade-cli=20optional=20stash=20actions,=20address=20CodeRabbi?= =?UTF-8?q?t=20meter=20and=20stash=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/services/sync/syncHostService.test.ts | 3 +++ apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift | 5 ++++- .../Views/Work/WorkChatHeaderAndMessageViews.swift | 2 +- apps/ios/ADE/Views/Work/WorkContextUsageViews.swift | 4 ++-- apps/ios/ADE/Views/Work/WorkPromptStash.swift | 12 +++++++++++- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 26557e0536..1e7cb25699 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -5954,6 +5954,9 @@ describe("CTO-gated Linear sync commands", () => { "prs.unstackGithubStack", "ai.openCursorCloudChat", "ai.watchCursorCloudMirror", + "chat.listPromptStashes", + "chat.createPromptStash", + "chat.deletePromptStash", ]); expect(MOBILE_SYNC_REQUIRED_REMOTE_COMMAND_ACTIONS).not.toEqual( expect.arrayContaining([...MOBILE_SYNC_OPTIONAL_REMOTE_COMMAND_ACTIONS]), diff --git a/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift b/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift index 82fd8b7051..8698f9f52d 100644 --- a/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift +++ b/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift @@ -365,7 +365,10 @@ func workChatInputAttachments( let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { let data = try await workChatRemoteImageData(from: url) - guard let image = UIImage(data: data), + guard let image = WorkChatAttachmentImagePreview.downsampledImage( + data: data, + maxPixelSize: 2400 + ), let attachment = workChatInputAttachment( from: image, filename: workChatAttachmentDisplayName(ref) diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index b428047ce8..b365c4f2a3 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -1148,7 +1148,7 @@ struct WorkTurnEndMarkerView: View { } .frame(maxWidth: .infinity) .padding(.vertical, 8) - .accessibilityElement(children: .combine) + .accessibilityElement(children: .contain) .accessibilityLabel(markerAccessibilityLabel) } diff --git a/apps/ios/ADE/Views/Work/WorkContextUsageViews.swift b/apps/ios/ADE/Views/Work/WorkContextUsageViews.swift index 60f27ee738..e4b8cbd776 100644 --- a/apps/ios/ADE/Views/Work/WorkContextUsageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkContextUsageViews.swift @@ -30,7 +30,7 @@ struct WorkContextUsageMeter: View { } var body: some View { - if usage.ratio != nil || usage.usedTokens != nil { + if usage.state != .measured || usage.ratio != nil || usage.usedTokens != nil { Button { withAnimation(.easeInOut(duration: 0.18)) { isPresented.toggle() @@ -104,7 +104,7 @@ struct WorkContextUsagePopover: View { private var description: String { if usage.state == .compacting { - return "Claude is compacting this chat. The previous exact reading is temporarily hidden." + return "The runtime is compacting this chat. The previous exact reading is temporarily hidden." } if usage.state == .recalculating { return "Compaction finished. ADE is waiting for the next authoritative usage snapshot." diff --git a/apps/ios/ADE/Views/Work/WorkPromptStash.swift b/apps/ios/ADE/Views/Work/WorkPromptStash.swift index beed2f4a75..85678c603b 100644 --- a/apps/ios/ADE/Views/Work/WorkPromptStash.swift +++ b/apps/ios/ADE/Views/Work/WorkPromptStash.swift @@ -165,15 +165,22 @@ final class WorkPromptStashController: ObservableObject { @Published var busy = false @Published var errorMessage: String? @Published var listPresented = false + private var refreshToken = UUID() func refresh(syncService: SyncService, scope: WorkPromptStashScope) async { + let token = UUID() + refreshToken = token guard syncService.canInvokeRemoteAction("chat.listPromptStashes") else { + guard refreshToken == token else { return } entries = [] return } do { - entries = try await listEntries(syncService: syncService, scope: scope) + let fetched = try await listEntries(syncService: syncService, scope: scope) + guard refreshToken == token else { return } + entries = fetched } catch { + guard refreshToken == token else { return } errorMessage = error.localizedDescription } } @@ -216,6 +223,7 @@ final class WorkPromptStashController: ObservableObject { onAttachmentsChange: ([WorkChatInputAttachment]) -> Void ) async { guard !busy else { return } + refreshToken = UUID() if workChatInputHasLoadingAttachments(attachments) { errorMessage = "Wait for images to finish loading before stashing." listPresented = true @@ -275,6 +283,7 @@ final class WorkPromptStashController: ObservableObject { onAttachmentsChange: ([WorkChatInputAttachment]) -> Void ) async { guard !busy else { return } + refreshToken = UUID() if workComposerHasStashableContent(text: currentText, attachments: currentAttachments) { listPresented = false return @@ -317,6 +326,7 @@ final class WorkPromptStashController: ObservableObject { scope: WorkPromptStashScope ) async { guard !busy else { return } + refreshToken = UUID() busy = true defer { busy = false } do {