Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Packages/Shared/CmuxHomeCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ let package = Package(
),
.testTarget(
name: "CmuxHomeCoreTests",
dependencies: ["CmuxHomeCore", "CmuxHomeCoreTestSupport"],
dependencies: ["CmuxHomeCore", "CmuxHomeCoreTestSupport", .product(name: "CmuxAgentQuestion", package: "CmuxAgentQuestion")],
swiftSettings: [.swiftLanguageMode(.v6)]
),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ public struct HomeLiveActivity: Hashable, Sendable {
self.line = line
self.since = since
}

/// The lines a run shows while the model itself works (optchat-chief `run.rs`: thinking, writing
/// the reply); every other line names a tool call.
public static let modelLines: Set<String> = ["Thinking", "Writing the reply", "Working"]

/// The run works in a tool, not the model.
public var isTool: Bool { !Self.modelLines.contains(line) }
}

public struct HomeLiveActivityFrame: Hashable, Sendable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ extension HomeStore {
func publishLive(_ conversation: ConversationID) {
let run = liveBook.shown(in: conversation)
if live[conversation] != run { live[conversation] = run }
// The list's rows change only when the run moves between the model and a tool (O(1) per token).
let inTool = run?.activity?.isTool == true
if toolRuns.contains(conversation) != inTool {
if inTool { toolRuns.insert(conversation) } else { toolRuns.remove(conversation) }
rebuildRows()
}
if drafts != draftBook.live { drafts = draftBook.live }
let rows = streamRows(conversation)
if streams[conversation, default: []] != rows { streams[conversation] = rows.isEmpty ? nil : rows }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public final class HomeStore {
/// Who has each conversation open (``HomeEvent/presence(_:viewing:)``), cleared with the
/// connection as typing is; a conversation nobody has open has no entry.
public internal(set) var viewing: [ConversationID: Set<ParticipantID>] = [:]
/// Conversations whose running Chief run is in a tool (``HomeLiveActivity/isTool``): the list's
/// rows read it, so it changes only when a run moves between the model and a tool, never per token.
@ObservationIgnored var toolRuns: Set<ConversationID> = []
/// The running agent reply of each conversation, while it is written.
public internal(set) var drafts: [ConversationID: HomeTurnDraft] = [:]
@ObservationIgnored var draftBook = HomeDraftBook()
Expand Down Expand Up @@ -381,7 +384,7 @@ public final class HomeStore {
/// notifies every observer of `rows` even when nothing changed. Rows are not in the client
/// cache, so a rebuild writes nothing (the mirror and the log schedule their own writes).
func rebuildRows() {
let next = mirror.inboxRows(log: log, typing: Set(typing.keys))
let next = mirror.inboxRows(log: log, typing: Set(typing.keys), tools: toolRuns)
if next != rows { rows = next }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,23 @@ public struct InboxRow: Hashable, Sendable, Identifiable {
public var isSending: Bool
public var hasFailedSend: Bool
public var isTyping: Bool
/// A Chief's running turn there is in a tool, not the model (``HomeLiveActivity/isTool``).
public var isRunningTool = false

public var id: ConversationID { summary.id }

/// The newest message, from someone other than `me`, asks something still open: a pending question
/// or an undecided approval. The conversation waits on the person.
public func waitsOnPerson(me: ParticipantID?) -> Bool {
guard let last = summary.lastMessage, !last.isRetracted, last.author != me else { return false }
return last.parts.contains {
switch $0 {
case .question(let question): question.isPending
case .approval(let approval): approval.decided == nil
default: false
}
}
}
}

extension Array where Element == InboxRow {
Expand All @@ -44,7 +59,7 @@ extension Array where Element == InboxRow {
extension HomeMirror {
/// The conversation list: the mirror overlaid with the intent log
/// (pending sends, pin/mute and read-cursor intents), in inbox order.
public func inboxRows(log: IntentLog, typing: Set<ConversationID> = []) -> [InboxRow] {
public func inboxRows(log: IntentLog, typing: Set<ConversationID> = [], tools: Set<ConversationID> = []) -> [InboxRow] {
let mirror = self
guard let me = mirror.me?.id else { return [] }
var summaries = mirror.conversations
Expand Down Expand Up @@ -100,7 +115,8 @@ extension HomeMirror {
isPinned: summary.pinRank != nil,
isSending: pending.map { if case .failed = $0.state { false } else { true } } ?? false,
hasFailedSend: failed.contains(summary.id),
isTyping: typing.contains(summary.id)
isTyping: typing.contains(summary.id),
isRunningTool: tools.contains(summary.id)
)
}
return rows.orderedForInbox()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import CmuxAgentQuestion
import Foundation
import Testing
@testable import CmuxHomeCore

/// What a Chief's avatar in the list reads from its row (relayed by Chief, 2026-11-03: "per-state
/// animations like the Codex status dot: thinking, tool running, waiting on you, done"): whether its
/// running turn is in a tool, and whether it asked the person something still open.
@MainActor @Suite struct ChiefAvatarStateTests {
let me = ParticipantID("user_me")
let chief = ParticipantID("agent_chief")
let conv = ConversationID("conv_a")

private func activity(_ store: HomeStore, _ line: String) {
store.handle(.live(.snapshot(conversation: conv, runs: [
HomeLiveRunSnapshot(author: chief, run: "run_1", steps: [],
activity: HomeLiveActivity(line: line, since: Date(timeIntervalSince1970: 0))),
])))
}

@Test func aRunMovesBetweenTheModelAndATool() {
let store = HomeStore(source: MockHomeSource(options: .immediate))
store.handle(.connection(.online))
activity(store, "Thinking")
#expect(!store.toolRuns.contains(conv), "the model works: thinking")
activity(store, "Running swift test")
#expect(store.toolRuns.contains(conv), "a tool call's line: the tool look")
activity(store, "Writing the reply")
#expect(!store.toolRuns.contains(conv))
activity(store, "Editing src/app.ts")
store.handle(.live(.snapshot(conversation: conv, runs: [])))
#expect(!store.toolRuns.contains(conv), "the run ended: no tool")
}

@Test func modelLinesAreNotTools() {
for line in HomeLiveActivity.modelLines {
#expect(!HomeLiveActivity(line: line, since: .distantPast).isTool)
}
#expect(HomeLiveActivity(line: "Reading README.md", since: .distantPast).isTool)
}

private func row(_ parts: [MessagePart], author: ParticipantID) -> InboxRow {
var summary = ConversationSummary(id: conv, participants: [], createdAt: .distantPast, updatedAt: .distantPast)
summary.lastMessage = Message(id: MessageID("msg_1"), conversation: conv, seq: 1, clientMessageID: IdempotencyKey("k1"),
author: author, parts: parts, createdAt: .distantPast)
return InboxRow(summary: summary, kind: .chief, title: "Chief", preview: "", previewAuthor: nil,
timestamp: .distantPast, unread: 1, isPinned: false, isSending: false, hasFailedSend: false, isTyping: false)
}

@Test func anOpenQuestionOrApprovalWaitsOnThePerson() {
let pending = AgentQuestion(id: "q1", source: .init(harness: .claude, session: "s1"),
items: [.init(id: "i1", prompt: "Which branch?", options: [])])
#expect(row([.question(pending)], author: chief).waitsOnPerson(me: me))
var answered = pending
answered.state = .answered(AgentQuestionAnswer(selections: [:], respondent: nil, answeredAtMs: nil))
#expect(!row([.question(answered)], author: chief).waitsOnPerson(me: me), "answered: nothing waits")

let approval = ApprovalRef(request: "r1", prompt: "Run the migration?", options: ["Yes", "No"])
#expect(row([.approval(approval)], author: chief).waitsOnPerson(me: me))
#expect(!row([.approval(ApprovalRef(request: "r1", prompt: "Run?", options: ["Yes"], decided: "Yes"))], author: chief)
.waitsOnPerson(me: me), "decided: nothing waits")

#expect(!row([.text("All done.")], author: chief).waitsOnPerson(me: me), "a plain reply is done, not waiting")
#expect(!row([.approval(approval)], author: me).waitsOnPerson(me: me), "my own message never waits on me")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ struct HomeTurnProgress: Equatable {
var text: String { rows.joined(separator: "\n") }

/// Lines the owner sends for its own writing, not a step of the turn's work.
static let notSteps: Set<String> = ["Thinking", "Writing the reply", "Working"]
static var notSteps: Set<String> { HomeLiveActivity.modelLines }
/// Most characters a narration row keeps.
static var narrationLimit: Int { String.thoughtHeadlineLimit }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,14 @@ public struct CmuxSidebarEntry: Hashable, Sendable {
/// A Chief's state, each drawn its own way on its avatar (the Codex pet's states).
public enum AvatarMotion: Hashable, Sendable {
case idle
/// Its turn is running.
case working
/// A reply is unread: it waits for the person.
/// Its turn runs in the model (thinking, writing the reply).
case thinking
/// Its turn runs a tool.
case tool
/// It asked the person something (a question or an approval) and waits on them.
case waiting
/// Its turn ended and the reply is unread.
case done
/// A message to it failed to send.
case failed
}
Expand Down Expand Up @@ -113,8 +117,10 @@ public struct CmuxSidebarEntry: Hashable, Sendable {
s.avatarMotion = own == nil ? nil : avatarMotion.map {
switch $0 {
case .idle: .idle
case .working: .working
case .thinking: .thinking
case .tool: .tool
case .waiting: .waiting
case .done: .done
case .failed: .failed
}
}
Expand Down
Loading
Loading