diff --git a/docs/macos.md b/docs/macos.md index d2060f0..22df01a 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -98,7 +98,7 @@ Connections belong to `Servers`, not to the window: every saved server's store a ## Views - **Transcript and Lead.** `TranscriptBuilder` is a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review (and stop while the review runs), question bubbles that follow their item's `inbox.updated` events, a finished review as its summary row followed by one row per finding, the agent's `respond_to_finding` calls as their own rows, and notes for the other event kinds. Rows that hold a review (`Row.review`) are kept per review id and updated by each later `review.*` event. It is incremental: a new batch extends the existing rows. The composer bar's status (`StatusButton`, from `ChatModel.status`) shows the task's closed state, its working state, or what needs the user (`ChatModel.attention`: open questions, findings of the latest review that neither the user nor the agent has answered, and the current ready-for-merge overview); a click on one of the latter scrolls to the next one, wrapping, and on any other to the end; and when one of those rows closes the transcript scrolls to the next remaining one (`ChatModel.closed`). Jumps from elsewhere (`Store.jumpTo` with a `JumpTarget`: a summary, a question or a snapshot's review) select the task's transcript and scroll there once the row exists. The view is a `ScrollView` with a plain `VStack`, stuck to the bottom unless the user has scrolled more than 80 points up; while an attention row is held (`ChatModel.held`), being stuck instead keeps that row top-anchored, which the scroll view clamps to the bottom until the row reaches the top. The stack isn't lazy because a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum. While the user is scrolling (`onScrollPhaseChange`), content height changes only update whether it is stuck and never pull it back to the bottom. The lead pages back when the view comes within one screen of the top: the fetched page is held while the scroll phase is scrolling and inserted once it is idle (`ChatModel.older`, `prependOlder`), and the next geometry change moves the offset down by the height added, so the rows on screen stay put; a page that adds no rows (all folded `NO_REPLY` turns) fetches the next one at once. Each Markdown text run's `NSTextView` measures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and its server's window is the key window of the active app (`PaneFocus.windowKey`, `markSummaryRead`), checked again whenever the app activates or a window becomes key. An active task's head carries Compact (with its context-usage fill; a long press opens a menu of models to continue on), Sync from main, Merge and Discard; a closed task has no head, and its `task.merged` note is drawn with a ✓ in the ok colour. -- **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered to the live session's vendor), Interrupt and Send. Enter sends, Shift-Enter adds a newline, and Enter during IME composition does neither. Pasted or dropped PNG, JPEG, GIF and WebP images become thumbnails with ×, uploaded to `POST /api/blobs` on send; any other image `NSBitmapImageRep` decodes, such as TIFF, is converted to PNG first, its size checked after conversion. A failed send puts the text and images back and shows the error. +- **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered to the live session's vendor), Interrupt and Send. Enter sends, Shift-Enter adds a newline, and Enter during IME composition does neither. Pasted or dropped PNG, JPEG, GIF and WebP images become thumbnails with ×, uploaded to `POST /api/blobs` on send; any other image `NSBitmapImageRep` decodes, such as TIFF, is converted to PNG first, its size checked after conversion. A failed send puts the text and images back and shows the error. The ⌄/⌃ button collapses the text box (with its thumbnails) into the bar and brings it back; it is collapsed by default while the task has an open question or is merged or discarded (`ChatModel.composerHiddenByDefault`), and the human's choice holds until that default changes or an explicit focus request (picking the task in the switcher, focusing the lead) opens it; the automatic focus of a task with no session yet only focuses a box that is shown. - **Questions.** An open question's bubble in the transcript holds its card, rendered from its Markdown context with each choice's marker replaced by a clickable block (pick-one, sends at once, with any Other… text as notes) or checkbox (pick-any, with Submit), the "recommended" tag, image-choice grids, attachments, Let the agent decide and Other…. Arrow keys move between choices. A 409 shows its error. Once answered or dismissed, the bubble shrinks to the question and who answered what. - **Changes, Files, Workflow, Project, Status, Settings.** Including the pinned-snapshot stepper, open-diff memory, per-task place in Files, nested subtasks, TODO Start and Discuss, rate-limit pace bars and lead notes. The Workflow tab draws its stage graph natively from the `WorkflowRun` (stages as boxes coloured by state, `after` as edges, laid out top to bottom by depth) with the layout and drawing code from "Diagrams", fed from the run's data rather than Mermaid text. - **Terminal.** A terminal view attached to `GET /api/tasks/{taskId}/terminal` with `URLSessionWebSocketTask`: binary input and output, `TerminalSize` JSON on resize, close code 4000 for `shell ended: ` with New shell, 1011 for a start failure, reconnect after 2 s while the task is active, one socket per open tab and selected task. Colours come from the palette. Ctrl combinations typed in it go to the shell. The cursor is a steady block (`setCursorStyle(.steadyBlock)` when the view is made): SwiftTerm's blinking cursor restarts its Core Animation fade on every keypress, scheduled 0.7 s out, and on a ProMotion (variable refresh rate) display macOS then holds the window's screen update until that fade starts, so each typed letter appears about 0.7 s late. A program in the shell can still ask for a blinking cursor with DECSCUSR. diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 6fb9dc0..c1bba6b 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -372,6 +372,13 @@ final class ChatModel { return groups.first.map(flagged) } + var composerHiddenByDefault: Bool { + guard let taskId, let task = store.findTask(taskId) else { + return false + } + return task.status != .active || store.inbox.contains(where: { $0.taskId == taskId }) + } + func showStatus(_ s: ChatStatus) { if let g = s.group { cycle(g) diff --git a/mac/Sources/Byre/Composer.swift b/mac/Sources/Byre/Composer.swift index 52fe829..82c5d60 100644 --- a/mac/Sources/Byre/Composer.swift +++ b/mac/Sources/Byre/Composer.swift @@ -283,8 +283,30 @@ struct ComposerView: View { @ViewState private var text = "" @ViewState private var pick: String? @ViewState private var targeted = false + @ViewState private var shownOverride: Bool? @FocusState private var focused: Bool + private var shown: Bool { + shownOverride ?? !chat.composerHiddenByDefault + } + + private func reveal() { + shownOverride = true + focused = true + } + + private func takeFocus() { + let asked = chat.taskId != nil && store.composerFocus == chat.taskId + if !chat.takeFocus() { + return + } + if asked { + reveal() + } else if shown { + focused = true + } + } + private var draftKey: String { "message:\(key)" } @@ -317,7 +339,7 @@ struct ComposerView: View { let lost = store.drafts?.lostImageCount(draftKey) ?? 0 VStack(alignment: .leading, spacing: 8) { - if lost > 0 { + if shown && lost > 0 { HStack(spacing: 8) { Text("\(lost == 1 ? "1 image" : "\(lost) images") from before the reload need re-attaching.") .font(Fonts.font(12)) @@ -333,10 +355,21 @@ struct ComposerView: View { .background(Palette.warnTint, in: RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) } - Thumbnails(key: draftKey) - editor + if shown { + Thumbnails(key: draftKey) + editor + } HStack(spacing: 8) { HStack(spacing: 8) { + Button(shown ? "⌄" : "⌃") { + if shown { + shownOverride = false + } else { + reveal() + } + } + .buttonStyle(WebButtonStyle()) + .help(shown ? "Hide the message box" : "Show the message box") if !chat.team { Picker("", selection: Binding(get: { selected }, set: { v in pick = v @@ -370,6 +403,7 @@ struct ComposerView: View { submit(modelState()) } .buttonStyle(WebButtonStyle()) + .disabled(!shown) } .frame(maxWidth: .infinity, alignment: .trailing) } @@ -382,14 +416,16 @@ struct ComposerView: View { } .onAppear { text = store.drafts?.composerText(draftKey) ?? "" - if chat.takeFocus() { - focused = true - } + takeFocus() } .onChange(of: store.composerFocus) { - if chat.takeFocus() { - focused = true - } + takeFocus() + } + .onChange(of: chat.composerHiddenByDefault) { + shownOverride = nil + } + .onChange(of: key) { + shownOverride = nil } .onChange(of: text) { store.drafts?.setComposerText(draftKey, text) @@ -409,7 +445,7 @@ struct ComposerView: View { } .onChange(of: overlays.leadFocus) { if chat.kind == .lead { - focused = true + reveal() } } .onChange(of: focused) { diff --git a/mac/Sources/Byre/Questions.swift b/mac/Sources/Byre/Questions.swift index 4e5f139..c57c678 100644 --- a/mac/Sources/Byre/Questions.swift +++ b/mac/Sources/Byre/Questions.swift @@ -570,6 +570,7 @@ private struct TextEntry: View { .padding(.horizontal, 10) .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(hover || focused ? Palette.accent : Palette.border)) .overlay { if focused {