diff --git a/docs/changes/unreleased/1426-home-task-working.md b/docs/changes/unreleased/1426-home-task-working.md new file mode 100644 index 0000000000..33f8095016 --- /dev/null +++ b/docs/changes/unreleased/1426-home-task-working.md @@ -0,0 +1,9 @@ +--- +kind: fixed +title: Home conversations keep their working mark while tasks run +pr: 1426 +surface: [chat, docs] +invalidates: + - Home read an owned conversation's answering flag alone, so tasks could keep running after its working mark disappeared; Home now shares the tab's live working state, including tasks and background commands, until the last work settles. A task waiting for a decision no longer hides its conversation's other running work on Home, and a conversation whose first act is a `/task`, before it has a name, is marked too. + - A stale Home task count could keep an owned conversation marked working after its tasks finished; the conversation's live status now takes precedence over the saved count. +--- diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index a41af8ecbb..96a1aa1792 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -106,7 +106,7 @@ Home has no `needs you` heading or empty attention panel. An amber `?` beside a conversation or task means it has an unanswered question. The question stays on that item's existing row instead of creating a duplicate. A task decision names the task when its question identifies it; other questions stay on the conversation. -The question mark takes priority over the conversation's answering or unread mark. +The question mark takes priority over the conversation's working or unread mark. The selected row's description shows the question and its available answers. The same digit keys answer it. Once the question is resolved and Home refreshes, the @@ -297,9 +297,10 @@ The choice survives closing the app. Folder actions use the task's conversation ## Why does only one row spin — the one spinner on home -**The first answering conversation wears the one turning cell.** However much is happening, exactly one cell on the frame turns: the other -running rows wear no mark at all, and the page stays a still page redrawn every three -seconds. An `ask here` errand's row takes the spinner while its answer is coming, and so does +**Only one working conversation animates.** The other running conversations keep a +still working mark. Home's latest activity reading chooses the spinner; if it has none, +the first conversation with live work takes it, passing over a row that shows `?`. The page refreshes its saved facts every +three seconds. An `ask here` errand's row takes the spinner while its answer is coming, and so does a row on its way here from another window. Two reasons, and they are the same reason: @@ -350,7 +351,7 @@ nothing. Home has **one bulleted conversation list under sessions**. It combines this window's open tabs and saved history, deduplicates them, and shows the fifteen most recent -conversations, newest first. Bullets show answering and unread replies. The current +conversations, newest first. Bullets show running work and unread replies. The current conversation is bold; its description can say `here` and show the last thing you wrote. Closed conversations keep their place in this same chronological list, dimmed. @@ -1521,7 +1522,7 @@ show up without you doing anything. There is no file watcher: home reads, and cl screen stops the reading. (Standing items — reminders, watches, rules — are a different mechanism and do keep going; see the keeping-an-eye page.) -The resting screen does not animate, except the one spinner on the first answering conversation row +The resting screen does not animate, except the one spinner on the first working conversation row (*Why does only one row spin*); the ages simply change on the next reading. In screen-reader (linear) mode nothing ever animates. @@ -2211,11 +2212,17 @@ which column each stands in is read off what it holds — a panel with rows is i the left, a quiet one in the rail at the right — so nothing about them is yours to set; only their heights and their sides move with the terminal and the day. -## Conversation bullets — answering and unread replies +## Conversation bullets — running tasks, answering and unread replies Every conversation in Home has a bullet. A dim bullet means there is no unread -reply known to this window. A working mark means the conversation is answering; -the first answering row animates when no other Home row owns the spinner. An amber `?` means there is an unanswered question and takes priority over those marks. +reply known to this window. A working mark means the conversation is answering or has +queued or running tasks or background commands, even after its answer ends. This applies +to the conversation in front, including one with no name yet, and those held in other tabs. The mark clears when the last +work finishes; one completed task does not hide another that is still running, and a +task waiting for your decision carries its `?` on the task's own row without hiding the +conversation's other work. +The first working row animates when no other Home row owns the spinner. An amber `?` +means there is an unanswered question and takes priority over those marks. A bright filled bullet means a reply finished while you were away. Opening the conversation clears that unread state. Recently closed rows keep dim bullets. These indicators use this window’s live conversations; they do not infer unread history from other diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index bc61feaaf2..d7394fdb2a 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -471,7 +471,7 @@ queued, waiting or unanswered work. Once that work settles, its whole tree moves completed; new work moves it back. Each task retains its own state word. Both sections default to newest activity first; click the age heading to reverse. Conversation titles match Home, and the same -bullets mark answering, unread and unanswered states. Every conversation and nested task +bullets mark running work, unread replies and unanswered questions. Every conversation and nested task starts expanded, with connecting tree lines and fold arrows immediately after titles in the left column. Projects have their own column. You can fold a branch yourself. `/history`, `ctrl+.` and `alt+2` open it. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index bfbd8c8a7a..5f8eba2469 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -2591,7 +2591,7 @@ conversations, retaining older parents and children instead of splitting their t Filtering never moves a conversation into a different section. Conversation names use the full title shown on Home and truncate only to fit. Their -bullets also match Home: dim at rest, working while answering, bright for unread replies, +bullets also match Home: dim at rest, working while answering or running tasks, bright for unread replies, and a question mark when an answer is needed. A missing transcript still leaves its tasks under an identified conversation row; nothing is promoted into a top-level task. diff --git a/internal/tui3/home.go b/internal/tui3/home.go index de8200e627..92c016444e 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -165,7 +165,7 @@ func (a *app) homeBeat(gen int) tea.Cmd { // twenty things out wakes it exactly as often — and costs the wire exactly as // much — as a machine with one. func (a *app) homeAnimating() bool { - return a.at(pageHome) && !a.linear && (a.homeSpins(a.home.spin) || a.homeAnsweringLine() >= 0) + return a.at(pageHome) && !a.linear && (a.homeSpins(a.home.spin) || a.homeWorkingLine() >= 0) } // homeGutter is the empty space between the two columns, and it is the ONLY diff --git a/internal/tui3/homebullets.go b/internal/tui3/homebullets.go index 56c1bc7650..51ccf235be 100644 --- a/internal/tui3/homebullets.go +++ b/internal/tui3/homebullets.go @@ -2,26 +2,52 @@ package tui3 import "github.com/Agent-Field/codeaf/internal/tui2/tokens" -// homeChatState reads only the foreground state and the keeper's cached events. -// A task running in the conversation does not mean its answer is still streaming. -func (a *app) homeChatState(cell *homeCell) (answering, unread bool) { - if cell == nil || cell.closed || cell.chatKey == "" { +// homeChatState shares the tab's live reading of work so tasks that outlast an +// answer keep their conversation working. Owned conversations use cached events; +// another window's tasks use Home's latest presence reading. +// +// IT READS THE WORK, NOT THE TAB'S RANKED SIGNAL. On a tab a question outranks +// work; on Home the question has its own mark, [cellMarkNeeds], which already +// outranks working in [conversationBullet], and a landed task's question sits on +// the task's row. A conversation waiting on one task's decision while another +// runs is still working. +// +// THE CONVERSATION IN FRONT IS KNOWN BY ITS ROW AS WELL AS BY ITS KEY. One that +// has never been named has no tab ([app.tabList]), so Home draws it from the +// saved rows, which carry `here` and no key — and a `/task` can be the first +// thing it does. +func (a *app) homeChatState(cell *homeCell) (working, unread bool) { + if cell == nil || cell.closed { return false, false } key := cell.chatKey - if key == a.frontTabKey() { - return a.state == stateWorking, a.unreadChats[key] + if (cell.row != nil && cell.row.here) || (key != "" && key == a.frontTabKey()) { + front := a.frontTabKey() + return a.frontWorking(), a.unreadChats[front] + } + if key == "" { + // Another window's conversation, or one this window holds without a name: + // the saved count is the only reading there is, and nothing here is unread. + return cell.row != nil && cell.row.session.Tasks.Running > 0, false } if held := a.behind[key]; held != nil && held.watch != nil { - return held.watch.turning.Load(), a.unreadChats[key] || held.watch.landedSince() > 0 + return held.watch.working(), a.unreadChats[key] || held.watch.landedSince() > 0 + } + if cell.row != nil { + working = cell.row.session.Tasks.Running > 0 } - return false, a.unreadChats[key] + return working, a.unreadChats[key] } -// The first answering conversation takes the spinner only if another panel has none. -func (a *app) homeAnsweringLine() int { +// The first working conversation takes the spinner only if another panel has none. +// +// A ROW WEARING A QUESTION IS PASSED OVER, because its `?` outranks the working +// mark and would hide the spinner: Home's frame clock would then run for a +// frame that never changes. +func (a *app) homeWorkingLine() int { for i, line := range a.home.lines { - if line.kind == homeSession && line.cell != nil && (line.cell.panel == panelRecent || line.cell.panel == panelSessions) { + if line.kind == homeSession && line.cell != nil && line.cell.mark != cellMarkNeeds && + (line.cell.panel == panelRecent || line.cell.panel == panelSessions) { if working, _ := a.homeChatState(line.cell); working { return i } @@ -32,12 +58,8 @@ func (a *app) homeAnsweringLine() int { func (a *app) homeConversationBullet(cell *homeCell, pal palette) string { working, unread := a.homeChatState(cell) - if cell != nil && !cell.closed && cell.row != nil { - _, moving := homeMovingAt(homeLine{kind: homeSession, row: cell.row.session}) - working = working || moving - } mark := pal.glyph(tokens.GWorking) - first := a.homeAnsweringLine() + first := a.homeWorkingLine() if working && !a.linear && ((a.home.spin >= 0 && a.home.lines[a.home.spin].cell == cell) || (a.home.spin < 0 && first >= 0 && a.home.lines[first].cell == cell)) { mark = a.homeSpinGlyph() diff --git a/internal/tui3/homebullets_test.go b/internal/tui3/homebullets_test.go index 171d396eca..5bbf1c15db 100644 --- a/internal/tui3/homebullets_test.go +++ b/internal/tui3/homebullets_test.go @@ -3,6 +3,7 @@ package tui3 import ( "strings" "testing" + "time" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/tui2/tokens" @@ -106,3 +107,220 @@ func TestHomeConversationBulletsFollowAnswerAndUnreadState(t *testing.T) { t.Fatal("closed conversation is not dim/idle") } } + +func TestHomeConversationKeepsWorkingUntilItsLastTaskSettles(t *testing.T) { + for _, background := range []bool{false, true} { + name := "foreground" + if background { + name = "background" + } + t.Run(name, func(t *testing.T) { + a, files := homeTabsFixture(t) + open, _ := homeConversationLines(a) + cell := open[0].cell + watch := &behindWatch{} + if background { + key := a.convKey(files[1]) + if a.behind == nil { + a.behind = make(map[string]*kept) + } + a.behind[key] = &kept{watch: watch} + for _, line := range open { + if line.cell.chatKey == key { + cell = line.cell + } + } + } + update := func(id uint64, state session.TaskState) { + event := signalSettled(id, state) + if background { + watch.noteTask(event.Task) + } else { + a.taskUpdate(event) + } + } + a.state = stateIdle + a.linear = true + update(21, session.TaskRunning) + update(22, session.TaskRunning) + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GWorking) { + t.Fatalf("Home shows %q while tasks run after the answer ended", got) + } + a.linear = false + a.home.spin = homeNoLine + first := plain(a.homeConversationBullet(cell, a.pal)) + a.paints += spinnerStep + if next := plain(a.homeConversationBullet(cell, a.pal)); next == first || !a.homeAnimating() { + t.Fatal("task-only conversation did not animate on Home") + } + a.linear = true + cell.mark = cellMarkNeeds + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GNeedsHuman) { + t.Fatalf("running tasks hid an unanswered question: %q", got) + } + cell.mark = cellMarkNone + update(21, session.TaskDone) + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GWorking) { + t.Fatalf("one finished task hid the other running task: %q", got) + } + update(22, session.TaskDone) + // A stale disk count must not overrule this window's completed tasks. + cell.row = &switcherRow{session: session.SessionRow{Tasks: session.TaskRollup{Running: 2}}} + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GProseBullet) { + t.Fatalf("Home still shows work after the last task settled: %q", got) + } + }) + } +} + +func TestHomeConversationReadsAnotherWindowsRunningTasks(t *testing.T) { + a, _ := homeTabsFixture(t) + a.linear = true + cell := &homeCell{row: &switcherRow{session: session.SessionRow{ + Live: true, Tasks: session.TaskRollup{Running: 1}, + }}} + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GWorking) { + t.Fatalf("another window's running task lost its working mark: %q", got) + } + cell.closed = true + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GProseBullet) { + t.Fatalf("a closed conversation kept a working mark: %q", got) + } +} + +// A task's decision is drawn on the task's own row, so the conversation that +// owns it keeps its working mark while other work of its own is still going. +func TestHomeConversationKeepsWorkingWhileOneOfItsTasksWaitsForADecision(t *testing.T) { + t.Run("background", func(t *testing.T) { + a, files := homeTabsFixture(t) + a.linear = true + key := a.convKey(files[1]) + watch := &behindWatch{} + if a.behind == nil { + a.behind = make(map[string]*kept) + } + a.behind[key] = &kept{watch: watch} + cell := &homeCell{chatKey: key} + watch.noteTask(&session.TaskNotice{ID: 41, State: session.TaskRunning}) + watch.waits.Store(true) + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GWorking) { + t.Fatalf("a decision on one task hid the conversation's running task: %q", got) + } + watch.noteTask(&session.TaskNotice{ID: 41, State: session.TaskDone}) + watch.turning.Store(true) + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GWorking) { + t.Fatalf("a decision on one task hid the conversation's answer: %q", got) + } + watch.turning.Store(false) + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GProseBullet) { + t.Fatalf("a waiting conversation with no work left still shows work: %q", got) + } + cell.mark = cellMarkNeeds + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GNeedsHuman) { + t.Fatalf("the conversation's own question lost its mark: %q", got) + } + }) + t.Run("foreground", func(t *testing.T) { + a, _ := homeTabsFixture(t) + open, _ := homeConversationLines(a) + cell := open[0].cell + a.linear = true + a.state = stateIdle + a.taskUpdate(signalSettled(42, session.TaskRunning)) + a.questions = append(a.questions, questionShown{question: session.Question{Kind: session.QuestionConsent}}) + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GWorking) { + t.Fatalf("an open question hid the conversation's running task: %q", got) + } + cell.mark = cellMarkNeeds + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GNeedsHuman) { + t.Fatalf("the conversation's own question lost its mark: %q", got) + } + }) +} + +// A conversation that has never been named has no tab, so Home draws it from the +// saved rows; a task that is the first thing it does still marks it working. +func TestHomeUnnamedConversationWorksWhileItsFirstTaskRuns(t *testing.T) { + lab := newHomeLab(t) + workspace := lab.workspace("project") + file := lab.session("project", "0000000000000001", "", workspace, time.Now()) + a := lab.app(file) + a.workspace = workspace + a.width, a.height = 180, 45 + a.linear = true + a.state = stateIdle + if tabs := a.tabList(); len(tabs) != 0 { + t.Fatalf("an unnamed conversation drew a tab: %+v", tabs) + } + a.taskUpdate(signalSettled(61, session.TaskRunning)) + a.openHome() + var cell *homeCell + for _, line := range a.home.lines { + if line.kind == homeSession && line.cell != nil && line.cell.row != nil && line.cell.row.here { + cell = line.cell + } + } + if cell == nil { + t.Fatal("Home drew no row for the conversation in front") + } + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GWorking) { + t.Fatalf("Home shows %q while the unnamed conversation's task runs", got) + } + a.taskUpdate(signalSettled(61, session.TaskDone)) + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GProseBullet) { + t.Fatalf("Home still shows work after the unnamed conversation's task settled: %q", got) + } +} + +// Another window's conversation keeps its working mark while one of its tasks +// runs, even when a different task's decision is waiting on the task's own row. +func TestHomeAnotherWindowsDecisionDoesNotHideItsRunningTask(t *testing.T) { + a, _ := homeTabsFixture(t) + a.linear = true + cell := &homeCell{row: &switcherRow{session: session.SessionRow{Live: true, + Tasks: session.TaskRollup{Running: 1}, + Presence: session.SessionPresence{State: session.PresenceWaiting, Reason: "your call on the parser"}}}} + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GWorking) { + t.Fatalf("a waiting decision hid another window's running task: %q", got) + } +} + +// Home's frame clock runs only for a spinner a person can see: a working row that +// wears a question draws a still `?`, so it never takes the spinner. +func TestHomeSpinnerSkipsARowWearingAQuestion(t *testing.T) { + a, files := homeTabsFixture(t) + open, _ := homeConversationLines(a) + front := open[0].cell + a.linear, a.pal.ascii = false, false + a.home.spin = homeNoLine + a.state = stateIdle + a.taskUpdate(signalSettled(71, session.TaskRunning)) + front.mark = cellMarkNeeds + if a.homeAnimating() { + t.Fatal("Home animates although its only working row draws a still question") + } + key := a.convKey(files[1]) + watch := &behindWatch{} + watch.noteTask(&session.TaskNotice{ID: 72, State: session.TaskRunning}) + if a.behind == nil { + a.behind = make(map[string]*kept) + } + a.behind[key] = &kept{watch: watch} + var held *homeCell + for _, line := range open { + if line.cell.chatKey == key { + held = line.cell + } + } + if held == nil { + t.Fatal("the held conversation has no Home row") + } + first := plain(a.homeConversationBullet(held, a.pal)) + a.paints += spinnerStep + if next := plain(a.homeConversationBullet(held, a.pal)); next == first || !a.homeAnimating() { + t.Fatal("the spinner did not move to the working row a person can see") + } + if got := plain(a.homeConversationBullet(front, a.pal)); got != a.pal.glyph(tokens.GNeedsHuman) { + t.Fatalf("the question lost its mark to the spinner: %q", got) + } +} diff --git a/internal/tui3/tabsignal.go b/internal/tui3/tabsignal.go index e0821d9a60..265d341d67 100644 --- a/internal/tui3/tabsignal.go +++ b/internal/tui3/tabsignal.go @@ -114,16 +114,30 @@ func (w *behindWatch) signal() tabSignal { switch { case w.waits.Load(): return tabNeedsPerson - case w.turning.Load(), w.tasking.Load(), w.jobbing.Load(): - // A TURN IN FLIGHT, OR WORK THAT OUTLIVED ONE. The second is the whole - // reason this is two loads rather than one: the watcher's turn flag goes - // false the moment the conversation's own stream ends, and the nodes it - // started keep running afterwards (keeper.go's [behindWatch.tasking]). + case w.working(): return tabWorking } return tabIdle } +// working says this held conversation has work in flight, whatever else it is +// waiting on. +// +// IT IS A TURN IN FLIGHT, OR WORK THAT OUTLIVED ONE. The second is the whole +// reason this is three loads rather than one: the watcher's turn flag goes +// false the moment the conversation's own stream ends, and the nodes it +// started keep running afterwards (keeper.go's [behindWatch.tasking]). +// +// IT IS SEPARATE FROM [behindWatch.signal] BECAUSE A QUESTION OUTRANKS WORK ON +// A TAB AND NOT ON HOME. The tab has one cell, so the question takes it; a Home +// conversation row carries the question only when the question is the +// conversation's own, and a landed task's `your call` is drawn on the task's +// row instead (homepanel_needs.go's [needsLandingsSpeakFor]). Reading the +// ranked signal there drew a conversation at rest while its other task ran. +func (w *behindWatch) working() bool { + return w != nil && (w.turning.Load() || w.tasking.Load() || w.jobbing.Load()) +} + // tabSignalFor is one conversation's state, by its canonical key ([app.convKey]) // and whether it is the one in front. // @@ -181,12 +195,19 @@ func (a *app) frontSignal() tabSignal { if a.asking() || a.asksConnect() || a.asksHarness() || a.asksStanding() || (a.awaitingTask() && a.task != nil && a.task.deadline.IsZero()) { return tabNeedsPerson } - if a.state == stateWorking || a.tasksInFlight() || a.jobsRunning() > 0 { + if a.frontWorking() { return tabWorking } return tabIdle } +// frontWorking is [behindWatch.working] for the conversation on screen: a turn +// in flight, a task node still going, or a background command still running, +// whatever question is also open. +func (a *app) frontWorking() bool { + return a.state == stateWorking || a.tasksInFlight() || a.jobsRunning() > 0 +} + // tasksInFlight says this conversation has a task node still going, which is the // front tab's half of the fact keeper.go caches for every other tab. //