diff --git a/companion/src/routes.ts b/companion/src/routes.ts index 9671225e35..a5f386d369 100644 --- a/companion/src/routes.ts +++ b/companion/src/routes.ts @@ -55,6 +55,12 @@ const ALLOWED: ReadonlyArray<{ method: string; path: RegExp }> = [ { method: "POST", path: /^\/api\/bots\/[\w-]+\/interrupt$/ }, { method: "POST", path: /^\/api\/bots\/[\w-]+\/read$/ }, { method: "POST", path: /^\/api\/bots\/[\w-]+\/always-allow$/ }, + { method: "POST", path: /^\/api\/bots\/[\w-]+\/messages\/[\w-]+\/edit$/ }, + { method: "POST", path: /^\/api\/bots\/[\w-]+\/active-branch$/ }, + { method: "POST", path: /^\/api\/bots\/[\w-]+\/tasks$/ }, + { method: "POST", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ }, + { method: "PATCH", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ }, + { method: "DELETE", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ }, // rooms { method: "POST", path: /^\/api\/groups\/[\w-]+\/messages$/ }, @@ -63,7 +69,10 @@ const ALLOWED: ReadonlyArray<{ method: string; path: RegExp }> = [ // a transcript, its images, and answering an approval { method: "GET", path: /^\/api\/threads\/[\w-]+\/messages$/ }, { method: "GET", path: /^\/api\/threads\/[\w-]+\/messages\/[\w-]+\/image$/ }, + { method: "POST", path: /^\/api\/threads\/[\w-]+\/messages\/[\w-]+\/reactions$/ }, + { method: "GET", path: /^\/api\/threads\/[\w-]+\/export$/ }, { method: "POST", path: /^\/api\/threads\/[\w-]+\/respond$/ }, + { method: "GET", path: /^\/api\/search$/ }, ]; /** Route families worth naming in the refusal. diff --git a/companion/test/routes.test.ts b/companion/test/routes.test.ts index b445707bf9..54c16426fc 100644 --- a/companion/test/routes.test.ts +++ b/companion/test/routes.test.ts @@ -36,11 +36,20 @@ describe("what the app may do", () => { ["POST", "/api/bots/bot_123/interrupt"], ["POST", "/api/bots/bot_123/read"], ["POST", "/api/bots/bot_123/always-allow"], + ["POST", "/api/bots/bot_123/messages/msg_2/edit"], + ["POST", "/api/bots/bot_123/active-branch"], + ["POST", "/api/bots/bot_123/tasks"], + ["POST", "/api/bots/bot_123/tasks/th_1"], + ["PATCH", "/api/bots/bot_123/tasks/th_1"], + ["DELETE", "/api/bots/bot_123/tasks/th_1"], ["POST", "/api/groups/room-1/messages"], ["POST", "/api/groups/room-1/read"], ["GET", "/api/threads/th_1/messages"], ["GET", "/api/threads/th_1/messages/msg_2/image"], + ["POST", "/api/threads/th_1/messages/msg_2/reactions"], + ["GET", "/api/threads/th_1/export"], ["POST", "/api/threads/th_1/respond"], + ["GET", "/api/search"], ]; for (const [method, path] of calls) { diff --git a/docs/ios-companion.md b/docs/ios-companion.md index e95678090b..b552d57ee2 100644 --- a/docs/ios-companion.md +++ b/docs/ios-companion.md @@ -214,8 +214,9 @@ distribution scope: 1. **Foundation:** sidecar, desktop controls, Swift core/app, pairing, chat, approvals, reconnect, simulator and contract CI. -2. **Current desktop parity:** task switching/creation, SQLite search, transcript - export/share, and explicit handling for archived or hidden chats. +2. **Desktop conversation parity:** task create/switch/rename/delete, SQLite + search with exact-message landing, transcript export/share, reactions, and + edit/version controls. Archived or hidden chat management remains desktop-only. 3. **Notifications:** APNs credentials, a relay or another wake-up design, notification actions, and background reconciliation. 4. **Distribution:** signing, bundle ownership, privacy declarations, diff --git a/ios/App/ChatListView.swift b/ios/App/ChatListView.swift index 9e9f1688c8..7848e35b5c 100644 --- a/ios/App/ChatListView.swift +++ b/ios/App/ChatListView.swift @@ -14,6 +14,8 @@ struct ChatListView: View { /// cannot push without a tap, and a new bot appearing silently at the /// bottom of the roster is a poor answer to pressing +. @State private var path = NavigationPath() + @State private var searchHits: [SearchHit] = [] + @State private var searching = false var body: some View { NavigationStack(path: $path) { @@ -40,6 +42,29 @@ struct ChatListView: View { } } + if !query.isEmpty, !searchHits.isEmpty { + HStack { + Text("Messages") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.secondary) + Spacer() + if searching { ProgressView().controlSize(.small) } + } + .padding(.top, 10) + .padding(.bottom, 4) + + ForEach(searchHits) { hit in + Button { + Task { + if let chat = await session.open(hit) { path.append(chat) } + } + } label: { + SearchHitRow(hit: hit) + } + .buttonStyle(.plain) + } + } + ForEach(chats) { summary in NavigationLink(value: summary.chat) { ChatRow( @@ -56,7 +81,7 @@ struct ChatListView: View { } .refreshable { await session.refresh() } .overlay { - if chats.isEmpty { + if chats.isEmpty && searchHits.isEmpty { ContentUnavailableView( query.isEmpty ? "No bots yet" : "Nothing matches", systemImage: query.isEmpty ? "bubble.left.and.bubble.right" : "magnifyingglass", @@ -73,6 +98,19 @@ struct ChatListView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .toolbar(.hidden, for: .navigationBar) .navigationDestination(for: Chat.self) { ChatView(chat: $0) } + .task(id: query) { + let expected = query + guard expected.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 else { + searchHits = [] + searching = false + return + } + searching = true + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled, query == expected else { return } + searchHits = await session.search(expected) + searching = false + } } } @@ -144,6 +182,39 @@ struct ChatListView: View { } } +struct SearchHitRow: View { + let hit: SearchHit + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: hit.role == .user ? "person.fill" : "bubble.left.fill") + .foregroundStyle(Color.secondary) + .frame(width: 26, height: 26) + .background(Circle().fill(Color.secondary.opacity(0.13))) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(hit.name).font(.system(size: 15, weight: .semibold)) + if let task = hit.task, !task.isEmpty { + Text(task).font(.system(size: 12)).foregroundStyle(Color.secondary) + } + Spacer() + Text(RelativeStamp.list(hit.at)) + .font(.system(size: 12)) + .foregroundStyle(Color.secondary) + } + Text(hit.snippet) + .font(.system(size: 14)) + .foregroundStyle(Color.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + } + .padding(.vertical, 10) + .contentShape(Rectangle()) + } +} + struct ChatRow: View { let chat: Chat let preview: String diff --git a/ios/App/ChatView.swift b/ios/App/ChatView.swift index b2dd6108fb..33e8a5b651 100644 --- a/ios/App/ChatView.swift +++ b/ios/App/ChatView.swift @@ -20,6 +20,8 @@ struct ChatView: View { @EnvironmentObject private var session: Session @Environment(\.dismiss) private var dismiss @State private var draft = "" + @State private var showingTasks = false + @State private var shareFile: ShareFile? @FocusState private var composerFocused: Bool /// The live bubble's scroll target. A constant because there is at most @@ -129,6 +131,20 @@ struct ChatView: View { guard length > 0 else { return } proxy.scrollTo(Self.liveBubbleId, anchor: .bottom) } + .onChange(of: session.focusedMessageId) { _, messageId in + guard let messageId, + messages.contains(where: { $0.id == messageId }) + else { return } + withAnimation { proxy.scrollTo(messageId, anchor: .center) } + session.consumeFocus(messageId) + } + .task { + guard let messageId = session.focusedMessageId, + messages.contains(where: { $0.id == messageId }) + else { return } + proxy.scrollTo(messageId, anchor: .center) + session.consumeFocus(messageId) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -174,6 +190,31 @@ struct ChatView: View { .accessibilityLabel("Watch \(bot.name)'s computer") } } + ToolbarItem(placement: .topBarTrailing) { + Menu { + if case let .bot(bot) = current { + Button("Tasks", systemImage: "square.stack") { showingTasks = true } + .disabled(bot.busy == true) + } + Button("Share as Markdown", systemImage: "doc.plaintext") { + Task { + if let url = await session.export(threadId: current.threadId, format: "markdown") { + shareFile = ShareFile(url: url) + } + } + } + Button("Share as JSON", systemImage: "curlybraces") { + Task { + if let url = await session.export(threadId: current.threadId, format: "json") { + shareFile = ShareFile(url: url) + } + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("Conversation actions") + } if current.busy, case let .bot(bot) = current { ToolbarItem(placement: .topBarTrailing) { Button("Stop") { Task { await session.interrupt(bot: bot) } } @@ -190,6 +231,12 @@ struct ChatView: View { // bit here rather than leaving a badge on an open conversation. if unread { Task { await session.markRead(current) } } } + .sheet(isPresented: $showingTasks) { + if case let .bot(bot) = current { TaskManagerView(bot: bot) } + } + .sheet(item: $shareFile) { file in + ActivityShareSheet(items: [file.url]) + } } /// True when this message opens a fresh stretch of conversation — the @@ -255,8 +302,87 @@ struct ChatView: View { struct MessageRow: View { let chat: Chat let message: Message + @EnvironmentObject private var session: Session + @State private var editingText = "" + @State private var showingEdit = false + + private static let reactionChoices = ["👍", "❤️", "😂", "🎉", "👀"] + + private var versions: [Message] { + session.state.versions(of: message, inThread: chat.threadId) + } var body: some View { + VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 6) { + content + + if let comm = message.comm { + Label("Messaged \(comm.withName)", systemImage: "arrow.up.right.bubble") + .font(.system(size: 12)) + .foregroundStyle(Color.secondary) + } + + if let reactions = message.reactions, !reactions.isEmpty { + HStack(spacing: 6) { + ForEach(reactionGroups(reactions), id: \.emoji) { group in + Button("\(group.emoji) \(group.count)") { + Task { await session.react(to: message, in: chat.threadId, emoji: group.emoji) } + } + .font(.system(size: 13)) + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .tint(group.mine ? Color.accentColor : Color.secondary) + } + } + } + + if versions.count > 1, let index = versions.firstIndex(where: { $0.id == message.id }), + case let .bot(bot) = chat { + HStack(spacing: 8) { + Button { + Task { await session.switchVersion(to: versions[index - 1], for: bot) } + } label: { Image(systemName: "chevron.left") } + .disabled(index == 0 || bot.busy == true) + Text("\(index + 1) of \(versions.count)") + Button { + Task { await session.switchVersion(to: versions[index + 1], for: bot) } + } label: { Image(systemName: "chevron.right") } + .disabled(index + 1 >= versions.count || bot.busy == true) + } + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.secondary) + } + } + .contextMenu { + ForEach(Self.reactionChoices, id: \.self) { emoji in + Button(emoji) { Task { await session.react(to: message, in: chat.threadId, emoji: emoji) } } + } + if message.role == .user, message.kind == .text, case let .bot(bot) = chat { + Divider() + Button("Edit and retry", systemImage: "pencil") { + editingText = message.text ?? "" + showingEdit = true + } + .disabled(bot.busy == true) + } + } + .alert("Edit and retry", isPresented: $showingEdit) { + TextField("Message", text: $editingText) + Button("Cancel", role: .cancel) {} + if case let .bot(bot) = chat { + Button("Send") { + let text = editingText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + Task { await session.edit(message, for: bot, text: text) } + } + } + } message: { + Text("This creates a new version and continues from there.") + } + } + + @ViewBuilder + private var content: some View { switch message.kind { case .text: TextBubble(message: message) @@ -277,6 +403,27 @@ struct MessageRow: View { } } } + + private func reactionGroups(_ reactions: [Reaction]) -> [(emoji: String, count: Int, mine: Bool)] { + Dictionary(grouping: reactions, by: \.emoji) + .map { (emoji: $0.key, count: $0.value.count, mine: $0.value.contains { $0.by == "user" }) } + .sorted { $0.emoji < $1.emoji } + } +} + +private struct ShareFile: Identifiable { + let url: URL + var id: String { url.path } +} + +private struct ActivityShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} } struct TextBubble: View { diff --git a/ios/App/Session.swift b/ios/App/Session.swift index e388f19fa8..915415b28c 100644 --- a/ios/App/Session.swift +++ b/ios/App/Session.swift @@ -33,6 +33,8 @@ final class Session: ObservableObject { @Published private(set) var status: Status = .unpaired /// Transient, user-facing failures from an action they just took. @Published var actionError: String? + /// One exact message the next opened chat should reveal. + @Published private(set) var focusedMessageId: String? private var client: CompanionClient? private var streamTask: Task? @@ -358,6 +360,110 @@ final class Session: ObservableObject { try? await client?.image(threadId: threadId, messageId: messageId) } + func search(_ query: String) async -> [SearchHit] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= 2, let client else { return [] } + do { return try await client.search(trimmed) } + catch { + actionError = error.localizedDescription + return [] + } + } + + /// Resolve a SQLite search hit into the live task/branch, load a page + /// around it, and hand navigation the current chat record. + func open(_ hit: SearchHit) async -> Chat? { + guard let client else { return nil } + do { + if let botId = hit.botId, var bot = state.bot(botId) { + if bot.threadId != hit.threadId { + bot = try await client.switchTask(botId: bot.id, threadId: hit.threadId) + state.apply(.bot(bot)) + } + if !hit.onActivePath { + let leaf = try await client.setActiveBranch(botId: bot.id, messageId: hit.messageId) + state.apply(.thread(threadId: hit.threadId, activeLeafId: leaf)) + } + let page = try await client.messages(threadId: hit.threadId, around: hit.messageId) + state.merge(page, intoThread: hit.threadId) + focusedMessageId = hit.messageId + return state.bot(bot.id).map(Chat.bot) + } + if let groupId = hit.groupId, + let room = state.rooms.first(where: { $0.id == groupId }) { + let page = try await client.messages(threadId: hit.threadId, around: hit.messageId) + state.merge(page, intoThread: hit.threadId) + focusedMessageId = hit.messageId + return .room(room) + } + } catch { actionError = error.localizedDescription } + return nil + } + + func consumeFocus(_ messageId: String) { + if focusedMessageId == messageId { focusedMessageId = nil } + } + + func createTask(for bot: Bot, title: String?) async { + guard let client else { return } + do { state.apply(.bot(try await client.createTask(botId: bot.id, title: title))) } + catch { actionError = error.localizedDescription } + } + + func switchTask(_ task: BotTask, for bot: Bot) async { + guard let client, task.threadId != bot.threadId else { return } + do { state.apply(.bot(try await client.switchTask(botId: bot.id, threadId: task.threadId))) } + catch { actionError = error.localizedDescription } + } + + func renameTask(_ task: BotTask, for bot: Bot, title: String) async { + guard let client else { return } + do { + try await client.renameTask(botId: bot.id, threadId: task.threadId, title: title) + await refresh() + } catch { actionError = error.localizedDescription } + } + + func deleteTask(_ task: BotTask, for bot: Bot) async { + guard let client else { return } + do { state.apply(.bot(try await client.deleteTask(botId: bot.id, threadId: task.threadId))) } + catch { actionError = error.localizedDescription } + } + + func react(to message: Message, in threadId: String, emoji: String) async { + guard let client else { return } + do { + let patched = try await client.toggleReaction(threadId: threadId, messageId: message.id, emoji: emoji) + state.apply(.messagePatch(threadId: threadId, message: patched)) + } catch { actionError = error.localizedDescription } + } + + func edit(_ message: Message, for bot: Bot, text: String) async { + await perform { try await $0.edit(botId: bot.id, messageId: message.id, text: text) } + } + + func switchVersion(to message: Message, for bot: Bot) async { + guard let client else { return } + do { + let leaf = try await client.setActiveBranch(botId: bot.id, messageId: message.id) + state.apply(.thread(threadId: bot.threadId, activeLeafId: leaf)) + } catch { actionError = error.localizedDescription } + } + + func export(threadId: String, format: String) async -> URL? { + guard let client else { return nil } + do { + let exported = try await client.export(threadId: threadId, format: format) + let name = URL(fileURLWithPath: exported.filename).lastPathComponent + let url = FileManager.default.temporaryDirectory.appendingPathComponent(name) + try exported.data.write(to: url, options: .atomic) + return url + } catch { + actionError = error.localizedDescription + return nil + } + } + private func perform(quietly: Bool = false, _ body: (CompanionClient) async throws -> Void) async { guard let client else { return } do { diff --git a/ios/App/TaskManagerView.swift b/ios/App/TaskManagerView.swift new file mode 100644 index 0000000000..4f9cba6663 --- /dev/null +++ b/ios/App/TaskManagerView.swift @@ -0,0 +1,91 @@ +import SwiftUI +import CompanionCore + +/// A bot's separate contexts. Tasks remain a compact sheet because they are +/// conversation navigation, not host configuration. +struct TaskManagerView: View { + let bot: Bot + @EnvironmentObject private var session: Session + @Environment(\.dismiss) private var dismiss + @State private var showingNewTask = false + @State private var taskToRename: BotTask? + @State private var title = "" + + private var current: Bot { session.state.bot(bot.id) ?? bot } + private var tasks: [BotTask] { current.tasks ?? [] } + + var body: some View { + NavigationStack { + List { + ForEach(tasks, id: \.threadId) { task in + Button { + Task { + await session.switchTask(task, for: current) + dismiss() + } + } label: { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(task.title.isEmpty ? "Untitled task" : task.title) + .foregroundStyle(Color.primary) + Text(RelativeStamp.list(task.createdAt)) + .font(.caption) + .foregroundStyle(Color.secondary) + } + Spacer() + if task.threadId == current.threadId { + Image(systemName: "checkmark.circle.fill").foregroundStyle(Color.accentColor) + } + } + } + .contextMenu { + Button("Rename", systemImage: "pencil") { + title = task.title + taskToRename = task + } + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + Task { await session.deleteTask(task, for: current) } + } label: { Label("Delete", systemImage: "trash") } + .disabled(tasks.count <= 1 || current.busy == true) + } + } + } + .navigationTitle("\(current.name)’s tasks") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } + ToolbarItem(placement: .primaryAction) { + Button("New task", systemImage: "plus") { + title = "" + showingNewTask = true + } + .disabled(current.busy == true) + } + } + } + .alert("New task", isPresented: $showingNewTask) { + TextField("Title (optional)", text: $title) + Button("Cancel", role: .cancel) {} + Button("Create") { + Task { + await session.createTask(for: current, title: title.trimmingCharacters(in: .whitespacesAndNewlines)) + dismiss() + } + } + } + .alert("Rename task", isPresented: Binding( + get: { taskToRename != nil }, + set: { if !$0 { taskToRename = nil } } + )) { + TextField("Title", text: $title) + Button("Cancel", role: .cancel) { taskToRename = nil } + Button("Save") { + guard let task = taskToRename else { return } + Task { await session.renameTask(task, for: current, title: title) } + taskToRename = nil + } + } + } +} diff --git a/ios/README.md b/ios/README.md index fde4dde880..2430baad53 100644 --- a/ios/README.md +++ b/ios/README.md @@ -156,12 +156,15 @@ mean losing the ability to lock it out. Software keyboards have no Shift+Return, so there `.onSubmit` sends. - **No affordance without a feature behind it.** The reference design this was modelled on has a composer mic; there is no dictation here, so it is not - drawn. Search and the roster's "+" are real: the latter creates the same - basic bot the desktop endpoint creates, then opens it. + drawn. Search covers the SQLite transcript store and opens the exact task, + branch, and message; the roster's "+" creates the same basic bot the desktop + endpoint creates, then opens it. ## Not in this version The app is foreground-only. There is no APNs delivery while it is closed, no -voice/call mode, no task-management or SQLite transcript-search UI, and no -hosted relay. Tailscale is supported through manual MagicDNS entry; it is not a -dependency and OpenMausBot does not operate a cloud copy of local data. +voice/call mode, and no hosted relay. Task management, SQLite transcript search, +transcript sharing, reactions, and edit/version controls use narrow companion +routes and the computer remains the source of truth. Tailscale is supported +through manual MagicDNS entry; it is not a dependency and OpenMausBot does not +operate a cloud copy of local data. diff --git a/ios/Sources/CompanionCore/Client.swift b/ios/Sources/CompanionCore/Client.swift index 59b5610fca..ab470b4779 100644 --- a/ios/Sources/CompanionCore/Client.swift +++ b/ios/Sources/CompanionCore/Client.swift @@ -249,6 +249,48 @@ public struct CompanionClient: Sendable { return try await send(try makeRequest("GET", "/api/threads/\(threadId)/messages", query: query), as: ThreadPage.self) } + /// A page containing one exact message, for landing on a search hit. + public func messages(threadId: String, around messageId: String, limit: Int = 50) async throws -> ThreadPage { + let query = [ + URLQueryItem(name: "limit", value: String(limit)), + URLQueryItem(name: "around", value: messageId), + ] + return try await send(try makeRequest("GET", "/api/threads/\(threadId)/messages", query: query), as: ThreadPage.self) + } + + public func search(_ query: String, limit: Int = 40) async throws -> [SearchHit] { + let items = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "limit", value: String(limit)), + ] + return try await send(try makeRequest("GET", "/api/search", query: items), as: SearchResponse.self).hits + } + + public func export(threadId: String, format: String) async throws -> TranscriptExport { + let request = try makeRequest( + "GET", + "/api/threads/\(threadId)/export", + query: [URLQueryItem(name: "format", value: format)] + ) + let (data, response) = try await perform(request) + try Self.check(response, data) + let http = response as? HTTPURLResponse + let fallback = "transcript.\(format == "json" ? "json" : "md")" + let disposition = http?.value(forHTTPHeaderField: "Content-Disposition") ?? "" + let filenamePart = disposition + .split(separator: ";") + .map { $0.trimmingCharacters(in: .whitespaces) } + .first { $0.lowercased().hasPrefix("filename=") } + let filename = filenamePart.map { + String($0.dropFirst("filename=".count)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } ?? fallback + return TranscriptExport( + data: data, + filename: filename, + contentType: http?.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream" + ) + } + public func instances() async throws -> [Instance] { try await send(try makeRequest("GET", "/api/instances"), as: InstanceList.self).instances } @@ -299,6 +341,46 @@ public struct CompanionClient: Sendable { try await send(try makeRequest("POST", "/api/bots/\(botId)/always-allow", body: ["allowKey": key])) } + public func toggleReaction(threadId: String, messageId: String, emoji: String) async throws -> Message { + try await send( + try makeRequest( + "POST", + "/api/threads/\(threadId)/messages/\(messageId)/reactions", + body: ["emoji": emoji] + ), + as: MessageResponse.self + ).message + } + + public func edit(botId: String, messageId: String, text: String) async throws { + try await send(try makeRequest("POST", "/api/bots/\(botId)/messages/\(messageId)/edit", body: ["text": text])) + } + + public func setActiveBranch(botId: String, messageId: String) async throws -> String { + try await send( + try makeRequest("POST", "/api/bots/\(botId)/active-branch", body: ["messageId": messageId]), + as: ActiveBranchResponse.self + ).activeLeafId + } + + public func createTask(botId: String, title: String? = nil) async throws -> Bot { + var body: [String: Any] = [:] + if let title, !title.isEmpty { body["title"] = title } + return try await send(try makeRequest("POST", "/api/bots/\(botId)/tasks", body: body), as: BotResponse.self).bot + } + + public func switchTask(botId: String, threadId: String) async throws -> Bot { + try await send(try makeRequest("POST", "/api/bots/\(botId)/tasks/\(threadId)"), as: BotResponse.self).bot + } + + public func renameTask(botId: String, threadId: String, title: String) async throws { + try await send(try makeRequest("PATCH", "/api/bots/\(botId)/tasks/\(threadId)", body: ["title": title])) + } + + public func deleteTask(botId: String, threadId: String) async throws -> Bot { + try await send(try makeRequest("DELETE", "/api/bots/\(botId)/tasks/\(threadId)"), as: BotResponse.self).bot + } + public func interrupt(botId: String) async throws { try await send(try makeRequest("POST", "/api/bots/\(botId)/interrupt")) } diff --git a/ios/Sources/CompanionCore/Models.swift b/ios/Sources/CompanionCore/Models.swift index bf9ee3d6fa..0868c845e7 100644 --- a/ios/Sources/CompanionCore/Models.swift +++ b/ios/Sources/CompanionCore/Models.swift @@ -216,6 +216,36 @@ public struct ThreadPage: Codable, Sendable { public var hasMore: Bool? } +public struct SearchHit: Codable, Hashable, Identifiable, Sendable { + public var threadId: String + public var messageId: String + public var at: Double + public var role: Message.Role + public var kind: Message.Kind + public var snippet: String + public var matchStart: Int + public var matchLength: Int + public var botId: String? + public var groupId: String? + public var name: String + public var task: String? + public var onActivePath: Bool + + public var id: String { "\(threadId):\(messageId)" } +} + +public struct TranscriptExport: Sendable { + public var data: Data + public var filename: String + public var contentType: String + + public init(data: Data, filename: String, contentType: String) { + self.data = data + self.filename = filename + self.contentType = contentType + } +} + public struct PairedDevice: Codable, Hashable, Identifiable, Sendable { public var id: String public var name: String @@ -307,3 +337,19 @@ public struct ScreenFrame: Hashable, Sendable { public struct CreatedBot: Codable, Sendable { public var bot: Bot } + +struct SearchResponse: Codable, Sendable { + var hits: [SearchHit] +} + +struct MessageResponse: Codable, Sendable { + var message: Message +} + +struct ActiveBranchResponse: Codable, Sendable { + var activeLeafId: String +} + +struct BotResponse: Codable, Sendable { + var bot: Bot +} diff --git a/ios/Sources/CompanionCore/Store.swift b/ios/Sources/CompanionCore/Store.swift index 2a5c41be3e..a701f460ff 100644 --- a/ios/Sources/CompanionCore/Store.swift +++ b/ios/Sources/CompanionCore/Store.swift @@ -120,6 +120,26 @@ public struct CompanionState: Sendable { hasMore[threadId] = page.hasMore ?? false } + /// Merge a search landing window into the pages already held. + public mutating func merge(_ page: ThreadPage, intoThread threadId: String) { + var byId = Dictionary( + uniqueKeysWithValues: (messages[threadId] ?? []).map { ($0.id, $0) } + ) + for message in page.messages { byId[message.id] = message } + messages[threadId] = byId.values.sorted { + $0.at == $1.at ? $0.id < $1.id : $0.at < $1.at + } + if let more = page.hasMore { hasMore[threadId] = more } + } + + /// User-message alternatives created by edit-and-retry, oldest first. + public func versions(of message: Message, inThread threadId: String) -> [Message] { + guard message.role == .user, message.kind == .text else { return [] } + return transcript(forThread: threadId) + .filter { $0.role == .user && $0.kind == .text && $0.parentId == message.parentId } + .sorted { $0.at == $1.at ? $0.id < $1.id : $0.at < $1.at } + } + // MARK: - Folding public mutating func apply(_ streamFrame: StreamFrame) { diff --git a/ios/TESTING.md b/ios/TESTING.md index 5ad47d0024..43fcc1d459 100644 --- a/ios/TESTING.md +++ b/ios/TESTING.md @@ -247,7 +247,8 @@ port — only the route to it is different. Not built yet, so not bugs: - **Nothing arrives while the app is closed.** No push until APNs. -- **No voice, routines, task management, or transcript search.** +- **No voice or routine management.** Tasks, SQLite transcript search/export, + reactions, and edit/version switching are available from the conversation UI. (Two entries that used to sit on this list have since shipped: replies stream token by token as the provider emits them, and each bot has a computer panel — diff --git a/ios/Tests/CompanionCoreTests/StoreTests.swift b/ios/Tests/CompanionCoreTests/StoreTests.swift index 8a34413e05..d9ec0c0460 100644 --- a/ios/Tests/CompanionCoreTests/StoreTests.swift +++ b/ios/Tests/CompanionCoreTests/StoreTests.swift @@ -75,6 +75,17 @@ final class StoreTests: XCTestCase { XCTAssertEqual(state.hasMore["t1"], true) } + func testSearchWindowMergesAndOrdersWithoutDuplicating() { + var state = CompanionState() + state.messages["t1"] = [message("d", at: 4), message("e", at: 5)] + state.merge( + ThreadPage(messages: [message("b", at: 2), message("c", at: 3), message("d", at: 4)], hasMore: true), + intoThread: "t1" + ) + XCTAssertEqual(state.transcript(forThread: "t1").map(\.id), ["b", "c", "d", "e"]) + XCTAssertEqual(state.hasMore["t1"], true) + } + // MARK: - Bots func testABotFrameMergesRatherThanWipingTheTranscript() throws { @@ -127,6 +138,21 @@ final class StoreTests: XCTestCase { XCTAssertEqual(state.visibleTranscript(forThread: bot.threadId).map(\.id), ["root", "fork", "tail"]) } + func testVersionsAreUserMessagesWithTheSameParent() { + var state = CompanionState() + let root = message("root") + var first = message("first", at: 2) + first.parentId = root.id + var second = message("second", at: 3) + second.parentId = root.id + var reply = message("reply", at: 4) + reply.role = .bot + reply.parentId = root.id + state.messages["t1"] = [root, second, reply, first] + + XCTAssertEqual(state.versions(of: first, inThread: "t1").map(\.id), ["first", "second"]) + } + func testMessageAppendMovesTheLeafAndBranchSwitchClearsLiveText() throws { var state = try hydrated() let bot = try XCTUnwrap(state.bots.first) diff --git a/server/index.test.ts b/server/index.test.ts index a355962567..a3703d4790 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -895,6 +895,19 @@ describe("message pages", () => { expect(top.body.messages).toHaveLength(6); }); + it("returns a bounded transcript window around a search result", async () => { + const full = await seedRoom(9); + const target = full.messages[4]; + const result = await api("GET", `/api/threads/${full.threadId}/messages?around=${target.id}&limit=5`); + expect(result.status).toBe(200); + expect(result.body.messages.map((message: { id: string }) => message.id)).toEqual( + full.messages.slice(2, 7).map((message: { id: string }) => message.id), + ); + expect(result.body.hasMore).toBe(true); + expect((await api("GET", `/api/threads/${full.threadId}/messages?around=nope`)).status).toBe(404); + expect((await api("GET", `/api/threads/${full.threadId}/messages?around=${target.id}&before=${target.id}`)).status).toBe(400); + }); + it("refuses a cursor or size it cannot page from", async () => { const full = await seedRoom(1); // silently answering with the newest page would paginate in a circle diff --git a/server/index.ts b/server/index.ts index 2ceea9b8b8..86ed79b8cd 100644 --- a/server/index.ts +++ b/server/index.ts @@ -286,6 +286,18 @@ function messagePage(threadId: string, limit: number | undefined, before?: strin return { messages: all.slice(start, stop).map(slimMessage), hasMore: start > 0 }; } +/** A bounded page centred on a known message, used when a search result is + * opened on a client that only hydrated the newest part of the transcript. */ +function messageWindow(threadId: string, messageId: string, limit: number) { + const all = store.messagesFor(threadId); + const index = all.findIndex((message) => message.id === messageId); + if (index < 0) return null; + const before = Math.floor((limit - 1) / 2); + const start = Math.max(0, Math.min(index - before, all.length - limit)); + const stop = Math.min(all.length, start + limit); + return { messages: all.slice(start, stop).map(slimMessage), hasMore: start > 0 }; +} + // ── SSE fan-out to clients ───────────────────────────────────────────── /** One connected client, and what it asked to be sent. */ interface SseClient { @@ -2117,6 +2129,13 @@ const server = createServer(async (req, res) => { const limit = pageSize(url.searchParams.get("limit")); if (limit === null) return json(res, 400, { error: "limit must be a non-negative whole number" }); const before = url.searchParams.get("before"); + const around = url.searchParams.get("around"); + if (before && around) return json(res, 400, { error: "before and around cannot be combined" }); + if (around) { + const window = messageWindow(threadId, around, limit ?? DEFAULT_PAGE); + if (!window) return json(res, 404, { error: "no such message" }); + return json(res, 200, window); + } // An unknown cursor must not silently answer with the newest page — // the client would paginate in a circle and never reach the top. if (before && !store.messagesFor(threadId).some((msg) => msg.id === before)) {