Add ⌘F find in transcripts and a TODO filter on the project page

This commit is contained in:
Ian Gulliver
2026-10-23 09:50:35 -07:00
parent fe86916ce6
commit fd0529b949
11 changed files with 434 additions and 34 deletions
+1
View File
@@ -111,6 +111,7 @@ struct ByreCommands: Commands {
goButton("Project", "p")
goButton("Lead", "l")
goButton("Merge", "s")
goButton("Find", "f")
serverButton("Previous Server", "[", -1)
serverButton("Next Server", "]", 1)
Divider()
+101 -15
View File
@@ -57,6 +57,18 @@ final class ChatModel {
private(set) var scrollRequest: (seq: Int, id: Int?, anchor: UnitPoint)?
private(set) var fetching = false
private(set) var older: [ByreEvent]?
private(set) var findOpen = false
private(set) var findFocus = 0
var findQuery = "" {
didSet {
if findQuery != oldValue {
refreshFind(jump: true)
}
}
}
private(set) var findHits: [FindHit] = []
private(set) var findIndex = 0
private(set) var findPath: Set<Int> = []
@ObservationIgnored var stuck = true
@ObservationIgnored var prepended = false
@ObservationIgnored var released: Int?
@@ -261,6 +273,55 @@ final class ChatModel {
settled = feed?.ready == true
}
jump()
if !findQuery.isEmpty {
refreshFind(jump: false)
}
}
func openFind() {
findOpen = true
findFocus += 1
}
func closeFind() {
findOpen = false
findQuery = ""
}
func stepFind(_ by: Int) {
if findHits.isEmpty {
return
}
findIndex = (findIndex + by + findHits.count) % findHits.count
revealFind()
}
private func refreshFind(jump: Bool) {
let current = findHits.indices.contains(findIndex) ? findHits[findIndex].id : nil
findHits = searchRows(builder.root.rows, findQuery)
if !findQuery.isEmpty {
loadOlder()
}
if jump || (current == nil && !findHits.isEmpty) {
findIndex = max(0, findHits.count - 1)
revealFind()
return
}
findIndex = findHits.firstIndex { $0.id == current } ?? min(findIndex, max(0, findHits.count - 1))
}
private func revealFind() {
guard findHits.indices.contains(findIndex) else {
findPath = []
return
}
let hit = findHits[findIndex]
findPath = Set(hit.path + [hit.id])
release()
stuck = false
DispatchQueue.main.async { [weak self] in
self?.requestScroll(hit.id, .top)
}
}
func takeFocus() -> Bool {
@@ -483,6 +544,9 @@ final class ChatModel {
let n = builder.root.rows.count
builder.prepend(events)
prepended = builder.root.rows.count > n || !hasOlder
if !findQuery.isEmpty {
refreshFind(jump: false)
}
if !prepended {
loadOlder()
}
@@ -582,13 +646,18 @@ final class ChatModel {
struct ChatView: View {
let kind: ChatKind
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var model: ChatModel?
var body: some View {
VStack(spacing: 0) {
if let model {
ChatHead(model: model)
if model.findOpen {
FindBar(model: model)
}
TranscriptScroll(model: model)
.environment(\.findQuery, model.findOpen ? model.findQuery : "")
if let key = model.key {
ComposerView(chat: model, key: key)
}
@@ -607,6 +676,11 @@ struct ChatView: View {
.onChange(of: store.jump) {
model?.jump()
}
.onChange(of: kind == .lead ? overlays.findLead : overlays.findTask) {
if model?.key != nil {
model?.openFind()
}
}
}
}
@@ -1173,6 +1247,7 @@ struct DetailsBox<Summary: View, Content: View>: View {
var working = false
var fade = false
var status: (text: String, color: Color)? = nil
var reveal = false
@ViewBuilder let summary: Summary
@ViewBuilder let content: Content
@Environment(\.paneAccent) private var accent
@@ -1185,7 +1260,7 @@ struct DetailsBox<Summary: View, Content: View>: View {
open.toggle()
} label: {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(open ? "▾" : "▸")
Text(open || reveal ? "▾" : "▸")
.foregroundStyle(Palette.muted)
summary
Spacer(minLength: 0)
@@ -1199,7 +1274,7 @@ struct DetailsBox<Summary: View, Content: View>: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if open {
if open || reveal {
content
}
}
@@ -1216,6 +1291,11 @@ struct DetailsBox<Summary: View, Content: View>: View {
.onAppear {
shown = true
}
.onChange(of: reveal, initial: true) {
if reveal {
open = true
}
}
}
}
@@ -1246,6 +1326,11 @@ struct RowView: View {
let model: ChatModel
let nested: Bool
@Environment(Store.self) private var store
@Environment(\.findQuery) private var query
private var reveal: Bool {
model.findPath.contains(row.id)
}
var body: some View {
if !row.hidden {
@@ -1289,8 +1374,8 @@ struct RowView: View {
}
case .byrePrompt(let brief, let when, let blocks):
DetailsBox(nested: nested) {
Text(styled([("byre", 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]))
DetailsBox(nested: nested, reveal: reveal) {
Text(highlighted(styled([("byre", 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]), query))
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 12)
WhenText(when: when).font(Fonts.font(12)).foregroundStyle(Palette.muted)
@@ -1332,9 +1417,9 @@ struct RowView: View {
}
case .tool(let name, let brief, let input):
DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row)) {
DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row), reveal: reveal) {
SubagentTag(show: row.tag)
Text(styled([(name, 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]))
Text(highlighted(styled([(name, 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)]), query))
} content: {
VStack(alignment: .leading, spacing: 6) {
PreBox(text: input)
@@ -1344,9 +1429,9 @@ struct RowView: View {
}
case .subagent(let title, let description, let prompt):
DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) {
DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent), reveal: reveal) {
SubagentTag(show: row.tag)
Text(styled([(title, 600, nil), (" ", 400, nil), (description, 400, Palette.muted)]))
Text(highlighted(styled([(title, 600, nil), (" ", 400, nil), (description, 400, Palette.muted)]), query))
} content: {
VStack(alignment: .leading, spacing: 6) {
MarkdownView(blocks: prompt, ctx: md)
@@ -1359,8 +1444,8 @@ struct RowView: View {
}
case .reviewer(let agentModel):
DetailsBox(nested: nested, working: row.active, fade: model.fades(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) {
Text(styled([(row.review?.stage ?? "review", 600, nil), (agentModel.isEmpty ? "" : " · \(agentModel)", 400, Palette.muted), (row.text.isEmpty ? "" : " \(row.text)", 400, Palette.muted)]))
DetailsBox(nested: nested, working: row.active, fade: model.fades(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent), reveal: reveal) {
Text(highlighted(styled([(row.review?.stage ?? "review", 600, nil), (agentModel.isEmpty ? "" : " · \(agentModel)", 400, Palette.muted), (row.text.isEmpty ? "" : " \(row.text)", 400, Palette.muted)]), query))
.lineLimit(1)
} content: {
VStack(alignment: .leading, spacing: 6) {
@@ -1373,7 +1458,7 @@ struct RowView: View {
}
case .group:
DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row)) {
DetailsBox(nested: nested, working: model.working(row), fade: model.fades(row), reveal: reveal) {
SubagentTag(show: row.tag)
RunSummary(calls: row.calls)
} content: {
@@ -1391,7 +1476,7 @@ struct RowView: View {
case .turnEnd(let error):
if let error {
Text(error)
Text(highlighted(error, query))
.font(Fonts.font(11))
.foregroundStyle(Palette.danger)
} else {
@@ -1426,7 +1511,7 @@ struct RowView: View {
ReadyBubble(when: when, blocks: blocks, discard: discard, taskId: row.taskId, latest: row.id == model.builder.lastReady, unmerged: model.hasUnmerged)
case .handoff:
DetailsBox(boxed: false) {
DetailsBox(boxed: false, reveal: reveal) {
NoteView(note: row.summary ?? Note(parts: []), cancelVisible: false)
} content: {
VStack(alignment: .leading, spacing: 12) {
@@ -1446,7 +1531,7 @@ struct RowView: View {
case .question:
if let item = row.question {
QuestionBubble(item: item, inherited: row.taskId != model.taskId)
QuestionBubble(item: item, inherited: row.taskId != model.taskId, reveal: reveal)
}
}
}
@@ -1455,6 +1540,7 @@ struct RowView: View {
private struct QuestionBubble: View {
let item: InboxItem
let inherited: Bool
let reveal: Bool
var body: some View {
let open = item.state == .open && !inherited
@@ -1463,7 +1549,7 @@ private struct QuestionBubble: View {
QuestionCard(item: item)
.id("\(item.id)/\(item.state.rawValue)/\(item.responses.count)")
} else {
DetailsBox(boxed: false) {
DetailsBox(boxed: false, reveal: reveal) {
VStack(alignment: .leading, spacing: 4) {
AttentionHeader(label: "asked", when: item.createdAt, open: false)
MarkdownView(blocks: parseMarkdown(jsString(item.payload.value["question"] ?? "")), ctx: MDContext(taskId: item.taskId))
+14
View File
@@ -56,6 +56,9 @@ final class Overlays {
var leftTab = LeftTab.lead
var leadFocus = 0
var tabFocus = 0
var findLead = 0
var findTask = 0
@ObservationIgnored var endFind: (token: String, run: () -> Void)?
var listKeys = false
var listCursor: String?
var projectTab = ProjectTab.tasks
@@ -148,6 +151,14 @@ final class Overlays {
#endif
}
func cancelFind() -> Bool {
guard let endFind else {
return false
}
endFind.run()
return true
}
func closeTop() {
if !alerts.isEmpty {
alerts.removeLast()
@@ -201,6 +212,9 @@ final class Overlays {
if !dialogOpen && flags.contains(.control) && !flags.contains(.command) && goKeys.contains(chars) {
return ctrl(chars)
}
if !dialogOpen && flags.subtracting(.capsLock) == .command && chars == "f" {
return ctrl(chars)
}
let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false
if code == 53 {
if isOpen {
+252
View File
@@ -0,0 +1,252 @@
import SwiftUI
private let findOptions: String.CompareOptions = [.caseInsensitive, .diacriticInsensitive]
extension EnvironmentValues {
@Entry var findQuery = ""
}
struct FindHit {
let id: Int
let path: [Int]
}
func matches(_ text: String, _ query: String) -> Bool {
!query.isEmpty && text.range(of: query, options: findOptions) != nil
}
func highlighted(_ a: AttributedString, _ query: String) -> AttributedString {
if query.isEmpty {
return a
}
var out = a
var from = out.startIndex
while let r = out[from...].range(of: query, options: findOptions) {
out[r].backgroundColor = Palette.findHit
#if os(macOS)
out[r].appKit.backgroundColor = NSColor(Palette.findHit)
#else
out[r].uiKit.backgroundColor = UIColor(Palette.findHit)
#endif
from = r.upperBound
}
return out
}
func highlighted(_ s: String, _ query: String) -> AttributedString {
highlighted(AttributedString(s), query)
}
func highlight(_ s: NSMutableAttributedString, _ query: String) {
if query.isEmpty {
return
}
let text = s.string as NSString
var from = 0
while from < text.length {
let r = text.range(of: query, options: findOptions, range: NSRange(location: from, length: text.length - from))
if r.location == NSNotFound {
return
}
s.addAttribute(.backgroundColor, value: PlatformColor(Palette.findHit), range: r)
from = NSMaxRange(r)
}
}
func mdText(_ blocks: [MDBlock]) -> String {
blocks.map(mdText).joined(separator: "\n")
}
private func mdText(_ block: MDBlock) -> String {
switch block {
case .paragraph(let inlines), .heading(_, let inlines):
return inlineText(inlines)
case .rule:
return ""
case .quote(let blocks):
return mdText(blocks)
case .list(_, _, let items, _):
return items.map { inlineText($0.inline) + "\n" + mdText($0.blocks) }.joined(separator: "\n")
case .code(let code), .diagram(let code):
return code
case .diff(let lines):
return lines.joined(separator: "\n")
case .details(let summary, let body):
return inlineText(summary) + "\n" + mdText(body)
case .table(let head, let rows):
return ([head] + rows).map { $0.map(inlineText).joined(separator: "\t") }.joined(separator: "\n")
}
}
private func inlineText(_ inlines: [MDInline]) -> String {
inlines.map { item in
switch item {
case .text(let t), .code(let t):
t
case .strong(let inner), .em(let inner), .link(_, let inner), .mark(_, let inner):
inlineText(inner)
case .image(let src, let alt):
alt.isEmpty ? src : alt
case .lineBreak:
"\n"
}
}.joined()
}
private func noteText(_ note: Note?) -> String {
(note?.parts ?? []).map { part in
if case .text(let t) = part {
return t
}
return ""
}.joined()
}
@MainActor
private func ownText(_ row: Row) -> String {
switch row.kind {
case .user(_, _, _, let md):
return mdText(md ?? [])
case .byrePrompt(let brief, _, let md):
return brief + "\n" + mdText(md)
case .agent(let md), .subagentMessage(_, let md), .thinking(let md), .ready(_, let md, _):
return mdText(md)
case .stream:
return mdText(row.md)
case .tool(let name, let brief, let input):
return [name, brief, input, row.result ?? ""].joined(separator: "\n")
case .subagent(let title, let description, let prompt):
return [title, description, mdText(prompt), row.result ?? ""].joined(separator: "\n")
case .reviewer:
return (row.review?.stage ?? "") + "\n" + row.text
case .orphan(let text):
return text
case .turnEnd(let error):
return error ?? ""
case .note(let note):
return noteText(note)
case .review(let note, let md):
return noteText(note) + "\n" + mdText(md)
case .finding(let index):
guard let review = row.review, findings(review).indices.contains(index) else {
return ""
}
let f = findings(review)[index]
return [str(f, "title") ?? "", str(f, "detail") ?? "", findingWhere(f)].joined(separator: "\n")
case .findingResponse(_, _, let response, let reason):
return response + "\n" + reason
case .handoff:
return noteText(row.summary)
case .question:
let p = row.question?.payload.value ?? [:]
return (str(p, "question") ?? "") + "\n" + (str(p, "context") ?? "")
case .group, .snapshot:
return ""
}
}
@MainActor
func searchRows(_ rows: [Row], _ query: String) -> [FindHit] {
var out: [FindHit] = []
func walk(_ rows: [Row], _ path: [Int]) {
for row in rows where !row.hidden {
if matches(ownText(row), query) {
out.append(FindHit(id: row.id, path: path))
}
if let body = row.body {
walk(body.rows, path + [row.id])
}
}
}
if !query.isEmpty {
walk(rows, [])
}
return out
}
@MainActor
struct FindField: View {
let placeholder: String
@Binding var text: String
let focusSignal: Int
var submit: (Bool) -> Void = { _ in }
let end: () -> Void
@Environment(Overlays.self) private var overlays
@FocusState private var focused: Bool
@ViewState private var token = newID("fnd")
var body: some View {
TextField(placeholder, text: $text)
.modifier(FieldStyle(focused: focused))
.focused($focused)
.onSubmit {
#if os(macOS)
submit(NSEvent.modifierFlags.contains(.shift))
#else
submit(false)
#endif
focused = true
}
.onChange(of: focusSignal, initial: true) { old, new in
if new != 0 && (old != new || !focused) {
focused = true
}
}
.onChange(of: focused, initial: true) {
if focused {
overlays.endFind = (token, end)
} else if overlays.endFind?.token == token {
overlays.endFind = nil
}
}
.onDisappear {
if overlays.endFind?.token == token {
overlays.endFind = nil
}
}
}
}
struct FindBar: View {
let model: ChatModel
var body: some View {
@Bindable var model = model
let count = model.findHits.count
HStack(spacing: 8) {
FindField(placeholder: "Find in transcript", text: $model.findQuery, focusSignal: model.findFocus) { back in
model.stepFind(back ? -1 : 1)
} end: {
model.closeFind()
}
Text(model.findQuery.isEmpty ? "" : count == 0 ? "No matches" : "\(model.findIndex + 1) of \(count)")
.font(Fonts.font(11))
.foregroundStyle(Palette.muted)
.monospacedDigit()
.fixedSize()
Group {
Button("↑") {
model.stepFind(-1)
}
.help("Previous match (Shift+Enter)")
.accessibilityLabel("Previous match")
Button("↓") {
model.stepFind(1)
}
.help("Next match (Enter)")
.accessibilityLabel("Next match")
}
.disabled(count == 0)
Button("Done") {
model.closeFind()
}
.help("Close the find bar (Esc)")
}
.buttonStyle(WebButtonStyle())
.padding(.horizontal, 16)
.padding(.vertical, 6)
.overlay(alignment: .bottom) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
+3 -2
View File
@@ -233,11 +233,12 @@ struct PreBox: View {
let text: String
var maxHeight: CGFloat? = 320
var size: CGFloat = 12
@Environment(\.findQuery) private var query
var body: some View {
Copyable(text: text) {
ScrollView {
Text(verbatim: text)
Text(highlighted(text, query))
.font(Fonts.font(size, mono: true))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -245,7 +246,7 @@ struct PreBox: View {
.padding(.vertical, 6)
}
.scrollBounceBehavior(.basedOnSize)
.frame(maxHeight: maxHeight)
.frame(maxHeight: matches(text, query) ? nil : maxHeight)
.fixedSize(horizontal: false, vertical: true)
.background(Palette.raised, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border))
+14 -7
View File
@@ -485,6 +485,7 @@ struct MarkdownView: View {
let blocks: [MDBlock]
var ctx = MDContext()
@Environment(Store.self) private var store
@Environment(\.findQuery) private var query
var body: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -502,7 +503,9 @@ struct MarkdownView: View {
private func flow(_ blocks: [MDBlock]) -> NSAttributedString {
var builder = FlowBuilder(store: store, ctx: ctx)
return builder.build(blocks)
let out = builder.build(blocks)
highlight(out, query)
return out
}
private var runs: [MDRun] {
@@ -564,7 +567,7 @@ private struct FlowBuilder {
self.ctx = ctx
}
mutating func build(_ blocks: [MDBlock]) -> NSAttributedString {
mutating func build(_ blocks: [MDBlock]) -> NSMutableAttributedString {
self.blocks(blocks, 0)
if out.length > 0 {
out.deleteCharacters(in: NSRange(location: out.length - 1, length: 1))
@@ -918,13 +921,15 @@ struct InlineView: View {
@Environment(Store.self) private var store
@Environment(\.choiceTap) private var choiceTap
@Environment(\.centerImages) private var centerImages
@Environment(\.findQuery) private var query
var body: some View {
let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId, project: ctx.project, weight: ctx.weight, dir: ctx.dir).segments(inlines)
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(segs.enumerated()), id: \.offset) { _, seg in
switch seg {
case .text(let a):
case .text(let plain):
let a = highlighted(plain, query)
if choiceTap != nil {
FlowText(text: flowString(a))
} else {
@@ -960,6 +965,7 @@ struct InlineView: View {
private struct BlockView: View {
let block: MDBlock
let ctx: MDContext
@Environment(\.findQuery) private var query
var body: some View {
switch block {
@@ -984,7 +990,7 @@ private struct BlockView: View {
case .code(let code):
Copyable(text: code) {
ScrollView(.horizontal) {
Text(verbatim: code)
Text(highlighted(code, query))
.font(Fonts.font(ctx.size, mono: true))
.foregroundStyle(Palette.text)
.textSelection(.enabled)
@@ -1000,7 +1006,7 @@ private struct BlockView: View {
case .diff(let lines):
DiffBlock(lines: lines, size: ctx.size)
case .details(let summary, let body):
DetailsBlock(summary: summary, blocks: body, ctx: ctx)
DetailsBlock(summary: summary, blocks: body, ctx: ctx, reveal: matches(mdText(body), query))
case .diagram(let source):
BoxDiagramView(source: source)
case .table(let head, let rows):
@@ -1115,6 +1121,7 @@ private struct DetailsBlock: View {
let summary: [MDInline]
let blocks: [MDBlock]
let ctx: MDContext
let reveal: Bool
@ViewState private var open = false
var body: some View {
@@ -1123,14 +1130,14 @@ private struct DetailsBlock: View {
open.toggle()
} label: {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(open ? "▾" : "▸")
Text(open || reveal ? "▾" : "▸")
.foregroundStyle(Palette.accent)
InlineView(inlines: summary, ctx: MDContext(size: ctx.size, color: Palette.accent, taskId: ctx.taskId, project: ctx.project, dir: ctx.dir))
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if open {
if open || reveal {
MarkdownView(blocks: blocks, ctx: ctx)
.padding(.leading, 11)
.overlay(alignment: .leading) {
+1
View File
@@ -15,6 +15,7 @@ enum Palette {
static let warnTint = dynamic(0xfdf3dc, 0x352a14)
static let ok = dynamic(0x1a7f37, 0x7fd88f)
static let okTint = dynamic(0xe5f4e9, 0x1b3322)
static let findHit = dynamic(0xfbe38e, 0x5a4a10)
static let task0 = dynamic(0xa4273f, 0xfe9fa6)
static let task1 = dynamic(0x9f3501, 0xffa383)
static let task2 = dynamic(0x376e00, 0x99d079)
+4
View File
@@ -125,6 +125,10 @@ final class PaneFocus {
#endif
}
var inTaskPane: Bool {
inside
}
func setFrame(_ frame: CGRect) {
paneFrame = frame
}
+1 -1
View File
@@ -60,7 +60,7 @@ struct MainView: View {
overlays.install { key in
ctrlGo(key, store, overlays)
} escape: {
store.closeSheet()
overlays.cancelFind() || store.closeSheet()
} list: { code in
listKey(code, store, overlays)
}
+34 -8
View File
@@ -11,6 +11,8 @@ struct ProjectView: View {
@Environment(Overlays.self) private var overlays
@ViewState private var todos: [String: Result<Todo, ApiError>] = [:]
@ViewState private var off: (() -> Void)?
@ViewState private var filter = ""
@ViewState private var filterFocus = 0
var body: some View {
VStack(spacing: 0) {
@@ -37,7 +39,7 @@ struct ProjectView: View {
} else {
ScrollView {
if let p = store.focusedProject() {
ProjectBlock(project: p, todo: todos[p.id], settings: overlays.projectTab == .settings)
ProjectBlock(project: p, todo: todos[p.id], settings: overlays.projectTab == .settings, filter: $filter, filterFocus: filterFocus)
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
@@ -76,6 +78,12 @@ struct ProjectView: View {
load(p.id)
}
}
.onChange(of: overlays.findTask) {
if store.focusedProject() != nil {
overlays.projectTab = .tasks
filterFocus += 1
}
}
}
private func refresh() {
@@ -152,11 +160,14 @@ private struct ProjectBlock: View {
let project: Project
let todo: Result<Todo, ApiError>?
let settings: Bool
@Binding var filter: String
let filterFocus: Int
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
var body: some View {
let active = (store.tasks[project.id] ?? []).filter { $0.status == .active }
let items = (try? todo?.get())?.items ?? []
VStack(alignment: .leading, spacing: 0) {
if settings {
heading("Settings")
@@ -193,8 +204,18 @@ private struct ProjectBlock: View {
.padding(.vertical, 6)
}
TaskRows(active: active)
heading("TODO")
TodoBlock(project: project, todo: todo, active: active)
HStack(alignment: .lastTextBaseline, spacing: 12) {
heading("TODO")
Spacer(minLength: 0)
if !items.isEmpty {
FindField(placeholder: "Filter TODO items", text: $filter, focusSignal: filterFocus, end: {
filter = ""
})
.frame(maxWidth: 260)
}
}
TodoBlock(project: project, todo: todo, active: active, filter: filter)
.environment(\.findQuery, filter)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -496,6 +517,7 @@ private struct TodoBlock: View {
let project: Project
let todo: Result<Todo, ApiError>?
let active: [ByreTask]
let filter: String
var body: some View {
switch todo {
@@ -504,22 +526,25 @@ private struct TodoBlock: View {
case .failure(let e):
row(e.message, error: true)
case .success(let t):
let items = filter.isEmpty ? t.items : t.items.filter { matches("\($0.section)\n\($0.title)\n\($0.body)", filter) }
if !t.exists {
row("No TODO.md on \(project.defaultBranch).")
} else if t.items.isEmpty {
row("TODO.md has no items.")
} else if items.isEmpty {
row("No TODO items match.")
} else {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(t.items.enumerated()), id: \.offset) { i, item in
if !item.section.isEmpty && (i == 0 || t.items[i - 1].section != item.section) {
Text(item.section)
ForEach(Array(items.enumerated()), id: \.offset) { i, item in
if !item.section.isEmpty && (i == 0 || items[i - 1].section != item.section) {
Text(highlighted(item.section, filter))
.font(Fonts.font(12, weight: 700))
.foregroundStyle(Palette.muted)
.padding(.top, i == 0 ? 10 : 2)
.padding(.bottom, 4)
}
TodoRow(project: project, item: item, task: active.first { $0.todoItem == item.title })
.padding(.bottom, i == t.items.count - 1 || t.items[i + 1].section != item.section ? 8 : 0)
.padding(.bottom, i == items.count - 1 || items[i + 1].section != item.section ? 8 : 0)
}
}
}
@@ -540,6 +565,7 @@ private struct TodoRow: View {
let task: ByreTask?
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@Environment(\.findQuery) private var query
var body: some View {
VStack(alignment: .leading, spacing: 4) {
@@ -558,7 +584,7 @@ private struct TodoRow: View {
@ViewBuilder
private var title: some View {
let text = Text(item.title)
let text = Text(highlighted(item.title, query))
.foregroundStyle(task.map { taskPalette[$0.color] } ?? Palette.text)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
+9 -1
View File
@@ -3,7 +3,7 @@ import SwiftUI
typealias WorkflowSummary = Components.Schemas.WorkflowSummary
let goKeys: Set<String> = ["k", "n", "t", "p", "l", "s", "/"]
let goKeys: Set<String> = ["k", "n", "t", "p", "l", "s", "f", "/"]
@MainActor
func runGo(_ key: String, _ store: Store, _ overlays: Overlays) {
@@ -44,6 +44,13 @@ func runGo(_ key: String, _ store: Store, _ overlays: Overlays) {
}
await confirmMerge(taskId, store, overlays)
}
case "f":
overlays.switcher?.close()
if store.paneFocus.inTaskPane {
overlays.findTask += 1
} else if overlays.leftTab == .lead {
overlays.findLead += 1
}
case "/":
overlays.showAlert(ShortcutsHelp())
default:
@@ -83,6 +90,7 @@ private let shortcuts: [(String, [(String, String)])] = [
("⌘P", "Show the current project's dashboard"),
("⌘L", "Open the Lead tab and its composer, closing any view opened over a pane"),
("⌘S", "Merge the right pane's task, or discard it when the agent suggests discarding or there is nothing to merge"),
("⌘F", "Find in the transcript of the pane you're in, or filter the TODO items on a project's page"),
("⌘[ ⌘]", "Bring the previous or next server's window forward"),
("⌘/", "Show these shortcuts"),
("Esc", "Close the switcher, a dialog, or a view opened over a pane"),