Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/ade-cli/src/services/sync/syncHostService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand Down
50 changes: 50 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2039,6 +2039,9 @@ describe("createSyncRemoteCommandService", () => {
"chat.rewindFiles",
"chat.getTurnFileDiff",
"chat.saveTempAttachment",
"chat.listPromptStashes",
"chat.createPromptStash",
"chat.deletePromptStash",
"chat.warmupModel",
"chat.launch",
"chat.getImageDataUrl",
Expand Down Expand Up @@ -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",
Expand All @@ -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" },
Expand Down
14 changes: 14 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/shared/syncMobileCompatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/shared/types/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions apps/ios/ADE.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -394,6 +397,8 @@
D10000000000000000000053 /* LaneDetailGitActionsPane.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDetailGitActionsPane.swift; path = ADE/Views/Lanes/LaneDetailGitActionsPane.swift; sourceTree = "<group>"; };
D10000000000000000000047 /* ADEInspectable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEInspectable.swift; path = ADE/Debug/ADEInspectorKit/ADEInspectable.swift; sourceTree = "<group>"; };
D1000000000000000000002E /* WorkChatComposerAndInputViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatComposerAndInputViews.swift; path = ADE/Views/Work/WorkChatComposerAndInputViews.swift; sourceTree = "<group>"; };
D10000000000000000000F03 /* WorkPromptStash.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPromptStash.swift; path = ADE/Views/Work/WorkPromptStash.swift; sourceTree = "<group>"; };
D10000000000000000000F04 /* WorkContextUsageViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkContextUsageViews.swift; path = ADE/Views/Work/WorkContextUsageViews.swift; sourceTree = "<group>"; };
D1000000000000000000002F /* WorkArtifactTerminalViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkArtifactTerminalViews.swift; path = ADE/Views/Work/WorkArtifactTerminalViews.swift; sourceTree = "<group>"; };
D10000000000000000000030 /* WorkMarkdownViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkMarkdownViews.swift; path = ADE/Views/Work/WorkMarkdownViews.swift; sourceTree = "<group>"; };
D10000000000000000000031 /* WorkModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkModels.swift; path = ADE/Views/Work/WorkModels.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -465,6 +470,7 @@
D30000000000000000000007 /* WorkMarkdownStreamingParsingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkMarkdownStreamingParsingTests.swift; path = ADETests/WorkMarkdownStreamingParsingTests.swift; sourceTree = "<group>"; };
D30000000000000000000008 /* PrMergeMergeStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrMergeMergeStateTests.swift; path = ADETests/PrMergeMergeStateTests.swift; sourceTree = "<group>"; };
D30000000000000000000009 /* WorkComposerTriggerDetectorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkComposerTriggerDetectorTests.swift; path = ADETests/WorkComposerTriggerDetectorTests.swift; sourceTree = "<group>"; };
D3000000000000000000000A /* WorkPromptStashTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPromptStashTests.swift; path = ADETests/WorkPromptStashTests.swift; sourceTree = "<group>"; };
FA000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionCanonicalStateTests.swift; path = ADETests/WorkSessionCanonicalStateTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
23 changes: 23 additions & 0 deletions apps/ios/ADE/Models/RemoteModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
121 changes: 121 additions & 0 deletions apps/ios/ADE/Services/SyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading