Watch: what changed on Recent cards and the thread screen, search in All threads, a switching complication with Allow and Deny in the Smart Stack, and question options on notifications

This commit is contained in:
gib committed 2026-11-02 13:56:10 -05:00
1 parent f8241e42bb
commit bfcbb69550
29 files changed
+1100 -137

No files matched your search

+165 -6
View File
@@ -1,11 +1,12 @@
// All threads, opened from the last row of the feed: hosts, then a host's projects sorted by title,
// then a project's threads with settled ones included. Hosts and projects come from
// `GET /v1/projects`, threads from `GET /v1/projects/:hostId/:projectId/threads`. Each screen loads
// when it appears; nothing polls. A thread opens the usual thread screen.
// All threads, opened from the last row of the feed: a search field over the hosts, then a host's
// projects sorted by title, then a project's threads with settled ones included. Hosts and projects
// come from `GET /v1/projects`, threads from `GET /v1/projects/:hostId/:projectId/threads`, and
// search results from `GET /v1/search`. Each screen loads when it appears; nothing polls. A thread or
// a search hit opens the usual thread screen.
import SwiftUI
/// Every host, offline ones dimmed. Each opens its projects.
/// A search field, then every host, offline ones dimmed. Each host opens its projects.
struct HostsView: View {
@Environment(AppModel.self) private var model
/// Snapshots render the rows without their List, which ImageRenderer leaves blank.
@@ -13,6 +14,9 @@ struct HostsView: View {
var body: some View {
BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) {
SearchField()
.feedRow()
} rows: {
if let hosts = model.projects?.hosts {
ForEach(hosts) { host in
BrowseRow(
@@ -105,6 +109,144 @@ struct ProjectThreadsView: View {
}
}
/// Threads on every host matching `query`: each hit's host and project, its title, and the words
/// around the match when it was inside a message. Says which hosts were left out.
struct SearchView: View {
@Environment(AppModel.self) private var model
let query: String
@State private var response: SearchResponse?
@State private var error: String?
private let scrolls: Bool
/// `response` starts the screen loaded, for snapshots.
init(query: String, response: SearchResponse? = nil, scrolls: Bool = true) {
self.query = query
self.scrolls = scrolls
_response = State(initialValue: response)
}
var body: some View {
BrowseList(scrolls: scrolls, error: error, loaded: response != nil, reload: load) {
if let response {
ForEach(response.hits) { hit in
SearchHitCard(hit: hit, query: query)
.feedRow()
}
if response.hits.isEmpty {
Text("No threads match.")
.font(.system(size: 13))
.foregroundStyle(Theme.secondaryText)
.frame(maxWidth: .infinity)
.padding(.top, 16)
.feedRow()
}
if let note = SearchResponse.unsearchedNote(response.unsearched, offline: offlineHosts) {
Text(note)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.horizontal, 8)
.feedRow()
}
}
}
.navigationTitle("\u{201C}\(query)\u{201D}")
}
/// Labels of hosts the feed or All threads last saw offline.
private var offlineHosts: Set<String> {
let feed = model.feed?.hosts.filter { $0.status != .online }.map(\.label) ?? []
let browsed = model.projects?.hosts.filter { $0.status != .online }.map(\.label) ?? []
return Set(feed + browsed)
}
private func load() async {
do {
response = try await model.api.search(query)
error = nil
} catch {
self.error = model.message(for: error)
}
}
}
/// The field at the top of All threads. Tapping it opens dictation or the keyboard, as any watchOS
/// text field does, and submitting opens the results.
private struct SearchField: View {
@Environment(AppModel.self) private var model
var body: some View {
TextFieldLink(prompt: Text("Search threads")) {
HStack(spacing: 7) {
Image(systemName: "magnifyingglass")
.foregroundStyle(Theme.secondaryText)
Text("Search")
.foregroundStyle(Theme.secondaryText)
Spacer(minLength: 4)
Image(systemName: "mic.fill")
.foregroundStyle(Theme.work)
}
.font(.system(size: 14, weight: .semibold))
.padding(.vertical, 9)
.padding(.horizontal, 12)
.background(Theme.control.opacity(0.7), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.contentShape(Rectangle())
} onSubmit: { text in
let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return }
model.path.append(.search(query: query))
}
.buttonStyle(.plain)
.accessibilityLabel("Search threads")
}
}
/// A search hit: "host · project", the title, dimmed once finished or settled, and the snippet with
/// the matched words picked out. Opens the thread.
private struct SearchHitCard: View {
@Environment(AppModel.self) private var model
var hit: SearchHit
var query: String
var body: some View {
Button { model.path.append(hit.thread.route) } label: {
Card(edge: Theme.card) {
Text("\(hit.hostLabel) · \(hit.projectTitle)")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.lineLimit(1)
Text(hit.thread.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(hit.thread.isActive ? Color.white : Theme.secondaryText)
.lineLimit(2)
.padding(.top, 1)
if let snippet = hit.snippet {
Text(highlighted(snippet))
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
.lineLimit(3)
.padding(.top, 3)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
/// The snippet with each match of the query bold and white.
private func highlighted(_ snippet: String) -> AttributedString {
var text = AttributedString(snippet)
var searchFrom = text.startIndex
while let range = text[searchFrom...].range(of: query, options: .caseInsensitive) {
text[range].inlinePresentationIntent = .stronglyEmphasized
text[range].foregroundColor = .white
searchFrom = range.upperBound
}
return text
}
}
/// A thread on one line: status dot, title, and `trailing` on the right. Opens the thread.
struct ThreadLine<Trailing: View>: View {
@Environment(AppModel.self) private var model
@@ -135,11 +277,13 @@ struct ThreadLine<Trailing: View>: View {
}
/// The List each browse screen scrolls, with loading, an error and Try again. Loads on appear.
private struct BrowseList<Rows: View>: View {
/// `header` shows above the rows even while they load.
private struct BrowseList<Header: View, Rows: View>: View {
var scrolls: Bool
var error: String?
var loaded: Bool
var reload: () async -> Void
@ViewBuilder var header: Header
@ViewBuilder var rows: Rows
var body: some View {
@@ -155,6 +299,7 @@ private struct BrowseList<Rows: View>: View {
}
@ViewBuilder private var content: some View {
header
if loaded {
rows
} else if error == nil {
@@ -174,6 +319,12 @@ private struct BrowseList<Rows: View>: View {
}
}
extension BrowseList where Header == EmptyView {
init(scrolls: Bool, error: String?, loaded: Bool, reload: @escaping () async -> Void, @ViewBuilder rows: () -> Rows) {
self.init(scrolls: scrolls, error: error, loaded: loaded, reload: reload, header: { EmptyView() }, rows: rows)
}
}
/// A plain card with a title, an optional line under it, and a chevron.
private struct BrowseRow: View {
@Environment(AppModel.self) private var model
@@ -228,6 +379,14 @@ private struct BrowseNote: View {
.fontDesign(.rounded)
}
#Preview("Search") {
NavigationStack {
SearchView(query: "containerfile")
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
#Preview("Threads") {
NavigationStack {
ProjectThreadsView(hostId: "mac-mini", projectId: "p-wc-mac")
+12 -23
View File
@@ -317,8 +317,8 @@ struct NeedsYouCard: View {
}
/// A teal card for a working thread, or a grey one for a recent thread. A working thread shows the
/// step it is on, or its latest message when the gateway names no step. A recent thread that failed
/// to settle shows why.
/// step it is on, or its latest message when the gateway names no step. A recent thread shows what
/// its latest run changed, such as "+42 −8 · 3 files", and why it failed to settle.
struct StatusCard: View {
@Environment(AppModel.self) private var model
var item: FeedItem
@@ -344,6 +344,12 @@ struct StatusCard: View {
.foregroundStyle(Theme.secondaryText)
.lineLimit(2)
.padding(.top, 3)
} else if !item.phase.isWorking, let changes = item.changes {
Text(changes: changes)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.lineLimit(1)
.padding(.top, 3)
}
if let error = model.settleErrors[item.id] {
Text(error)
@@ -374,7 +380,7 @@ struct ActivityLine: View {
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Image(systemName: symbol)
Image(systemName: activity.kind.symbol)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(Theme.work)
.frame(width: 14)
@@ -393,30 +399,13 @@ struct ActivityLine: View {
}
private var label: Text {
// Marked as code, which Text sets in monospace. A monospaced Font loses to the app's rounded design.
let mono = { (text: String) -> Text in
var code = AttributedString(text)
code.inlinePresentationIntent = .code
return Text(code).font(.system(size: 11))
}
let mono = Text(code: activity.text).font(.system(size: 11))
return switch activity.kind {
case .command: mono(activity.text)
case .edit: Text("Editing \(mono(activity.text))")
case .command: mono
case .edit: Text("Editing \(mono)")
default: Text(activity.text)
}
}
private var symbol: String {
switch activity.kind {
case .command: "terminal"
case .edit: "pencil"
case .search: "magnifyingglass"
case .web: "globe"
case .subagent: "arrow.triangle.branch"
case .thinking: "ellipsis"
default: "wrench.adjustable"
}
}
}
#Preview("Feed") {
+35 -1
View File
@@ -1,5 +1,5 @@
// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done,
// and red means broken.
// and red means broken. Green and red also count lines added and removed.
import SwiftUI
@@ -12,6 +12,40 @@ enum Theme {
static let secondaryText = Color(red: 0.682, green: 0.682, blue: 0.698) // #aeaeb2
static let bodyText = Color(red: 0.898, green: 0.898, blue: 0.918) // #e5e5ea
static let bad = Color(red: 1.0, green: 0.420, blue: 0.369) // #ff6b5e
static let add = Color(red: 0.353, green: 0.820, blue: 0.478) // #5ad17a
}
extension Text {
/// Text marked as inline code, which Text sets in monospace. A monospaced Font loses to the app's
/// rounded design.
init(code: String) {
var attributed = AttributedString(code)
attributed.inlinePresentationIntent = .code
self.init(attributed)
}
/// "+42 −8 · 3 files" in monospace, the additions green, the deletions red and the rest in the
/// current style. Without `files`, just "+42 −8", as on a changed file's row.
init(changes: ChangeTotals, files: Bool = true) {
let parts = changes.parts
let counts = Text("\(Text(code: parts.additions).foregroundStyle(Theme.add))\(Text(code: " "))\(Text(code: parts.deletions).foregroundStyle(Theme.bad))")
self = files ? Text("\(counts)\(Text(code: " · \(parts.files)"))") : counts
}
}
extension Activity.Kind {
/// The symbol in front of an activity line.
var symbol: String {
switch self {
case .command: "terminal"
case .edit: "pencil"
case .search: "magnifyingglass"
case .web: "globe"
case .subagent: "arrow.triangle.branch"
case .thinking: "ellipsis"
default: "wrench.adjustable"
}
}
}
extension Phase {
+42 -6
View File
@@ -1,5 +1,6 @@
// One thread: the agent's latest message large, older messages one scroll up with Load earlier above
// them, the pending approval or question with its options, the thread's subagents, Reply by
// them, the pending approval or question with its options, the thread's subagents, the files its
// latest run changed with their line counts, Reply by
// dictation or Scribble, Settle once it finished or failed, an Ask before acting switch, and Stop
// while a run is active. A subagent's request shows first, labeled with the subagent. Message text
// renders as markdown.
@@ -143,11 +144,7 @@ struct ThreadView: View {
}
if !detail.subagents.isEmpty {
Text("Subagents")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 6)
.padding(.top, 4)
sectionLabel("Subagents")
ForEach(detail.subagents) { row in
ThreadLine(row: row) {
Text(row.statusWord)
@@ -156,6 +153,13 @@ struct ThreadView: View {
}
}
if !detail.changedFiles.isEmpty {
sectionLabel("Changed")
ForEach(detail.changedFiles) { file in
ChangedFileRow(file: file)
}
}
if item.phase == .completed && item.request == nil {
QuickReplies { send($0) }
.disabled(sending)
@@ -196,6 +200,15 @@ struct ThreadView: View {
}
}
/// A small grey title over a list, such as Subagents or Changed.
private func sectionLabel(_ title: String) -> some View {
Text(title)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 6)
.padding(.top, 4)
}
@ViewBuilder
private func requestCards(_ request: PendingRequest, from via: FeedItem.Via?) -> some View {
if request.isQuestion {
@@ -517,6 +530,29 @@ private struct MessageRow: View {
}
}
/// A file the latest run changed: its name in monospace, and its line counts on the right. Read only.
private struct ChangedFileRow: View {
var file: ChangedFile
var body: some View {
Card(edge: Theme.card) {
HStack(spacing: 6) {
Text(code: file.name)
.foregroundStyle(.white)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 4)
Text(changes: ChangeTotals(files: 1, additions: file.additions, deletions: file.deletions), files: false)
.lineLimit(1)
.layoutPriority(1)
}
.font(.system(size: 12))
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(file.name), \(file.additions) added, \(file.deletions) removed")
}
}
/// One-tap replies for a finished turn, like mock B's chips.
private struct QuickReplies: View {
var send: (String) -> Void