notes get their own left tab

This commit is contained in:
Ian Gulliver
2026-10-18 23:37:56 -07:00
parent d294159fcd
commit d567261fbd
6 changed files with 166 additions and 143 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ When the owner tells the lead "deploy byre", or anything that means the same, th
- Exactly two panes, side by side, each exactly half the width, at every window size. Never three or more columns, never a sidebar, never a resizable or collapsible split.
- No navbar. The panes fill the window, below only the identity banner when it shows.
- The left pane has four tabs: Lead (the user's lead agent), Status, Server (the byre service on this Mac) and Settings (the user's and this client's preferences), with the connection dot and the user's name at the end of its tab strip. Nothing else goes in it. The right pane always shows one task, through tabs of that task's views; the lead sets the task, which tabs come first and which one is selected, and the human can switch the task (⌘K) and tabs too. A new task view is a new tab in the right pane, never a new column or region.
- The left pane has five tabs: Lead (the user's lead agent), Notes (what the lead has noted about the user), Status, Server (the byre service on this Mac) and Settings (the user's and this client's preferences), with the connection dot and the user's name at the end of its tab strip. Nothing else goes in it. The right pane always shows one task, through tabs of that task's views; the lead sets the task, which tabs come first and which one is selected, and the human can switch the task (⌘K) and tabs too. A new task view is a new tab in the right pane, never a new column or region.
- Overlays (the task switcher, confirmation dialogs) are allowed; they sit above the panes and don't change the layout.
## UI look
+2 -2
View File
@@ -274,12 +274,12 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie
- **Move hold.** Each `PUT /api/me/focus` from the user (switching task or tab, by any means: the switcher, a tab, a link, a notification, or the app's own switch to Questions when a question arrives) records the time in `Manager.humanMoved`, and for 10 seconds after it (`moveHold`) every `show` that would change the view is refused, whatever the pane lock above would allow, with an error giving how long ago the move was. A `show` of what the pane already shows still applies its tab order. A refusal also marks the user in `Manager.moveRefused`, which each move by the user clears. When the 10 seconds pass without another move by the user (`endMoveHold`, on a timer), byre clears both and, only if a `show` was refused since that move, sends the lead the signal "The move hold is over: …"; its guidance tells it to show what it held back if the pane's other rules allow, or reply `NO_REPLY`. Because only a refusal arranges the signal, the lead's system prompt and the `show` tool's description tell it to call `show` at once whenever the user asks to see something or it wants to show something, never holding back in expectation of a refusal. It is in memory, so a restart clears it.
- **Start hold.** Start on a TODO item in the Project tab leaves the pane where it is. When the user starts one themselves (`StartTodo` with the user as actor, not the lead's `start_todo`), byre records the time in `Manager.startedTodo` for them, and for 10 seconds after it (`startHold`), while the pane's selected tab is `project`, every `show` that would change the view is refused, focused or not, with an error telling the lead the human is likely to start more. A refusal also marks the user in `Manager.startRefused`. The user's own `PUT /api/me/focus` (switching task or tab) or a message to the lead clears both, and another Start restarts the hold and clears the mark. The refusal and the `show` tool's description both tell the lead the hold lasts 10 seconds. When the 10 seconds pass without it being cleared or restarted (`endStartHold`, on a timer), byre clears both and, only if a `show` was refused since that Start and the pane's selected tab is still `project`, sends the lead the signal "The start hold is over: …" so it can show what it held back; its guidance tells it to reply `NO_REPLY` when that no longer matters. It is in memory, so a restart clears it.
- **Compaction.** The pane's **Compact** button asks the lead for a handoff (`leadHandoffPrompt`). The lead can't see its own context size, so the first time one of its turns ends (other than a handoff or a `compact_self` turn) with the latest `session.context` `used` at half of its `window` or more, byre holds a signal for it giving the percentage and asking it to call `compact_self` at the next logical point (`warnLeadContext` in `internal/agents/lead.go`); it is sent once per live process, so a byre restart can send it again once the window is known. The lead calls `compact_self`; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts from `leadHandoffPreamble` plus the handoff; held input follows once that turn ends.
- **Notes.** The lead keeps notes on how its user works (priorities, preferences, the reasoning behind choices) in `lead_notes`, one row per note keyed by `owner`, through its `remember` (add, or replace the text and project of an `id`), `forget` and `list_notes` tools (`internal/agents/leadnotes.go`). A note has a `project_id` when it applies to one project and none (the column empty) when it applies to the user in every project; `remember` takes `project_id` and refuses one that names no project, and the guidance tells the lead to file a note about one project's code, UI, docs or process under it and to apply it only to that project's tasks. Each change records a `lead.note_saved` or `lead.note_deleted` event owned by the user. When a lead's agent process starts, `notesText` lists every note with its id after `leadGuidance`, the every-project notes first and then each project's under its name and id, in the session's instructions (`--append-system-prompt` for Claude, which keeps what the session's first request carried; `developerInstructions` for Codex), but not in the per-turn guidance reminder, so a new or compacted lead session starts from the current notes and notes written mid-session are already in its context. The guidance tells the lead when to write a note (an answer that departs from a recommendation, a correction, a stated preference), to cite a note's id when it shapes a recommendation, and that it may answer a task agent's question itself when a note settles it outright. `GET /api/me/lead/notes` lists the caller's notes and `DELETE /api/me/lead/notes/{noteId}` deletes one (`Manager.DeleteLeadNote`), and, when the user has a live lead session, holds a message for it from `byre` naming the note's id and text and telling it to stop applying it, since the running session still has the note in its instructions. It is held as a message, not a signal, so it keeps its place in line with the human's own messages: it goes out ahead of any message they send after the delete, as its own turn (drawn like a signal batch's, and folded away on `NO_REPLY`) or mid-turn after the lead's next tool call; the Settings tab shows one set at a time, picked from a dropdown of Every project and each project by name with its note count (the pick is kept in memory across tab switches), each note with a small delete button styled like the transcript's, refetching on either event. `sqlite3 byre.db "SELECT owner, project_id, id, text FROM lead_notes ORDER BY created_at"` shows them.
- **Notes.** The lead keeps notes on how its user works (priorities, preferences, the reasoning behind choices) in `lead_notes`, one row per note keyed by `owner`, through its `remember` (add, or replace the text and project of an `id`), `forget` and `list_notes` tools (`internal/agents/leadnotes.go`). A note has a `project_id` when it applies to one project and none (the column empty) when it applies to the user in every project; `remember` takes `project_id` and refuses one that names no project, and the guidance tells the lead to file a note about one project's code, UI, docs or process under it and to apply it only to that project's tasks. Each change records a `lead.note_saved` or `lead.note_deleted` event owned by the user. When a lead's agent process starts, `notesText` lists every note with its id after `leadGuidance`, the every-project notes first and then each project's under its name and id, in the session's instructions (`--append-system-prompt` for Claude, which keeps what the session's first request carried; `developerInstructions` for Codex), but not in the per-turn guidance reminder, so a new or compacted lead session starts from the current notes and notes written mid-session are already in its context. The guidance tells the lead when to write a note (an answer that departs from a recommendation, a correction, a stated preference), to cite a note's id when it shapes a recommendation, and that it may answer a task agent's question itself when a note settles it outright. `GET /api/me/lead/notes` lists the caller's notes and `DELETE /api/me/lead/notes/{noteId}` deletes one (`Manager.DeleteLeadNote`), and, when the user has a live lead session, holds a message for it from `byre` naming the note's id and text and telling it to stop applying it, since the running session still has the note in its instructions. It is held as a message, not a signal, so it keeps its place in line with the human's own messages: it goes out ahead of any message they send after the delete, as its own turn (drawn like a signal batch's, and folded away on `NO_REPLY`) or mid-turn after the lead's next tool call; the Notes tab (`mac/Sources/Byre/Notes.swift`) shows one set at a time, picked from a dropdown of Every project and each project by name with its note count (the pick is kept in memory across tab switches), each note with a small delete button styled like the transcript's, refetching on either event. `sqlite3 byre.db "SELECT owner, project_id, id, text FROM lead_notes ORDER BY created_at"` shows them.
Task agents get notes too. An implementer session's instructions end with `taskNotesGuidance` and `notesText` for the task's creator, limited to the every-project notes and the task's project's (`startLive` in `internal/agents/manager.go`), fixed when the agent process starts like the lead's. Reviewers get none. Implementers have a `suggest_note` tool (`text`, `evidence`) that queues a lead signal for the task's creator naming the task, the suggested text, the evidence and the project id, and refuses when the creator has no live lead session. The lead's guidance tells it to save a suggestion with `remember`, choosing its project, only when it holds and isn't already covered, and to tell the human nothing either way. A suggestion leaves no record of its own beyond the signal; a saved one shows as the lead's `lead.note_saved`.
- **Restarts.** On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left `running` for a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input.
The UI is two panes filling the window, with no navbar. The left has four tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Status, Server and Settings, and at the end of its tab strip the connection dot and the user's name. Server is about the byre service on the Mac the app runs on, whatever server the app is connected to (`mac/Sources/Byre/Server.swift`): it is On when launchd has the service loaded (`launchctl print gui/<uid>/run.fc.byre` succeeds), showing the address from `tailscale serve status --json` and Turn off (`launchctl bootout`), and Off otherwise, showing Turn on, which runs the bundle's `byre install` as the connection screen's Run byre on this Mac does. Below that, Tools on this Mac lists every check from the bundle's `byre check` (see "Running as a service"), each Found or Missing, with how to install a missing one; Missing is in the danger colour only when it blocks the server. Run byre on this Mac runs `byre check` first, and when something blocks it lists the missing checks in the same form instead of installing. Both Turn on and Run byre on this Mac use the running service's address instead of reinstalling when it is already on, since a reinstall restarts it and the old service could answer the first status check. The app runs both helpers through the user's login shell, so they see the `PATH` the service is given. It reads the state and checks again each time it shows and when the app becomes active. While the service is on, Agents' permissions has Check permissions, which calls `POST /api/admin/permissions` on the service at that address (see "Permissions" under "Screen and app control") and lists each grant as Granted, Missing (with Open System Settings, which opens the grant's pane) or Not checked. Settings holds the user's and this client's preferences; its This Mac's permissions has the same Check permissions for the app's own grants, the ones `client_hands` uses, running the bundle's `byre-hands check` from a copy in the temporary directory (`checkLocalPermissions` in `Server.swift`). Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Status, Server or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Questions, Reviews, Workflow and Project, the lead's `tabs` first. Board shows only on a team task (see "Teams"), Reviews only once it has a `review.*` event, Inbox only while the task has open items, and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there, and More carries the Questions highlight and count when Questions is in it. The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open.
The UI is two panes filling the window, with no navbar. The left has five tabs: Lead, the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), Notes, the lead's notes (see "Notes" under the lead), Status, Server and Settings, and at the end of its tab strip the connection dot and the user's name. Server is about the byre service on the Mac the app runs on, whatever server the app is connected to (`mac/Sources/Byre/Server.swift`): it is On when launchd has the service loaded (`launchctl print gui/<uid>/run.fc.byre` succeeds), showing the address from `tailscale serve status --json` and Turn off (`launchctl bootout`), and Off otherwise, showing Turn on, which runs the bundle's `byre install` as the connection screen's Run byre on this Mac does. Below that, Tools on this Mac lists every check from the bundle's `byre check` (see "Running as a service"), each Found or Missing, with how to install a missing one; Missing is in the danger colour only when it blocks the server. Run byre on this Mac runs `byre check` first, and when something blocks it lists the missing checks in the same form instead of installing. Both Turn on and Run byre on this Mac use the running service's address instead of reinstalling when it is already on, since a reinstall restarts it and the old service could answer the first status check. The app runs both helpers through the user's login shell, so they see the `PATH` the service is given. It reads the state and checks again each time it shows and when the app becomes active. While the service is on, Agents' permissions has Check permissions, which calls `POST /api/admin/permissions` on the service at that address (see "Permissions" under "Screen and app control") and lists each grant as Granted, Missing (with Open System Settings, which opens the grant's pane) or Not checked. Settings holds the user's and this client's preferences; its This Mac's permissions has the same Check permissions for the app's own grants, the ones `client_hands` uses, running the bundle's `byre-hands check` from a copy in the temporary directory (`checkLocalPermissions` in `Server.swift`). Which left tab is selected is kept only in memory (`Overlays`); the Lead view stays mounted while another tab shows, and Notes, Status, Server or Settings mounts each time its tab is selected. The right shows the focused task, or project with no task: a top row holding the task picker, centred, and the task's preview links at its right end (see "Previews"), then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Board, Changes, Files, Terminal, Questions, Reviews, Workflow and Project, the lead's `tabs` first. Board shows only on a team task (see "Teams"), Reviews only once it has a `review.*` event, Inbox only while the task has open items, and Workflow only on a task with a `workflow_run` (see "Workflows"); the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`MoreButton` in `mac/Sources/Byre/Panes.swift`); the selected tab never moves there, and More carries the Questions highlight and count when Questions is in it. The **Go** menu holds the shortcuts, each also on its Ctrl key outside the terminal. ⌘K opens the task switcher. ⌘P selects the Project tab, keeping the right pane's task. ⌘L selects the Lead tab and puts the cursor in the lead's composer. ⌘S opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled. ⌘/ opens a dialog listing every keyboard shortcut; the list is `shortcuts` in `mac/Sources/Byre/Switcher.swift`, kept in step with the menu by hand. The shortcuts do nothing while a dialog is open.
## TODO
+1 -1
View File
@@ -37,7 +37,7 @@ struct OverlayAlert: Identifiable {
}
enum LeftTab {
case lead, status, server, settings
case lead, notes, status, server, settings
}
@MainActor
+156
View File
@@ -0,0 +1,156 @@
import ByreAPI
import SwiftUI
typealias LeadNote = Components.Schemas.LeadNote
@MainActor
private var notesProject = ""
struct NotesView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Your lead notes your priorities and preferences as it works with you, and starts every session with them.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 16)
LeadNotes()
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Palette.bg)
}
}
private struct LeadNotes: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var list: [LeadNote]?
@ViewState private var failure: String?
@ViewState private var loads = 0
@ViewState private var project = notesProject
@ViewState private var off: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let list {
if list.isEmpty {
hint("Your lead hasn't noted anything about how you work yet.")
} else {
let groups = [("", "Every project")] + store.projects.map { ($0.id, $0.name) }
let current = groups.contains { $0.0 == project } ? project : ""
let shown = list.filter { ($0.projectId ?? "") == current }
let label = { (g: (String, String)) in "\(g.1) (\(list.filter { ($0.projectId ?? "") == g.0 }.count))" }
Menu {
ForEach(groups, id: \.0) { g in
Button(label(g)) {
project = g.0
notesProject = g.0
}
}
} label: {
HStack(spacing: 8) {
Text(label(groups.first { $0.0 == current } ?? groups[0]))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(Palette.text)
.modifier(FieldStyle())
.contentShape(Rectangle())
}
.menuStyle(.button)
.buttonStyle(.plain)
.menuIndicator(.hidden)
.fixedSize()
.help("Which notes to show")
.padding(.bottom, 8)
if shown.isEmpty {
hint("No notes here yet.")
} else {
ForEach(shown, id: \.id) { note in
NoteRow(note: note)
}
}
}
} else if let failure {
hint(failure)
}
}
.padding(.top, 8)
.onAppear {
off = store.events.add { ev in
if ev.kind == "lead.note_saved" || ev.kind == "lead.note_deleted" {
load()
}
}
load()
}
.onDisappear {
off?()
off = nil
loads += 1
}
}
private func hint(_ text: String) -> some View {
Text(text).foregroundStyle(Palette.muted)
}
private func load() {
loads += 1
let n = loads
Task {
do {
let loaded = try await store.api.client.listLeadNotes().ok.body.json
if n == loads {
list = loaded
}
} catch {
failure = errorText(error)
list = nil
}
}
}
}
private struct NoteRow: View {
let note: LeadNote
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var frame: CGRect = .zero
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(note.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
Button("delete") {
Task {
if !(await overlays.showConfirm(["Delete this note? Your lead stops applying it.", note.text], action: "Delete", danger: true, anchor: frame)) {
return
}
do {
_ = try await store.api.client.deleteLeadNote(path: .init(noteId: note.id)).noContent
} catch {
overlays.showAlert(errorText(error))
}
}
}
.buttonStyle(SmallLinkButtonStyle())
.onGeometryChange(for: CGRect.self) { g in
g.frame(in: .named("window"))
} action: { f in
frame = f
}
}
.padding(.vertical, 6)
.overlay(alignment: .top) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}
+6 -1
View File
@@ -136,6 +136,9 @@ private struct LeftPane: View {
PaneTabButton(label: "Lead", active: tab == .lead) {
overlays.leftTab = .lead
}
PaneTabButton(label: "Notes", active: tab == .notes) {
overlays.leftTab = .notes
}
PaneTabButton(label: "Status", active: tab == .status) {
overlays.leftTab = .status
}
@@ -171,7 +174,9 @@ private struct LeftPane: View {
.opacity(tab == .lead ? 1 : 0)
.allowsHitTesting(tab == .lead)
.accessibilityHidden(tab != .lead)
if tab == .status {
if tab == .notes {
NotesView()
} else if tab == .status {
StatusView()
} else if tab == .server {
ServerView()
-138
View File
@@ -2,7 +2,6 @@ import ByreAPI
import SwiftUI
import UserNotifications
typealias LeadNote = Components.Schemas.LeadNote
typealias NotificationPrefs = Components.Schemas.NotificationPrefs
@MainActor
@@ -13,9 +12,6 @@ private let kinds: [(key: WritableKeyPath<NotificationPrefs, Bool>, label: Strin
(\.rateLimits, "Rate limits"),
]
@MainActor
private var notesProject = ""
struct SettingsView: View {
@Environment(Store.self) private var store
@@ -54,11 +50,6 @@ struct SettingsView: View {
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
PermissionsCheck(run: checkLocalPermissions)
heading("What your lead has learned")
Text("Your lead notes your priorities and preferences as it works with you, and starts every session with them.")
.foregroundStyle(Palette.muted)
.fixedSize(horizontal: false, vertical: true)
LeadNotes()
}
.padding(.horizontal, 16)
.padding(.top, 8)
@@ -224,132 +215,3 @@ private struct ModelMenu: View {
.padding(.top, 8)
}
}
private struct LeadNotes: View {
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var list: [LeadNote]?
@ViewState private var failure: String?
@ViewState private var loads = 0
@ViewState private var project = notesProject
@ViewState private var off: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if let list {
if list.isEmpty {
hint("Your lead hasn't noted anything about how you work yet.")
} else {
let groups = [("", "Every project")] + store.projects.map { ($0.id, $0.name) }
let current = groups.contains { $0.0 == project } ? project : ""
let shown = list.filter { ($0.projectId ?? "") == current }
let label = { (g: (String, String)) in "\(g.1) (\(list.filter { ($0.projectId ?? "") == g.0 }.count))" }
Menu {
ForEach(groups, id: \.0) { g in
Button(label(g)) {
project = g.0
notesProject = g.0
}
}
} label: {
HStack(spacing: 8) {
Text(label(groups.first { $0.0 == current } ?? groups[0]))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(Palette.text)
.modifier(FieldStyle())
.contentShape(Rectangle())
}
.menuStyle(.button)
.buttonStyle(.plain)
.menuIndicator(.hidden)
.fixedSize()
.help("Which notes to show")
.padding(.bottom, 8)
if shown.isEmpty {
hint("No notes here yet.")
} else {
ForEach(shown, id: \.id) { note in
NoteRow(note: note)
}
}
}
} else if let failure {
hint(failure)
}
}
.padding(.top, 8)
.onAppear {
off = store.events.add { ev in
if ev.kind == "lead.note_saved" || ev.kind == "lead.note_deleted" {
load()
}
}
load()
}
.onDisappear {
off?()
off = nil
loads += 1
}
}
private func hint(_ text: String) -> some View {
Text(text).foregroundStyle(Palette.muted)
}
private func load() {
loads += 1
let n = loads
Task {
do {
let loaded = try await store.api.client.listLeadNotes().ok.body.json
if n == loads {
list = loaded
}
} catch {
failure = errorText(error)
list = nil
}
}
}
}
private struct NoteRow: View {
let note: LeadNote
@Environment(Store.self) private var store
@Environment(Overlays.self) private var overlays
@ViewState private var frame: CGRect = .zero
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(note.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
Button("delete") {
Task {
if !(await overlays.showConfirm(["Delete this note? Your lead stops applying it.", note.text], action: "Delete", danger: true, anchor: frame)) {
return
}
do {
_ = try await store.api.client.deleteLeadNote(path: .init(noteId: note.id)).noContent
} catch {
overlays.showAlert(errorText(error))
}
}
}
.buttonStyle(SmallLinkButtonStyle())
.onGeometryChange(for: CGRect.self) { g in
g.frame(in: .named("window"))
} action: { f in
frame = f
}
}
.padding(.vertical, 6)
.overlay(alignment: .top) {
Rectangle().fill(Palette.border).frame(height: 1)
}
}
}