diff --git a/TODO.md b/TODO.md index a613598..5ba72ed 100644 --- a/TODO.md +++ b/TODO.md @@ -4,9 +4,6 @@ # M2 — Seeing the work -- Comment on diff lines in the Changes view - Clicking a diff line opens a comment box under it; pending comments live in `Drafts.swift` beside the composer draft, and "Send N comments" posts them as one ordinary message, each as `path:line`, the quoted line and the comment. No new API. Conductor, Claude Code desktop, the Codex app, Warp, Factory and Superset all send batched line comments to the agent this way. - # M3 — Second vendor and review # M4 — Merging and history diff --git a/docs/macos.md b/docs/macos.md index 438dfbf..b7c32d9 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -45,7 +45,7 @@ SwiftUI's own macros don't build here: the SDK declares `@State`, `@Entry`, `@An | `Markdown.swift`, `MarkdownView.swift` | byre's Markdown parser to a block tree, and its views | | `Diagram.swift` | Native flowchart parser, layout and drawing for Mermaid blocks and inbox `diagram` attachments | | `Inbox.swift` | One item at a time, choices, Other, Let the agent decide | -| `Changes.swift` | Since main, This turn, pinned snapshots, per-file diffs | +| `Changes.swift` | Since main, This turn, pinned snapshots, per-file diffs, line comments | | `Files.swift` | Read-only worktree browser | | `Terminal.swift` | The terminal view and its WebSocket | | `Reviews.swift` | Review cards, finding diffs, Fix and Ignore | @@ -53,7 +53,7 @@ SwiftUI's own macros don't build here: the SDK declares `@State`, `@Entry`, `@An | `Project.swift` | Project tab and TODO items | | `Status.swift`, `Settings.swift` | Left-pane tabs | | `Switcher.swift` | ⌘K overlay, the shortcuts help and the Go actions (`runGo`) | -| `Drafts.swift` | Composer and answer drafts | +| `Drafts.swift` | Composer, answer and line-comment drafts | | `Notifications.swift` | `UNUserNotificationCenter` delivery | | `Dialogs.swift`, `Format.swift` | Overlays (alerts, modals with `showConfirm` and `showChoice`, the switcher's slot, the More menu, the image view) and the window's key monitor, `ago`, Copy, shared button and layout styles, diff lines | | `Images.swift` | Blob, worktree and `data:` images, their cache and the full-size overlay | diff --git a/docs/operations.md b/docs/operations.md index 3fd6e03..d63a0f0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -216,7 +216,7 @@ Event payloads are stored whole in `events.payload`, and `agent.tool_result` con 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. 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), and 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 tool calls collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. 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`). byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `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) stands on its own, 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 call ends the main agent's run of tool calls, so it never joins a group. 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. A queued message's `user.queued` event takes over its bubble (the oldest local one with the same text), and the transcript shows a "queued…" bubble below everything else for every `user.queued` not yet followed by a `user.message` with the same text, each `user.message` clearing the oldest such bubble first (queued messages are delivered oldest first), then a local one, 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/Reply 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). 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. +**Drafts.** Unsent composer text (task and lead) and unsent inbox answers (checked choices and Other/Reply 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. The task switcher (⌘K, or the project ▸ task picker centred in the right pane's top row) lists active tasks with their task state and open inbox item count (the Project tab shows the same state), live while it is open. Tasks with inbox items come first; within each group, the task least recently visited comes first (never-visited tasks before all others, the right pane's own task after all others, ties in the server's order: projects by name, tasks newest first), so ⌘K Enter repeated cycles through the tasks. Visit times are SwiftData `VisitRecord`s synced through iCloud (`docs/macos.md`, "Persistence"), stamped when a task is selected and again when it is left. After the tasks come the projects, one row each; picking one points the right pane at the project with no task, on the Project tab. Typing filters both and adds a last row that creates a task with the typed text as its title, in the right pane's project (the first project when there is none); Enter picks the highlighted row. Text before a first colon that starts at least one project's name (ignoring case, no spaces) picks those projects instead: `by: fix` lists only their tasks whose titles contain "fix", their project rows that match, and one row per project creating the task titled "fix" there. When no project name starts with it, the whole text is the title, so "Fix: login" stays as typed. A create row shows the user's default model (see "Agent sessions"), the model the composer offers first for the new task's first message; the task itself starts with no session. Below each create row, a "Start in …" row creates the same task and starts its agent on the default model with the title as its first message, through `POST /api/projects/{id}/tasks` with `prompt` set to the title. Picking a task points the right pane at it on the Inbox tab when it has open inbox items, otherwise on the Transcript tab, scrolled to its latest summary when that is unread (see below and "Lead agent"). @@ -325,7 +325,7 @@ A byre restart drops every connection; each app reconnects 2s later, and calls i `GET /api/tasks/{taskId}/changes` snapshots the task's worktree as if everything were staged: it copies the worktree's index to a temporary file, runs `git add --all` against that copy (`GIT_INDEX_FILE`), and `git write-tree`s it, so committed, staged, unstaged and untracked work is included, `.gitignore` is respected, and the worktree's real index is never touched. Starting from the real index rather than `read-tree HEAD` keeps git's stat cache, so unchanged files aren't rehashed. The tree is compared with `git merge-base HEAD `, or with `since` when given, using rename detection, giving per-file status and line counts. Work already merged into the default branch therefore drops out. The Changes view toggles between **Since main** and **This turn**, which passes the latest `user.message` event's `head` as `since`; the summary above the inbox always shows since main. `GET /api/tasks/{taskId}/diff` returns one file's patch between a `base` and `tree` from that response. Each request writes the changed blobs and a tree into the repo's object store as loose objects. -The Changes view lists the files with their counts, opens a file's diff on click (open files stay open across refreshes), and refreshes when it opens, when the selected task changes, and 1.5s after the selected task's last tool result or turn end. It can also be pinned to a snapshot (see "Snapshots"). The Inbox view shows just its summary line (file count, +/− totals, base) for the selected task at the top. +The Changes view lists the files with their counts, opens a file's diff on click (open files stay open across refreshes), and refreshes when it opens, when the selected task changes, and 1.5s after the selected task's last tool result or turn end. It can also be pinned to a snapshot (see "Snapshots"). Clicking a diff line (added, removed or context) opens a comment box under it and marks the line with an accent bar; clicking the line again closes an empty box, and **remove** drops the comment. Pending comments are drafts (see "Drafts" below), kept per task and pruned when it closes. Once any has text, the header shows **Send N comments**, which posts them as one ordinary message to the task's live session, or starts a session when there is none (`Store.messageTask`, also used by a review finding's Fix), in the order they were made, each as `` `path:line` `` (the new file's line; a removed line gives the old file's line and adds "(removed line)"), the diff line quoted with its `+`, `-` or space, and the comment, separated by blank lines (`commentsMessage` in `mac/Sources/Byre/Changes.swift`). Empty boxes stay open; a failed send puts the comments back. The Inbox view shows just its summary line (file count, +/− totals, base) for the selected task at the top. ## Files diff --git a/mac/Sources/Byre/Changes.swift b/mac/Sources/Byre/Changes.swift index 90bb682..1bb09ff 100644 --- a/mac/Sources/Byre/Changes.swift +++ b/mac/Sources/Byre/Changes.swift @@ -183,6 +183,17 @@ final class ChangesModel { ChangesMemory.open = open } + func shows(_ a: LineAnchor) -> Bool { + guard open.contains(a.path), case .success(let patch)? = diffs[a.path] else { + return false + } + let lines = patch.components(separatedBy: "\n") + guard let start = lines.firstIndex(where: { $0.hasPrefix("@@") }) else { + return false + } + return patchRows(lines[start...], a.path).contains { $0.anchor == a } + } + func ensureDiff(_ c: TaskChanges, _ f: FileChange) async { guard let taskId = store.selectedTaskId, diffs[f.path] == nil, !fetching.contains(f.path) else { return @@ -240,6 +251,7 @@ struct ChangesView: View { private struct ChangesHead: View { let model: ChangesModel @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays var body: some View { HStack(alignment: .center, spacing: 12) { @@ -253,11 +265,34 @@ private struct ChangesHead: View { .textSelection(.enabled) } Spacer(minLength: 0) + if !model.compact, let taskId = store.selectedTaskId, let drafts = store.drafts { + let n = drafts.lineComments(taskId).filter { !trimmed($0.text).isEmpty }.count + if n > 0 { + Button("Send \(n) \(n == 1 ? "comment" : "comments")") { + sendComments(taskId, drafts) + } + .buttonStyle(WebButtonStyle(primary: true)) + .fixedSize() + .help("Send the line comments to the task's agent as one message") + } + } toggle } .modifier(HeaderBar()) } + private func sendComments(_ taskId: String, _ drafts: Drafts) { + let sent = drafts.takeLineComments(taskId) + Task { + do { + try await store.messageTask(taskId, commentsMessage(sent)) + } catch { + drafts.restoreLineComments(taskId, sent) + overlays.showAlert(errorText(error)) + } + } + } + @ViewBuilder private var summary: some View { if let c = model.changes { @@ -340,16 +375,21 @@ private struct ChangesList: View { @Environment(Store.self) private var store var body: some View { - if store.selectedTaskId == nil { - EmptyText(text: "No task selected.") - } else if let c = model.changes { - if c.files.isEmpty { - EmptyText(text: model.empty) + if let taskId = store.selectedTaskId, let drafts = store.drafts { + let hidden = drafts.lineComments(taskId).filter { !model.shows($0.anchor) } + let files = model.changes?.files ?? [] + if files.isEmpty && hidden.isEmpty { + EmptyText(text: model.changes != nil ? model.empty : model.error.isEmpty ? "Loading…" : "") } else { ScrollView { LazyVStack(alignment: .leading, spacing: 6) { - ForEach(c.files.sorted { $0.path.localizedCompare($1.path) == .orderedAscending }, id: \.path) { f in - FileRow(model: model, changes: c, file: f) + if !hidden.isEmpty { + HiddenComments(drafts: drafts, taskId: taskId, comments: hidden) + } + if let c = model.changes { + ForEach(c.files.sorted { $0.path.localizedCompare($1.path) == .orderedAscending }, id: \.path) { f in + FileRow(model: model, changes: c, file: f) + } } } .padding(.horizontal, 16) @@ -358,11 +398,38 @@ private struct ChangesList: View { } } } else { - EmptyText(text: model.error.isEmpty ? "Loading…" : "") + EmptyText(text: "No task selected.") } } } +private struct HiddenComments: View { + let drafts: Drafts + let taskId: String + let comments: [LineComment] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Comments on lines not in the diff shown") + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + ForEach(comments, id: \.anchor) { c in + VStack(alignment: .leading, spacing: 2) { + Text("\(c.anchor.path):\(c.anchor.line)\(c.anchor.old ? " (removed line)" : "")") + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + Text(verbatim: c.anchor.quote) + .font(Fonts.font(11)) + .lineLimit(1) + .truncationMode(.tail) + LineCommentBox(drafts: drafts, taskId: taskId, anchor: c.anchor) + } + } + } + .padding(.bottom, 6) + } +} + private struct FileRow: View { let model: ChangesModel let changes: TaskChanges @@ -410,7 +477,7 @@ private struct FileRow: View { case .failure(let e): Text(e.message).foregroundStyle(Palette.danger).padding(.horizontal, 10).padding(.vertical, 6) case .success(let patch): - PatchView(patch: patch) + PatchView(patch: patch, path: file.path, taskId: model.store.selectedTaskId) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -438,13 +505,53 @@ private struct FileRow: View { } } +private let hunkHead = Re(#"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@"#) + +private func patchRows(_ lines: ArraySlice, _ path: String) -> [DiffRow] { + var old = 0 + var new = 0 + return lines.map { l in + var row = DiffRow(text: l, kind: PatchLine(l)) + switch row.kind { + case .hunk: + if let m = hunkHead.exec(l) { + old = Int(m[1]!) ?? 0 + new = Int(m[2]!) ?? 0 + } + case .add: + row.anchor = LineAnchor(path: path, line: new, old: false, quote: l) + new += 1 + case .del: + row.anchor = LineAnchor(path: path, line: old, old: true, quote: l) + old += 1 + case .ctx: + if l.hasPrefix(" ") { + row.anchor = LineAnchor(path: path, line: new, old: false, quote: l) + old += 1 + new += 1 + } + case .meta: + break + } + return row + } +} + +func commentsMessage(_ comments: [LineComment]) -> String { + comments.map { c in + "`\(c.anchor.path):\(c.anchor.line)`\(c.anchor.old ? " (removed line)" : "")\n> \(c.anchor.quote)\n\n\(trimmed(c.text))" + }.joined(separator: "\n\n") +} + struct PatchView: View { let patch: String + let path: String + let taskId: String? var body: some View { let lines = patch.components(separatedBy: "\n") if let start = lines.firstIndex(where: { $0.hasPrefix("@@") }) { - DiffLines(rows: lines[start...].map { DiffRow(text: $0, kind: PatchLine($0)) }) + DiffLines(rows: patchRows(lines[start...], path), taskId: taskId) } else { Text("No textual changes.") .foregroundStyle(Palette.muted) @@ -459,34 +566,34 @@ struct DiffRow { let kind: PatchLine var lineno: String? var mark = false + var anchor: LineAnchor? } struct DiffLines: View { let rows: [DiffRow] + var taskId: String? + @Environment(Store.self) private var store @ViewState private var width: CGFloat = 0 var body: some View { ScrollView(.horizontal) { StretchColumn(minWidth: width) { ForEach(Array(rows.enumerated()), id: \.offset) { _, row in - HStack(spacing: 0) { - if let n = row.lineno { - Text(n) - .foregroundStyle(Palette.muted) - .frame(minWidth: 30, alignment: .trailing) - .padding(.trailing, 8) - } - Text(verbatim: row.text.isEmpty ? " " : row.text) - .foregroundStyle(row.kind.color) - } - .fixedSize() - .padding(.horizontal, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(row.kind.background) - .overlay(alignment: .leading) { - if row.mark { - Rectangle().fill(Palette.accent).frame(width: 3) + if let taskId, let anchor = row.anchor, let drafts = store.drafts { + let comment = drafts.lineComment(taskId, anchor) + DiffLineRow(row: row, mark: comment != nil, hoverable: true) + .simultaneousGesture(TapGesture().onEnded { + drafts.toggleLineComment(taskId, anchor) + }) + if comment != nil { + LineCommentBox(drafts: drafts, taskId: taskId, anchor: anchor) + .frame(width: max(0, width - 20)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) } + } else { + DiffLineRow(row: row, mark: row.mark, hoverable: false) } } } @@ -502,3 +609,78 @@ struct DiffLines: View { } } } + +private struct DiffLineRow: View { + let row: DiffRow + let mark: Bool + let hoverable: Bool + @ViewState private var hover = false + + var body: some View { + HStack(spacing: 0) { + if let n = row.lineno { + Text(n) + .foregroundStyle(Palette.muted) + .frame(minWidth: 30, alignment: .trailing) + .padding(.trailing, 8) + } + Text(verbatim: row.text.isEmpty ? " " : row.text) + .foregroundStyle(row.kind.color) + } + .fixedSize() + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(row.kind.background) + .background(hover ? Palette.accent.opacity(0.08) : .clear) + .overlay(alignment: .leading) { + if mark { + Rectangle().fill(Palette.accent).frame(width: 3) + } + } + .contentShape(Rectangle()) + .help(hoverable ? "Click to comment on this line" : "") + .onHover { on in + hover = hoverable && on + } + } +} + +private struct LineCommentBox: View { + let drafts: Drafts + let taskId: String + let anchor: LineAnchor + @FocusState private var focused: Bool + + var body: some View { + let text = Binding(get: { drafts.lineComment(taskId, anchor)?.text ?? "" }, set: { drafts.setLineComment(taskId, anchor, $0) }) + VStack(alignment: .trailing, spacing: 4) { + TextEditor(text: text) + .font(Fonts.font(12)) + .scrollContentBackground(.hidden) + .focused($focused) + .padding(.horizontal, 5) + .padding(.vertical, 4) + .frame(height: 64) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: 6)) + .overlay(alignment: .topLeading) { + if text.wrappedValue.isEmpty { + Text("Comment on line \(anchor.line)…") + .foregroundStyle(Palette.muted) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .allowsHitTesting(false) + } + } + .overlay(RoundedRectangle(cornerRadius: 6).stroke(focused ? Palette.accent : Palette.border)) + Button("remove") { + drafts.dropLineComment(taskId, anchor) + } + .buttonStyle(SmallLinkButtonStyle()) + } + .onAppear { + if text.wrappedValue.isEmpty { + focused = true + } + } + } +} diff --git a/mac/Sources/Byre/Drafts.swift b/mac/Sources/Byre/Drafts.swift index 036f0ad..c21ea95 100644 --- a/mac/Sources/Byre/Drafts.swift +++ b/mac/Sources/Byre/Drafts.swift @@ -11,6 +11,19 @@ private struct Saved: Codable { var composer: [String: Composer] = [:] var checked: [String: [String]] = [:] var texts: [String: String] = [:] + var comments: [String: [LineComment]]? = nil +} + +struct LineAnchor: Codable, Hashable { + let path: String + let line: Int + let old: Bool + let quote: String +} + +struct LineComment: Codable { + let anchor: LineAnchor + var text: String } final class DraftSend { @@ -40,6 +53,7 @@ final class Drafts { private var lostImages: [String: Int] = [:] private var checked: [String: [String]] = [:] private var texts: [String: String] = [:] + private var comments: [String: [LineComment]] = [:] @ObservationIgnored private let scope: String @ObservationIgnored private var timer: Task? @ObservationIgnored private var dirty = false @@ -59,6 +73,7 @@ final class Drafts { } checked = saved.checked texts = saved.texts + comments = saved.comments ?? [:] } func reload() { @@ -94,6 +109,10 @@ final class Drafts { changed = true } } + for taskId in comments.keys where store.findTask(taskId)?.status != .active { + comments[taskId] = nil + changed = true + } for id in Set(checked.keys).union(texts.keys) where !store.inbox.contains(where: { $0.id == id }) { checked[id] = nil texts[id] = nil @@ -122,7 +141,7 @@ final class Drafts { return } dirty = false - var out = Saved(checked: checked, texts: texts) + var out = Saved(checked: checked, texts: texts, comments: comments) let keys = Set(composerTexts.keys).union(composerImages.keys).union(sending.map(\.key)).union(lostImages.keys) for key in keys { let inFlight = sending.filter { $0.key == key } @@ -233,6 +252,67 @@ final class Drafts { save() } + func lineComments(_ taskId: String) -> [LineComment] { + comments[taskId] ?? [] + } + + func lineComment(_ taskId: String, _ anchor: LineAnchor) -> LineComment? { + comments[taskId]?.first { $0.anchor == anchor } + } + + func toggleLineComment(_ taskId: String, _ anchor: LineAnchor) { + var list = comments[taskId] ?? [] + if let i = list.firstIndex(where: { $0.anchor == anchor }) { + if !trimmed(list[i].text).isEmpty { + return + } + list.remove(at: i) + } else { + list.append(LineComment(anchor: anchor, text: "")) + } + comments[taskId] = list.isEmpty ? nil : list + save() + } + + func setLineComment(_ taskId: String, _ anchor: LineAnchor, _ text: String) { + guard let i = comments[taskId]?.firstIndex(where: { $0.anchor == anchor }), comments[taskId]![i].text != text else { + return + } + comments[taskId]![i].text = text + save() + } + + func dropLineComment(_ taskId: String, _ anchor: LineAnchor) { + comments[taskId]?.removeAll { $0.anchor == anchor } + if comments[taskId]?.isEmpty == true { + comments[taskId] = nil + } + save() + } + + func takeLineComments(_ taskId: String) -> [LineComment] { + let all = comments[taskId] ?? [] + let ready = all.filter { !trimmed($0.text).isEmpty } + let rest = all.filter { trimmed($0.text).isEmpty } + comments[taskId] = rest.isEmpty ? nil : rest + dirty = true + flush() + return ready + } + + func restoreLineComments(_ taskId: String, _ back: [LineComment]) { + var list = back + for c in comments[taskId] ?? [] { + if let i = list.firstIndex(where: { $0.anchor == c.anchor }) { + list[i].text = [list[i].text, c.text].filter { !trimmed($0).isEmpty }.joined(separator: "\n\n") + } else { + list.append(c) + } + } + comments[taskId] = list + save() + } + func clearAnswer(_ id: String) { checked[id] = nil texts[id] = nil diff --git a/mac/Sources/Byre/Reviews.swift b/mac/Sources/Byre/Reviews.swift index d0f573f..9b9d726 100644 --- a/mac/Sources/Byre/Reviews.swift +++ b/mac/Sources/Byre/Reviews.swift @@ -442,12 +442,7 @@ private struct FindingActions: View { busy = true Task { do { - let sessions = try await store.api.client.listSessions(path: .init(taskId: taskId)).ok.body.json - if let live = sessions.last(where: { $0.state != .completed }) { - _ = try await store.api.client.sendMessage(path: .init(sessionId: live.id), body: .json(.init(text: text))).accepted - } else { - _ = try await store.api.client.createSession(path: .init(taskId: taskId), body: .json(.init(prompt: text))).created - } + try await store.messageTask(taskId, text) } catch { busy = false overlays.showAlert(errorText(error)) diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index 5c87b45..aadf632 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -257,6 +257,15 @@ final class Store { sessions[taskId] = list } + func messageTask(_ taskId: String, _ text: String) 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))).accepted + } else { + _ = try await api.client.createSession(path: .init(taskId: taskId), body: .json(.init(prompt: text))).created + } + } + func loadInbox() async throws { let latest = claimRequest("inbox") let items = try await api.client.listInbox().ok.body.json