diff --git a/api/openapi.yaml b/api/openapi.yaml index f93405f..81b198d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -148,7 +148,9 @@ paths: /api/me/lead/notes: get: operationId: listLeadNotes - description: What the calling user's lead agent has noted about how they work, oldest first. The lead keeps them with its remember and forget tools. + description: > + What the calling user's lead agent has noted about how they work, oldest first. The lead keeps them with its remember and forget tools. + lead.note_saved (payload: the LeadNote) and lead.note_deleted (payload: its id) events tell the user's clients of changes. responses: "200": description: The notes. @@ -173,7 +175,7 @@ paths: operationId: listSavedCommands description: > The calling user's saved commands on this server, by name. Each starts a terminal in a task that runs it. - command.saved and command.deleted events tell the user's clients of changes. + command.saved (payload: the SavedCommand) and command.deleted (payload: its id) events tell the user's clients of changes. responses: "200": description: The saved commands. @@ -1426,6 +1428,9 @@ paths: /api/workflows: get: operationId: listWorkflows + description: > + Every saved workflow. workflow.updated (payload: the WorkflowSummary) and workflow.deleted (payload: its name) events + tell clients of changes; a rename sends workflow.deleted for the old name, then workflow.updated for the new one. responses: "200": description: Every workflow's name and description, by name. diff --git a/internal/agents/clients.go b/internal/agents/clients.go index 04c2bd6..0bf5622 100644 --- a/internal/agents/clients.go +++ b/internal/agents/clients.go @@ -22,6 +22,7 @@ import ( const ( defaultReadLimit = 2000 clientByre = "/Applications/Byre.app/Contents/Helpers/byre" + clientProperty = `"client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}` ) func (m *Manager) clientShellTools() []mcp.Tool { @@ -46,7 +47,7 @@ func (m *Manager) clientShellTools() []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, + ` + clientProperty + `, "command": {"type": "string"}, "timeout_ms": {"type": "integer", "description": "Kill the command after this long (at most 600000)."} }, @@ -66,7 +67,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, + ` + clientProperty + `, "path": {"type": "string", "description": "The file or directory, relative to your worktree, absolute, or starting with ~/."}, "dest": {"type": "string", "description": "Its new path on the client, absolute or starting with ~/, e.g. ~/Applications/Byre.app."} }, @@ -84,7 +85,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, + ` + clientProperty + `, "path": {"type": "string", "description": "The file or directory, relative to your worktree, absolute, or starting with ~/."} }, "required": ["client", "path"] @@ -103,7 +104,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, + ` + clientProperty + `, "path": {"type": "string", "description": "The file, relative to your worktree, absolute, or starting with ~/."}, "url": {"type": "string", "description": "Instead of path: an http or https address to open in a new tab."} }, @@ -125,7 +126,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, + ` + clientProperty + `, "op": {"type": "string", "enum": ["tabs", "navigate", "js", "capture"]}, "tab": {"type": "integer", "description": "The tab's number from tabs (default: the tab the Page view shows)."}, "url": {"type": "string", "description": "For navigate."}, @@ -145,7 +146,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, + ` + clientProperty + `, "path": {"type": "string", "description": "The file or directory, relative to your worktree, absolute, or starting with ~/."} }, "required": ["client", "path"] @@ -161,7 +162,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, + ` + clientProperty + `, "path": {"type": "string", "description": "The file or directory on the client, absolute or starting with ~/."}, "dest": {"type": "string", "description": "Its new path, relative to your worktree, absolute, or starting with ~/; parent directories are created."} }, @@ -178,7 +179,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, + ` + clientProperty + `, "path": {"type": "string", "description": "The file, absolute or starting with ~/."}, "offset": {"type": "integer", "description": "The line number to start from (default 1)."}, "limit": {"type": "integer", "description": "The most lines to return (default 2000)."} @@ -193,7 +194,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, + ` + clientProperty + `, "path": {"type": "string", "description": "The file, absolute or starting with ~/."}, "content": {"type": "string"} }, @@ -208,7 +209,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, + ` + clientProperty + `, "path": {"type": "string", "description": "The file, absolute or starting with ~/."}, "old_string": {"type": "string"}, "new_string": {"type": "string"} @@ -223,7 +224,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, + ` + clientProperty + `, "pattern": {"type": "string"}, "path": {"type": "string", "description": "The file or directory to search, absolute or starting with ~/."}, "glob": {"type": "string", "description": "Search only files whose names match this glob, e.g. *.go."}, @@ -240,7 +241,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, + ` + clientProperty + `, "path": {"type": "string", "description": "The directory, absolute or starting with ~/."} }, "required": ["client", "path"] @@ -257,7 +258,7 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { InputSchema: json.RawMessage(`{ "type": "object", "properties": { - "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, + ` + clientProperty + `, "tool": {"type": "string", "enum": ["screen_list", "capture", "ax_tree", "act", "browser", "chrome"]}, "arguments": {"type": "object", "description": "The tool's arguments."} }, diff --git a/internal/agents/guidance.go b/internal/agents/guidance.go index 4b4fe90..3159002 100644 --- a/internal/agents/guidance.go +++ b/internal/agents/guidance.go @@ -71,7 +71,7 @@ func (m *Manager) takeBounce(ctx context.Context, l *live, final string) (bool, if !asksQuestion(final) || m.isPaused(l.id) { return false, nil } - first, err := m.store.FirstPrompt(ctx, l.taskID) + first, err := m.store.FirstTaskMessage(ctx, l.taskID) if err != nil { return false, err } diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index edc11c9..2589000 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -5139,115 +5139,117 @@ var swaggerSpec = []string{ "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", - "8QGDu+K+fmvKyY8f//8BAA==", + "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", + "atmPH///AQA=", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/store/discard.go b/internal/store/discard.go index e3f3869..e2ab190 100644 --- a/internal/store/discard.go +++ b/internal/store/discard.go @@ -104,11 +104,7 @@ func (s *Store) StartDiscardTurn(ctx context.Context, taskID, sessionID, actor s if err != nil { return err } - _, err = tx.ExecContext(ctx, `UPDATE agent_sessions SET state = ? WHERE id = ?`, string(api.AgentSessionStateRunning), sessionID) - if err != nil { - return err - } - return emit(sessionEvent("session.state", projectID, taskID, "", sessionID, actor, map[string]any{"state": string(api.AgentSessionStateRunning)})) + return setSessionStateTx(ctx, tx, emit, sessionID, api.AgentSessionStateRunning, actor, nil, projectID, taskID, "") }) } @@ -152,15 +148,7 @@ func (s *Store) EndTurn(ctx context.Context, sessionID, actor string, payload ma } handsOff := continues && role == string(api.Implementer) && state == DiscardNone if !handsOff { - _, err = tx.ExecContext(ctx, `UPDATE agent_sessions SET state = ? WHERE id = ?`, string(api.AgentSessionStateIdle), sessionID) - if err != nil { - return err - } - if payload == nil { - payload = map[string]any{} - } - payload["state"] = string(api.AgentSessionStateIdle) - err = emit(sessionEvent("session.state", projectID, taskID, owner, sessionID, actor, payload)) + err = setSessionStateTx(ctx, tx, emit, sessionID, api.AgentSessionStateIdle, actor, payload, projectID, taskID, owner) if err != nil { return err } diff --git a/internal/store/store.go b/internal/store/store.go index 7973e6c..f75c4f0 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -995,15 +995,6 @@ func (s *Store) TranscriptEvents(ctx context.Context, taskID string, after int64 return s.queryEvents(ctx, query+" ORDER BY seq LIMIT ?", append(args, limit)...) } -func (s *Store) FirstPrompt(ctx context.Context, taskID string) (string, error) { - text := "" - err := s.db.QueryRowContext(ctx, ` - SELECT coalesce(json_extract(payload, '$.text'), '') FROM events - WHERE task_id = ? AND kind = 'user.message' ORDER BY seq LIMIT 1 - `, taskID).Scan(&text) - return text, err -} - func (s *Store) LastMessage(ctx context.Context, sessionID string) (api.Event, bool, error) { evs, err := s.queryEvents(ctx, ` SELECT `+eventColumns+` @@ -1324,12 +1315,14 @@ func transitionSessionTx(ctx context.Context, tx *sql.Tx, emit emitFunc, id stri if state == api.AgentSessionStateRunning && role == string(api.Implementer) && landing != "" && landing != id { return fmt.Errorf("%w: held while task %s merges and continues", ErrStateChanged, taskID) } + return setSessionStateTx(ctx, tx, emit, id, state, actor, payload, projectID, taskID, owner) +} - _, err = tx.ExecContext(ctx, `UPDATE agent_sessions SET state = ? WHERE id = ?`, string(state), id) +func setSessionStateTx(ctx context.Context, tx *sql.Tx, emit emitFunc, id string, state api.AgentSessionState, actor string, payload map[string]any, projectID, taskID, owner string) error { + _, err := tx.ExecContext(ctx, `UPDATE agent_sessions SET state = ? WHERE id = ?`, string(state), id) if err != nil { return err } - if payload == nil { payload = map[string]any{} } diff --git a/internal/store/workflow.go b/internal/store/workflow.go index 8b5f4c0..56c095d 100644 --- a/internal/store/workflow.go +++ b/internal/store/workflow.go @@ -88,7 +88,7 @@ func (s *Store) PutWorkflow(ctx context.Context, name, from string, def api.Work if err != nil { return err } - return emit(api.Event{Kind: "workflow.updated", Actor: actor, Payload: map[string]any{"name": name}}) + return emit(api.Event{Kind: "workflow.updated", Actor: actor, Payload: ToPayload(api.WorkflowSummary{Name: name, Description: def.Description})}) }) } diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 1c07b40..e5d4ed4 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -506,18 +506,7 @@ final class ChatModel { store.focusTask(task.id) return } - if team { - if images != nil { - throw ApiError(status: 0, message: "The board takes text only; drop the images to post.") - } - _ = try await api.postToBoard(path: .init(taskId: id ?? ""), body: .json(.init(text: text))).noContent - return - } - if let sessionId { - _ = try await api.sendMessage(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: model, effort: effort, sendId: sendID))).accepted - return - } - _ = try await api.createSession(path: .init(taskId: id ?? ""), body: .json(.init(prompt: text, model: firstModel, effort: firstEffort, images: images, sendId: sendID))).created + try await store.messageTask(id ?? "", text, images: images, model: model, effort: effort, firstModel: firstModel, firstEffort: firstEffort, sendID: sendID) } } diff --git a/mac/Sources/Byre/Notes.swift b/mac/Sources/Byre/Notes.swift index eb8925b..c930afe 100644 --- a/mac/Sources/Byre/Notes.swift +++ b/mac/Sources/Byre/Notes.swift @@ -28,79 +28,43 @@ struct NotesView: View { private struct LeadNotes: View { @Environment(Store.self) private var store - @ViewState private var list: [LeadNote]? - @ViewState private var failure: String? - @ViewState private var loads = 0 @ViewState private var project = notesProject - @ViewState private var off: (() -> Void)? var body: some View { + let list = store.leadNotes VStack(alignment: .leading, spacing: 0) { - if let list { - if list.isEmpty { - hint("Your lead hasn't noted anything about how you work yet.") - } else { - let groups = [("", "Every project")] + store.projects.map { ($0.id, $0.name) } - let current = groups.contains { $0.0 == project } ? project : "" - let shown = list.filter { ($0.projectId ?? "") == current } - let label = { (g: (String, String)) in "\(g.1) (\(list.filter { ($0.projectId ?? "") == g.0 }.count))" } - DropdownMenu(label: label(groups.first { $0.0 == current } ?? groups[0]), boxed: true) { - ForEach(groups, id: \.0) { g in - Button(label(g)) { - project = g.0 - notesProject = g.0 - } - } - } - .help("Which notes to show") - .padding(.bottom, 8) - if shown.isEmpty { - hint("No notes here yet.") - } else { - ForEach(shown, id: \.id) { note in - NoteRow(note: note) + if list.isEmpty { + hint("Your lead hasn't noted anything about how you work yet.") + } else { + let groups = [("", "Every project")] + store.projects.map { ($0.id, $0.name) } + let current = groups.contains { $0.0 == project } ? project : "" + let shown = list.filter { ($0.projectId ?? "") == current } + let label = { (g: (String, String)) in "\(g.1) (\(list.filter { ($0.projectId ?? "") == g.0 }.count))" } + DropdownMenu(label: label(groups.first { $0.0 == current } ?? groups[0]), boxed: true) { + ForEach(groups, id: \.0) { g in + Button(label(g)) { + project = g.0 + notesProject = g.0 } } } - } else if let failure { - hint(failure) + .help("Which notes to show") + .padding(.bottom, 8) + if shown.isEmpty { + hint("No notes here yet.") + } else { + ForEach(shown, id: \.id) { note in + NoteRow(note: note) + } + } } } .padding(.top, 8) - .onAppear { - off = store.events.add { ev in - if ev.kind == "lead.note_saved" || ev.kind == "lead.note_deleted" { - load() - } - } - load() - } - .onDisappear { - off?() - off = nil - loads += 1 - } } private func hint(_ text: String) -> some View { Text(text).foregroundStyle(Palette.muted) } - - private func load() { - loads += 1 - let n = loads - Task { - do { - let loaded = try await store.api.client.listLeadNotes().ok.body.json - if n == loads { - list = loaded - } - } catch { - failure = errorText(error) - list = nil - } - } - } } private struct NoteRow: View { diff --git a/mac/Sources/Byre/Project.swift b/mac/Sources/Byre/Project.swift index ddc4e57..d136f5d 100644 --- a/mac/Sources/Byre/Project.swift +++ b/mac/Sources/Byre/Project.swift @@ -108,24 +108,16 @@ private struct ProjectWorkflowMenu: View { let audit: Bool @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays - @ViewState private var workflows: [String] = [] var body: some View { let current = audit ? project.auditWorkflow : project.reviewWorkflow DropdownMenu(label: current.isEmpty ? "Off" : current, size: 11, color: Palette.muted) { Button("Off") { set("") } - ForEach(workflows, id: \.self) { name in - Button(name) { set(name) } + ForEach(store.workflows, id: \.name) { w in + Button(w.name) { set(w.name) } } } .help(audit ? "The workflow byre runs in the background on \(project.defaultBranch)'s newest commit to check the whole codebase" : "The review workflow whose reviewers review every snapshot of this project's tasks") - .task { - do { - workflows = try await store.api.client.listWorkflows().ok.body.json.map(\.name) - } catch { - workflows = [] - } - } } private func set(_ name: String) { diff --git a/mac/Sources/Byre/QuickRun.swift b/mac/Sources/Byre/QuickRun.swift index dd63a3e..cb3f4d5 100644 --- a/mac/Sources/Byre/QuickRun.swift +++ b/mac/Sources/Byre/QuickRun.swift @@ -4,8 +4,6 @@ import SwiftUI struct QuickRunSetting: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays - @ViewState private var workflows: [WorkflowSummary] = [] - @ViewState private var off: (() -> Void)? var body: some View { let current = store.me?.quickRun ?? .init(projectId: "", workflow: "") @@ -14,7 +12,7 @@ struct QuickRunSetting: View { Button("None") { save(.init(projectId: "", workflow: "")) } - ForEach(workflows, id: \.name) { w in + ForEach(store.workflows, id: \.name) { w in Button(w.name) { save(.init(projectId: current.projectId.isEmpty ? store.projects.first?.id ?? "" : current.projectId, workflow: w.name)) } @@ -35,28 +33,6 @@ struct QuickRunSetting: View { } } .padding(.top, 8) - .onAppear { - off = store.events.add { ev in - if ev.kind == "workflow.updated" || ev.kind == "workflow.deleted" { - load() - } - } - load() - } - .onDisappear { - off?() - off = nil - } - } - - private func load() { - Task { - do { - workflows = try await store.api.client.listWorkflows().ok.body.json - } catch { - overlays.showAlert(errorText(error)) - } - } } private func save(_ q: Components.Schemas.QuickRun) { diff --git a/mac/Sources/Byre/Schedules.swift b/mac/Sources/Byre/Schedules.swift index 4a5efb7..8984d10 100644 --- a/mac/Sources/Byre/Schedules.swift +++ b/mac/Sources/Byre/Schedules.swift @@ -103,17 +103,16 @@ private final class ScheduleDraft { var dayOfMonth: Int var workflow: String var prompt: String - var workflows: [String] = [] var failure = "" - init(_ sc: Schedule?) { + init(_ sc: Schedule?, workflows: [WorkflowSummary]) { title = sc?.title ?? "" every = sc?.every ?? .week weekdays = Set(sc?.weekdays ?? [.mon]) hour = sc?.hour ?? 9 minute = sc?.minute ?? 0 dayOfMonth = sc.map { $0.dayOfMonth == 0 ? 1 : $0.dayOfMonth } ?? 1 - workflow = sc?.workflow ?? "" + workflow = sc?.workflow ?? workflows.first?.name ?? "" prompt = sc?.prompt ?? "" } @@ -137,13 +136,7 @@ private final class ScheduleDraft { @MainActor func editSchedule(_ project: Project, _ existing: Schedule?, _ store: Store, _ overlays: Overlays) async { - let draft = ScheduleDraft(existing) - Task { - draft.workflows = (try? await store.api.client.listWorkflows().ok.body.json.map(\.name)) ?? [] - if draft.workflow.isEmpty, let first = draft.workflows.first { - draft.workflow = first - } - } + let draft = ScheduleDraft(existing, workflows: store.workflows) while true { let buttons = existing == nil ? [ModalButton(label: "Cancel", value: "cancel"), ModalButton(label: "Create", value: "save", primary: true)] @@ -180,6 +173,7 @@ func editSchedule(_ project: Project, _ existing: Schedule?, _ store: Store, _ o private struct ScheduleEditor: View { @Bindable var draft: ScheduleDraft let project: Project + @Environment(Store.self) private var store var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -214,7 +208,8 @@ private struct ScheduleEditor: View { } HStack(alignment: .firstTextBaseline, spacing: 8) { Text("Run workflow").foregroundStyle(Palette.muted) - let names = draft.workflow.isEmpty || draft.workflows.contains(draft.workflow) ? draft.workflows : [draft.workflow] + draft.workflows + let saved = store.workflows.map(\.name) + let names = draft.workflow.isEmpty || saved.contains(draft.workflow) ? saved : [draft.workflow] + saved ChoiceMenu(value: $draft.workflow, options: names.map { ($0, $0) }) } Text("The workflow's brief, given to every stage (optional).") diff --git a/mac/Sources/Byre/Settings.swift b/mac/Sources/Byre/Settings.swift index 15a166d..284c366 100644 --- a/mac/Sources/Byre/Settings.swift +++ b/mac/Sources/Byre/Settings.swift @@ -289,15 +289,13 @@ private struct PrefsBox: View { private struct SavedCommands: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays - @ViewState private var list: [SavedCommand] = [] @ViewState private var editing: String? @ViewState private var name = "" @ViewState private var command = "" - @ViewState private var off: (() -> Void)? var body: some View { VStack(alignment: .leading, spacing: 0) { - ForEach(list, id: \.id) { c in + ForEach(store.savedCommands, id: \.id) { c in if editing == c.id { form(c.id) } else { @@ -333,18 +331,6 @@ private struct SavedCommands: View { } } .padding(.top, 8) - .onAppear { - off = store.events.add { ev in - if ev.kind == "command.saved" || ev.kind == "command.deleted" { - load() - } - } - load() - } - .onDisappear { - off?() - off = nil - } } private func form(_ id: String?) -> some View { @@ -379,16 +365,6 @@ private struct SavedCommands: View { .padding(.vertical, 6) } - private func load() { - Task { - do { - list = try await store.api.client.listSavedCommands().ok.body.json - } catch { - overlays.showAlert(errorText(error)) - } - } - } - private func save(_ id: String?, _ valid: Bool) { if !valid { return diff --git a/mac/Sources/Byre/Startup.swift b/mac/Sources/Byre/Startup.swift index 0ba48bb..10697a4 100644 --- a/mac/Sources/Byre/Startup.swift +++ b/mac/Sources/Byre/Startup.swift @@ -248,7 +248,10 @@ final class Connection { async let lead: Void = store.loadLeadSessions() async let reads: Void = store.loadSummaryReads() async let terminals: Void = store.loadTerminals() - _ = try await (me, projects, inbox, lead, reads, terminals) + async let workflows: Void = store.loadWorkflows() + async let notes: Void = store.loadLeadNotes() + async let commands: Void = store.loadSavedCommands() + _ = try await (me, projects, inbox, lead, reads, terminals, workflows, notes, commands) try await store.loadFocus() guard !stopped else { await store.stop() diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index e17c884..afc7f17 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -66,6 +66,9 @@ final class Store { var projects: [Project] = [] var tasks: [String: [ByreTask]] = [:] var schedules: [String: [Schedule]] = [:] + var workflows: [WorkflowSummary] = [] + var leadNotes: [LeadNote] = [] + var savedCommands: [SavedCommand] = [] var sessions: [String: [AgentSession]] = [:] var inbox: [InboxItem] = [] private(set) var terminals: [String: [TaskTerminal]] = [:] @@ -105,7 +108,7 @@ final class Store { @ObservationIgnored private var sessionReloads: Set = [] @ObservationIgnored private var sessionRefetches: Set = [] @ObservationIgnored private var taskRefetches: [String: Set] = [:] - @ObservationIgnored private var terminalChanges = 0 + @ObservationIgnored private var changeCounts: [String: Int] = [:] @ObservationIgnored private var streamOpens = 0 @ObservationIgnored private var globalStream: EventStream? @ObservationIgnored private var leadStream: EventStream? @@ -341,27 +344,58 @@ final class Store { } func loadSchedules(_ projectId: String) async throws { - let latest = claimRequest("schedules:\(projectId)") - let list = try await api.client.listSchedules(path: .init(projectId: projectId)).ok.body.json - if !latest() { + try await fetch("schedules:\(projectId)", { try await api.client.listSchedules(path: .init(projectId: projectId)).ok.body.json }) { schedules[projectId] = $0 } + } + + func loadWorkflows() async throws { + try await fetch("workflows", { try await api.client.listWorkflows().ok.body.json }) { workflows = $0 } + } + + func loadLeadNotes() async throws { + try await fetch("leadNotes", { try await api.client.listLeadNotes().ok.body.json }) { leadNotes = $0 } + } + + func loadSavedCommands() async throws { + try await fetch("savedCommands", { try await api.client.listSavedCommands().ok.body.json }) { savedCommands = $0 } + } + + // A reload lands only when no newer request for the same key has started since, and reloads instead when a stream event changed the key's data while it ran. + private func fetch(_ key: String, _ get: () async throws -> T, _ set: (T) -> Void) async throws { + let latest = claimRequest(key) + let seen = changeCounts[key, default: 0] + let value = try await get() + guard latest() else { return } - schedules[projectId] = list + if changeCounts[key, default: 0] != seen { + try await fetch(key, get, set) + return + } + set(value) + } + + private func changed(_ key: String) { + changeCounts[key, default: 0] += 1 } private func applySchedule(_ ev: ByreEvent) { guard let projectId = ev.projectId else { return } + changed("schedules:\(projectId)") var list = schedules[projectId] ?? [] if ev.kind == "schedule.saved" { - let sc = try! decodePayload(ev.payload, as: Schedule.self) - list.removeAll { $0.id == sc.id } - list.append(sc) + upsert(&list, try! decodePayload(ev.payload, as: Schedule.self), by: \.id) { ($0.nextRun, $0.id) < ($1.nextRun, $1.id) } } else if let id = str(ev.payload.value, "id") { list.removeAll { $0.id == id } } - schedules[projectId] = list.sorted { ($0.nextRun, $0.id) < ($1.nextRun, $1.id) } + schedules[projectId] = list + } + + private func upsert(_ list: inout [T], _ item: T, by key: (T) -> K, order: (T, T) -> Bool) { + list.removeAll { key($0) == key(item) } + list.append(item) + list.sort(by: order) } private func claimRequest(_ key: String) -> () -> Bool { @@ -462,45 +496,33 @@ final class Store { } func loadSessions(_ taskId: String) async throws { - let latest = claimRequest("sessions:\(taskId)") - let list = try await api.client.listSessions(path: .init(taskId: taskId)).ok.body.json - if !latest() { - return - } - sessions[taskId] = list + try await fetch("sessions:\(taskId)", { try await api.client.listSessions(path: .init(taskId: taskId)).ok.body.json }) { sessions[taskId] = $0 } } - func messageTask(_ taskId: String, _ text: String, images ids: [String]? = nil) async throws { - let sessions = try await api.client.listSessions(path: .init(taskId: taskId)).ok.body.json - if let live = sessions.last(where: { $0.state != .completed }) { - _ = try await api.client.sendMessage(path: .init(sessionId: live.id), body: .json(.init(text: text, images: ids))).accepted - } else { - _ = try await api.client.createSession(path: .init(taskId: taskId), body: .json(.init(prompt: text, images: ids))).created + func messageTask(_ taskId: String, _ text: String, images: [String]? = nil, model: String? = nil, effort: String? = nil, firstModel: String? = nil, firstEffort: String? = nil, sendID: String? = nil) async throws { + if !team(taskId).isEmpty { + if images != nil { + throw ApiError(status: 0, message: "The board takes text only; drop the images to post.") + } + _ = try await api.client.postToBoard(path: .init(taskId: taskId), body: .json(.init(text: text))).noContent + return } + if let live = currentSession(taskId), live.state != .completed { + _ = try await api.client.sendMessage(path: .init(sessionId: live.id), body: .json(.init(text: text, images: images, model: model, effort: effort, sendId: sendID))).accepted + return + } + _ = try await api.client.createSession(path: .init(taskId: taskId), body: .json(.init(prompt: text, model: firstModel, effort: firstEffort, images: images, sendId: sendID))).created } func loadInbox() async throws { - let latest = claimRequest("inbox") - let items = try await api.client.listInbox().ok.body.json - if !latest() { - return + try await fetch("inbox", { try await api.client.listInbox().ok.body.json }) { + inbox = $0 + drafts?.prune(self) } - inbox = items - drafts?.prune(self) } func loadTerminals() async throws { - let latest = claimRequest("terminals") - let changes = terminalChanges - let list = try await api.client.listTerminals().ok.body.json - if !latest() { - return - } - if changes != terminalChanges { - try await loadTerminals() - return - } - terminals = Dictionary(grouping: list.terminals, by: \.taskId) + try await fetch("terminals", { try await api.client.listTerminals().ok.body.json }) { terminals = Dictionary(grouping: $0.terminals, by: \.taskId) } } func loadLeadSessions() async throws { @@ -508,12 +530,7 @@ final class Store { } func loadFocus() async throws { - let latest = claimRequest("focus") - let f = try await api.client.getFocus().ok.body.json - if !latest() { - return - } - applyFocus(f) + try await fetch("focus", { try await api.client.getFocus().ok.body.json }) { applyFocus($0) } } func applyFocus(_ f: Focus) { @@ -802,6 +819,15 @@ final class Store { spawn("load summary reads") { try await self.loadSummaryReads() } + spawn("load workflows") { + try await self.loadWorkflows() + } + spawn("load lead notes") { + try await self.loadLeadNotes() + } + spawn("load saved commands") { + try await self.loadSavedCommands() + } for (_, task) in activeTasks() { spawn("load sessions") { try await self.loadSessions(task.id) @@ -829,7 +855,7 @@ final class Store { confirmSend(taskId, ev) } if ev.kind == "terminals.changed", let taskId = ev.taskId { - terminalChanges += 1 + changed("terminals") terminals[taskId] = try! decodePayload(ev.payload, as: TerminalList.self).terminals } if ev.seq == 0 { @@ -850,6 +876,30 @@ final class Store { } case "schedule.saved", "schedule.deleted": applySchedule(ev) + case "workflow.updated": + changed("workflows") + upsert(&workflows, try! decodePayload(ev.payload, as: WorkflowSummary.self), by: \.name) { $0.name < $1.name } + case "workflow.deleted": + changed("workflows") + if let name = str(ev.payload.value, "name") { + workflows.removeAll { $0.name == name } + } + case "lead.note_saved": + changed("leadNotes") + upsert(&leadNotes, try! decodePayload(ev.payload, as: LeadNote.self), by: \.id) { ($0.createdAt, $0.id) < ($1.createdAt, $1.id) } + case "lead.note_deleted": + changed("leadNotes") + if let id = str(ev.payload.value, "id") { + leadNotes.removeAll { $0.id == id } + } + case "command.saved": + changed("savedCommands") + upsert(&savedCommands, try! decodePayload(ev.payload, as: SavedCommand.self), by: \.id) { ($0.name, $0.id) < ($1.name, $1.id) } + case "command.deleted": + changed("savedCommands") + if let id = str(ev.payload.value, "id") { + savedCommands.removeAll { $0.id == id } + } case "task.created", "task.title", "task.merge_title", "task.merge_queued", "task.merge_unqueued", "task.merge_after_review", "task.merge_after_review_ended", "task.merge_after_review_canceled", "task.parent_merge_started", "task.parent_merge_stopped", "task.merge_started", "task.merge_ended", "task.discard_started", "task.merged", "task.discarded", "task.state", "task.ready", "task.discard_suggested", "task.ready_cleared", "snapshot.created": if let taskId = ev.taskId, ["task.merged", "task.discarded"].contains(ev.kind) || (ev.kind == "snapshot.created" && (try? decodePayload(ev.payload, as: Snapshot.self))?.isCutPoint != true) { clearTask(self, taskId) diff --git a/mac/Sources/Byre/Switcher.swift b/mac/Sources/Byre/Switcher.swift index 57a79ce..002294a 100644 --- a/mac/Sources/Byre/Switcher.swift +++ b/mac/Sources/Byre/Switcher.swift @@ -178,23 +178,15 @@ final class SwitcherModel { #if os(macOS) @ObservationIgnored var pending: NSEvent? #endif - var workflows: [WorkflowSummary] = [] init(store: Store, overlays: Overlays, anchor: CGRect) { self.store = store self.overlays = overlays self.anchor = anchor - Task { - do { - workflows = try await store.api.client.listWorkflows().ok.body.json - } catch { - overlays.showAlert(errorText(error)) - } - } } private func workflowMatch(_ title: String) -> (WorkflowSummary, String)? { - for w in workflows.sorted(by: { $0.name.count > $1.name.count }) { + for w in store.workflows.sorted(by: { $0.name.count > $1.name.count }) { guard title.lowercased().hasPrefix(w.name.lowercased()) else { continue } diff --git a/mac/Sources/Byre/Terminal.swift b/mac/Sources/Byre/Terminal.swift index 67bde67..2e0df3e 100644 --- a/mac/Sources/Byre/Terminal.swift +++ b/mac/Sources/Byre/Terminal.swift @@ -19,12 +19,10 @@ func terminalFocused() -> Bool { struct TerminalTab: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays - @ViewState private var commands: [SavedCommand] = [] @ViewState private var selected: String? @ViewState private var status = "" @ViewState private var failure: String? @ViewState private var starting = false - @ViewState private var off: (() -> Void)? var body: some View { let taskId = store.selectedTaskId @@ -55,7 +53,7 @@ struct TerminalTab: View { .buttonStyle(SmallLinkButtonStyle()) .help("Open another shell in the task's worktree") .padding(.leading, 8) - ForEach(commands, id: \.id) { c in + ForEach(store.savedCommands, id: \.id) { c in Button("▸ \(c.name)") { start(c.id) } @@ -105,28 +103,6 @@ struct TerminalTab: View { start(nil) } } - .onAppear { - off = store.events.add { ev in - if ev.kind == "command.saved" || ev.kind == "command.deleted" { - loadCommands() - } - } - loadCommands() - } - .onDisappear { - off?() - off = nil - } - } - - private func loadCommands() { - Task { - do { - commands = try await store.api.client.listSavedCommands().ok.body.json - } catch { - overlays.showAlert(errorText(error)) - } - } } private func start(_ commandId: String?) { diff --git a/mac/Sources/Byre/Workflows.swift b/mac/Sources/Byre/Workflows.swift index 54c1480..a2ddab5 100644 --- a/mac/Sources/Byre/Workflows.swift +++ b/mac/Sources/Byre/Workflows.swift @@ -50,59 +50,18 @@ struct WorkflowsView: View { private struct WorkflowList: View { let edit: (String) -> Void @Environment(Store.self) private var store - @ViewState private var list: [WorkflowSummary]? - @ViewState private var failure: String? - @ViewState private var loads = 0 - @ViewState private var off: (() -> Void)? var body: some View { VStack(alignment: .leading, spacing: 0) { - if let list { - if list.isEmpty { - hint("No saved workflows. Click New to write one.") - } else { - ForEach(list.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }, id: \.name) { w in - WorkflowRow(workflow: w) { edit(w.name) } - } + if store.workflows.isEmpty { + Text("No saved workflows. Click New to write one.").foregroundStyle(Palette.muted) + } else { + ForEach(store.workflows.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }, id: \.name) { w in + WorkflowRow(workflow: w) { edit(w.name) } } - } else if let failure { - hint(failure) } } .padding(.top, 8) - .onAppear { - off = store.events.add { ev in - if ev.kind == "workflow.updated" || ev.kind == "workflow.deleted" { - load() - } - } - load() - } - .onDisappear { - off?() - off = nil - loads += 1 - } - } - - private func hint(_ text: String) -> some View { - Text(text).foregroundStyle(Palette.muted) - } - - private func load() { - loads += 1 - let n = loads - Task { - do { - let loaded = try await store.api.client.listWorkflows().ok.body.json - if n == loads { - list = loaded - } - } catch { - failure = errorText(error) - list = nil - } - } } }