cross server notifications

This commit is contained in:
Ian Gulliver
2026-10-19 09:06:08 -07:00
parent dec87b3dc3
commit eb7e0a13c4
3 changed files with 57 additions and 19 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ Focus lives in `Store`. In-app `#/` links go through `Store.route`: `#/task/<id>
## Window and layout ## Window and layout
One window (a `Window` scene, id `main`) shows one saved server at a time: `Servers.current`, the origin it shows, or nil for the connection screen. The title bar holds no title, only the server picker (`ServerPicker`, a toolbar item in the principal placement): the current server's colour dot and short name (its host up to the first dot, `serverName`), or Add Server with none. Its menu lists every saved server in saved order (`ServerRecord.saved`, the order Previous and Next Server follow), then Add Server…, which shows the connection screen. Switching servers swaps the window's content; every connection keeps running in the background. Each server has its own `Overlays`, held by its `Connection`, so its switcher, dialogs and left-pane tab survive switching away and back, and its `MainView` is rebuilt (`.id(origin)`) so no view state carries from one server to another. The content sits inside a 3-point border in the server's colour (`Servers.color`, one of the task colours, picked from a row of swatches in Settings under Window colour and kept in the server's `ServerRecord`, so every Mac shows the same colour). The connection screen has no border. The panes follow the "UI layout" rules in `AGENTS.md`: an `HStack` of two panes of exactly equal width at every size, no sidebar, no navbar, nothing above them but the identity banner when the user has no git identity. One window (a `Window` scene, id `main`) shows one saved server at a time: `Servers.current`, the origin it shows, or nil for the connection screen. The title bar holds no title, only the server picker (`ServerPicker`, a toolbar item in the principal placement): a row of every saved server in saved order (`ServerRecord.saved`, the order Previous and Next Server follow), each its colour dot (hollow until its connection is running) and short name (its host up to the first dot, `serverName`), then **+**, which shows the connection screen. Clicking a server shows it. Since every connection runs in the background, each server carries what needs the user there: its count of open inbox items as a badge, with the server's name in the warning colour and tint, and its count of active tasks with an unread turn summary (`Store.unreadSummaries`) as "N new". The current server's name is bold on the surface colour. Switching servers swaps the window's content; every connection keeps running in the background. Each server has its own `Overlays`, held by its `Connection`, so its switcher, dialogs and left-pane tab survive switching away and back, and its `MainView` is rebuilt (`.id(origin)`) so no view state carries from one server to another. The content sits inside a 3-point border in the server's colour (`Servers.color`, one of the task colours, picked from a row of swatches in Settings under Window colour and kept in the server's `ServerRecord`, so every Mac shows the same colour). The connection screen has no border. The panes follow the "UI layout" rules in `AGENTS.md`: an `HStack` of two panes of exactly equal width at every size, no sidebar, no navbar, nothing above them but the identity banner when the user has no git identity.
At launch the window shows the oldest saved server. A CloudKit import that brings a server saved on another Mac starts its connection, and the window takes it while it shows an empty connection screen. A CloudKit import that removes the current server switches to the first saved one. SwiftUI's own window restoration is off (`restorationBehavior(.disabled)`); the scene keeps the id `main` that earlier builds used, because AppKit restores their saved window by that id and SwiftUI then opens no launch window at all when the id names no scene. At launch the window shows the oldest saved server. A CloudKit import that brings a server saved on another Mac starts its connection, and the window takes it while it shows an empty connection screen. A CloudKit import that removes the current server switches to the first saved one. SwiftUI's own window restoration is off (`restorationBehavior(.disabled)`); the scene keeps the id `main` that earlier builds used, because AppKit restores their saved window by that id and SwiftUI then opens no launch window at all when the id names no scene.
+52 -18
View File
@@ -458,28 +458,62 @@ struct ServerWindow: View {
struct ServerPicker: View { struct ServerPicker: View {
var body: some View { var body: some View {
let servers = Servers.shared let servers = Servers.shared
Menu { HStack(spacing: 4) {
ForEach(servers.origins, id: \.self) { origin in ForEach(servers.origins, id: \.self) { origin in
Button(serverName(origin)) { ServerChip(origin: origin)
servers.current = origin
}
} }
Divider() Button("+") {
Button("Add Server…") {
servers.current = nil servers.current = nil
} }
} label: { .buttonStyle(.plain)
HStack(spacing: 6) { .foregroundStyle(servers.current == nil ? Palette.text : Palette.muted)
if let current = servers.current { .padding(.horizontal, 8)
Circle() .padding(.vertical, 3)
.fill(servers.color(current)) .help("Add Server")
.frame(width: 8, height: 8)
Text(serverName(current))
} else {
Text("Add Server")
}
}
.font(Fonts.font(13))
} }
.font(Fonts.font(13))
}
}
private struct ServerChip: View {
let origin: String
@ViewState private var hover = false
var body: some View {
let servers = Servers.shared
let store = servers.connections[origin]?.store
let questions = store?.inbox.count ?? 0
let summaries = store?.unreadSummaries ?? 0
let current = servers.current == origin
Button {
servers.current = origin
} label: {
HStack(spacing: 6) {
Circle()
.fill(store == nil ? .clear : servers.color(origin))
.stroke(servers.color(origin))
.frame(width: 8, height: 8)
Text(serverName(origin))
.fontWeight(current || questions > 0 ? .bold : .regular)
if questions > 0 {
Badge(count: questions)
.help("open questions")
}
if summaries > 0 {
Text("\(summaries) new")
.font(Fonts.font(11, weight: 700))
.foregroundStyle(Palette.accent)
.help("Turn summaries you haven't read")
}
}
.foregroundStyle(hover || current ? Palette.text : questions > 0 ? Palette.warn : Palette.muted)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(questions > 0 ? Palette.warnTint : current ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 6))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(store == nil ? "\(serverName(origin)): not connected" : serverName(origin))
.onHover { hover = $0 }
} }
} }
+4
View File
@@ -482,6 +482,10 @@ final class Store {
task.workflow == nil && task.summarySeq > (summaryReads[task.id] ?? 0) task.workflow == nil && task.summarySeq > (summaryReads[task.id] ?? 0)
} }
var unreadSummaries: Int {
tasks.values.joined().filter { $0.status == .active && unreadSummary($0) }.count
}
private func noteSummaryRead(_ taskId: String, _ seq: Int64) -> Bool { private func noteSummaryRead(_ taskId: String, _ seq: Int64) -> Bool {
if (summaryReads[taskId] ?? 0) >= seq { if (summaryReads[taskId] ?? 0) >= seq {
return false return false