641 lines
24 KiB
Swift
641 lines
24 KiB
Swift
// 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 under a header
|
|
// that collapses them, 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.
|
|
|
|
import SwiftUI
|
|
|
|
struct ThreadView: View {
|
|
@Environment(AppModel.self) private var model
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@Environment(\.dismiss) private var dismiss
|
|
let hostId: String
|
|
let threadId: String
|
|
|
|
@State private var detail: ThreadDetail?
|
|
@State private var error: String?
|
|
@State private var sending = false
|
|
@State private var visible = false
|
|
@State private var answers: [String: RespondRequest.Answer] = [:]
|
|
/// Options ticked so far on multi-select questions, by question id.
|
|
@State private var selections: [String: Set<String>] = [:]
|
|
@State private var scrolledToLatest = false
|
|
/// Why the last Ask before acting change failed. It shows under the switch.
|
|
@State private var modeError: String?
|
|
/// The Ask before acting value still being sent, which a poll landing meanwhile keeps.
|
|
@State private var pendingAskFirst: Bool?
|
|
/// Pages Load earlier fetched. The 10 s refresh replaces only the latest window.
|
|
@State private var history = MessageHistory()
|
|
@State private var loadingEarlier = false
|
|
/// Whether the Subagents section is open, once tapped. Nil follows the default: open while a
|
|
/// subagent waits on you, closed otherwise. Kept for as long as the screen is.
|
|
@State private var subagentsOpen: Bool?
|
|
|
|
/// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank.
|
|
private let scrolls: Bool
|
|
|
|
/// `detail`, `selections` and `subagentsOpen` start the screen with a thread loaded, options
|
|
/// ticked and the Subagents section set, for snapshots and previews.
|
|
init(
|
|
hostId: String, threadId: String,
|
|
detail: ThreadDetail? = nil, selections: [String: Set<String>] = [:], subagentsOpen: Bool? = nil,
|
|
scrolls: Bool = true
|
|
) {
|
|
self.hostId = hostId
|
|
self.threadId = threadId
|
|
self.scrolls = scrolls
|
|
_detail = State(initialValue: detail)
|
|
_selections = State(initialValue: selections)
|
|
_subagentsOpen = State(initialValue: subagentsOpen)
|
|
}
|
|
|
|
var body: some View {
|
|
if scrolls {
|
|
screen
|
|
} else {
|
|
column
|
|
}
|
|
}
|
|
|
|
private var column: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if let detail {
|
|
content(detail)
|
|
} else if error == nil {
|
|
Text("Loading")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(Theme.secondaryText)
|
|
}
|
|
if let error {
|
|
Text(error)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Theme.secondaryText)
|
|
}
|
|
}
|
|
.padding(.horizontal, 4)
|
|
}
|
|
|
|
private var screen: some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
column
|
|
}
|
|
.onChange(of: detail?.messages.last?.id) {
|
|
guard !scrolledToLatest, detail != nil else { return }
|
|
scrolledToLatest = true
|
|
proxy.scrollTo("latest", anchor: .top)
|
|
}
|
|
.onAppear {
|
|
if detail != nil, !scrolledToLatest {
|
|
scrolledToLatest = true
|
|
proxy.scrollTo("latest", anchor: .top)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(detail?.item.title ?? "Thread")
|
|
.onAppear { visible = true }
|
|
.onDisappear { visible = false }
|
|
.task(id: visible && scenePhase == .active) {
|
|
guard visible && scenePhase == .active else { return }
|
|
while !Task.isCancelled {
|
|
await load()
|
|
try? await Task.sleep(for: .seconds(10))
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func content(_ detail: ThreadDetail) -> some View {
|
|
let split = MessageSplit(history.messages(before: detail.messages))
|
|
let item = detail.item
|
|
|
|
if let cursor = history.cursor(detail) {
|
|
Button(loadingEarlier ? "Loading" : "Load earlier") { loadEarlier(cursor) }
|
|
.buttonStyle(ChoiceButtonStyle(fill: Theme.card, foreground: Theme.secondaryText))
|
|
.disabled(loadingEarlier)
|
|
}
|
|
|
|
ForEach(split.earlier) { message in
|
|
MessageRow(message: message)
|
|
}
|
|
|
|
// A subagent's request opens the screen, above the parent's latest message.
|
|
if let via = item.via, let request = item.request {
|
|
requestCards(request, from: via)
|
|
.id("latest")
|
|
.padding(.top, split.earlier.isEmpty ? 0 : 6)
|
|
}
|
|
|
|
Text(Markdown.render(split.latest?.text ?? item.lastMessage ?? "No reply yet."))
|
|
.font(.system(size: 16))
|
|
.foregroundStyle(Theme.bodyText)
|
|
.lineSpacing(1)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.id(item.via == nil ? "latest" : "message")
|
|
.padding(.top, split.earlier.isEmpty || item.via != nil ? 0 : 6)
|
|
|
|
ForEach(split.after) { message in
|
|
MessageRow(message: message)
|
|
}
|
|
|
|
Text(statusLine(item))
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(item.phase.color)
|
|
|
|
if item.via == nil, let request = item.request {
|
|
requestCards(request, from: nil)
|
|
}
|
|
|
|
if !detail.subagents.isEmpty {
|
|
let open = subagentsOpen ?? detail.subagents.contains { $0.statusWord == "waiting" }
|
|
subagentsHeader(detail.subagents, open: open)
|
|
if open {
|
|
ForEach(detail.subagents) { row in
|
|
ThreadLine(row: row) {
|
|
Text(row.statusWord)
|
|
.foregroundStyle(row.isActive ? row.color : Theme.secondaryText)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
let settleable = (item.phase == .completed || item.phase == .failed) && item.request == nil
|
|
TextFieldLink(prompt: Text("Reply")) {
|
|
Label("Reply", systemImage: "mic.fill")
|
|
} onSubmit: { text in
|
|
send(text)
|
|
}
|
|
.buttonStyle(settleable ? PillButtonStyle(fill: Theme.card, foreground: .white) : PillButtonStyle(fill: Theme.work))
|
|
.disabled(sending)
|
|
|
|
if settleable {
|
|
Button { settle() } label: {
|
|
Label("Settle", systemImage: "checkmark")
|
|
}
|
|
.buttonStyle(PillButtonStyle(fill: Theme.work))
|
|
.disabled(sending)
|
|
}
|
|
|
|
if let askFirst = detail.askFirst {
|
|
SwitchCard(title: "Ask before acting", isOn: Binding(get: { askFirst }, set: { setAskFirst($0) }))
|
|
if let modeError {
|
|
Text(modeError)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Theme.bad)
|
|
}
|
|
}
|
|
|
|
if detail.canStop {
|
|
Button(role: .destructive) { stop() } label: {
|
|
Label("Stop", systemImage: "stop.fill")
|
|
}
|
|
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .red))
|
|
.disabled(sending)
|
|
}
|
|
}
|
|
|
|
/// A small grey title over a list, such as 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)
|
|
}
|
|
|
|
/// The Subagents title as a row that opens and closes the list, with the count, how many wait or
|
|
/// work, and a chevron on the right, as in "2 · 1 working".
|
|
private func subagentsHeader(_ rows: [ThreadRow], open: Bool) -> some View {
|
|
let counts = ["waiting", "working"].compactMap { word -> String? in
|
|
let count = rows.count { $0.statusWord == word }
|
|
return count > 0 ? "\(count) \(word)" : nil
|
|
}
|
|
let summary = (["\(rows.count)"] + counts).joined(separator: " · ")
|
|
return Button { subagentsOpen = !open } label: {
|
|
HStack(spacing: 4) {
|
|
Text("Subagents")
|
|
Spacer(minLength: 4)
|
|
Text(summary)
|
|
.lineLimit(1)
|
|
Image(systemName: open ? "chevron.down" : "chevron.right")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.frame(width: 12)
|
|
}
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(Theme.secondaryText)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 6)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel("Subagents, \(summary)")
|
|
.accessibilityValue(open ? "Expanded" : "Collapsed")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func requestCards(_ request: PendingRequest, from via: FeedItem.Via?) -> some View {
|
|
if request.isQuestion {
|
|
questions(request, from: via)
|
|
} else {
|
|
approval(request, from: via)
|
|
}
|
|
}
|
|
|
|
/// "From subagent" and its title, at the top of a request card a subagent sent.
|
|
@ViewBuilder
|
|
private func subagentLabel(_ via: FeedItem.Via?) -> some View {
|
|
if let via {
|
|
Text("From subagent")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(Theme.you)
|
|
Text(via.title)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.padding(.top, 1)
|
|
.padding(.bottom, 3)
|
|
}
|
|
}
|
|
|
|
private func approval(_ request: PendingRequest, from via: FeedItem.Via?) -> some View {
|
|
Card(edge: Theme.you) {
|
|
subagentLabel(via)
|
|
if let prompt = request.prompt {
|
|
Text(prompt)
|
|
.font(.system(size: 13, design: .monospaced))
|
|
.foregroundStyle(.white)
|
|
} else {
|
|
Text(request.summary)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
}
|
|
if let reason = request.reason {
|
|
Text(reason)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Theme.secondaryText)
|
|
.padding(.top, 3)
|
|
}
|
|
VStack(spacing: 6) {
|
|
ForEach(Array(request.options.enumerated()), id: \.offset) { index, option in
|
|
Button(option.label) { respond(.decision(requestId: request.id, decision: option.decision)) }
|
|
.buttonStyle(ChoiceButtonStyle(
|
|
fill: index == 0 ? Theme.you : Theme.control,
|
|
foreground: index == 0 ? .black : .white))
|
|
}
|
|
}
|
|
.padding(.top, 8)
|
|
.disabled(sending)
|
|
}
|
|
}
|
|
|
|
private func questions(_ request: PendingRequest, from via: FeedItem.Via?) -> some View {
|
|
ForEach(request.questions) { question in
|
|
Card(edge: Theme.you) {
|
|
subagentLabel(via)
|
|
Text(question.header)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundStyle(Theme.you)
|
|
Text(question.question)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.padding(.top, 1)
|
|
if question.multiSelect {
|
|
Text("Choose any")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(Theme.secondaryText)
|
|
.padding(.top, 1)
|
|
}
|
|
VStack(spacing: 6) {
|
|
if question.multiSelect {
|
|
multiSelectOptions(request, question)
|
|
} else {
|
|
ForEach(question.options, id: \.value) { choice in
|
|
let chosen = answers[question.id] == .text(choice.value)
|
|
Button(choice.label) { answer(request, question.id, .text(choice.value)) }
|
|
.buttonStyle(ChoiceButtonStyle(
|
|
fill: chosen ? Theme.you : Theme.control,
|
|
foreground: chosen ? .black : .white))
|
|
}
|
|
}
|
|
if question.allowCustomAnswer {
|
|
TextFieldLink(prompt: Text(question.question)) {
|
|
Text("Other…")
|
|
} onSubmit: { text in
|
|
answer(request, question.id, .text(text))
|
|
}
|
|
.buttonStyle(ChoiceButtonStyle())
|
|
}
|
|
}
|
|
.padding(.top, 8)
|
|
.disabled(sending)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Checkmark toggles, then Send. With more questions still open, the button says Done and the
|
|
/// answers go out together once the last one is in.
|
|
@ViewBuilder
|
|
private func multiSelectOptions(_ request: PendingRequest, _ question: PendingRequest.Question) -> some View {
|
|
let picked = selections[question.id, default: []]
|
|
ForEach(question.options, id: \.value) { choice in
|
|
let checked = picked.contains(choice.value)
|
|
Button { toggle(question.id, choice.value) } label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: checked ? "checkmark.circle.fill" : "circle")
|
|
Text(choice.label)
|
|
.multilineTextAlignment(.leading)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
.buttonStyle(ChoiceButtonStyle(
|
|
fill: checked ? Theme.you : Theme.control,
|
|
foreground: checked ? .black : .white))
|
|
.accessibilityAddTraits(checked ? .isSelected : [])
|
|
}
|
|
let last = request.questions.allSatisfy { $0.id == question.id || answers[$0.id] != nil }
|
|
Button(last ? "Send" : "Done") {
|
|
let values = question.options.map(\.value).filter(picked.contains)
|
|
answer(request, question.id, .values(values))
|
|
}
|
|
.buttonStyle(PillButtonStyle(
|
|
fill: picked.isEmpty ? Theme.control : Theme.you,
|
|
foreground: picked.isEmpty ? Theme.done : .black))
|
|
.disabled(picked.isEmpty)
|
|
}
|
|
|
|
private func statusLine(_ item: FeedItem) -> String {
|
|
let place = "\(item.hostLabel) · \(item.projectTitle)"
|
|
return switch item.phase {
|
|
case .waitingForApproval: "Needs approval · \(place)"
|
|
case .waitingForInput: "Has a question · \(place)"
|
|
case .starting, .running: "Working · \(place)"
|
|
case .failed: "Failed · \(place)"
|
|
default: place
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func load() async {
|
|
do {
|
|
var fresh = try await model.api.thread(hostId: hostId, threadId: threadId)
|
|
if let pendingAskFirst { fresh.askFirst = pendingAskFirst }
|
|
if let detail { history.keep(detail.messages) }
|
|
detail = fresh
|
|
error = nil
|
|
} catch {
|
|
self.error = model.message(for: error)
|
|
}
|
|
}
|
|
|
|
/// Fetches the page before `cursor` and puts it above the messages already shown.
|
|
private func loadEarlier(_ cursor: String) {
|
|
loadingEarlier = true
|
|
Task {
|
|
do {
|
|
history.prepend(try await model.api.history(hostId: hostId, threadId: threadId, cursor: cursor))
|
|
} catch {
|
|
self.error = model.message(for: error)
|
|
}
|
|
loadingEarlier = false
|
|
}
|
|
}
|
|
|
|
private func perform(_ action: @escaping @MainActor () async throws -> Void) {
|
|
sending = true
|
|
error = nil
|
|
Task {
|
|
do {
|
|
try await action()
|
|
} catch let failure as GatewayError where failure.isStale {
|
|
self.error = "Already answered."
|
|
} catch {
|
|
self.error = model.message(for: error)
|
|
}
|
|
await load()
|
|
await model.refreshFeed()
|
|
sending = false
|
|
}
|
|
}
|
|
|
|
private func send(_ text: String) {
|
|
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !text.isEmpty else { return }
|
|
perform { try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) }
|
|
}
|
|
|
|
/// Answers the pending request on the thread that asked, which is the subagent when `via` is set.
|
|
private func respond(_ request: RespondRequest) {
|
|
let target: (hostId: String, threadId: String) = detail?.item.requestThread ?? (hostId, threadId)
|
|
perform { try await model.api.respond(hostId: target.hostId, threadId: target.threadId, request: request) }
|
|
}
|
|
|
|
private func toggle(_ questionId: String, _ value: String) {
|
|
if selections[questionId, default: []].remove(value) == nil {
|
|
selections[questionId, default: []].insert(value)
|
|
}
|
|
}
|
|
|
|
/// Records one answer. Once every question has one, sends them together.
|
|
private func answer(_ request: PendingRequest, _ questionId: String, _ value: RespondRequest.Answer) {
|
|
answers[questionId] = value
|
|
let all = request.questions.allSatisfy { answers[$0.id] != nil }
|
|
guard all else { return }
|
|
let payload = answers.filter { key, _ in request.questions.contains { $0.id == key } }
|
|
answers = [:]
|
|
selections = [:]
|
|
respond(.answers(requestId: request.id, answers: payload))
|
|
}
|
|
|
|
private func stop() {
|
|
perform { try await model.api.stop(hostId: hostId, threadId: threadId) }
|
|
}
|
|
|
|
/// Flips the switch at once and tells the gateway. If it refuses, the switch flips back and the
|
|
/// line under it shows why.
|
|
private func setAskFirst(_ on: Bool) {
|
|
detail?.askFirst = on
|
|
pendingAskFirst = on
|
|
modeError = nil
|
|
Task {
|
|
do {
|
|
try await model.api.setAskFirst(hostId: hostId, threadId: threadId, askFirst: on)
|
|
} catch {
|
|
detail?.askFirst = !on
|
|
modeError = model.message(for: error)
|
|
}
|
|
pendingAskFirst = nil
|
|
}
|
|
}
|
|
|
|
/// Settles the thread and goes back to the feed. On failure the screen stays and shows why.
|
|
private func settle() {
|
|
sending = true
|
|
error = nil
|
|
Task {
|
|
do {
|
|
try await model.api.settle(hostId: hostId, threadId: threadId)
|
|
dismiss()
|
|
await model.refreshFeed()
|
|
} catch {
|
|
self.error = model.message(for: error)
|
|
}
|
|
sending = false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Splits a thread's messages around the agent's latest message, which the screen shows large.
|
|
struct MessageSplit {
|
|
var earlier: [Message]
|
|
var latest: Message?
|
|
var after: [Message]
|
|
|
|
init(_ messages: [Message]) {
|
|
guard let index = messages.lastIndex(where: { $0.role == .assistant }) else {
|
|
earlier = messages
|
|
latest = nil
|
|
after = []
|
|
return
|
|
}
|
|
earlier = Array(messages[..<index])
|
|
latest = messages[index]
|
|
after = Array(messages[(index + 1)...])
|
|
}
|
|
}
|
|
|
|
/// Older pages fetched by Load earlier, oldest first, kept in front of the latest window the thread
|
|
/// route returns. Once a page is in, a refresh's outgoing window is kept too, so no gap opens when
|
|
/// new messages push the window forward. A message shows once, by id, the latest window's copy first.
|
|
struct MessageHistory {
|
|
private(set) var older: [Message] = []
|
|
/// The cursor for the page before `older`, once a page has loaded.
|
|
private var nextCursor: String?
|
|
private var loaded = false
|
|
|
|
/// Where Load earlier fetches from next, or nil when the thread starts in view.
|
|
func cursor(_ detail: ThreadDetail) -> String? {
|
|
loaded ? nextCursor : detail.earlierCursor
|
|
}
|
|
|
|
/// The older pages, then `latest`.
|
|
func messages(before latest: [Message]) -> [Message] {
|
|
let ids = Set(latest.map(\.id))
|
|
return older.filter { !ids.contains($0.id) } + latest
|
|
}
|
|
|
|
mutating func prepend(_ page: HistoryResponse) {
|
|
older = Self.unique(page.messages + older)
|
|
nextCursor = page.earlierCursor
|
|
loaded = true
|
|
}
|
|
|
|
/// Keeps the window a refresh replaces, once older pages are in.
|
|
mutating func keep(_ window: [Message]) {
|
|
guard loaded else { return }
|
|
older = Self.unique(older + window)
|
|
}
|
|
|
|
private static func unique(_ messages: [Message]) -> [Message] {
|
|
var seen = Set<String>()
|
|
return messages.filter { seen.insert($0.id).inserted }
|
|
}
|
|
}
|
|
|
|
private struct MessageRow: View {
|
|
var message: Message
|
|
|
|
var body: some View {
|
|
let mine = message.role == .user
|
|
Text(Markdown.render(message.text))
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(mine ? Color.white : Theme.secondaryText)
|
|
.padding(.horizontal, mine ? 9 : 0)
|
|
.padding(.vertical, mine ? 6 : 0)
|
|
.background(mine ? Theme.card : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.frame(maxWidth: .infinity, alignment: mine ? .trailing : .leading)
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
|
|
var body: some View {
|
|
FlowLayout {
|
|
ForEach(["Yes", "Continue", "Commit it"], id: \.self) { reply in
|
|
Button(reply) { send(reply) }
|
|
.buttonStyle(ChipButtonStyle())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview("Finished") {
|
|
NavigationStack {
|
|
ThreadView(hostId: "littleman", threadId: "t-next")
|
|
}
|
|
.environment(AppModel.preview())
|
|
}
|
|
|
|
#Preview("Approval") {
|
|
NavigationStack {
|
|
ThreadView(hostId: "mac-mini", threadId: "t-ocr")
|
|
}
|
|
.environment(AppModel.preview())
|
|
}
|
|
|
|
#Preview("Subagent") {
|
|
NavigationStack {
|
|
ThreadView(hostId: "mac-mini", threadId: "t-homelab")
|
|
}
|
|
.environment(AppModel.preview())
|
|
}
|
|
|
|
#Preview("Question") {
|
|
NavigationStack {
|
|
ThreadView(hostId: "server-gib", threadId: "t-db")
|
|
}
|
|
.environment(AppModel.preview())
|
|
}
|
|
|
|
#Preview("Multi-select") {
|
|
NavigationStack {
|
|
ThreadView(hostId: "mac-mini", threadId: "t-release", selections: ["checks": ["unit", "lint"]])
|
|
}
|
|
.environment(AppModel.preview())
|
|
}
|