Comment on diff lines in the Changes view
This commit is contained in:
@@ -4,9 +4,6 @@
|
|||||||
|
|
||||||
# M2 — Seeing the work
|
# 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
|
# M3 — Second vendor and review
|
||||||
|
|
||||||
# M4 — Merging and history
|
# M4 — Merging and history
|
||||||
|
|||||||
+2
-2
@@ -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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `Files.swift` | Read-only worktree browser |
|
||||||
| `Terminal.swift` | The terminal view and its WebSocket |
|
| `Terminal.swift` | The terminal view and its WebSocket |
|
||||||
| `Reviews.swift` | Review cards, finding diffs, Fix and Ignore |
|
| `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 |
|
| `Project.swift` | Project tab and TODO items |
|
||||||
| `Status.swift`, `Settings.swift` | Left-pane tabs |
|
| `Status.swift`, `Settings.swift` | Left-pane tabs |
|
||||||
| `Switcher.swift` | ⌘K overlay, the shortcuts help and the Go actions (`runGo`) |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `Images.swift` | Blob, worktree and `data:` images, their cache and the full-size overlay |
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
+209
-27
@@ -183,6 +183,17 @@ final class ChangesModel {
|
|||||||
ChangesMemory.open = open
|
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 {
|
func ensureDiff(_ c: TaskChanges, _ f: FileChange) async {
|
||||||
guard let taskId = store.selectedTaskId, diffs[f.path] == nil, !fetching.contains(f.path) else {
|
guard let taskId = store.selectedTaskId, diffs[f.path] == nil, !fetching.contains(f.path) else {
|
||||||
return
|
return
|
||||||
@@ -240,6 +251,7 @@ struct ChangesView: View {
|
|||||||
private struct ChangesHead: View {
|
private struct ChangesHead: View {
|
||||||
let model: ChangesModel
|
let model: ChangesModel
|
||||||
@Environment(Store.self) private var store
|
@Environment(Store.self) private var store
|
||||||
|
@Environment(Overlays.self) private var overlays
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .center, spacing: 12) {
|
HStack(alignment: .center, spacing: 12) {
|
||||||
@@ -253,11 +265,34 @@ private struct ChangesHead: View {
|
|||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
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
|
toggle
|
||||||
}
|
}
|
||||||
.modifier(HeaderBar())
|
.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
|
@ViewBuilder
|
||||||
private var summary: some View {
|
private var summary: some View {
|
||||||
if let c = model.changes {
|
if let c = model.changes {
|
||||||
@@ -340,16 +375,21 @@ private struct ChangesList: View {
|
|||||||
@Environment(Store.self) private var store
|
@Environment(Store.self) private var store
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if store.selectedTaskId == nil {
|
if let taskId = store.selectedTaskId, let drafts = store.drafts {
|
||||||
EmptyText(text: "No task selected.")
|
let hidden = drafts.lineComments(taskId).filter { !model.shows($0.anchor) }
|
||||||
} else if let c = model.changes {
|
let files = model.changes?.files ?? []
|
||||||
if c.files.isEmpty {
|
if files.isEmpty && hidden.isEmpty {
|
||||||
EmptyText(text: model.empty)
|
EmptyText(text: model.changes != nil ? model.empty : model.error.isEmpty ? "Loading…" : "")
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 6) {
|
LazyVStack(alignment: .leading, spacing: 6) {
|
||||||
ForEach(c.files.sorted { $0.path.localizedCompare($1.path) == .orderedAscending }, id: \.path) { f in
|
if !hidden.isEmpty {
|
||||||
FileRow(model: model, changes: c, file: f)
|
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)
|
.padding(.horizontal, 16)
|
||||||
@@ -358,11 +398,38 @@ private struct ChangesList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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 {
|
private struct FileRow: View {
|
||||||
let model: ChangesModel
|
let model: ChangesModel
|
||||||
let changes: TaskChanges
|
let changes: TaskChanges
|
||||||
@@ -410,7 +477,7 @@ private struct FileRow: View {
|
|||||||
case .failure(let e):
|
case .failure(let e):
|
||||||
Text(e.message).foregroundStyle(Palette.danger).padding(.horizontal, 10).padding(.vertical, 6)
|
Text(e.message).foregroundStyle(Palette.danger).padding(.horizontal, 10).padding(.vertical, 6)
|
||||||
case .success(let patch):
|
case .success(let patch):
|
||||||
PatchView(patch: patch)
|
PatchView(patch: patch, path: file.path, taskId: model.store.selectedTaskId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.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<String>, _ 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 {
|
struct PatchView: View {
|
||||||
let patch: String
|
let patch: String
|
||||||
|
let path: String
|
||||||
|
let taskId: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let lines = patch.components(separatedBy: "\n")
|
let lines = patch.components(separatedBy: "\n")
|
||||||
if let start = lines.firstIndex(where: { $0.hasPrefix("@@") }) {
|
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 {
|
} else {
|
||||||
Text("No textual changes.")
|
Text("No textual changes.")
|
||||||
.foregroundStyle(Palette.muted)
|
.foregroundStyle(Palette.muted)
|
||||||
@@ -459,34 +566,34 @@ struct DiffRow {
|
|||||||
let kind: PatchLine
|
let kind: PatchLine
|
||||||
var lineno: String?
|
var lineno: String?
|
||||||
var mark = false
|
var mark = false
|
||||||
|
var anchor: LineAnchor?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DiffLines: View {
|
struct DiffLines: View {
|
||||||
let rows: [DiffRow]
|
let rows: [DiffRow]
|
||||||
|
var taskId: String?
|
||||||
|
@Environment(Store.self) private var store
|
||||||
@ViewState private var width: CGFloat = 0
|
@ViewState private var width: CGFloat = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
StretchColumn(minWidth: width) {
|
StretchColumn(minWidth: width) {
|
||||||
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||||||
HStack(spacing: 0) {
|
if let taskId, let anchor = row.anchor, let drafts = store.drafts {
|
||||||
if let n = row.lineno {
|
let comment = drafts.lineComment(taskId, anchor)
|
||||||
Text(n)
|
DiffLineRow(row: row, mark: comment != nil, hoverable: true)
|
||||||
.foregroundStyle(Palette.muted)
|
.simultaneousGesture(TapGesture().onEnded {
|
||||||
.frame(minWidth: 30, alignment: .trailing)
|
drafts.toggleLineComment(taskId, anchor)
|
||||||
.padding(.trailing, 8)
|
})
|
||||||
}
|
if comment != nil {
|
||||||
Text(verbatim: row.text.isEmpty ? " " : row.text)
|
LineCommentBox(drafts: drafts, taskId: taskId, anchor: anchor)
|
||||||
.foregroundStyle(row.kind.color)
|
.frame(width: max(0, width - 20))
|
||||||
}
|
.padding(.horizontal, 10)
|
||||||
.fixedSize()
|
.padding(.vertical, 4)
|
||||||
.padding(.horizontal, 10)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(row.kind.background)
|
|
||||||
.overlay(alignment: .leading) {
|
|
||||||
if row.mark {
|
|
||||||
Rectangle().fill(Palette.accent).frame(width: 3)
|
|
||||||
}
|
}
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,19 @@ private struct Saved: Codable {
|
|||||||
var composer: [String: Composer] = [:]
|
var composer: [String: Composer] = [:]
|
||||||
var checked: [String: [String]] = [:]
|
var checked: [String: [String]] = [:]
|
||||||
var texts: [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 {
|
final class DraftSend {
|
||||||
@@ -40,6 +53,7 @@ final class Drafts {
|
|||||||
private var lostImages: [String: Int] = [:]
|
private var lostImages: [String: Int] = [:]
|
||||||
private var checked: [String: [String]] = [:]
|
private var checked: [String: [String]] = [:]
|
||||||
private var texts: [String: String] = [:]
|
private var texts: [String: String] = [:]
|
||||||
|
private var comments: [String: [LineComment]] = [:]
|
||||||
@ObservationIgnored private let scope: String
|
@ObservationIgnored private let scope: String
|
||||||
@ObservationIgnored private var timer: Task<Void, Never>?
|
@ObservationIgnored private var timer: Task<Void, Never>?
|
||||||
@ObservationIgnored private var dirty = false
|
@ObservationIgnored private var dirty = false
|
||||||
@@ -59,6 +73,7 @@ final class Drafts {
|
|||||||
}
|
}
|
||||||
checked = saved.checked
|
checked = saved.checked
|
||||||
texts = saved.texts
|
texts = saved.texts
|
||||||
|
comments = saved.comments ?? [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
func reload() {
|
func reload() {
|
||||||
@@ -94,6 +109,10 @@ final class Drafts {
|
|||||||
changed = true
|
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 }) {
|
for id in Set(checked.keys).union(texts.keys) where !store.inbox.contains(where: { $0.id == id }) {
|
||||||
checked[id] = nil
|
checked[id] = nil
|
||||||
texts[id] = nil
|
texts[id] = nil
|
||||||
@@ -122,7 +141,7 @@ final class Drafts {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
dirty = false
|
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)
|
let keys = Set(composerTexts.keys).union(composerImages.keys).union(sending.map(\.key)).union(lostImages.keys)
|
||||||
for key in keys {
|
for key in keys {
|
||||||
let inFlight = sending.filter { $0.key == key }
|
let inFlight = sending.filter { $0.key == key }
|
||||||
@@ -233,6 +252,67 @@ final class Drafts {
|
|||||||
save()
|
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) {
|
func clearAnswer(_ id: String) {
|
||||||
checked[id] = nil
|
checked[id] = nil
|
||||||
texts[id] = nil
|
texts[id] = nil
|
||||||
|
|||||||
@@ -442,12 +442,7 @@ private struct FindingActions: View {
|
|||||||
busy = true
|
busy = true
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let sessions = try await store.api.client.listSessions(path: .init(taskId: taskId)).ok.body.json
|
try await store.messageTask(taskId, text)
|
||||||
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
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
busy = false
|
busy = false
|
||||||
overlays.showAlert(errorText(error))
|
overlays.showAlert(errorText(error))
|
||||||
|
|||||||
@@ -257,6 +257,15 @@ final class Store {
|
|||||||
sessions[taskId] = list
|
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 {
|
func loadInbox() async throws {
|
||||||
let latest = claimRequest("inbox")
|
let latest = claimRequest("inbox")
|
||||||
let items = try await api.client.listInbox().ok.body.json
|
let items = try await api.client.listInbox().ok.body.json
|
||||||
|
|||||||
Reference in New Issue
Block a user