diff --git a/docs/operations.md b/docs/operations.md index e89f722..f7b564e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -277,7 +277,7 @@ Agent activity becomes events with `agent_session_id` set: `user.message`, `agen Event payloads are stored whole in `events.payload`, and `agent.tool_result` content (full command output, file reads, diffs) dominates the log's size; the app loads every active task's whole history when it starts (see "Events"), so this is what its startup costs. `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events GROUP BY kind ORDER BY 3 DESC"` shows the split, and `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events WHERE task_id = '' GROUP BY kind ORDER BY 3 DESC"` one task's. -Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. Review findings forwarded to the agent come this way too, but their `user.message` carries `findings: true` (`isFindingsMessage` in `internal/agents/review.go`) and the transcript never draws it, since it shows them itself: a `review.finished` event with findings draws its counts note with the review's `summary` (one bullet per finding) expanded under it, behind a border-coloured rule, and then each finding as its own row (see "Reviews"). The transcript view has a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`open` in `TranscriptBuilder`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it) or the task's state is `review_pending` or `reviewing` (see "Task state in the store"), and, only in the first case, a Cylon-style scanner in the same colour: a row of 2pt dots, 5pt apart along an 84pt track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It is an overlay 8pt below the transcript's last row, so showing or hiding it never changes the transcript's scroll height or position, and it is drawn with Core Animation (`ScannerView` in `mac/Sources/Byre/Chat.swift`: a gradient layer masked by the dots, its position animated by a repeating `CABasicAnimation`), so the render server animates it and SwiftUI never redraws the transcript for it. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. Consecutive collapsed boxes (tool calls, byre's messages and subagent calls) fold into one group whose summary counts calls per tool and file, with `byre` for each of byre's messages, and file paths show relative to the worktree. A note the agent's own tool call caused (actor `agent`: "agent set the task title to …", the merge title, the summary, an exposed port) folds into the same group without adding to its summary. `ToolSearch` calls, Claude Code loading a tool's schema, are never drawn. Thinking output appears as expanded Markdown text with a small muted "thinking" label and ends the current tool-call group just like an agent message, including inside subagent rows. While the scanner shows, a tool call still waiting for its result, and any group or subagent row holding one, gets a border in the task's colour, so a running tool reads apart from waiting on the model without expanding anything (`working` in `mac/Sources/Byre/Chat.swift`). The braille spinner the Project tab shows for task states is drawn with Core Animation too (`SpinnerView` in `mac/Sources/Byre/Format.swift`: its ten glyphs rendered once into images in the surrounding font and colour, cycled every 80ms by a repeating discrete `CAKeyframeAnimation` on the layer's contents), so it never makes SwiftUI redraw or lay out the views around it. When a turn ends, or a turn-starting `user.message` (not mid-turn) arrives, any call still without a result, other than those inside an active subagent row (below), reads "no result" instead of "running…", so an interrupted call never spins during a later turn. The second case covers a turn a restart cut off: it has no `turn.ended`, and the restart notice starts the next turn (`endTools` in `mac/Sources/Byre/TranscriptBuilder.swift`). MCP tools show without their `mcp____` prefix, in title case (`mcp__byre__create_task` shows as Create Task, `mcp__hands__screen_list` as Screen List; `toolLabel` in `mac/Sources/Byre/TranscriptBuilder.swift`); other tools show under the name the agent used. A Claude Code subagent call (the `Agent` or `Task` tool) is collapsed, labelled with its `subagent_type` and `description`; expanding it shows the prompt and the call's result. Its summary carries a status from the `agent.vendor` `system` events whose `tool_use_id` names it (`task_started`: running, or running in the background; `task_progress`: its `description`; `task_notification`: its `status`, red unless `completed`). From its `task_started` to its `task_notification` the row is active, and an active row gets the task-coloured border while the scanner shows or while the session's `subagents` count is above zero, as do the waiting tool calls inside it in the latter case; so a background subagent still reads as running after the turn that started it has ended. A turn's end leaves the waiting calls inside an active row waiting; the row's end (its `task_notification`, or a `session.subagents` event with 0, which ends every still-active row as "ended with its process", replayed history included) marks the calls inside it still waiting "no result". The subagent's own messages, thinking and tool calls (events whose `parent_tool_use_id` names the call) go inside that call's row, between the prompt and the result, so parallel subagents don't interleave in the main stream; its consecutive tool calls group there the same way, its messages get an accent rule down their left edge, and a subagent it starts nests inside it in turn. An event naming a subagent call the transcript never saw stays in the main stream marked `↳ subagent`. A subagent's messages never end the main agent's live message, count as a turn's final message or hide a quiet turn. Claude Code streams a background subagent's activity, but for a foreground one only its prompt, so a foreground subagent shows only as its call, its status and its result. Selecting a task that has never had a session focuses the composer, and so does picking any task in the ⌘K switcher when it lands on the transcript (`Store.composerFocus`). In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task (see "Drafts" below), so it survives switching tabs or tasks and relaunching the app. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session, and stays enabled while a turn runs: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a bubble reading "queued…" while the session is running and "sending…" otherwise, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails. These local bubbles live in the `Store` per task (`LocalSends`), not in the open transcript, so they survive switching tasks, and the `Store` confirms them from the live streams whether or not that transcript is showing (`confirmSend`). Messages are matched by id, never by text: the composer gives each send a `send_id` (a UUID) in `sendMessage`, `createSession` or `messageLead`, and the server carries it on the `user.queued` and `user.message` events that send records, through `task_inputs.send_id` and `lead_inputs.send_id` while it waits (`store.AddTaskInput` gives a queued message without one an id of its own, so every queued message has one). A request that fails after its `user.queued` or `user.message` has already confirmed it shows no alert and leaves the composer empty, since the server has the message. A `user.queued` event takes over its local bubble, and the transcript shows a "queued…" bubble below everything else for every `user.queued` until a `user.message` with its `send_id`, and `task.merged` or `task.discarded` clearing them all, since closing the task deletes its queue, so a queued message stays visible across switching tasks, relaunching the app, and on every view. A bubble from a `user.queued` event has a × that withdraws the message (`ChatModel.withdraw`): it first downloads the message's images, so a failed download leaves the message queued, then sends the `DELETE`; on success the bubble goes, its text goes back into that task's composer ahead of whatever is there, separated by a blank line, and its images are added back as composer attachments. A `user.unqueued` event removes the bubble with its `id` on every other view. A refusal (the agent already has the message) shows as an alert and the bubble stays until the `user.message` clears it. +Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. Review findings forwarded to the agent come this way too, but their `user.message` carries `findings: true` (`isFindingsMessage` in `internal/agents/review.go`) and the transcript never draws it, since it shows them itself: a `review.finished` event with findings draws its counts note with the review's `summary` (one bullet per finding) expanded under it, behind a border-coloured rule, and then each finding as its own row (see "Reviews"). The transcript view has a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`open` in `TranscriptBuilder`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it) or the task's state is `review_pending` or `reviewing` (see "Task state in the store"), and, only in the first case, a Cylon-style scanner in the same colour: a row of 2pt dots, 5pt apart along an 84pt track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It is an overlay 8pt below the transcript's last row, so showing or hiding it never changes the transcript's scroll height or position, and it is drawn with Core Animation (`ScannerView` in `mac/Sources/Byre/Chat.swift`: a gradient layer masked by the dots, its position animated by a repeating `CABasicAnimation`), so the render server animates it and SwiftUI never redraws the transcript for it. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. Consecutive collapsed boxes (tool calls, byre's messages and subagent calls) fold into one group whose summary counts calls per tool and file, with `byre` for each of byre's messages, and file paths show relative to the worktree. A note the agent's own tool call caused (actor `agent`: "agent set the task title to …", the merge title, the summary, an exposed port) folds into the same group without adding to its summary. `ToolSearch` calls, Claude Code loading a tool's schema, are never drawn. Thinking output appears as expanded Markdown text with a small muted "thinking" label and ends the current tool-call group just like an agent message, including inside subagent rows. While the scanner shows, a tool call still waiting for its result, and any group or subagent row holding one, gets a border in the task's colour, so a running tool reads apart from waiting on the model without expanding anything (`working` in `mac/Sources/Byre/Chat.swift`). The braille spinner the Project tab shows for task states is drawn with Core Animation too (`SpinnerView` in `mac/Sources/Byre/Format.swift`: its ten glyphs rendered once into images in the surrounding font and colour, cycled every 80ms by a repeating discrete `CAKeyframeAnimation` on the layer's contents), so it never makes SwiftUI redraw or lay out the views around it. When a turn ends, or a turn-starting `user.message` (not mid-turn) arrives, any call still without a result, other than those inside an active subagent row (below), reads "no result" instead of "running…", so an interrupted call never spins during a later turn. The second case covers a turn a restart cut off: it has no `turn.ended`, and the restart notice starts the next turn (`endTools` in `mac/Sources/Byre/TranscriptBuilder.swift`). MCP tools show without their `mcp____` prefix, in title case (`mcp__byre__create_task` shows as Create Task, `mcp__hands__screen_list` as Screen List; `toolLabel` in `mac/Sources/Byre/TranscriptBuilder.swift`); other tools show under the name the agent used. A Claude Code subagent call (the `Agent` or `Task` tool) is collapsed, labelled with its `subagent_type` and `description`; expanding it shows the prompt and the call's result. Its summary carries a status from the `agent.vendor` `system` events whose `tool_use_id` names it (`task_started`: running, or running in the background; `task_progress`: its `description`; `task_notification`: its `status`, red unless `completed`). From its `task_started` to its `task_notification` the row is active, and an active row gets the task-coloured border while the scanner shows or while the session's `subagents` count is above zero, as do the waiting tool calls inside it in the latter case; so a background subagent still reads as running after the turn that started it has ended. A turn's end leaves the waiting calls inside an active row waiting; the row's end (its `task_notification`, or a `session.subagents` event with 0, which ends every still-active row as "ended with its process", replayed history included) marks the calls inside it still waiting "no result". The subagent's own messages, thinking and tool calls (events whose `parent_tool_use_id` names the call) go inside that call's row, between the prompt and the result, so parallel subagents don't interleave in the main stream; its consecutive tool calls group there the same way, its messages get an accent rule down their left edge, and a subagent it starts nests inside it in turn. An event naming a subagent call the transcript never saw stays in the main stream marked `↳ subagent`. A subagent's messages never end the main agent's live message, count as a turn's final message or hide a quiet turn. Claude Code streams a background subagent's activity, but for a foreground one only its prompt, so a foreground subagent shows only as its call, its status and its result. Selecting a task that has never had a session focuses the composer, and so does picking any task in the ⌘K switcher when it lands on the transcript (`Store.composerFocus`). In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task (see "Drafts" below), so it survives switching tabs or tasks and relaunching the app. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session, and stays enabled while a turn runs: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a bubble reading "queued…" while the session is running and "sending…" otherwise, replaced by the server's `user.message` event with the same text when it arrives. A send never gives up on a failure that leaves it unknown whether the server has the message (a connection error or a 5xx): the bubble stays and the app makes the same request again, with the same `send_id`, every 2 seconds (uploading the images only until that succeeds) until it succeeds or the stream confirms the send. A 4xx is the server refusing the message, so it removes the bubble, shows an alert and puts the text and images back in the composer; a team board post, which has no `send_id`, does the same on any failure. These local bubbles live in the `Store` per task (`LocalSends`), not in the open transcript, so they survive switching tasks, and the `Store` confirms them from the live streams whether or not that transcript is showing (`confirmSend`). Messages are matched by id, never by text: the composer gives each send a `send_id` (a UUID) in `sendMessage`, `createSession` or `messageLead`, and the server carries it on the `user.queued` and `user.message` events that send records, through `task_inputs.send_id` and `lead_inputs.send_id` while it waits (`store.AddTaskInput` gives a queued message without one an id of its own, so every queued message has one). A send the server already has is accepted again without being queued, recorded or delivered twice: `Manager.Message`, `Manager.Create` and `Manager.MessageLead` first look its `send_id` up (`Store.SendSession`: a `user.queued` or `user.message` event carrying it, through the `events_send_id` index, or a queued `lead_inputs` row) and, when it is there, answer with the session that has it. `Manager.Create` holds a per-task lock and ignores the request's cancellation, so a dropped connection can't leave a session without its first message, and a retry waits for the attempt before it and then finds its `send_id`. When an attempt made the session but failed before recording the message, the task has an idle session that was never sent anything (`started` and `pending` both 0, `Store.UnusedSession`), and `Create` sends the message to it instead of refusing because the task already has a session. A request that fails after its `user.queued` or `user.message` has already confirmed it shows no alert and leaves the composer empty, since the server has the message. A `user.queued` event takes over its local bubble, and the transcript shows a "queued…" bubble below everything else for every `user.queued` until a `user.message` with its `send_id`, and `task.merged` or `task.discarded` clearing them all, since closing the task deletes its queue, so a queued message stays visible across switching tasks, relaunching the app, and on every view. A bubble from a `user.queued` event has a × that withdraws the message (`ChatModel.withdraw`): it first downloads the message's images, so a failed download leaves the message queued, then sends the `DELETE`; on success the bubble goes, its text goes back into that task's composer ahead of whatever is there, separated by a blank line, and its images are added back as composer attachments. A `user.unqueued` event removes the bubble with its `id` on every other view. A refusal (the agent already has the message) shows as an alert and the bubble stays until the `user.message` clears it. **Drafts.** Unsent composer text (task and lead) and unsent inbox answers (checked choices and Other text) are kept in memory by `Drafts` (`mac/Sources/Byre/Drafts.swift`) and saved to a SwiftData `DraftRecord` per server and login (`docs/macos.md`, "Drafts"), one JSON object with `composer` (keyed by `message:` or `message:lead`, each `{text, images}`), `checked` and `texts` (both keyed by inbox item id), and `comments`, the Changes view's pending line comments keyed by task id, each `{anchor: {path, line, old, quote}, text}`. A comment shows only under a diff line whose path, line number, side and text all still match, so after the agent moves lines a comment never attaches to a different line. Every pending comment not shown under a line (its file closed or not loaded, or its line gone from the diff) is listed at the top of the Changes view with its `path:line`, the quoted line, its editable text and **remove**, so nothing is sent unseen. Writes are debounced (400 ms) and flushed at once when a composer or answer box loses focus, on send and on quit. A composer draft is cleared only once its send succeeds, and an inbox answer only once its response is accepted; each send in flight is tracked on its own and the stored draft holds the text of every unconfirmed send (oldest first, then the composer's current text, separated by blank lines) and counts its images, so a relaunch mid-send brings back everything not yet confirmed. A failed send's text goes back into the composer ahead of whatever is there now (text typed since, or earlier failures), separated by a blank line, rather than replacing it. The composer follows its `Drafts` text as it changes, so text put back from outside it (a withdrawn queued message, a draft synced in from another Mac) shows at once. Drafts for tasks that are no longer active and for inbox items no longer open are pruned whenever the task list or inbox reloads. A record that doesn't decode is logged and ignored, as is a write that fails; drafts then live only in memory. Pending images are not stored, only their count: after a relaunch the composer shows how many need re-attaching, until dismissed or the next successful send. diff --git a/internal/agents/lead.go b/internal/agents/lead.go index db49dc7..f5ebcfd 100644 --- a/internal/agents/lead.go +++ b/internal/agents/lead.go @@ -127,6 +127,16 @@ func (m *Manager) MessageLead(ctx context.Context, user api.User, text, model, s if err != nil { return api.AgentSession{}, err } + prior, sent, err := m.priorSend(ctx, sendID) + if err != nil { + return api.AgentSession{}, err + } + if sent && prior.Id == "" { + return sess, nil + } + if sent { + return prior, nil + } if !found { return m.createLead(ctx, user, text, model, user.Login, sendID, images) } diff --git a/internal/agents/manager.go b/internal/agents/manager.go index 0172ea7..ac5e109 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -186,6 +186,22 @@ func (m *Manager) taskFor(ctx context.Context, sess api.AgentSession) (api.Task, } func (m *Manager) Create(ctx context.Context, task api.Task, creator api.User, prompt, model, sendID string, images ...string) (api.AgentSession, error) { + unlock := m.lockDelivery("create:" + task.Id) + defer unlock() + + ctx = context.WithoutCancel(ctx) + prior, sent, err := m.priorSend(ctx, sendID) + if err != nil || sent { + return prior, err + } + unused, found, err := m.store.UnusedSession(ctx, task.Id, "") + if err != nil { + return api.AgentSession{}, err + } + if found { + slog.Info("first message to an unused session", "session", unused, "task", task.Id) + return m.Send(ctx, unused, creator.Login, prompt, model, sendID, images...) + } return m.create(ctx, task, creator, "", prompt, model, creator.Login, sendID, images) } diff --git a/internal/agents/queue.go b/internal/agents/queue.go index 788c3c9..904dadb 100644 --- a/internal/agents/queue.go +++ b/internal/agents/queue.go @@ -43,6 +43,11 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model, se unlock := m.lockDelivery(sessionID) defer unlock() + prior, sent, err := m.priorSend(ctx, sendID) + if err != nil || sent { + return prior, false, err + } + sess, err := m.store.GetSession(ctx, sessionID) if err != nil { return api.AgentSession{}, false, err @@ -83,6 +88,22 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model, se return sess, true, nil } +func (m *Manager) priorSend(ctx context.Context, sendID string) (api.AgentSession, bool, error) { + if sendID == "" { + return api.AgentSession{}, false, nil + } + sessionID, found, err := m.store.SendSession(ctx, sendID) + if err != nil || !found || sessionID == "" { + return api.AgentSession{}, found, err + } + sess, err := m.store.GetSession(ctx, sessionID) + if err != nil { + return api.AgentSession{}, false, err + } + slog.Info("send already accepted", "session", sess.Id, "send", sendID) + return sess, true, nil +} + func automatedActor(actor string) bool { return actor == "byre" || actor == agentActor || actor == boardActor } diff --git a/internal/store/migrations/070-index-event-send-ids.sql b/internal/store/migrations/070-index-event-send-ids.sql new file mode 100644 index 0000000..41570cc --- /dev/null +++ b/internal/store/migrations/070-index-event-send-ids.sql @@ -0,0 +1 @@ +CREATE INDEX events_send_id ON events(json_extract(payload, '$.send_id')) WHERE kind IN ('user.queued', 'user.message'); diff --git a/internal/store/store.go b/internal/store/store.go index 60c542e..e66518b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1356,6 +1356,16 @@ func (s *Store) SessionStarted(ctx context.Context, sessionID string) (bool, err return started, err } +func (s *Store) UnusedSession(ctx context.Context, taskID, member string) (string, bool, error) { + id := "" + err := s.db.QueryRowContext(ctx, `SELECT id FROM agent_sessions WHERE task_id = ? AND member = ? AND role = ? AND state = ? AND started = 0 AND pending = 0 LIMIT 1`, + taskID, member, string(api.Implementer), string(api.AgentSessionStateIdle)).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + return id, err == nil, err +} + func (s *Store) SessionPending(ctx context.Context, sessionID string) (bool, error) { pending := false err := s.db.QueryRowContext(ctx, `SELECT pending FROM agent_sessions WHERE id = ?`, sessionID).Scan(&pending) diff --git a/internal/store/taskinputs.go b/internal/store/taskinputs.go index 722904c..669e11b 100644 --- a/internal/store/taskinputs.go +++ b/internal/store/taskinputs.go @@ -45,6 +45,22 @@ func (s *Store) AddTaskInput(ctx context.Context, in TaskInput, projectID, sessi }) } +func (s *Store) SendSession(ctx context.Context, sendID string) (string, bool, error) { + sessionID := sql.NullString{} + err := s.db.QueryRowContext(ctx, `SELECT agent_session_id FROM events WHERE kind IN ('user.queued', 'user.message') AND json_extract(payload, '$.send_id') = ? LIMIT 1`, sendID).Scan(&sessionID) + if err == nil { + return sessionID.String, true, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return "", false, err + } + err = s.db.QueryRowContext(ctx, `SELECT 1 FROM lead_inputs WHERE send_id = ? LIMIT 1`, sendID).Scan(new(int)) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + return "", err == nil, err +} + func (s *Store) WithdrawTaskInput(ctx context.Context, taskID string, id int64, projectID, actor string) (string, error) { sessionID := "" err := s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { diff --git a/mac/Sources/Byre/Composer.swift b/mac/Sources/Byre/Composer.swift index c4b3da5..cbe8350 100644 --- a/mac/Sources/Byre/Composer.swift +++ b/mac/Sources/Byre/Composer.swift @@ -521,23 +521,42 @@ struct ComposerView: View { let sent = drafts.startSend(draftKey, value, imgs.count) let sendID = UUID().uuidString let cancel = chat.team ? { true } : chat.builder.showPending(value, imgs, sendID) + let sessionId = m.live?.id Task { - do { - let ids = try await uploadImages(store.api, imgs) - try await chat.send(sessionId: m.live?.id, text: value, images: ids, model: m.selected != m.current ? m.selected : nil, firstModel: m.selected, sendID: sendID) - modelChoices[m.modelKey] = nil - pick = nil - drafts.endSend(sent, true) - } catch { - guard cancel() else { - log.notice("send failed after the server confirmed it: \(errorText(error), privacy: .public)") + var ids: [String]? = nil + var uploaded = false + while true { + do { + if !uploaded { + ids = try await uploadImages(store.api, imgs) + uploaded = true + } + try await chat.send(sessionId: sessionId, text: value, images: ids, model: m.selected != m.current ? m.selected : nil, firstModel: m.selected, sendID: sendID) + modelChoices[m.modelKey] = nil + pick = nil drafts.endSend(sent, true) return + } catch { + let refused = (rootError(error) as? ApiError).map { $0.status < 500 } ?? false + if chat.team || refused { + guard cancel() else { + log.notice("send failed after the server confirmed it: \(errorText(error), privacy: .public)") + drafts.endSend(sent, true) + return + } + drafts.endSend(sent, false) + text = drafts.composerText(draftKey) + images.restore(draftKey, imgs) + overlays.showAlert(errorText(error)) + return + } + guard chat.builder.unconfirmed(sendID) else { + drafts.endSend(sent, true) + return + } + log.notice("send failed, retrying: \(errorText(error), privacy: .public)") + try? await Task.sleep(for: .seconds(2)) } - drafts.endSend(sent, false) - text = drafts.composerText(draftKey) - images.restore(draftKey, imgs) - overlays.showAlert(errorText(error)) } } } diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index e107e80..9496bf0 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -275,6 +275,10 @@ final class TranscriptBuilder { } } + func unconfirmed(_ sendID: String) -> Bool { + local.items.contains { $0.sendID == sendID } + } + func withdrawn(_ inputId: Int) { queued.removeAll { $0.inputId == inputId } }