diff --git a/companion/src/routes.ts b/companion/src/routes.ts index 46baaa500..255aed643 100644 --- a/companion/src/routes.ts +++ b/companion/src/routes.ts @@ -85,6 +85,10 @@ const ALLOWED: ReadonlyArray<{ method: string; path: RegExp }> = [ { method: "POST", path: /^\/api\/groups$/ }, { method: "POST", path: /^\/api\/groups\/[\w-]+\/messages$/ }, { method: "POST", path: /^\/api\/groups\/[\w-]+\/read$/ }, + { method: "POST", path: /^\/api\/groups\/[\w-]+\/tasks$/ }, + { method: "POST", path: /^\/api\/groups\/[\w-]+\/tasks\/[\w-]+$/ }, + { method: "PATCH", path: /^\/api\/groups\/[\w-]+\/tasks\/[\w-]+$/ }, + { method: "DELETE", path: /^\/api\/groups\/[\w-]+\/tasks\/[\w-]+$/ }, // a transcript, its images, and answering an approval { method: "GET", path: /^\/api\/threads\/[\w-]+\/messages$/ }, diff --git a/companion/test/routes.test.ts b/companion/test/routes.test.ts index 19e5c9b91..e05b3a29c 100644 --- a/companion/test/routes.test.ts +++ b/companion/test/routes.test.ts @@ -57,6 +57,10 @@ describe("what the app may do", () => { ["POST", "/api/bots/bot_123/computer/join"], ["POST", "/api/groups/room-1/messages"], ["POST", "/api/groups/room-1/read"], + ["POST", "/api/groups/room-1/tasks"], + ["POST", "/api/groups/room-1/tasks/th_1"], + ["PATCH", "/api/groups/room-1/tasks/th_1"], + ["DELETE", "/api/groups/room-1/tasks/th_1"], ["GET", "/api/threads/th_1/messages"], ["GET", "/api/threads/th_1/messages/msg_2/image"], ["POST", "/api/threads/th_1/messages/msg_2/reactions"], diff --git a/ios/App/ChatView.swift b/ios/App/ChatView.swift index e1929048e..ed3af6391 100644 --- a/ios/App/ChatView.swift +++ b/ios/App/ChatView.swift @@ -290,7 +290,7 @@ struct ChatView: View { if listening { composerFocused = false } } .sheet(isPresented: $showingTasks) { - if case let .bot(bot) = current { TaskManagerView(bot: bot) } + if current.supportsTasks { TaskManagerView(chat: current) } } .sheet(isPresented: $showingProfile) { if case let .bot(bot) = current { AgentProfileView(bot: bot) } @@ -495,6 +495,17 @@ struct ChatView: View { subtitle: "Live view of what \(bot.name) is doing" ) { showingComputer = true }) } + if case let .room(room) = current, room.dm != true { + out.append(PlusAction( + id: "task", systemImage: "plus.square.on.square", title: "New task", + subtitle: "Start a fresh conversation in \(room.name)", + disabled: current.busy || hasPendingApproval + ) { Task { await session.createTask(for: room, title: nil) } }) + out.append(PlusAction( + id: "tasks", systemImage: "square.stack", title: "Tasks", + subtitle: "Switch, rename or remove one" + ) { showingTasks = true }) + } out.append(PlusAction( id: "share", systemImage: "doc.plaintext", title: "Share transcript", subtitle: "This chat as Markdown" @@ -583,7 +594,9 @@ struct ChatView: View { isVisible: $showCommandHUD, commands: current.isBot ? CommandSkillHUDView.defaultCommands - : CommandSkillHUDView.defaultCommands.filter { $0.id != "computer" && $0.id != "tasks" }, + : CommandSkillHUDView.defaultCommands.filter { + $0.id != "computer" && (current.supportsTasks || $0.id != "tasks") + }, accentColor: MausPalette.color(current.color) ) { command in switch command.id { diff --git a/ios/App/Session.swift b/ios/App/Session.swift index faf09ec5d..5595a1f7f 100644 --- a/ios/App/Session.swift +++ b/ios/App/Session.swift @@ -761,11 +761,15 @@ final class Session: ObservableObject { return state.bot(bot.id).map(Chat.bot) } if let groupId = hit.groupId, - let room = state.rooms.first(where: { $0.id == groupId }) { + var room = state.rooms.first(where: { $0.id == groupId }) { + if room.threadId != hit.threadId { + room = try await client.switchTask(groupId: room.id, threadId: hit.threadId) + state.apply(.room(room)) + } let page = try await client.messages(threadId: hit.threadId, around: hit.messageId) state.merge(page, intoThread: hit.threadId) focusedMessageId = hit.messageId - return .room(room) + return state.rooms.first(where: { $0.id == groupId }).map(Chat.room) } } catch { actionError = error.localizedDescription } return nil @@ -801,6 +805,32 @@ final class Session: ObservableObject { catch { actionError = error.localizedDescription } } + func createTask(for room: Room, title: String?) async { + guard let client else { return } + do { state.apply(.room(try await client.createTask(groupId: room.id, title: title))) } + catch { actionError = error.localizedDescription } + } + + func switchTask(_ task: BotTask, for room: Room) async { + guard let client, task.threadId != room.threadId else { return } + do { state.apply(.room(try await client.switchTask(groupId: room.id, threadId: task.threadId))) } + catch { actionError = error.localizedDescription } + } + + func renameTask(_ task: BotTask, for room: Room, title: String) async { + guard let client else { return } + do { + try await client.renameTask(groupId: room.id, threadId: task.threadId, title: title) + await refresh() + } catch { actionError = error.localizedDescription } + } + + func deleteTask(_ task: BotTask, for room: Room) async { + guard let client else { return } + do { state.apply(.room(try await client.deleteTask(groupId: room.id, threadId: task.threadId))) } + catch { actionError = error.localizedDescription } + } + // MARK: - Agent profile func updateProfile(_ patch: BotProfilePatch, for bot: Bot) async -> Bot? { @@ -964,7 +994,18 @@ final class Session: ObservableObject { // A room's approval/question notification carries the asker bot // with the ROOM's thread id — open the room rather than asking // the bot to switch to a thread it does not own (a 404). - if let room = state.rooms.first(where: { $0.threadId == target.threadId }) { + if var room = state.rooms.first(where: { + $0.threadId == target.threadId || ($0.tasks ?? []).contains(where: { $0.threadId == target.threadId }) + }) { + if room.threadId != target.threadId { + do { + room = try await client.switchTask(groupId: room.id, threadId: target.threadId) + state.apply(.room(room)) + } catch { + // A stale notification should still open the channel's + // current task instead of leaving the person nowhere. + } + } notificationChat = .room(room) return } @@ -1130,6 +1171,15 @@ enum Chat: Identifiable, Hashable { return false } + var supportsTasks: Bool { + switch self { + case .bot: return true + // `tasks == nil` means an older paired desktop. Hide the affordance + // instead of sending it a route it does not know yet. + case let .room(room): return room.dm != true && room.tasks != nil + } + } + var subtitle: String { switch self { case let .bot(bot): return bot.title diff --git a/ios/App/TaskManagerView.swift b/ios/App/TaskManagerView.swift index 94d73edef..036fc43cc 100644 --- a/ios/App/TaskManagerView.swift +++ b/ios/App/TaskManagerView.swift @@ -1,69 +1,81 @@ import SwiftUI import CompanionCore -/// A bot's separate contexts. Tasks remain a compact sheet because they are -/// conversation navigation, not host configuration. +/// Separate contexts for either an agent or a channel. Keeping one sheet for +/// both makes "task" mean the same operation everywhere in the app. struct TaskManagerView: View { - let bot: Bot + let chat: Chat @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 ?? [] } + private var current: Chat { + switch chat { + case let .bot(bot): return session.state.bot(bot.id).map(Chat.bot) ?? chat + case let .room(room): + return session.state.rooms.first(where: { $0.id == room.id }).map(Chat.room) ?? chat + } + } + + private var tasks: [BotTask] { + switch current { + case let .bot(bot): return bot.tasks ?? [] + case let .room(room): return room.tasks ?? [] + } + } var body: some View { NavigationStack { List { Section { HStack(spacing: 12) { - BotAvatarView(bot: current, size: 48, state: .idle, animated: false) + ChatAvatarView(chat: current, size: 48, state: .idle, animated: false) VStack(alignment: .leading, spacing: 2) { Text(current.name).font(.headline) - Text(current.title.isEmpty ? "Agent tasks" : current.title) + Text(current.isBot ? "Agent tasks" : "Channel tasks") .font(.subheadline).foregroundStyle(.secondary) } } } footer: { - Text("A task is one conversation and result. Routines create fresh tasks on a schedule.") + Text("A task is one conversation and result, with its own context and working folder.") } Section("Tasks") { 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) + Button { + Task { + await switchTo(task) + dismiss() } - Spacer() - if task.threadId == current.threadId { - Image(systemName: "checkmark.circle.fill").foregroundStyle(Color.accentColor) + } 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 + .contextMenu { + Button("Rename", systemImage: "pencil") { + title = task.title + taskToRename = task + } + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + Task { await delete(task) } + } label: { Label("Delete", systemImage: "trash") } + .disabled(tasks.count <= 1 || current.busy) } - } - .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) - } } } } @@ -76,7 +88,7 @@ struct TaskManagerView: View { title = "" showingNewTask = true } - .disabled(current.busy == true) + .disabled(current.busy) } } } @@ -85,7 +97,7 @@ struct TaskManagerView: View { Button("Cancel", role: .cancel) {} Button("Create") { Task { - await session.createTask(for: current, title: title.trimmingCharacters(in: .whitespacesAndNewlines)) + await create(title.trimmingCharacters(in: .whitespacesAndNewlines)) dismiss() } } @@ -98,9 +110,37 @@ struct TaskManagerView: View { Button("Cancel", role: .cancel) { taskToRename = nil } Button("Save") { guard let task = taskToRename else { return } - Task { await session.renameTask(task, for: current, title: title) } + Task { await rename(task, title: title) } taskToRename = nil } } } + + private func create(_ title: String) async { + switch current { + case let .bot(bot): await session.createTask(for: bot, title: title) + case let .room(room): await session.createTask(for: room, title: title) + } + } + + private func switchTo(_ task: BotTask) async { + switch current { + case let .bot(bot): await session.switchTask(task, for: bot) + case let .room(room): await session.switchTask(task, for: room) + } + } + + private func rename(_ task: BotTask, title: String) async { + switch current { + case let .bot(bot): await session.renameTask(task, for: bot, title: title) + case let .room(room): await session.renameTask(task, for: room, title: title) + } + } + + private func delete(_ task: BotTask) async { + switch current { + case let .bot(bot): await session.deleteTask(task, for: bot) + case let .room(room): await session.deleteTask(task, for: room) + } + } } diff --git a/ios/Sources/CompanionCore/Client.swift b/ios/Sources/CompanionCore/Client.swift index 326c934af..8958276a4 100644 --- a/ios/Sources/CompanionCore/Client.swift +++ b/ios/Sources/CompanionCore/Client.swift @@ -920,6 +920,24 @@ public struct CompanionClient: Sendable { try await send(try makeRequest("DELETE", "/api/bots/\(botId)/tasks/\(threadId)"), as: BotResponse.self).bot } + public func createTask(groupId: String, title: String? = nil) async throws -> Room { + var body: [String: Any] = [:] + if let title, !title.isEmpty { body["title"] = title } + return try await send(try makeRequest("POST", "/api/groups/\(groupId)/tasks", body: body), as: RoomResponse.self).group + } + + public func switchTask(groupId: String, threadId: String) async throws -> Room { + try await send(try makeRequest("POST", "/api/groups/\(groupId)/tasks/\(threadId)"), as: RoomResponse.self).group + } + + public func renameTask(groupId: String, threadId: String, title: String) async throws { + try await send(try makeRequest("PATCH", "/api/groups/\(groupId)/tasks/\(threadId)", body: ["title": title])) + } + + public func deleteTask(groupId: String, threadId: String) async throws -> Room { + try await send(try makeRequest("DELETE", "/api/groups/\(groupId)/tasks/\(threadId)"), as: RoomResponse.self).group + } + 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 75ea2cd38..e014a7483 100644 --- a/ios/Sources/CompanionCore/Models.swift +++ b/ios/Sources/CompanionCore/Models.swift @@ -234,6 +234,9 @@ public struct Room: Codable, Hashable, Identifiable, Sendable { public var createdAt: Double public var dm: Bool? public var busyBotId: String? + /// Independent user conversations in this channel. Bot-to-bot rooms + /// omit tasks because their transcript is the canonical private chat. + public var tasks: [BotTask]? public var messages: [Message]? public var hasMore: Bool? } @@ -850,6 +853,9 @@ struct ActiveBranchResponse: Codable, Sendable { struct BotResponse: Codable, Sendable { var bot: Bot } +struct RoomResponse: Codable, Sendable { + var group: Room +} struct VoiceListResponse: Codable, Sendable { var voices: [Voice] var error: String? diff --git a/ios/Sources/CompanionCore/Store.swift b/ios/Sources/CompanionCore/Store.swift index a701f460f..46eb5e735 100644 --- a/ios/Sources/CompanionCore/Store.swift +++ b/ios/Sources/CompanionCore/Store.swift @@ -233,7 +233,19 @@ public struct CompanionState: Sendable { case let .room(room): if let index = rooms.firstIndex(where: { $0.id == room.id }) { var merged = room - merged.messages = rooms[index].messages + let previous = rooms[index] + // Ordinary room frames are metadata-only and preserve the + // active transcript. A task switch includes messages and is + // authoritative, just like a bot task switch. + if let replacement = room.messages { + messages[room.threadId] = replacement + hasMore[room.threadId] = room.hasMore ?? false + merged.messages = replacement + clearStream(previous.threadId) + if previous.threadId != room.threadId { clearStream(room.threadId) } + } else { + merged.messages = previous.messages + } rooms[index] = merged } else { rooms.append(room) diff --git a/ios/Tests/CompanionCoreTests/StoreTests.swift b/ios/Tests/CompanionCoreTests/StoreTests.swift index d9ec0c046..db4419ee5 100644 --- a/ios/Tests/CompanionCoreTests/StoreTests.swift +++ b/ios/Tests/CompanionCoreTests/StoreTests.swift @@ -122,6 +122,21 @@ final class StoreTests: XCTestCase { XCTAssertFalse(state.transcript(forThread: "another-task").contains { $0.id == "old-tail" }) } + func testAChannelTaskSwitchReplacesTheActiveTranscript() throws { + var state = try hydrated() + var room = try XCTUnwrap(state.rooms.first) + let previousThread = room.threadId + state.apply(.message(threadId: previousThread, message: message("old-room-tail"))) + + room.threadId = "another-room-task" + room.messages = [message("new-room-root", text: "new channel task")] + state.apply(.room(room)) + + XCTAssertEqual(state.rooms.first(where: { $0.id == room.id })?.threadId, "another-room-task") + XCTAssertEqual(state.transcript(forThread: "another-room-task").map(\.id), ["new-room-root"]) + XCTAssertFalse(state.transcript(forThread: "another-room-task").contains { $0.id == "old-room-tail" }) + } + func testVisibleTranscriptFollowsTheActiveBranch() throws { var state = try hydrated() let bot = try XCTUnwrap(state.bots.first) diff --git a/server/group-tasks.test.ts b/server/group-tasks.test.ts new file mode 100644 index 000000000..a1684d093 --- /dev/null +++ b/server/group-tasks.test.ts @@ -0,0 +1,108 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +let home: string; + +async function freshStore() { + home = mkdtempSync(join(tmpdir(), "omb-group-tasks-")); + vi.resetModules(); + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); + const { Store, UNTITLED_TASK } = await import("./store.ts"); + return { store: new Store(() => ({ instanceId: "claude", model: "m" })), Store, UNTITLED_TASK }; +} + +afterEach(async () => { + const { closeMessageDb } = await import("./message-db.ts"); + closeMessageDb(); + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); +}); + +describe("channel tasks", () => { + it("gives a user-created channel one task while DMs stay single-threaded", async () => { + const { store, UNTITLED_TASK } = await freshStore(); + const bot = store.createBot(); + const channel = store.createGroup("Product", [bot.id]); + const dm = store.createGroup("DM", [bot.id], true); + + expect(store.groupTasks(channel.id)).toEqual([ + expect.objectContaining({ threadId: channel.threadId, title: UNTITLED_TASK }), + ]); + expect(store.groupTasks(dm.id)).toEqual([]); + expect(store.createGroupTask(dm.id)).toBeNull(); + }); + + it("keeps transcripts, pins, and folders isolated while switching", async () => { + const { store } = await freshStore(); + const bot = store.createBot(); + const channel = store.createGroup("Product", [bot.id]); + const first = channel.threadId; + store.appendMessage(first, { role: "user", kind: "text", text: "Plan launch" }); + store.titleGroupTaskFromFirstMessage(channel.id, "Plan launch", first); + store.patchGroup(channel.id, { cwd: "/tmp/product" }); + expect(store.pinGroupCwd(channel.id, first)).toBe("/tmp/product"); + store.patchGroup(channel.id, { pinnedMessageId: "launch-pin" }); + + const second = store.createGroupTask(channel.id)!; + expect(second.threadId).not.toBe(first); + expect(store.group(channel.id)).toMatchObject({ threadId: second.threadId }); + expect(store.group(channel.id)?.pinnedCwd).toBeUndefined(); + expect(store.group(channel.id)?.pinnedMessageId).toBeUndefined(); + expect(store.messagesFor(second.threadId)).toEqual([]); + + store.appendMessage(second.threadId, { role: "user", kind: "text", text: "Audit onboarding" }); + store.titleGroupTaskFromFirstMessage(channel.id, "Audit onboarding", second.threadId); + store.patchGroup(channel.id, { pinnedMessageId: "audit-pin" }); + + expect(store.switchGroupTask(channel.id, first)).toMatchObject({ + threadId: first, + pinnedCwd: "/tmp/product", + pinnedMessageId: "launch-pin", + }); + expect(store.messagesFor(first).some((message) => message.text === "Plan launch")).toBe(true); + expect(store.groupTaskByThread(channel.id, second.threadId)).toMatchObject({ + title: "Audit onboarding", + pinnedMessageId: "audit-pin", + }); + expect(store.groupByThread(second.threadId)?.id).toBe(channel.id); + }); + + it("renames and deletes tasks but never removes the final conversation", async () => { + const { store } = await freshStore(); + const bot = store.createBot(); + const channel = store.createGroup("Product", [bot.id]); + const first = channel.threadId; + const second = store.createGroupTask(channel.id)!; + store.appendMessage(second.threadId, { role: "user", kind: "text", text: "private branch" }); + + expect(store.renameGroupTask(channel.id, second.threadId, " Research ")?.title).toBe("Research"); + expect(store.deleteGroupTask(channel.id, second.threadId)).toMatchObject({ threadId: first }); + expect(store.messagesFor(second.threadId)).toEqual([]); + expect(store.deleteGroupTask(channel.id, first)).toBeNull(); + }); + + it("adopts a legacy channel thread without losing its folder or pin", async () => { + const { store, Store } = await freshStore(); + const bot = store.createBot(); + const channel = store.createGroup("Legacy", [bot.id]); + store.appendMessage(channel.threadId, { role: "user", kind: "text", text: "Prepare the report" }); + const legacy = store.group(channel.id)!; + delete legacy.tasks; + legacy.pinnedCwd = "/tmp/legacy"; + legacy.pinnedMessageId = "legacy-pin"; + store.patchGroup(channel.id, { name: "Legacy saved" }); + + const reloaded = new Store(() => ({ instanceId: "claude", model: "m" })); + expect(reloaded.groupTasks(channel.id)).toEqual([ + expect.objectContaining({ + threadId: channel.threadId, + title: "Prepare the report", + pinnedCwd: "/tmp/legacy", + pinnedMessageId: "legacy-pin", + }), + ]); + }); +}); diff --git a/server/index.test.ts b/server/index.test.ts index 969651863..29a23f786 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -451,6 +451,53 @@ describe("harness HTTP API", () => { } }); + it("creates, switches, renames and deletes independent channel tasks", async () => { + const bot = (await api("POST", "/api/bots")).body.bot; + const room = (await api("POST", "/api/groups", { name: "Parallel work", memberIds: [bot.id] })).body.group; + try { + expect(room.tasks).toHaveLength(1); + expect(room.tasks[0].threadId).toBe(room.threadId); + const originalThread = room.threadId; + + const created = await api("POST", `/api/groups/${room.id}/tasks`, { title: "Launch plan" }); + expect(created.status).toBe(201); + expect(created.body.group.threadId).toBe(created.body.task.threadId); + expect(created.body.group.messages).toEqual([]); + expect(created.body.group.tasks).toHaveLength(2); + + const newThread = created.body.task.threadId; + const renamed = await api("PATCH", `/api/groups/${room.id}/tasks/${newThread}`, { + title: "Release plan", + }); + expect(renamed.status).toBe(200); + expect(renamed.body.task.title).toBe("Release plan"); + + const switched = await api("POST", `/api/groups/${room.id}/tasks/${originalThread}`); + expect(switched.status).toBe(200); + expect(switched.body.group.threadId).toBe(originalThread); + expect(switched.body.group.tasks.find((task: { threadId: string }) => task.threadId === newThread).title).toBe("Release plan"); + + const removed = await api("DELETE", `/api/groups/${room.id}/tasks/${newThread}`); + expect(removed.status).toBe(200); + expect(removed.body.group.tasks).toHaveLength(1); + expect((await api("DELETE", `/api/groups/${room.id}/tasks/${originalThread}`)).status).toBe(400); + expect((await api("POST", `/api/groups/${room.id}/tasks/missing-thread`)).status).toBe(404); + } finally { + await api("DELETE", `/api/groups/${room.id}`); + await api("DELETE", `/api/bots/${bot.id}`); + } + }); + + it("keeps bot-to-bot channels single-threaded and blocks task changes on an open approval", async () => { + const dm = await api("POST", "/api/groups/test-dm/tasks", {}); + expect(dm.status).toBe(400); + expect(dm.body.error).toMatch(/one canonical conversation/i); + + const blocked = await api("POST", "/api/groups/test-stranded-room/tasks", {}); + expect(blocked.status).toBe(409); + expect(blocked.body.error).toMatch(/waiting on you/i); + }); + it("keeps direct-message channels folderless at the API boundary", async () => { const attempted = await api("PATCH", "/api/groups/test-dm", { cwd: home }); expect(attempted.status).toBe(400); diff --git a/server/index.ts b/server/index.ts index cfe2fc0dd..f0244c2b9 100644 --- a/server/index.ts +++ b/server/index.ts @@ -91,6 +91,7 @@ import { Store, type GroupDefaultResponder, type GroupRecord, + type GroupTaskRecord, type Message, type TaskRecord, } from "./store.ts"; @@ -324,6 +325,7 @@ store.seedIfEmpty(); * than the desktop window did. Stripped here rather than at each call site * so a new broadcast cannot forget. */ const wireTask = ({ resumeCursors, lastInstanceId, ...task }: TaskRecord) => task; +const wireGroupTask = (task: GroupTaskRecord) => task; const wireBot = (bot: NonNullable>) => { const { resumeCursors, tasks, ...rest } = bot; @@ -344,6 +346,13 @@ const publicBot = (bot: NonNullable>) => ({ tasks: store.tasks(bot.id).map(wireTask), }); +const groupWithThread = (group: GroupRecord) => ({ + ...group, + messages: store.messagesFor(group.threadId), + activeLeafId: store.activeLeaf(group.threadId), + ...(group.dm ? {} : { tasks: store.groupTasks(group.id).map(wireGroupTask) }), +}); + // The store tells us what it wrote; this is the ONE place that turns those // into SSE frames. No mutation path can persist without emitting — the // property holds by construction, not by every call site remembering to @@ -1951,6 +1960,7 @@ _loadPending(); async function runGroupMemberTurn( groupId: string, + threadId: string, botId: string, hop: number, // bots that already spoke for this user message — "@Scout ask @Pixel" @@ -1961,13 +1971,16 @@ async function runGroupMemberTurn( ): Promise { const group = store.group(groupId); const bot = store.bot(botId); - if (!group || !bot) return false; + const ownsThread = group?.dm + ? group.threadId === threadId + : Boolean(group && store.groupTaskByThread(group.id, threadId)); + if (!group || !bot || !ownsThread) return false; spoken.add(botId); const instance = registry.get(bot.modelSelection.instanceId); const userName = cfg.profile?.name?.trim() || "User"; if (!instance) { const message = `${bot.name}'s model is unavailable`; - store.appendMessage(group.threadId, { + store.appendMessage(threadId, { role: "bot", kind: "activity", from: { botId: bot.id, name: bot.name, color: bot.color }, @@ -1982,7 +1995,7 @@ async function runGroupMemberTurn( // reached one of them. if (bot.busy) { const message = `${bot.name} is busy in another conversation — skipped this round`; - store.appendMessage(group.threadId, { + store.appendMessage(threadId, { role: "bot", kind: "activity", from: { botId: bot.id, name: bot.name, color: bot.color }, @@ -1993,10 +2006,10 @@ async function runGroupMemberTurn( } const integrations: NonNullable[0]["integrations"]> = {}; if (hop < MAX_COMMS_DEPTH && instance.adapter.capabilities.agentsMcp === true) { - integrations.agents = agentsIntegration(bot.id, group.threadId, hop); + integrations.agents = agentsIntegration(bot.id, threadId, hop); } const selectedSkills = selectBundledSkills( - serializeRoomContext(group.threadId, userName), + serializeRoomContext(threadId, userName), instance.adapter.capabilities.phoneMcp === true ? ["phoneMcp"] : [], availableSkills(), ); @@ -2005,12 +2018,12 @@ async function runGroupMemberTurn( } try { if (bot.composio !== false && composio.configured(cfg) && instance.adapter.capabilities.composioMcp === true) { - const connection = await connectedAppsIntegration(bot.id, group.threadId); + const connection = await connectedAppsIntegration(bot.id, threadId); if (connection) integrations.composio = connection; } } catch (error) { const message = `connected apps are unavailable — ${error instanceof Error ? error.message : String(error)}`; - store.appendMessage(group.threadId, { + store.appendMessage(threadId, { role: "bot", kind: "activity", from: { botId: bot.id, name: bot.name, color: bot.color }, @@ -2022,7 +2035,7 @@ async function runGroupMemberTurn( store.setActivity(bot.id, "working"); store.patchGroup(group.id, { busyBotId: bot.id }); // the store's change stream carries the frame - groupSpeakers.set(group.threadId, { botId: bot.id, name: bot.name, color: bot.color }); + groupSpeakers.set(threadId, { botId: bot.id, name: bot.name, color: bot.color }); const roster = group.memberIds .map((id) => store.bot(id)) @@ -2042,7 +2055,7 @@ async function runGroupMemberTurn( .filter(Boolean) .join("\n"); - const text = `${serializeRoomContext(group.threadId, userName)}\n\n(Reply to the conversation above as ${bot.name}.)${ + const text = `${serializeRoomContext(threadId, userName)}\n\n(Reply to the conversation above as ${bot.name}.)${ cardContinuation ? `\n\n${cardContinuation}` : "" }`; @@ -2056,7 +2069,7 @@ async function runGroupMemberTurn( // has its folder moved underneath it. Off-host members skip the folder // but must not decide the pin: the room's desk is a property of the // room, not of whichever member happened to speak first. - const cwd = groupTurnCwd(workspace, () => store.pinGroupCwd(group.id)); + const cwd = groupTurnCwd(workspace, () => store.pinGroupCwd(group.id, threadId)); const roomSystem = system + sectionContextSystemPrompt(bot.section) + @@ -2073,8 +2086,8 @@ async function runGroupMemberTurn( let unsub = () => {}; let unregisterStall = () => {}; const deadline = new RoomTurnDeadline(timeoutMinutes, () => { - void instance.adapter.interruptTurn(group.threadId).catch(() => {}); - store.appendMessage(group.threadId, { + void instance.adapter.interruptTurn(threadId).catch(() => {}); + store.appendMessage(threadId, { role: "bot", kind: "activity", from: { botId: bot.id, name: bot.name, color: bot.color }, @@ -2091,7 +2104,7 @@ async function runGroupMemberTurn( resolve(value); }; unsub = bus.subscribe((e: RuntimeEvent) => { - if (e.threadId !== group.threadId) return; + if (e.threadId !== threadId) return; if (e.type === "item.completed" && e.itemType === "assistant_text") replyText += `\n${e.text}`; else if (e.type === "turn.completed") finish("settled"); // Waiting on a person is not turn work: hold the ceiling while an @@ -2101,11 +2114,11 @@ async function runGroupMemberTurn( else if (e.type === "request.resolved") deadline.setWaitingOnHuman(false); }); deadline.start(); - unregisterStall = roomStallCompletions.register(group.threadId, () => finish("stalled")); - watchdog.watch(group.threadId, bot.id); + unregisterStall = roomStallCompletions.register(threadId, () => finish("stalled")); + watchdog.watch(threadId, bot.id); instance.adapter .sendTurn({ - threadId: group.threadId, + threadId, text, system: roomSystem, cwd, @@ -2114,14 +2127,14 @@ async function runGroupMemberTurn( }) .catch((err) => { const message = err instanceof Error ? err.message : "turn failed"; - store.appendMessage(group.threadId, { + store.appendMessage(threadId, { role: "bot", kind: "activity", from: { botId: bot.id, name: bot.name, color: bot.color }, tool: { name: `error: ${message.slice(0, 140)}`, ok: false }, }); onDispatchError?.(message); - watchdog.settle(group.threadId); + watchdog.settle(threadId); finish("dispatch_failed"); }); }); @@ -2133,7 +2146,7 @@ async function runGroupMemberTurn( // when this invocation still owns the room; otherwise it would emit a // duplicate group frame or clear a newer speaker's state. if (store.group(group.id)?.busyBotId === bot.id) { - groupSpeakers.delete(group.threadId); + groupSpeakers.delete(threadId); store.patchGroup(group.id, { busyBotId: null, unread: true }); if (store.bot(bot.id)?.busy) store.setActivity(bot.id, "idle"); } @@ -2152,7 +2165,7 @@ async function runGroupMemberTurn( .filter((b): b is NonNullable => Boolean(b) && b!.id !== bot.id); for (const next of roomResponders(replyText, members, { kind: "mentions" })) { if (spoken.has(next.id)) continue; - if (!(await runGroupMemberTurn(groupId, next.id, hop + 1, spoken))) return false; + if (!(await runGroupMemberTurn(groupId, threadId, next.id, hop + 1, spoken))) return false; } } return true; @@ -2164,7 +2177,11 @@ function startGroupTurn(groupId: string, text: string, replyTo?: Message) { if (roomSetupPending(group)) { throw Object.assign(new Error("finish room setup before sending the first message"), { status: 409 }); } - store.appendMessage(group.threadId, { role: "user", kind: "text", text, replyToId: replyTo?.id }); + // Capture the active thread once. Every queued responder below is bound to + // this task even if another client asks to switch later. + const threadId = group.threadId; + store.appendMessage(threadId, { role: "user", kind: "text", text, replyToId: replyTo?.id }); + if (!group.dm) store.titleGroupTaskFromFirstMessage(group.id, text, threadId); const members = group.memberIds .map((id) => store.bot(id)) @@ -2173,7 +2190,7 @@ function startGroupTurn(groupId: string, text: string, replyTo?: Message) { const archived = members.filter((member) => member.hidden); const mentionedArchived = mentionedBots(text, archived.map(({ name }) => ({ name })))[0]; if (mentionedArchived) { - store.appendMessage(group.threadId, { + store.appendMessage(threadId, { role: "bot", kind: "activity", tool: { @@ -2185,7 +2202,7 @@ function startGroupTurn(groupId: string, text: string, replyTo?: Message) { let responders = roomResponders(text, members, group.defaultResponder); // bot⇄bot channels: chipping in without a tag addresses the last speaker if (!responders.length && group.dm) { - const lastSpeakerId = [...store.messagesFor(group.threadId)] + const lastSpeakerId = [...store.messagesFor(threadId)] .reverse() .find((msg) => msg.kind === "text" && msg.from)?.from?.botId; const last = availableMembers.find((b) => b.id === lastSpeakerId) ?? availableMembers[0]; @@ -2201,7 +2218,7 @@ function startGroupTurn(groupId: string, text: string, replyTo?: Message) { unavailableMessage = `${defaultArchived.name} is archived and can't respond — restore it or mention an active room member.`; } if (unavailableMessage) { - store.appendMessage(group.threadId, { + store.appendMessage(threadId, { role: "bot", kind: "activity", tool: { name: unavailableMessage, ok: false }, @@ -2215,7 +2232,7 @@ function startGroupTurn(groupId: string, text: string, replyTo?: Message) { const current = store.group(groupId); if (current?.busyBotId) { const owner = store.bot(current.busyBotId); - store.appendMessage(current.threadId, { + store.appendMessage(threadId, { role: "bot", kind: "activity", tool: { name: `${owner?.name ?? "A room member"} is still stopping — this message was not dispatched`, ok: false }, @@ -2225,7 +2242,7 @@ function startGroupTurn(groupId: string, text: string, replyTo?: Message) { const spoken = new Set(); for (const responder of responders) { if (spoken.has(responder.id)) continue; - if (!(await runGroupMemberTurn(groupId, responder.id, 0, spoken))) break; + if (!(await runGroupMemberTurn(groupId, threadId, responder.id, 0, spoken))) break; } }); groupQueues.set(groupId, next.catch(() => {})); @@ -2308,7 +2325,7 @@ function dispatchConnectorResume(entry: { botId: string; threadId: string; resum pendingConnectorResumes.set(`${entry.threadId}:${entry.resumeKey}`, entry); return; } - await runGroupMemberTurn(current.group.id, entry.botId, 0, new Set(), prompt); + await runGroupMemberTurn(current.group.id, entry.threadId, entry.botId, 0, new Set(), prompt); }); groupQueues.set(owner.group.id, next.catch((error) => { markConnectorResumeFailed(entry.threadId, entry.resumeKey, error instanceof Error ? error.message : String(error)); @@ -2391,6 +2408,7 @@ function dispatchSecretResume(entry: SecretResumeEntry) { } await runGroupMemberTurn( current.group.id, + entry.threadId, entry.botId, 0, new Set(), @@ -3372,7 +3390,10 @@ const server = createServer(async (req, res) => { const task = store.taskByThread(bot.id, hit.threadId); return { ...hit, botId: bot.id, name: bot.name, task: task?.title, onActivePath: active }; } - if (group) return { ...hit, groupId: group.id, name: group.name, onActivePath: active }; + if (group) { + const task = store.groupTaskByThread(group.id, hit.threadId); + return { ...hit, groupId: group.id, name: group.name, task: task?.title, onActivePath: active }; + } return null; }) .filter((hit): hit is NonNullable => hit !== null); @@ -3390,7 +3411,9 @@ const server = createServer(async (req, res) => { if (format !== "markdown" && format !== "json") { return json(res, 400, { error: "format must be markdown or json" }); } - const title = bot ? (store.taskByThread(bot.id, threadId)?.title || bot.name) : group!.name; + const title = bot + ? (store.taskByThread(bot.id, threadId)?.title || bot.name) + : (store.groupTaskByThread(group!.id, threadId)?.title || group!.name); const filename = (title.replace(/[^\w\- ]+/g, "").trim() || "conversation").slice(0, 60); const messages = store.activePath(threadId); if (format === "json") { @@ -3787,6 +3810,78 @@ const server = createServer(async (req, res) => { if (!updated) return json(res, 404, { error: "no such room" }); return json(res, 200, { group: updated }); } + + // ── channel tasks: separate conversations for the same team ──────── + const channelTaskBlocked = (group: GroupRecord) => + Boolean(group.busyBotId) || + store.groupTasks(group.id).some((task) => + store.messagesFor(task.threadId).some( + (message) => + message.kind === "options" && + message.card?.requestId && + !message.card.answered && + !message.card.dismissed, + ), + ); + + m = path.match(/^\/api\/groups\/([\w-]+)\/tasks$/); + if (m && method === "POST") { + const group = store.group(m[1]); + if (!group) return json(res, 404, { error: "no such channel" }); + if (group.dm) return json(res, 400, { error: "bot-to-bot channels keep one canonical conversation" }); + if (channelTaskBlocked(group)) { + return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" }); + } + const body = await readBody(req); + const task = store.createGroupTask(group.id, typeof body.title === "string" ? body.title : undefined); + if (!task) return json(res, 500, { error: "couldn't create that task" }); + const fresh = groupWithThread(store.group(group.id)!); + broadcast({ kind: "group", group: fresh }); + return json(res, 201, { group: fresh, task: wireGroupTask(task) }); + } + + m = path.match(/^\/api\/groups\/([\w-]+)\/tasks\/([\w-]+)$/); + if (m && method === "POST") { + const group = store.group(m[1]); + if (!group) return json(res, 404, { error: "no such channel" }); + if (group.dm) return json(res, 400, { error: "bot-to-bot channels keep one canonical conversation" }); + if (channelTaskBlocked(group)) { + return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" }); + } + const switched = store.switchGroupTask(group.id, m[2]); + if (!switched) return json(res, 404, { error: "no such channel task" }); + const fresh = groupWithThread(switched); + broadcast({ kind: "group", group: fresh }); + return json(res, 200, { group: fresh }); + } + if (m && method === "PATCH") { + const group = store.group(m[1]); + if (!group) return json(res, 404, { error: "no such channel" }); + if (group.dm) return json(res, 400, { error: "bot-to-bot channels keep one canonical conversation" }); + if (channelTaskBlocked(group)) { + return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" }); + } + const body = await readBody(req); + const task = store.renameGroupTask(m[1], m[2], String(body.title ?? "")); + if (!task) return json(res, 404, { error: "no such channel task" }); + return json(res, 200, { task: wireGroupTask(task) }); + } + if (m && method === "DELETE") { + const group = store.group(m[1]); + if (!group) return json(res, 404, { error: "no such channel" }); + if (group.dm) return json(res, 400, { error: "bot-to-bot channels keep one canonical conversation" }); + if (channelTaskBlocked(group)) { + return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" }); + } + if (!store.groupTaskByThread(group.id, m[2])) return json(res, 404, { error: "no such channel task" }); + lastReply.delete(m[2]); + const updated = store.deleteGroupTask(group.id, m[2]); + if (!updated) return json(res, 400, { error: "a channel keeps at least one task" }); + const fresh = groupWithThread(updated); + broadcast({ kind: "group", group: fresh }); + return json(res, 200, { group: fresh }); + } + m = path.match(/^\/api\/groups\/([\w-]+)$/); if (m && method === "PATCH") { const body = await readBody(req); @@ -3870,12 +3965,15 @@ const server = createServer(async (req, res) => { if (m && method === "DELETE") { const group = store.group(m[1]); if (!group) return json(res, 404, { error: "no such room" }); - lastReply.delete(group.threadId); + const threadIds = new Set([group.threadId, ...(group.tasks ?? []).map((task) => task.threadId)]); + for (const threadId of threadIds) lastReply.delete(threadId); store.deleteGroup(group.id); - for (const dir of [EVENTS_DIR, NATIVE_DIR]) { - try { - unlinkSync(join(dir, `${group.threadId}.ndjson`)); - } catch {} + for (const threadId of threadIds) { + for (const dir of [EVENTS_DIR, NATIVE_DIR]) { + try { + unlinkSync(join(dir, `${threadId}.ndjson`)); + } catch {} + } } return json(res, 200, { ok: true }); } diff --git a/server/store.ts b/server/store.ts index d028f050d..9e60908f6 100644 --- a/server/store.ts +++ b/server/store.ts @@ -131,13 +131,28 @@ export type GroupDefaultResponder = | { kind: "everyone" } | { kind: "mentions" }; +/** One independent conversation inside a user-created channel. Channel + * membership and instructions stay on GroupRecord; transcript-bound state + * lives here so switching tasks never moves a pin or working directory into + * another provider context. */ +export interface GroupTaskRecord { + threadId: ThreadId; + title: string; + createdAt: number; + pinnedCwd?: string | null; + pinnedMessageId?: string; +} + /** A room: a shared thread where several bots + the user talk. Plain * messages follow `defaultResponder`; explicit @mentions always override it. * The bulletin is the room's shared instructions — every member's turn gets * it as part of its system prompt. */ export interface GroupRecord { id: string; + /** The active task's thread. Direct-message channels remain single-threaded. */ threadId: ThreadId; + /** User-created channels have independent tasks, newest first. */ + tasks?: GroupTaskRecord[]; name: string; memberIds: string[]; defaultResponder: GroupDefaultResponder; @@ -153,12 +168,9 @@ export interface GroupRecord { * overriding each member's own folder. The room pins its own copy on its * first turn (pinnedCwd). Absent = each member's own default. */ cwd?: string; - /** the folder this room's turns actually run in, pinned on the first - * turn that dispatches. null = each member's own default; absent = not - * pinned yet. See pinGroupCwd for why it never moves. */ + /** Compatibility mirror of the active task's pinned folder. */ pinnedCwd?: string | null; - /** the one message pinned to the top of this room's transcript. A pin id - * that no longer resolves (edited away, deleted) simply renders nothing. */ + /** Compatibility mirror of the active task's pinned message. */ pinnedMessageId?: string; /** sidebar section heading this room is filed under; shares the bots' * namespace so one heading can hold a project's room and its people */ @@ -571,6 +583,36 @@ export class Store { const normalized = normalizeGroupDefaultResponder(g.defaultResponder, g.memberIds, Boolean(g.dm)); if (JSON.stringify(normalized) !== JSON.stringify(g.defaultResponder)) groupsMigrated = true; g.defaultResponder = normalized; + // Bot-to-bot channels intentionally remain one canonical thread. + if (g.dm) { + if (g.tasks !== undefined) { + delete g.tasks; + groupsMigrated = true; + } + continue; + } + if (!g.tasks?.length) { + g.tasks = [ + { + threadId: g.threadId, + title: this.firstUserLine(g.threadId) ?? UNTITLED_TASK, + createdAt: g.createdAt, + ...(g.pinnedCwd !== undefined ? { pinnedCwd: g.pinnedCwd } : {}), + ...(g.pinnedMessageId ? { pinnedMessageId: g.pinnedMessageId } : {}), + }, + ]; + groupsMigrated = true; + } + // Repair a malformed/stale active pointer conservatively. Every task + // transcript is retained; the newest known task becomes active. + let active = g.tasks.find((task) => task.threadId === g.threadId); + if (!active) { + active = g.tasks[0]!; + g.threadId = active.threadId; + groupsMigrated = true; + } + g.pinnedCwd = active.pinnedCwd; + g.pinnedMessageId = active.pinnedMessageId; } if (botsMigrated) this.saveBots(); if (groupsMigrated) this.saveGroups(); @@ -592,7 +634,7 @@ export class Store { // pending JSON files are touched; already-migrated threads stay lazy. const knownThreads = new Set([ ...this.bots.flatMap((b) => [b.threadId, ...(b.tasks ?? []).map((task) => task.threadId)]), - ...this.groups.map((group) => group.threadId), + ...this.groups.flatMap((group) => [group.threadId, ...(group.tasks ?? []).map((task) => task.threadId)]), ]); for (const threadId of knownThreads) { const legacyFile = messagesFile(threadId); @@ -631,13 +673,19 @@ export class Store { } groupByThread(threadId: string): GroupRecord | undefined { - return this.groups.find((g) => g.threadId === threadId); + return this.groups.find( + (group) => group.threadId === threadId || group.tasks?.some((task) => task.threadId === threadId), + ); } createGroup(name: string, memberIds: string[], dm = false, section?: string): GroupRecord { + const threadId = newId(); const group: GroupRecord = { id: newId(), - threadId: newId(), + threadId, + ...(dm + ? {} + : { tasks: [{ threadId, title: UNTITLED_TASK, createdAt: Date.now() }] }), name, memberIds, defaultResponder: dm ? { kind: "mentions" } : { kind: "member", botId: memberIds[0] }, @@ -662,10 +710,14 @@ export class Store { ); } - patchGroup(id: string, patch: Partial>): GroupRecord | null { + patchGroup(id: string, patch: Partial>): GroupRecord | null { const group = this.group(id); if (!group) return null; Object.assign(group, patch); + if (!group.dm && Object.prototype.hasOwnProperty.call(patch, "pinnedMessageId")) { + const active = this.activeGroupTask(group.id); + if (active) active.pinnedMessageId = patch.pinnedMessageId; + } group.defaultResponder = normalizeGroupDefaultResponder( group.defaultResponder, group.memberIds, @@ -692,11 +744,93 @@ export class Store { if (!group) return false; this.groups = this.groups.filter((g) => g.id !== id); this.saveGroups(); - this.deleteThreadRecord(group.threadId); + for (const threadId of new Set([group.threadId, ...(group.tasks ?? []).map((task) => task.threadId)])) { + this.deleteThreadRecord(threadId); + } this.emit({ type: "group.deleted", groupId: id }); return true; } + // ── channel tasks ──────────────────────────────────────────────────── + groupTasks(groupId: string): GroupTaskRecord[] { + const group = this.group(groupId); + return group?.dm ? [] : (group?.tasks ?? []); + } + + activeGroupTask(groupId: string): GroupTaskRecord | undefined { + const group = this.group(groupId); + return group?.tasks?.find((task) => task.threadId === group.threadId); + } + + groupTaskByThread(groupId: string, threadId: string): GroupTaskRecord | undefined { + const group = this.group(groupId); + if (!group || group.dm) return undefined; + return group.tasks?.find((task) => task.threadId === threadId); + } + + createGroupTask(groupId: string, title?: string): GroupTaskRecord | null { + const group = this.group(groupId); + if (!group || group.dm) return null; + const task: GroupTaskRecord = { + threadId: newId(), + title: title?.trim() || UNTITLED_TASK, + createdAt: Date.now(), + }; + group.tasks = [task, ...(group.tasks ?? [])]; + group.threadId = task.threadId; + group.pinnedCwd = undefined; + group.pinnedMessageId = undefined; + this.saveGroups(); + this.emit({ type: "group", groupId }); + return task; + } + + switchGroupTask(groupId: string, threadId: string): GroupRecord | null { + const group = this.group(groupId); + const task = group?.tasks?.find((candidate) => candidate.threadId === threadId); + if (!group || group.dm || !task) return null; + group.threadId = task.threadId; + group.pinnedCwd = task.pinnedCwd; + group.pinnedMessageId = task.pinnedMessageId; + this.saveGroups(); + this.emit({ type: "group", groupId }); + return group; + } + + renameGroupTask(groupId: string, threadId: string, title: string): GroupTaskRecord | null { + const task = this.groupTaskByThread(groupId, threadId); + if (!task) return null; + task.title = title.trim().slice(0, 80) || UNTITLED_TASK; + this.saveGroups(); + this.emit({ type: "group", groupId }); + return task; + } + + titleGroupTaskFromFirstMessage(groupId: string, text: string, threadId?: string) { + const task = threadId ? this.groupTaskByThread(groupId, threadId) : this.activeGroupTask(groupId); + if (!task || task.title !== UNTITLED_TASK) return; + task.title = titleFromMessage(text); + this.saveGroups(); + this.emit({ type: "group", groupId }); + } + + deleteGroupTask(groupId: string, threadId: string): GroupRecord | null { + const group = this.group(groupId); + if (!group || group.dm || !group.tasks || group.tasks.length < 2) return null; + if (!group.tasks.some((task) => task.threadId === threadId)) return null; + group.tasks = group.tasks.filter((task) => task.threadId !== threadId); + this.deleteThreadRecord(threadId); + if (group.threadId === threadId) { + const next = group.tasks[0]!; + group.threadId = next.threadId; + group.pinnedCwd = next.pinnedCwd; + group.pinnedMessageId = next.pinnedMessageId; + } + this.saveGroups(); + this.emit({ type: "group", groupId }); + return group; + } + /** Toggle an emoji reaction on a message ("user" or a member botId). */ toggleReaction(threadId: string, messageId: string, emoji: string, by: string): Message | null { const existing = this.messagesFor(threadId).find((m) => m.id === messageId); @@ -1056,15 +1190,27 @@ export class Store { * future rooms, never under a room that already started working * somewhere. Returns the pinned value: a path, or null = each member's * own default. */ - pinGroupCwd(groupId: string): string | null { + pinGroupCwd(groupId: string, threadId?: string): string | null { const group = this.group(groupId); if (!group) return null; - if (group.pinnedCwd === undefined) { - group.pinnedCwd = group.cwd ?? null; + const task = threadId ? this.groupTaskByThread(groupId, threadId) : this.activeGroupTask(groupId); + // Direct-message channels retain the original single-thread contract. + if (!task) { + if (!group.dm) return null; + if (group.pinnedCwd === undefined) { + group.pinnedCwd = group.cwd ?? null; + this.saveGroups(); + this.emit({ type: "group", groupId: group.id }); + } + return group.pinnedCwd; + } + if (task.pinnedCwd === undefined) { + task.pinnedCwd = group.cwd ?? null; + if (group.threadId === task.threadId) group.pinnedCwd = task.pinnedCwd; this.saveGroups(); this.emit({ type: "group", groupId: group.id }); } - return group.pinnedCwd; + return task.pinnedCwd; } // ── tasks ───────────────────────────────────────────────────────────── diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index 32e704293..483ab1f8a 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -178,7 +178,7 @@ export function Composer({ // Per-thread draft: switching bots unmounts this component, so both the // text and its attachment chips have to outlive it (see lib/drafts). const [text, setText, attachments, setAttachments] = useComposerDraft( - group ? `group:${group.id}` : `bot:${bot?.id ?? ""}`, + group ? `group:${group.id}:${group.threadId}` : `bot:${bot?.id ?? ""}`, ); const addAttachments = useCallback( (next: Attachment[]) => setAttachments((prev) => [...prev, ...next]), diff --git a/src/components/GroupView.tsx b/src/components/GroupView.tsx index 79c378f9c..26bfc0e82 100644 --- a/src/components/GroupView.tsx +++ b/src/components/GroupView.tsx @@ -22,6 +22,7 @@ import { effectiveDefaultResponder, groupResponseHint } from "@/lib/group-routin import { ChatMarkdown } from "./ChatMarkdown"; import { Composer } from "./Composer"; import { ChatFindBar } from "./ChatFindBar"; +import { GroupTaskPicker } from "./TaskPicker"; import { ReplyQuote } from "./ReplyQuote"; import { ConnectorCard } from "./ConnectorCard"; import { SecretRequestCard } from "./SecretRequestCard"; @@ -378,7 +379,7 @@ function RoomWorkingFolder({ group }: { group: Group }) { {shownCwd ? shortPath(shownCwd, home) : Each bot's own folder}
- Fixed after this channel's first turn. Create a new channel and choose its folder before sending the first message to work somewhere else. + Fixed for this task after its first turn. Start a new task to work somewhere else.
) : canPick ? ( @@ -1008,7 +1009,10 @@ export function GroupView({ group }: { group: Group }) { )} style={drag} > - {group.name} +
+ {group.name} + {!setupPending && !group.dm && } +
); } + +export function TaskPicker({ bot }: { bot: Bot }) { + const { dispatch } = useStore(); + return ( + dispatch({ type: "newTask", botId: bot.id })} + onSwitch={(threadId) => dispatch({ type: "switchTask", botId: bot.id, threadId })} + onRename={(threadId, title) => dispatch({ type: "renameTask", botId: bot.id, threadId, title })} + onDelete={(threadId) => dispatch({ type: "deleteTask", botId: bot.id, threadId })} + /> + ); +} + +/** The same task affordance in a channel. DMs never render it because their + * transcript is the private bot-to-bot exchange rather than user work. */ +export function GroupTaskPicker({ group }: { group: Group }) { + const { dispatch } = useStore(); + return ( + dispatch({ type: "newGroupTask", groupId: group.id })} + onSwitch={(threadId) => dispatch({ type: "switchGroupTask", groupId: group.id, threadId })} + onRename={(threadId, title) => dispatch({ type: "renameGroupTask", groupId: group.id, threadId, title })} + onDelete={(threadId) => dispatch({ type: "deleteGroupTask", groupId: group.id, threadId })} + /> + ); +} diff --git a/src/lib/focus-message.ts b/src/lib/focus-message.ts index 692a36da5..1cb10971d 100644 --- a/src/lib/focus-message.ts +++ b/src/lib/focus-message.ts @@ -24,6 +24,10 @@ export async function landOnSearchHit( const result = await api(`/api/bots/${bot.id}/tasks/${hit.threadId}`, { method: "POST" }); if (result?.bot) dispatch({ type: "taskSwitched", bot: result.bot }); } + if (group && group.threadId !== hit.threadId) { + const result = await api(`/api/groups/${group.id}/tasks/${hit.threadId}`, { method: "POST" }); + if (result?.group) dispatch({ type: "groupPatched", group: result.group }); + } if (bot && !hit.onActivePath) { const branch = await api(`/api/bots/${bot.id}/active-branch`, { method: "POST", diff --git a/src/state/store.test.ts b/src/state/store.test.ts index a59c4329e..59bb29ebc 100644 --- a/src/state/store.test.ts +++ b/src/state/store.test.ts @@ -6,12 +6,20 @@ import { openNotificationTarget, reducer, type Bot, + type Group, type Message, } from "./store"; describe("notification routing", () => { const bots = [{ id: "bot-1", threadId: "main-thread", tasks: [{ threadId: "detached-thread" }] }] as never; - const groups = [{ id: "room-1", threadId: "room-thread" }] as never; + const groups = [{ + id: "room-1", + threadId: "room-thread", + tasks: [ + { threadId: "room-thread", title: "Current", createdAt: 1 }, + { threadId: "older-room-thread", title: "Older", createdAt: 0 }, + ], + }] as never; it("selects the bot and switches to the notification's exact task", () => { const dispatch = vi.fn(); @@ -34,6 +42,17 @@ describe("notification routing", () => { expect(dispatch.mock.calls.map(([action]) => action)).toEqual([{ type: "select", id: "room-1" }]); }); + it("opens the room and restores the exact inactive channel task", () => { + const dispatch = vi.fn(); + + openNotificationTarget(dispatch, { botId: "bot-1", threadId: "older-room-thread" }, { bots, groups }); + + expect(dispatch.mock.calls.map(([action]) => action)).toEqual([ + { type: "select", id: "room-1" }, + { type: "switchGroupTask", groupId: "room-1", threadId: "older-room-thread" }, + ]); + }); + it("lands on a plain bot select for a thread it cannot place, not an error", () => { const dispatch = vi.fn(); @@ -98,6 +117,30 @@ describe("task rename", () => { expect(next.bots[0]?.tasks?.find((task) => task.threadId === "t1")?.title).toBe("Renamed"); expect(next.bots[0]?.tasks?.find((task) => task.threadId === "t2")?.title).toBe("Other"); }); + + it("updates a channel task title in local state immediately", () => { + const group = { + id: "room", + threadId: "room-task-1", + name: "Launch", + memberIds: [], + defaultResponder: { kind: "everyone" }, + bulletin: "", + unread: false, + createdAt: 1, + messages: [], + tasks: [ + { threadId: "room-task-1", title: "New task", createdAt: 1 }, + { threadId: "room-task-2", title: "Other", createdAt: 2 }, + ], + } satisfies Group; + const next = reducer( + { ...initialState, groups: [group] }, + { type: "renameGroupTask", groupId: group.id, threadId: "room-task-1", title: "Renamed" }, + ); + expect(next.groups[0]?.tasks?.find((task) => task.threadId === "room-task-1")?.title).toBe("Renamed"); + expect(next.groups[0]?.tasks?.find((task) => task.threadId === "room-task-2")?.title).toBe("Other"); + }); }); describe("Teach a skill feature flag", () => { diff --git a/src/state/store.tsx b/src/state/store.tsx index 4ce5f7596..1e126ebc8 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -136,9 +136,22 @@ export interface Group { /** New user-created rooms remain in setup until Save or Skip. */ setupCompletedAt?: number | null; setupSkippedAt?: number | null; + /** Separate conversations in this channel. DMs deliberately stay on one + * thread and omit this collection. */ + tasks?: GroupTask[]; messages: Message[]; } +/** One of a channel's independent conversations. The channel's threadId + * points at the active one; folder and pin state belong to the task. */ +export interface GroupTask { + threadId: string; + title: string; + createdAt: number; + pinnedCwd?: string | null; + pinnedMessageId?: string; +} + export interface ModelSelection { instanceId: string; model: string; @@ -441,6 +454,10 @@ export type Action = patch: Partial>; } | { type: "deleteGroup"; groupId: string } + | { type: "newGroupTask"; groupId: string } + | { type: "switchGroupTask"; groupId: string; threadId: string } + | { type: "renameGroupTask"; groupId: string; threadId: string; title: string } + | { type: "deleteGroupTask"; groupId: string; threadId: string } | { type: "toggleReaction"; threadId: string; messageId: string; emoji: string } | { type: "interruptGroup"; groupId: string } | { type: "instances"; instances: InstanceInfo[] } @@ -508,9 +525,16 @@ export function openNotificationTarget( // GROUP's thread id; asking the bot to switch to that thread would 404. // Open the room itself. A thread that is neither a room nor one of the // bot's own lands on a plain bot select instead of an error banner. - const group = state.groups.find((candidate) => candidate.threadId === target.threadId); + const group = state.groups.find( + (candidate) => + candidate.threadId === target.threadId || + (candidate.tasks ?? []).some((task) => task.threadId === target.threadId), + ); if (group) { dispatch({ type: "select", id: group.id }); + if (group.threadId !== target.threadId) { + dispatch({ type: "switchGroupTask", groupId: group.id, threadId: target.threadId }); + } return; } dispatch({ type: "select", id: target.botId }); @@ -1050,6 +1074,9 @@ export function reducer(state: AppState, action: Action): AppState { case "newTask": case "switchTask": case "deleteTask": + case "newGroupTask": + case "switchGroupTask": + case "deleteGroupTask": return state; case "renameTask": return updateBot(state, action.botId, (bot) => ({ @@ -1058,6 +1085,20 @@ export function reducer(state: AppState, action: Action): AppState { task.threadId === action.threadId ? { ...task, title: action.title } : task, ), })); + case "renameGroupTask": + return { + ...state, + groups: state.groups.map((group) => + group.id === action.groupId + ? { + ...group, + tasks: (group.tasks ?? []).map((task) => + task.threadId === action.threadId ? { ...task, title: action.title } : task, + ), + } + : group, + ), + }; case "taskSwitched": return updateBot(state, action.bot.id, (bot) => ({ ...bot, ...action.bot, messages: action.bot.messages ?? [] })); case "newBot": @@ -1498,6 +1539,29 @@ export function StoreProvider({ children }: { children: ReactNode }) { .then((r: any) => r?.bot && dispatch({ type: "taskSwitched", bot: r.bot })) .catch(showError); break; + // Channel tasks mirror bot tasks, but hydrate the whole channel so + // switching atomically replaces its transcript, folder and pin. + case "newGroupTask": + api(`/api/groups/${action.groupId}/tasks`, { method: "POST", body: "{}" }) + .then((r: any) => r?.group && dispatch({ type: "groupPatched", group: r.group })) + .catch(showError); + break; + case "switchGroupTask": + api(`/api/groups/${action.groupId}/tasks/${action.threadId}`, { method: "POST" }) + .then((r: any) => r?.group && dispatch({ type: "groupPatched", group: r.group })) + .catch(showError); + break; + case "renameGroupTask": + api(`/api/groups/${action.groupId}/tasks/${action.threadId}`, { + method: "PATCH", + body: JSON.stringify({ title: action.title }), + }).catch(showError); + break; + case "deleteGroupTask": + api(`/api/groups/${action.groupId}/tasks/${action.threadId}`, { method: "DELETE" }) + .then((r: any) => r?.group && dispatch({ type: "groupPatched", group: r.group })) + .catch(showError); + break; case "interruptGroup": api(`/api/groups/${action.groupId}/interrupt`, { method: "POST" }).catch(showError); break;