From 404728e8614431f892fc052a554973e3bca8d06a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 11:10:31 -0400 Subject: [PATCH 01/13] chat: keep completion results on demand and preserve upgrade history --- .../unreleased/1893-quiet-completions.md | 11 + internal/e2e/chatclean_e2e_test.go | 8 + internal/manual/chat/commands.md | 17 +- internal/manual/chat/models-and-cost.md | 11 +- internal/manual/chat/screen.md | 52 ++-- internal/manual/chat/tasks.md | 222 ++++++--------- internal/session/agent.go | 60 ++++- internal/session/completion_presentation.go | 48 ++++ .../session/completion_presentation_test.go | 115 ++++++++ internal/session/presentation.go | 6 + internal/session/sessionfile.go | 5 + internal/session/task_index.go | 3 + internal/session/task_run.go | 1 + internal/session/task_run_index.go | 2 + internal/session/taskcrew_index_test.go | 40 +++ internal/tui3/app.go | 34 ++- internal/tui3/bundle_test.go | 253 +++--------------- internal/tui3/commands.go | 1 + .../completion_cleanup_regression_test.go | 88 ++++++ internal/tui3/completion_projection_test.go | 50 ++++ internal/tui3/crew.go | 34 +-- internal/tui3/crew_test.go | 33 ++- internal/tui3/jobpage.go | 24 +- internal/tui3/jobsection.go | 4 +- internal/tui3/jobstate.go | 6 + internal/tui3/landinside_test.go | 60 ++--- internal/tui3/nested_landing_accept_test.go | 12 +- internal/tui3/place_sessions.go | 7 +- internal/tui3/planrail.go | 17 ++ internal/tui3/processes.go | 99 +++++++ internal/tui3/processes_test.go | 69 +++++ internal/tui3/programbadge_test.go | 2 +- internal/tui3/quicktask_test.go | 6 +- internal/tui3/railclick_test.go | 2 +- internal/tui3/raildone1556_test.go | 124 +++------ internal/tui3/railview_test.go | 52 ++-- internal/tui3/senior_dev_landing_fold_test.go | 38 ++- internal/tui3/sidecol.go | 41 +-- internal/tui3/sidecol_test.go | 100 ++----- internal/tui3/task.go | 31 ++- internal/tui3/taskcost_test.go | 18 +- internal/tui3/taskcrewdetails.go | 85 ++++++ internal/tui3/taskcrewdetails_test.go | 157 +++++++++++ internal/tui3/taskdismiss.go | 2 +- internal/tui3/taskdismiss_test.go | 15 +- internal/tui3/taskdone.go | 43 ++- internal/tui3/taskending_test.go | 6 +- internal/tui3/taskgroundword_test.go | 8 +- internal/tui3/taskmodel_test.go | 6 +- internal/tui3/taskrecord.go | 44 ++- internal/tui3/taskresult_test.go | 66 ++--- internal/tui3/taskretry.go | 33 +++ internal/tui3/taskretry_test.go | 2 +- internal/tui3/tasktier_test.go | 6 +- internal/tui3/taskview_test.go | 2 +- internal/tui3/teamcard.go | 2 + internal/tui3/workfold.go | 4 +- internal/tui3/worktab_test.go | 11 +- 58 files changed, 1434 insertions(+), 864 deletions(-) create mode 100644 docs/changes/unreleased/1893-quiet-completions.md create mode 100644 internal/session/completion_presentation.go create mode 100644 internal/session/completion_presentation_test.go create mode 100644 internal/session/taskcrew_index_test.go create mode 100644 internal/tui3/completion_cleanup_regression_test.go create mode 100644 internal/tui3/completion_projection_test.go create mode 100644 internal/tui3/processes.go create mode 100644 internal/tui3/processes_test.go create mode 100644 internal/tui3/taskcrewdetails.go create mode 100644 internal/tui3/taskcrewdetails_test.go diff --git a/docs/changes/unreleased/1893-quiet-completions.md b/docs/changes/unreleased/1893-quiet-completions.md new file mode 100644 index 0000000000..cb27baba94 --- /dev/null +++ b/docs/changes/unreleased/1893-quiet-completions.md @@ -0,0 +1,11 @@ +--- +kind: changed +title: task results stay on demand instead of filling the conversation +pr: 1893 +surface: [chat, engine, docs] +invalidates: + - "Task endings and crew receipts used to accumulate in chat and return on tab switches. Routine completions now remain in task results, while decisions and actionable stops stay visible." + - "The sidebar used to list completed tasks and processes. It now shows active work and decisions; click Tasks for retained results or use /processes for process history and logs." + - "Crew models, estimated and actual spend, and the stronger retry action used to live in a separate transcript receipt. They now live with the selected task result, including restored routed runs." + - "Runtime completion notes used to replay as extra conversation rows. New receipts carry their presentation audience through compaction and reopen; exact standalone older receipts are projected quietly without rewriting their journal." +--- diff --git a/internal/e2e/chatclean_e2e_test.go b/internal/e2e/chatclean_e2e_test.go index 759470f3f1..0f515e3860 100644 --- a/internal/e2e/chatclean_e2e_test.go +++ b/internal/e2e/chatclean_e2e_test.go @@ -413,6 +413,11 @@ func cleanChatHasAnswer(screen, want string) bool { line = before } } + // An open sidebar is outside the conversation too. Only the padded + // layout seam qualifies; an answer containing a literal rule does not. + if seam := strings.Index(line, " │"); seam >= 0 { + line = line[:seam] + } line = strings.Trim(strings.ReplaceAll(strings.TrimSpace(line), " / ", " "), ". ") if line == want { return true @@ -428,6 +433,9 @@ func TestCleanChatAnswerIgnoresOnlyTheRailGutter(t *testing.T) { }{ {" Still ready for review", true}, {" Still ready for review. ❮", true}, + {" Still ready for review. │ Tasks", true}, + {" Still ready for review│", false}, + {" Still ready for review │ quoted separator", false}, {" Still ready for review❮", false}, {" Still ready for review but not yet", false}, {" › Still ready for review", false}, diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 66ff67a0d6..e2cdb2fabb 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -2226,8 +2226,15 @@ tabs included, and opening a row brings the tab and its draft back too. ## Dismiss task notifications -`/dismiss` hides settled task notifications in the current conversation window. -`/dismiss undo` restores them. With an empty message box, select a task -notification and press `delete` to dismiss just that notification. Pending -`your call` decisions stay visible. Dismissal does not stop work, accept a -result, delete a task, or remove its output from Sessions. +Routine task completions stay in Tasks automatically, including older conversations. +`/dismiss` hides manually dismissible settled notifications; `/dismiss undo` restores +those dismissals without making routine task completions appear in chat. Pending +`your call` decisions stay visible. Dismissal does not stop work, accept a result, +delete a task, or remove its output. Open Tasks to read results and run details. + +## Process history and logs — /processes + +`/processes` opens this conversation's running and finished processes. Click a row or +select it with the arrow keys and press Enter to read its log. `/processes ` opens +a known process directly. Escape returns from a log to the list, then back to chat. +The sidebar itself only shows running processes, without a completed count. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 7094c55d4e..bb053a2dbc 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -1105,10 +1105,9 @@ A task you hand off does not run on it. Each task gets a **crew** — a worker, a checker — picked for each task from what kind of work it is; `/crew` shows the seats and `/crew pin` fixes one. -What a task costs is on its crew line. When it starts, the line gives the estimate — -`task 2 crew · bugfix · worker glm-5.3-flash (openrouter) · checker glm-5.3-flash · est $0.013` -— and when it lands the line moves to the end of the conversation with what it actually -cost beside the estimate. Every task is held to the per-task limit, and all crews together +What a task costs is on its result page in Tasks, together with the routed crew, +its estimate and actual spending. Starting or finishing does not append a separate +crew receipt to chat. Every task is held to the per-task limit, and all crews together to the crew daily cap when one is set (both on `/crew`'s cap row). `/cost` says what this conversation has spent, and `/spend` the whole machine. @@ -1121,8 +1120,8 @@ conversation has spent, and `/spend` the whole machine. 3. the crew's **worker** — your pin, or the model the crew picked for this task; 4. the model you are talking to, only when nothing above answers. -The task's crew line, its row on the roster, its room's status line and its finished card -all name the model it actually ran on. The seats are settled when the task starts, so +The task's result details, its row on the roster and its room's status line +name the model it actually ran on. The seats are settled when the task starts, so changing the crew — or `/model` — half way through does not move work already going. A second task handed off while one is still running joins it and rides its crew. diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index 2c466a1803..f31a8da88f 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -3159,28 +3159,26 @@ quiet ones. This is its Tasks view; *What is that column on the right* has the w and the team manager page has its Traffic view. ``` -Tasks 14 · Traffic 3 new alt+l +Tasks · Traffic 3 new alt+l ? Port the parser · your call · nobod… -✕ parser bench incomplete ────────────────────────────────────── Running 4 ⠙ rebase the parser onto dev 2m ⠙ bench the lexer at p99 Queued 3 ▸ -Done 6 ▸ + /task ``` - **The header** is the column's two words in a chat in a team, one word elsewhere. The - word in front is bold ink and the other is dim, and each keeps its count; a count of `0` - is dim, and a count of new traffic, `3 new`, is in ink. The `alt+l` at the right is dim. + word in front is bold ink and the other is dim. Tasks opens history on demand, without + a completed count; new traffic, `3 new`, is in ink. The `alt+l` at the right is dim. - **The needs-you band** is the one place with colour of its own: what is waiting on you - leads with its `?` in the needs-you amber, and a failure nobody has opened leads with its - `✕` in ordinary ink. A thin dim rule closes it. With nothing waiting it is not drawn. -- **Group headings** (`Running 4`, `Done 6 ▸`) are muted with a dim count; the `▸` of a + leads with its `?` in the needs-you amber. A key or credit stop keeps its concrete remedy + visible; ordinary failures stay in task history. A thin dim rule closes it. With nothing waiting it is not drawn. +- **Group headings** (`Running 4`, `Queued 3 ▸`) are muted with a dim count; the `▸` of a folded group and the `▾` of an open one are muted too. `Running` wears no mark, because it does not fold. -- A **running** task's name is in ink, the body colour. **Queued, waiting and finished** +- A **running** task's name is in ink, the body colour. **Queued and waiting** names are muted, a step quieter, and the room you are standing in is the one name in the accent and bold, with a colour band across its whole row. The time at the right of a row is dim. @@ -3190,7 +3188,7 @@ Done 6 ▸ **The header and the band never scroll**, however long the list gets: they are pinned to the top of the column and everything under them scrolls. -**Every task is one line**, running or finished. What a row used to carry underneath +**Every active task is one line**. What a row used to carry underneath (what it is doing, what it is costing, what it waits on, the reason it is your call, the merge word, the branch) is on the hint line while the pointer is on the row, with the whole name first. So the column's height goes to rows rather than to a day's history. Nothing is @@ -3228,22 +3226,12 @@ colour the band leans on, and where there is none the `?` still says it. ## Opening a finished row on the task column: how to see the log path of a job that already finished, and where the merge word, price or branch went -A finished task's row is one line, and the line it used to carry underneath is **on the hint -line, not deleted**. Point at the row and the hint line under the message box says the -whole of it: the whole name, then that task's own last word (`merged · $0.42` for work that -came home clean, the branch it kept, the reason it is your call), then `click opens it`. -There is no fold on a task row and no `▸` in its state cell; the cell is the task's state, -and a press on it opens the task's room like the rest of the row. +Finished tasks leave the sidebar. Click its `Tasks` heading to open their results and +history on demand. Existing outputs, reports, branches and run details remain there. +The sidebar has no Done or Ended fold and no completed-work total. -With the keyboard, `alt+t` hands the column the keyboard and `↑` `↓` walk to the row; the -room behind `enter` has everything the row does and more. - -Finished work sits under `Done`, which starts folded to its heading: press `Done 6 ▸`, or -`enter` on it, to open it, and it stays open for the session. - -A background job is not a finished task row: it lives in the `jobs` section, and its log -path is on the job's page. The full record of any task is on the activity page (`ctrl+.`, -`/history`). +Background processes stay in the `jobs` section while running. Use `/processes` to +open running and finished processes on demand; click a row to read its log. ## Scrolling the task column: the mouse wheel over the sidebar, and the keys that walk it @@ -3286,12 +3274,11 @@ untouched empty conversation has no column until the first keystroke or a task; empty screen* page says why.) ``` -Tasks 3 alt+l +Tasks alt+l ? Port the parser · your call · nobo… ────────────────────────────────────── Running 1 ⠙ Fix the nil-map 4m -Done 1 ▸ + /task automations @@ -3304,16 +3291,13 @@ automations ctrl+. earlier ``` -- **`jobs`** is this conversation's background work (a server, a build, a watch, a - render), one line — `jobs · 2 running`, `jobs · 1 running · 4m12s`, `jobs · 6 ran` — - that enter or a click opens and closes. Open, running jobs come first, then finished - ones, newest first, with an `N earlier` line counting what did not fit; a row opens that - job's page, not a chat. Zero jobs draws nothing (*Background jobs on the column*, on the - tasks page). +- **`jobs`** is this conversation's running background work: a server, build, watch + or render. Enter or a click expands it; a row opens that process's page. Finished + processes leave the sidebar, with no ended count. Their logs remain accessible. - **The roster scrolls; the sections under it do not.** It is given what is left after the doors, the automations rows and the jobs section are reserved. - **An idle column draws nothing to mark an absence**: no `none` rows, no empty group - headings, no band. The header keeps its words with a dim `0`, and `+ /task` and + headings, no band. The Tasks header remains a clickable door to task history, and `+ /task` and `+ /automations` remain as the doors. Jobs have no `+` door; a tool starts a job. - **The `+` rows type, they do not arm.** Pressing one puts that command and a space at the head of your message box and hands the keyboard back: plain text you can edit or diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index a5a71be262..6c2fe1d826 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -62,8 +62,8 @@ that must pass, the behaviour that must hold, the output that must appear. The o done-condition stay on the record. A correction from you can change the effective assignment through `revise_assignment`; the task keeps both your words and its revision. -You can keep working while a task runs. codeaf tells you not to wait for it: its report -arrives in the conversation when it lands. +You can keep working while a task runs. Its state updates in Tasks, where its result +stays available. When the result answers your question, codeaf still replies in chat. **One other thing on the roster is a task, and it is not work in a copy of its own.** A sub-harness being designed runs as a task too — same row, same room, same `x` — with its own phases @@ -340,9 +340,8 @@ reads `waiting · machine busy`; its held parts read `queued · machine busy` on own rows. Those begin by themselves as earlier pieces finish; there is nothing to do about it and nothing to come back for. how-tasks-run has the arithmetic. -What it will *not* do is watch them. Each landing writes one dim line in the -conversation and does not start a turn, so once nothing is left that is independent of the -work it handed out, the turn simply ends — a reply that sat there polling would have spent +What it will *not* do is watch them. Their state changes stay in Tasks, so once +nothing is left that is independent of the work it handed out, the turn simply ends — a reply that sat there polling would have spent your money to learn what it was going to be told anyway. If the pieces share what they learn, they are one quick task's items instead, and they stay in order. ## Can a quick task start more work — quick tasks inside quick tasks, and the two bounds @@ -873,7 +872,7 @@ and stopped from there. **If the reply had written anything at all, none of this applies.** One edit is enough: the answer takes the ordinary road above — a task in its own copy of the folder, briefed, checked -and landed — and you read the two lines that road writes. +and landed — and its result stays in Tasks. ## Can it keep what it read — does the moved work start over, why did it stop reading and start a task, inherit @@ -1828,38 +1827,31 @@ the roster or `jobs kill` is your decision and is drawn as `stopped`, with the b ## Where did my task's result go when it finished while codeaf was still answering -If a task finishes while codeaf is still answering, its landed card and its crew line (the -`task N crew · …` line with what it cost and `not right? /redo stronger`) appear after that -answer ends, under it, and never inside the `▸ worked` chip the answer's steps fold into. -They still appear if the answer fails or you stop it. A task that finishes while nothing is -being answered appears at once. When two tasks finish, their landings stay in the order they -arrived. +A task finishing updates its existing row in Tasks. Routine `done`, `incomplete` and +`stopped` results do not add completion cards or crew receipts to the conversation, +including when you switch tabs or reopen an older conversation. Nothing is deleted +from the journal. The answer codeaf owes your question still appears in chat. -## How work lands — what the card means by merged, branch kept, in your own folder, or conflicted +Open the task from the roster or Tasks to read its result, report, files, branch, +models and spending. Process logs remain in Processes. A pending `your call` decision +stays visible with its reason and working answer controls until it is resolved. -Every landing writes a compact, expandable notification into the conversation and moves -the task's row on the roster. A settled notification takes one line: +## How work lands — merged, branch kept, in your own folder, or conflicted -``` -✓ ◆ Fix nil-map crash · done · 4m12s · 3 files · ctrl+o -``` +The task's result page explains whether its work merged, stayed on its branch, or +needs your decision. Routine completion state belongs to that task, rather than a +second stream of notifications above your message box. -Click the notification or select it and press `ctrl+o` to read its output and details. -Press `enter` on the selected notification to open the task's room. Pending `your call` -decisions keep their reason visible, including inside a folded batch. - -With an empty message box, select a notification and press `delete` to dismiss it. -Use `/dismiss` to hide settled task notifications from this conversation view, or -`/dismiss undo` to show them again. Dismissal does not delete tasks, output or history, -and cannot hide a pending `your call` decision. The task remains available in Sessions. +`/dismiss` and `/dismiss undo` remain available for manually dismissed notifications. +Undo does not turn routine task state changes back into chat messages. Neither command +hides a pending `your call`, deletes work, accepts a result or changes the task history. **The conversation replies only when its task was started while answering your question.** That task carries the question until its family lands, then one short reply answers from the root task’s report. A part does not reply when it finishes, and neither does a check; the family replies once, when the root lands. If you typed `/task` yourself, you asked for work -rather than an answer, so finishing only shows the landing line and makes no model call. -The same is true when a task started by the conversation carries no question: the landing -line is the whole arrival. +rather than an answer, so finishing updates Tasks and makes no model call. +The same is true when a task started by the conversation carries no question. **Every fact on the head is joined by ` · `, the state word included.** It used to read `done 4m12s`, with the state and the clock fused into one phrase while `3 files` beside @@ -1998,95 +1990,23 @@ On a frame too narrow for one whole chip plus its `+N`, the first chip is drawn land between chips a few cells apart, so the phone tier trades the tab row for one door. See *Tasks on a phone*. -## The roster: the column of all the work +## The roster: active work and decisions on the right -The roster is the **Tasks view of the column on the right**. The column is one column in -every chat, with one header, and it reads like this: +The sidebar shows `Running`, `Queued` and `Waiting` work, plus unresolved decisions +in its needs-you band. It has no `Done` or `Ended` section, no completed-work count, +and no list of old failures. A routine completion leaves the sidebar automatically. -``` -Tasks 14 · Traffic 3 new alt+l -? Port the parser · your call · nobody co… -✕ parser bench incomplete -────────────────────────────────────────── -Running 4 - ⠙ rebase the parser onto dev 2m - ⠙ port the key table 40s -Queued 3 ▸ -Done 6 ▸ -+ /task -``` +Click the `Tasks` heading to open the existing Tasks page for results and history. +It does not show a total of past tasks. In a team chat, the `Traffic` word keeps its +existing message count and switches to team traffic; click `Tasks` from Traffic to +return to active work, then click it again to open task history. + +A task's row opens its room. The needs-you band also opens the relevant task, +question or team decision. Work that needs your answer remains present until answered; +ordinary completed and incomplete work stays available on demand in Tasks. -**The header is the column's two words.** `Tasks 14` is this conversation's work, counted -whole. `Traffic 3 new` is what passes in the team this chat is in; a chat in no team has -only the first word. The word in front is bold ink and the other is dim, and the other one -keeps its count, so traffic that arrives while you read the tasks still says `3 new` on the -header. Each word is a button with a hover ground and a hint (`Show this chat's tasks · -click`); press the other word to bring it to the front. A count of `0` is dim. At the right -of the header is the column's own key, `alt+l` (`opt+l` on a Mac), and it is a button too: -press it and the column goes away (*Hiding the task column* below). - -**Which word is in front is remembered for the session, per kind of chat.** A manager's -chat opens on the Traffic and every other chat opens on the Tasks. Change it in a member's -chat and every member's chat you visit this session opens the same way; the manager's chat -keeps its own answer. The team manager page has the Traffic view. - -**Under the header is the needs-you band**, when anything needs you, and nothing at all -when nothing does: - -- a task whose next step is yours (a landing nobody could judge, a branch that - conflicted), led by its own state mark, `?` for your call, in the needs-you amber; -- in a chat in a team, a member's question to you, or a decision put to you, led by `?` in - amber; -- a task that failed while this window watched it and that you have not opened yet, led by - its `✕` in ordinary ink. A failure is news, not a demand, so it is never amber. - -The band draws at most three rows. A fourth item is drawn as a fourth row, and five or -more show the first three and a `+2 more` row; press it to show them all, and `fewer` to go -back to three. A thin rule closes the band. **An item in the band is not drawn again in -the list under it**, and opening a failure is looking at it: it leaves the band and takes -its place under Done. Each band row opens its thing: a task's room, the member's chat at the -question, or the teams page for a decision. - -**Under the band the work is grouped by what it is doing:** `Running`, `Queued` (nothing in -its way but a slot), `Waiting` (behind other work) and `Done`. Running work is always open -and its heading wears no mark, because it does not fold. The other three start folded to -one line, `Done 6 ▸`; press the heading, or `enter` on it, to open the group (`Done 6 ▾`) -and again to fold it. What you open stays open for the rest of the session, through new -work, new landings and switching chats. A group with nothing in it is not drawn at all; -there are no empty headings and no `none` rows. When only queued tasks remain and the -conversation is idle, the main box footer says `queued`. It says `working` when a task -or background command is running. - -**Every task is one line**: its state glyph two cells in, its name cut with `…` where the -column is too narrow, and how long it has been at it (or how long it took) in the muted ink -at the right, `40s`, `2m`, `1h`. The time gives way before the name does: it is left off -rather than cut a name that would fit whole without it. Inside each group the newest work -is first. Point at a row and the hint line says the whole of it: the whole name, then what -the row has no room for, the way it used to be written under it (what it is doing, what it -waits on, how its branch came home, what it cost), then `click opens it`. - -The roster holds every task **this conversation** has admitted, not just the live ones. -Tasks *other* sessions ran are not on it; the page `/history` opens is the one that has -them, and one dim line at the foot of the column, `ctrl+. earlier`, is the door onto it. -Work finishing never puts the column away, and neither does `/new`: that takes this -session's tasks with it and leaves the column standing. - -Under the task list the same column carries `+ /task`, then the automations this -conversation set up, ending in `+ /automations` — *Automations* has that half, under *The -automations in a conversation's side column* — and, when this conversation has started any, -a third section labelled `jobs` (*Background jobs on the column* below). The header and the -band stand still at the top while the list under them scrolls. - -**The column is permanent** from 100 columns up: it stands from the session's first -keystroke, before any task exists, and work fills it rather than raising it. It is a -quarter of the frame, never under 28 columns or over 40: 28 at 110, 30 at 120, 40 at 160. -It is the same width in every chat and whichever word is in front, so switching words, -opening a group, a new row and the band appearing never move the conversation beside it. -From 120 columns up `alt+w` widens it by 16 columns, when the conversation keeps at least -56. The one frame without it is the untouched empty conversation, which opens on a centred -greeting and no column at all until you type, a task lands, or it has an automation of its -own to list (*The empty screen* page). Under 100 columns there is no column and no edge, -and `alt+t` (or `alt+l`) lays the same column over the body instead. +`alt+l` (`opt+l` on a Mac) still hides the sidebar. The `+ /task` and +`+ /automations` controls still put their command into your message box. ## Where did the workers go on the task page — find them as rows under their task @@ -2153,7 +2073,7 @@ Under the task rows, one dim `+ /task` row closes the list; press it and `/task into your message box. Then a blank line, then the automations this conversation set up — at most three, each its mark and title, a press opening `/automations` on it — and their `+ /automations` row (*Automations*). There are no state totals at the foot: the header -counts the tasks and each group's heading counts its own. The session's spend and tokens +opens task history and each active group's heading counts its own. The session's spend and tokens are on the status row. Below those are up to two door lines: `ctrl+. earlier` when the full-screen page holds work @@ -2162,7 +2082,7 @@ this column does not, and, while the column holds the keyboard or the pointer is the right of its header. **Non-running rows are drawn quieter.** A running task's name is in the ordinary text -colour; queued, waiting and finished names are muted, headings are muted with dim counts, +colour; queued and waiting names are muted, headings are muted with dim counts, and the room you are standing in is the one row in the accent. A glance at the column lands on what is moving. @@ -2187,27 +2107,19 @@ the label toggles it. The shapes: ``` jobs · 2 running jobs · 1 running · 4m12s -jobs · 2 running · 6 ran -jobs · 6 ran ``` The clock appears **only when exactly one job is running**, because then there is one duration to name. Past that the count is the news. -**Expanded**, every running job draws (oldest first), then finished ones fill whatever -room is left, newest first, and the remainder is counted on an `N earlier` line rather -than dropped. That remainder line is a count, not a door: it opens nothing, and it wears -**no fold mark** for exactly that reason — it used to read `▸ 4 earlier` under a section -whose head is also a `▸`, which invited a keypress that did nothing. It sits at the rows' -own indent, under the rows it is counting. +**Expanded**, every running job draws, oldest first. Finished processes do not +remain in the sidebar or add an ended count. Their records and logs remain available +through the existing `/processes` and `jobs output N`. -**Zero jobs draws nothing at all** — no label, no empty row, no `0 jobs`. A conversation -that has started none looks as it did before jobs had a section. - -**`/new` and switching conversations drop the section.** A job belongs to the conversation -that started it. Coming back, or reopening it, redraws the jobs it ran, settled: a job -still going when codeaf closed comes back `stopped`. Nothing is restarted. The log stays -under this conversation's folder, in `logs/jobs/` — never in your project. +**No running jobs draws nothing at all** — no label, no empty row, no history count. +Switching tabs or reopening the conversation does not bring completed process rows back. +A process still going when codeaf closed comes back stopped; nothing restarts itself. +The logs remain under the conversation folder in `logs/jobs/`. The `jobs` tool, `jobs output N`, `jobs kill N`, `ctrl+g` promotion and the `still running as job 3` sentence are all unchanged. To kill a server you started, @@ -2629,7 +2541,7 @@ where you type. The other things on the column that answer a press say so by lighting under the pointer: -- **A group's heading** (`Queued 3 ▸`, `Done 6 ▾`) opens or folds that group. The hint line +- **A group's heading** (`Queued 3 ▸`, `Waiting 2 ▾`) opens or folds that group. The hint line says `Show what is done · click` or `Fold what is done · click`. The `Running` heading does not fold, wears no mark and does not light. - **A band row** opens its thing: a task's room, the member's chat at the question, or the @@ -3319,8 +3231,7 @@ as it was: the page is somewhere you go and come back from, not a state the colu beside this one. This is the common case: any directory you have worked in before has one. **`ctrl+. view more` is gone.** It stood for a family the column had folded away. The column -folds its own groups now (`Done 6 ▸`), and the heading is the door: press it and the rows -open in place. +folds its active groups in place. The `Tasks` heading opens task results and history on demand. A landed task of this session's, already on the column, does not earn the line: it would be offering to show you what you are looking at. So a first-ever session in a fresh directory, @@ -5281,7 +5192,7 @@ same word. The row's kind is not drawn, and its cost is available in the detail `←` folds the branch under the cursor and `→` opens it again. When you fold one, the section heading says how many rows are hidden, such as `completed · 4 folded away`. The list scrolls through every conversation tree selected by the time window. -This includes tasks from the run store. The side column inside a conversation has its own groups: Done starts folded, and opening it shows every task row its heading counts, including separate attempts at the same request. That column does not change the Sessions page's folds or chronological order. +This includes tasks from the run store. The side column inside a conversation shows active work and decisions only. Completed attempts remain on the Tasks page, with their original order and results. Type to filter; every section narrows at once, and a section the query empties is not drawn. The one printable keys that are not the filter are `1` and `2` over a row the pane is offering @@ -5343,14 +5254,12 @@ A task's **page** shows everything below it under its steps the same way, each with its live step while its worker is on one. Opening a row (`enter`) and leaving a note are unchanged by the tree. -## Why is this group one line? — Done folds its task rows +## Where did Done go — completed work stays in Tasks -The side column puts each task under Running, Queued, Waiting or Done. Running -stays open; the other groups start folded to one heading, such as `Done 2 ▸`. -Press the heading or use `enter` on it to see its task rows (`Done 2 ▾`). Every -task the heading counts gets a row, even when two attempts at the same request -have the same title. A run's parts fold with the task row that carries them. -Press a task row to open its page and see the work inside it. +The sidebar is for active work and unresolved decisions. It has no Done or Ended +fold. Click its `Tasks` heading to open results and history on demand. Finished tasks +keep their results, reports, files, branch and run details; hiding routine completion +receipts never deletes their records. ## What does queued behind it mean? — open tasks are waiting on this one @@ -5787,3 +5696,30 @@ before its cost was saved also stays interrupted: inspect the saved work before asking again. Its cost is never guessed or reset to make it resume, it is never counted as completed, and its folder is never guessed. Programs that had already started keep their existing interrupted-program behavior; this restart rule concerns ordinary planned tasks. + +## Crew details and run again with a stronger crew on the task result + +Open a finished task result in Tasks to read its crew, model seats, provider, estimate, +actual spending and any stop cause. Those details do not appear as separate chat receipts. +When the current conversation can run the selected task again, the result offers +`r run again with a stronger crew`. Press `r` or click that action to retry that exact +task, rather than whichever task finished most recently. The action is absent for running +work, another conversation's task, a program, or a crew stopped for a key or credit issue. +Existing ordinary retry and `/redo stronger ` controls remain available where supported. + +## Processes and finished process logs — /processes + +Use `/processes` to open this conversation's processes on demand, including completed +ones. Click a row or select it with the arrow keys and press Enter to open its log page. +Escape from a log returns to the list; Escape again returns to chat. `/processes ` +opens that process directly. The existing log controls still copy its path, put output +in your message and stop a process while it is running. Finished processes never stay +in the active-work sidebar or add an ended count. + +## Open a finished routed task's result or its full output + +Opening a finished task with a routed crew shows its result card, with the produced +answer, report, crew models, cost and retry actions in one place. Use `o open full output` +or click that action to open the existing task transcript room. Tasks without routed +crew details keep their ordinary room door. An old record with no crew details does not +invent them; reopening its owning conversation restores the details its checkpoint kept. diff --git a/internal/session/agent.go b/internal/session/agent.go index b1897c625b..6601095d36 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1194,6 +1194,9 @@ type userMessage struct { // nonTaskNews preserves untagged news inside a batch for the conservative // stopped-result routing guard, without changing the turn's owed asks. nonTaskNews bool + // completionOnly distinguishes task/job delivery receipts from requested + // watch alerts and mixed background batches. It never changes model delivery. + completionOnly bool // wake marks a note the model OWES AN ANSWER FOR: a task's completion // (task_run.go's reportTaskNode), a background job's exit (jobs.go's reap), @@ -1431,6 +1434,7 @@ func jobNote(text string) userMessage { // ([userMessage.ending]). note.ending = true note.otherResults = true + note.completionOnly = true return note } @@ -2643,6 +2647,23 @@ func (a *Agent) recordLocked(message ai.Message) { // must not write. Everything the model and the tools produce is text and goes // through recordLocked exactly as before. func (a *Agent) recordUserLocked(user userMessage) { + if !user.authored || user.completionOnly { + // Presentation follows this occurrence, not equal words a person might + // later quote. The model still receives the complete original payload. + mark := a.presentation.of(user.message) + user.message.Content = append([]ai.ContentPart(nil), user.message.Content...) + if a.presentation == nil { + a.presentation = &presentationIndex{} + } + if user.authored { + mark = &messagePresentation{Audience: "operational", RoutineCompletion: true} + } else if mark == nil { + mark = &messagePresentation{Audience: "human"} + } else { + mark.Audience = "human" + } + a.presentation.remember(user.message, mark) + } // AND WHOEVER SENT IT IS OWED AN ANSWER ONLY IF THE RECORD REALLY HOLDS IT. // `durable` is set by each road below: the journal write answers whether the // line reached the file, and a write that failed may not settle a delivery — @@ -3383,6 +3404,7 @@ func batchSessionNotes(notes []userMessage) userMessage { repeats := make(map[string]*repeated) order := make([]string, 0, len(notes)) parts := make([]string, 0, len(notes)) + completionOnly := len(notes) > 0 var ( wake bool otherResults bool @@ -3391,6 +3413,7 @@ func batchSessionNotes(notes []userMessage) userMessage { delivered []durableDelivery ) for _, note := range notes { + completionOnly = completionOnly && note.completionOnly wake = wake || note.wake otherResults = otherResults || note.otherResults nonTaskNews = nonTaskNews || note.nonTaskNews || len(note.replyTags) == 0 @@ -3435,13 +3458,14 @@ func batchSessionNotes(notes []userMessage) userMessage { text = "while you worked:\n\n" + strings.Join(rendered, "\n\n") } return userMessage{ - message: textMessage("user", text), - replyTags: tags, - otherResults: otherResults, - nonTaskNews: nonTaskNews, - delivered: delivered, - wake: wake, - authored: true, + message: textMessage("user", text), + completionOnly: completionOnly, + replyTags: tags, + otherResults: otherResults, + nonTaskNews: nonTaskNews, + delivered: delivered, + wake: wake, + authored: true, } } @@ -4613,6 +4637,9 @@ type DisplayEntry struct { // mark, so a line from a file written before the mark existed still arrives as // "user", which is exactly what it always was. Role string + // RoutineCompletion is a delivery receipt whose full result belongs to + // its task or process. It is retained as data rather than another chat row. + RoutineCompletion bool // Answer marks a completed tool-free response or an explicit human update. This // boundary survives replay so a later response cannot demote its message. Answer bool @@ -4883,6 +4910,10 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese displayText = presentation.personWords(msg) } interrupted, explicitlyHuman := false, false + routineCompletion := false + if mark := presentation.of(msg); mark != nil && msg.Role == "user" && mark.RoutineCompletion { + role, routineCompletion = "aside", true + } if mark := presentation.of(msg); role == "assistant" && mark != nil { interrupted = mark.Interrupted explicitlyHuman = mark.Audience == "human" @@ -4907,13 +4938,14 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese displayText, update = UserFacingUpdate(displayText) } entries = append(entries, DisplayEntry{ - Role: role, - Answer: role == "assistant" && !interrupted && (len(msg.ToolCalls) == 0 || update), - Addressed: role == "assistant" && (update || interrupted && explicitlyHuman), - Interrupted: interrupted, - Text: displayText, - ImageRefs: journal.imageRefs(msg), - ReplyTags: tags, + Role: role, + RoutineCompletion: routineCompletion, + Answer: role == "assistant" && !interrupted && (len(msg.ToolCalls) == 0 || update), + Addressed: role == "assistant" && (update || interrupted && explicitlyHuman), + Interrupted: interrupted, + Text: displayText, + ImageRefs: journal.imageRefs(msg), + ReplyTags: tags, // The journal is the only thing that remembers a user line was typed // INTO the turn above it rather than opening one of its own: the // message itself is an ordinary user message, because that is what the diff --git a/internal/session/completion_presentation.go b/internal/session/completion_presentation.go new file mode 100644 index 0000000000..bcdf8a1d48 --- /dev/null +++ b/internal/session/completion_presentation.go @@ -0,0 +1,48 @@ +package session + +import ( + "strconv" + "strings" +) + +// legacyRoutineCompletion recognizes only standalone runtime receipts whose +// journal already proves nobody typed them. Mixed batches cannot prove that +// they contain no requested watch alert, so they retain their original audience. +func legacyRoutineCompletion(entry sessionEntry) bool { + if entry.Role != "user" || !entry.Note || len(entry.ToolCalls) != 0 { + return false + } + text := entry.Content + if strings.HasPrefix(text, "while you worked:") { + return false + } + // The complete reserved job instruction is stronger provenance than a + // sentence merely saying a program finished. Output remains in its job log. + if head, tail, ok := strings.Cut(text, "\n"); ok && strings.HasPrefix(tail, jobExitNewsRule) { + fields := strings.Fields(head) + if len(fields) >= 4 && fields[0] == "job" && fields[2] == "exited" { + id, idErr := strconv.ParseUint(fields[1], 10, 64) + _, codeErr := strconv.Atoi(strings.TrimSuffix(fields[3], ":")) + if idErr == nil && id > 0 && codeErr == nil && (tail == jobExitNewsRule || strings.HasPrefix(tail, jobExitNewsRule+"\n")) { + return true + } + } + } + // Older task receipts without a batch envelope carry the exact delivery + // lead and the task's canonical head. Ordinary prose or quoted examples do + // not satisfy both, even when they contain the same task title. + for _, notice := range []TaskNotice{{State: TaskDone}, {State: TaskFailed}, {State: TaskUnverified}, {State: TaskFailed, Stopped: true}} { + lead := landingNoteLead(notice) + if lead == "" || !strings.HasPrefix(text, lead) { + continue + } + head, _, _ := strings.Cut(strings.TrimPrefix(text, lead), "\n") + fields := strings.Fields(head) + if len(fields) < 3 || fields[0] != "task" { + return false + } + id, err := strconv.ParseUint(fields[1], 10, 64) + return err == nil && id > 0 && strings.HasPrefix(head, "task "+fields[1]+" "+ProjectTask(notice.StatusFacts()).Word+": ") && strings.Contains(head, " · transcript ") + } + return false +} diff --git a/internal/session/completion_presentation_test.go b/internal/session/completion_presentation_test.go new file mode 100644 index 0000000000..b070ac2754 --- /dev/null +++ b/internal/session/completion_presentation_test.go @@ -0,0 +1,115 @@ +package session + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +func TestCompletionPresentationSurvivesCompactionAndReopenWithoutChangingContext(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.SessionFile = path }) + text := "job 7 exited 0: report ready\n" + jobExitNewsRule + routine := jobNote(text) + routine.authored = true + agent.mu.Lock() + agent.recordUserLocked(routine) + // Equal words typed by the person are a separate occurrence, not a receipt. + agent.recordUserLocked(userText(text)) + alert := wakeNote("Your requested watch matched: release is ready") + alert.authored = true + agent.recordUserLocked(alert) + agent.mu.Unlock() + assert := func(entries []DisplayEntry) { + t.Helper() + if len(entries) != 3 || !entries[0].RoutineCompletion || entries[0].Role != "aside" || entries[0].Text != text || entries[1].RoutineCompletion || entries[1].Text != text || entries[2].RoutineCompletion { + t.Fatalf("receipt presentation crossed occurrences or removed an alert: %#v", entries) + } + } + assert(agent.Transcript()) + window := append([]ai.Message(nil), agent.snapshot()[1:]...) + if messageContentText(window[0]) != text { + t.Fatal("model completion payload changed") + } + agent.file.appendCompaction(compactionPass{}, 0, window) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + replayed, err := replaySessionFile(path) + if err != nil { + t.Fatal(err) + } + assert(shapeEntries(replayed.messages, &sessionFile{presentation: replayed.presentation, notes: replayed.notes})) + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("presentation cleanup modified the original journal") + } +} + +func TestCompletionPresentationKeepsMixedWatchAndTeamBatchesVisible(t *testing.T) { + task := wakeNote("task 3 done: Build") + task.completionOnly = true + job := jobNote("job 7 exited 0") + watch := wakeNote("Requested watch matched") + team := wakeNote("A teammate sent a result") + for _, tc := range []struct { + name string + notes []userMessage + quiet bool + }{ + {"tasks and processes", []userMessage{task, job}, true}, + {"watch", []userMessage{task, watch}, false}, + {"team", []userMessage{job, team}, false}, + {"empty", nil, false}, + } { + t.Run(tc.name, func(t *testing.T) { + batch := batchSessionNotes(tc.notes) + if batch.completionOnly != tc.quiet { + t.Fatalf("mixed result visibility changed: %#v", batch) + } + if len(tc.notes) > 0 && !batch.wake { + t.Fatal("presentation removed the owed engine wake") + } + for _, note := range tc.notes { + if !bytes.Contains([]byte(batch.text()), []byte(note.text())) { + t.Fatal("presentation discarded background evidence") + } + } + }) + } +} + +func TestLegacyCompletionPresentationRequiresStandaloneRuntimeProvenance(t *testing.T) { + notice := TaskNotice{ID: 9, State: TaskDone, Title: "Build report"} + task := landingNoteLead(notice) + taskNote(notice, "task://report", TaskSettle(""), landingAddress{}) + job := "job 7 exited 0: report ready\n" + jobExitNewsRule + "\n\nretained output" + for _, tc := range []struct { + name, text string + note bool + quiet bool + }{ + {"old task receipt", task, true, true}, + {"old job receipt", job, true, true}, + {"person quotes task", task, false, false}, + {"person quotes job", job, false, false}, + {"unmarked exit", "job 7 exited 0", true, false}, + {"quoted runtime text", "Here is an example:\n" + task, true, false}, + {"legacy mixed batch", "while you worked:\n\n" + task + "\n\nWatch matched", true, false}, + {"fallback answer", "program ended, but the chat could not finish its answer, so here is its result", true, false}, + {"work stop", "The time limit was reached; work stopped", true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + entry := sessionEntry{Type: "message", Role: "user", Note: tc.note, Content: tc.text} + if got := legacyRoutineCompletion(entry); got != tc.quiet { + t.Fatalf("legacy classification = %v, want %v", got, tc.quiet) + } + }) + } +} diff --git a/internal/session/presentation.go b/internal/session/presentation.go index 4bb1b59e00..463f4f654d 100644 --- a/internal/session/presentation.go +++ b/internal/session/presentation.go @@ -24,6 +24,9 @@ type messagePresentation struct { Text *string `json:"text,omitempty"` Interrupted bool `json:"interrupted,omitempty"` SkillsBlock string `json:"-"` + // RoutineCompletion is the runtime's delivery receipt, not the answer + // the person asked for. Its owner keeps the report and output available. + RoutineCompletion bool `json:"routineCompletion,omitempty"` } // Message identity follows its immutable content allocation, as reasoning repair @@ -116,6 +119,9 @@ func interruptedPresentation(text string) *messagePresentation { // text and mixed replies remain untouched. New records always write an explicit // audience, so even these exact words can be deliberately addressed to a person. func legacyPresentation(entry sessionEntry) *messagePresentation { + if legacyRoutineCompletion(entry) { + return &messagePresentation{Audience: "operational", RoutineCompletion: true} + } if entry.Role != "assistant" || len(entry.ToolCalls) != 0 { return nil } diff --git a/internal/session/sessionfile.go b/internal/session/sessionfile.go index 4240470154..0a9dcefb35 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -1332,6 +1332,11 @@ func (s *sessionFile) isNote(message ai.Message) bool { if s == nil { return false } + if mark := s.presentation.of(message); mark != nil && message.Role == "user" && mark.Audience == "human" { + // A person's quotation cannot inherit a runtime receipt's text hash + // when compaction writes the two occurrences back to the journal. + return false + } key := noteKey(message) if key == "" { return false diff --git a/internal/session/task_index.go b/internal/session/task_index.go index d252bab15b..db4ea29296 100644 --- a/internal/session/task_index.go +++ b/internal/session/task_index.go @@ -69,6 +69,7 @@ import ( "sync" "time" + "github.com/Agent-Field/codeaf/internal/crewroute" "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/plandb" "github.com/Agent-Field/codeaf/internal/reportline" @@ -263,6 +264,8 @@ type TaskIndexEntry struct { // checkpoint and in the node journal's header too, and a row without it made // re-pricing a landed task a three-file join. Model string `json:"model,omitempty"` + // Crew keeps the accepted seats and estimate with the task result rather than in a separate chat receipt. + Crew *crewroute.Decision `json:"crew,omitempty"` // RepairedOn is the model a REPAIR ROUND ran on, and it is here only when // that was not the model beside it: work the checker sent back is handed to // the careful tier (internal/session's repair_role.go), and this is the one diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 23f8d0fce0..8b3b2b3536 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -4111,6 +4111,7 @@ func (n *TaskNode) settlesNote(claim noteClaim) durableDelivery { // written by the caller, after the seam. func (a *Agent) postTaskMessage(node *TaskNode, tag TaskReplyTag, note, record string, durable []durableDelivery, settle settleMark, mark func()) deliveryReceipt { message := wakeNote(note) + message.completionOnly = true // AND A LANDING THAT HANDS THE MODEL THE DECISION MARKS ITS WAKE A SETTLE ONE, // so the turn it starts runs under the checker's own bound rather than the // run's wall ([settleWake], agent.go's [Agent.wakeLocked]). diff --git a/internal/session/task_run_index.go b/internal/session/task_run_index.go index e8d3cee92a..ef70d0e82a 100644 --- a/internal/session/task_run_index.go +++ b/internal/session/task_run_index.go @@ -61,6 +61,8 @@ func (a *Agent) indexRunRow(notice TaskNotice) { // AND WHICH PROGRAM HAS IT, so every surface drawing this file can put the // program's badge on the row ([TaskIndexEntry.Program]). Program: notice.Program, + Crew: notice.Crew, + Model: notice.Model, } // A PERSON'S STOP IS THE ROW'S ENDING, as it is on a node's row // ([TaskNode.endingLocked]): the stop road publishes the flag and no word. diff --git a/internal/session/taskcrew_index_test.go b/internal/session/taskcrew_index_test.go new file mode 100644 index 0000000000..7adad72bdc --- /dev/null +++ b/internal/session/taskcrew_index_test.go @@ -0,0 +1,40 @@ +package session + +import ( + "encoding/json" + "testing" + + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +func TestTaskIndexKeepsCrewWithItsCanonicalResult(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.SessionFile = t.TempDir() + "/conversation.jsonl" }) + crew := &crewroute.Decision{EstUSD: .52, Stopped: "add credits", Crew: []crewroute.Pick{{Seat: crewroute.Worker, Model: "worker-model"}, {Seat: crewroute.Planner, Model: "planner-model"}, {Seat: crewroute.Checker, Model: "checker-model"}}} + agent.indexRunRow(TaskNotice{ID: 7, Title: "Repair export", State: TaskFailed, Crew: crew, Model: "worker-model", CostUSD: .31}) + rows := ReadTaskIndex(agent.config.taskIndexFile()) + if len(rows) != 1 || rows[0].Crew == nil { + t.Fatalf("crew missing from task result: %+v", rows) + } + got := rows[0] + if got.Model != "worker-model" || got.Cost != .31 || got.Crew.EstUSD != .52 || got.Crew.Stopped != "add credits" || len(got.Crew.Crew) != 3 { + t.Fatalf("result lost crew facts: %+v", got) + } + var legacy TaskIndexEntry + if err := json.Unmarshal([]byte(`{"id":"7","title":"Old result","status":"done"}`), &legacy); err != nil || legacy.Crew != nil { + t.Fatalf("legacy index cannot load: %+v %v", legacy, err) + } +} + +// Older installations already keep routed crews in the run checkpoint, even +// when the project index predates its crew field. Restore uses those exact facts. +func TestOlderRunCheckpointRestoresCrewWithoutTaskIndexMigration(t *testing.T) { + raw := []byte(`{"id":7,"title":"Repair export","state":"failed","crewState":{"Version":1,"Routed":true,"Decision":{"EstUSD":0.52,"Stopped":"add credits","Crew":[{"Seat":"worker","Model":"worker-model"},{"Seat":"planner","Model":"planner-model"},{"Seat":"checker","Model":"checker-model"}]}}}`) + var saved runRecord + if err := json.Unmarshal(raw, &saved); err != nil { + t.Fatal(err) + } + notice := runRowNotice(saved) + if notice.Crew == nil || notice.Crew.Stopped != "add credits" || notice.Crew.EstUSD != .52 || len(notice.Crew.Crew) != 3 { + t.Fatalf("old checkpoint crew facts not restored: %+v", notice.Crew) + } +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 0fbf6c11b2..65c9d5fe46 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2113,11 +2113,15 @@ type app struct { // truth instead of a map and a slice kept in step with each other. jobs []session.JobNotice jobsOpen bool - // jobPage is the id of the job whose page is open, and 0 is every frame that + // jobPage is a process id, processListPage for its list, or 0 for a frame that // is not on one. It is a JOB'S OWN NUMBER and not a roster id, which is the // point of the whole change: one thing, one number, the same one `jobs kill` // takes. jobPage int + // jobList remembers the on-demand process list beneath an opened log. + jobList bool + jobCursor int + jobHover int // jobDraw is that page's own reading — the log tail it is showing, the beat // it is on and where it is scrolled to (jobpage.go). It is nil whenever no // page is open, and it is replaced rather than reused when the page moves to @@ -4223,6 +4227,17 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // for a gesture made over a row that is not that list's. Over the nav on // a place the wheel walks the PLACES, one room a tick (placemouse.go's // [app.placeTabWheel]). + if a.jobPageOpen() && !a.asking() { + delta := placeWheelDelta(msg.Mouse().Button) + if a.jobPage == processListPage { + a.jobCursor = min(max(0, a.jobCursor+delta), max(0, len(a.jobs)-1)) + a.jobHover = -1 + a.touch() + } else { + a.jobPageScroll(delta) + } + return a, nil + } if cmd, took := a.placeTabWheel(msg.Mouse().Y, placeWheelDelta(msg.Mouse().Button)); took { return a, cmd } @@ -4538,8 +4553,7 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // open a tool call in a conversation that is not even on the frame // (jobpage.go). Its edges are the way back and its body is read. if a.jobPageOpen() { - a.jobPagePress(msg.Mouse().X, msg.Mouse().Y) - return a, nil + return a, a.jobPagePress(msg.Mouse().X, msg.Mouse().Y) } // And the rewind timeline at the same rung and for the same reason: a // press that fell through to the conversation underneath would open a @@ -4894,6 +4908,17 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { return a, cmd } a.sawAPerson() + if a.jobPage == processListPage && !a.asking() { + width, height := a.size() + _, hits, _, _ := a.processListFrame(width, height) + a.jobHover = -1 + if y := msg.Mouse().Y; y >= 0 && y < len(hits) && hits[y] >= 1000 { + a.jobHover = int(hits[y]) - 1000 + } + a.touch() + return a, nil + } + // THE WALL OWNS MOTION WHILE IT IS UP, as it owns the press: its own // targets light under the pointer, and the strip above it still does // (wall.go). @@ -8111,6 +8136,9 @@ func (a *app) slash(line string) tea.Cmd { // taskcommand.go's [app.runRedo]). return a.runRedo(rest) + case "processes": + return a.showProcesses(rest) + case "history": // The place onto every task this MACHINE has run, this session's and every // conversation's before it, across every project (place_tasks.go). It is diff --git a/internal/tui3/bundle_test.go b/internal/tui3/bundle_test.go index ef9cb49387..61c731faca 100644 --- a/internal/tui3/bundle_test.go +++ b/internal/tui3/bundle_test.go @@ -3278,64 +3278,29 @@ func taskAnswerAt(t *testing.T, a *app, want int) (int, int) { func TestTheProposalBlockAndTheLandedCardEndInABlank(t *testing.T) { a, agent, _ := taskApp(t) agent.pending = []uint64{7} - drive(t, a, - streamEventMsg{gen: a.gen, ev: proposal(a, 7, 4*time.Second)}, - key("esc"), - streamEventMsg{gen: a.gen, ev: update(7, "Fix the nil-map crash", session.TaskDone, session.TaskNotice{ - Elapsed: 8 * time.Second, Merge: mergeWordMerged, - })}, - ) + drive(t, a, streamEventMsg{gen: a.gen, ev: proposal(a, 7, 4*time.Second)}, key("esc"), streamEventMsg{gen: a.gen, ev: update(7, "Fix the nil-map crash", session.TaskDone, session.TaskNotice{Elapsed: 8 * time.Second, Merge: mergeWordMerged})}) + drive(t, a, streamEventMsg{gen: a.gen, ev: session.Event{Kind: session.EventTextDelta, Text: "and now the reply."}}, frameMsg{}) rows := a.visible(a.bodyWidth()) foot := -1 for i, r := range rows { if strings.HasPrefix(strings.TrimSpace(plain(r.text)), taskFootCorner) { foot = i } - } - if foot < 0 { - t.Fatalf("the settled block has no foot:\n%s", taskText(a)) - } - if foot+1 >= len(rows) || strings.TrimSpace(plain(rows[foot+1].text)) != "" { - t.Fatalf("the block runs straight into what follows it:\n%s", taskText(a)) - } - // THE CARD IS FOUND BY ITS HIT and not by its words: it is a block of two - // rows now, and what this test owns is the blank under the LAST of them. - // - // THE HEAD IS SPELLED THE WAY THIS WAVE SPELLS IT, and both halves of that - // moved on 2026-09-03. The name arrives WHOLE — taskident.go's [taskTitleOf] - // stopped cutting to three words, because a fitter cannot give back cells - // spent before it was asked (rowfit.go's first law) — and the span is joined - // to the state word with ` · ` like every other fact on the row, because - // `done 8s` fuses two separate facts into one phrase (taskdone.go's - // [app.doneTail] states the whole of it). - if !strings.Contains(taskText(a), "Fix the nil-map crash · "+doneWord+" · 8s · "+mergeWordMerged) { - t.Fatalf("the landed card is not in the transcript:\n%s", taskText(a)) - } - // The card is the last entry here, so what it owes the next one is asserted - // by putting one after it. - drive(t, a, streamEventMsg{gen: a.gen, ev: session.Event{Kind: session.EventTextDelta, Text: "and now the reply."}}, frameMsg{}) - rows = a.visible(a.bodyWidth()) - last := -1 - for i, r := range rows { if r.hit == hitDone { - last = i + t.Fatal("routine landing inserted a transcript card") } } - if last < 0 { - t.Fatalf("the landed card left no rows of its own:\n%s", taskText(a)) + if foot < 0 || foot+1 >= len(rows) || strings.TrimSpace(plain(rows[foot+1].text)) != "" { + t.Fatalf("proposal lost its spacing before the answer:\n%s", taskText(a)) + } + if !strings.Contains(taskText(a), "and now the reply.") { + t.Fatal("reply missing") } - if last+1 >= len(rows) || strings.TrimSpace(plain(rows[last+1].text)) != "" { - t.Fatalf("the landed card runs straight into the next entry:\n%s", taskText(a)) + if card := a.doneCardFor(7); card == nil || card.span != 8*time.Second || card.merge != mergeWordMerged { + t.Fatal("landing facts lost") } } -// ESC IS LATER, AND IT IS NOT THE TURN'S EITHER. -// -// It used to decline the proposal, which was the honest reading of "get this off -// my screen" while the block was modal and there was no other way out of it. -// There is one now (question.go): the rows fold to the chip, the proposal stays -// open, the engine stays waiting, and NOTHING IS DECIDED BY MAKING SOMETHING GO -// AWAY. The decline is `2`, which is drawn on the row beside it. func TestEscFoldsTheProposalRatherThanDecliningItOrTheTurn(t *testing.T) { a, agent, _ := taskApp(t) agent.pending = []uint64{7} @@ -3378,198 +3343,60 @@ func TestEscFoldsTheProposalRatherThanDecliningItOrTheTurn(t *testing.T) { // raising it (task.go's [app.railShowing]). func TestTheRailStandsWhileWorkIsAliveAndGoesWhenItLands(t *testing.T) { a, _, advance := taskApp(t) - if !a.railShowing() { - t.Fatal("an empty session drew no rail") - } - if a.bodyWidth() != 200-sideColsFor(200) { - t.Fatalf("the empty column is not charged against the conversation: body=%d", a.bodyWidth()) - } - // AND IT SAYS WHAT IT IS FOR. Thirty blank columns beside a paragraph read as - // a rendering fault, so the empty column carries its one dim label. - if rail := plain(strings.Join(a.railRows(10), "\n")); strings.Contains(rail, "no tasks yet") { - t.Fatalf("the empty column announces its emptiness:\n%s", rail) - } - drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "Fix the nil-map crash", session.TaskRunning, session.TaskNotice{})}) - if !a.railShowing() { - t.Fatal("a running node did not keep the rail standing") - } - // The label leaves with the emptiness: a column with a row in it needs no - // explanation of itself. - if rail := plain(strings.Join(a.railRows(10), "\n")); strings.Contains(rail, "no tasks yet") { - t.Fatalf("the label outlived the emptiness it explains:\n%s", rail) - } advance(12 * time.Second) - rail := plain(strings.Join(a.railRows(10), "\n")) - // THE ROW CARRIES THE NAME, not the whole title: taskident.go cuts the - // engine's sentence to the two-or-three-word label this column is wide enough - // to read. - if !strings.Contains(rail, "Fix the nil-map") || !strings.Contains(rail, "12s") { - t.Fatalf("the running node is not on the rail with its clock:\n%s", rail) + if rail := plain(strings.Join(a.railRows(12), "\n")); !strings.Contains(rail, "Fix the nil-map") || !strings.Contains(rail, "12s") { + t.Fatal(rail) } - - // A queued node is a hollow circle, a failure is the bad glyph, and a node - // whose branch did not come home keeps the branch name. - drive(t, a, - streamEventMsg{gen: a.gen, ev: update(8, "Mix audio", session.TaskQueued, session.TaskNotice{})}, - streamEventMsg{gen: a.gen, ev: update(9, "Collect sources", session.TaskRunning, session.TaskNotice{})}, - streamEventMsg{gen: a.gen, ev: update(9, "Collect sources", session.TaskFailed, session.TaskNotice{ - Report: "the tests did not build", Merge: mergeWordAborted, Branch: "task/collect", - })}, - streamEventMsg{gen: a.gen, ev: update(7, "Fix the nil-map crash", session.TaskDone, session.TaskNotice{ - Elapsed: 130 * time.Second, Merge: mergeWordConflicted, Branch: "task/fix-nil-map", - })}, - ) - // THE QUEUED WORK IS ONE FOLDED ROW UNTIL IT IS OPENED, and the two rows - // that need the person are the band's: the conflicted branch in the - // needs-you amber, the failure a ✗ in ink (sidecol.go). + drive(t, a, streamEventMsg{gen: a.gen, ev: update(8, "Mix audio", session.TaskQueued, session.TaskNotice{})}, streamEventMsg{gen: a.gen, ev: update(9, "Collect sources", session.TaskFailed, session.TaskNotice{Report: "tests did not build", Branch: "task/collect"})}, streamEventMsg{gen: a.gen, ev: update(7, "Fix the nil-map crash", session.TaskDone, session.TaskNotice{Merge: mergeWordConflicted, Branch: "task/fix-nil-map"})}) a.sideToggleGroup(railIdle) - rail = plain(strings.Join(a.railRows(16), "\n")) - for _, want := range []string{ - glyphQueued + " Mix audio", - a.taskStateMark(a.tasks[9]) + " Collect sources", - a.taskStateMark(a.tasks[7]) + " Fix the nil-map", - } { - if !strings.Contains(rail, want) { - t.Fatalf("the rail is missing %q:\n%s", want, rail) - } - } - // A BRANCH NAME IS NEVER ELLIPSIZED: the row is one line, and the hint line - // under the pointer carries the handle back to the work whole. - for id, want := range map[uint64]string{7: "task/fix-nil-map", 9: "task/collect"} { - if hint := railHint(a, id); !strings.Contains(hint, want) { - t.Fatalf("the hint over node %d does not name %q: %q", id, want, hint) - } + rail := plain(strings.Join(a.railRows(20), "\n")) + if !strings.Contains(rail, "Mix audio") || !strings.Contains(rail, "Fix the nil-map") || strings.Contains(rail, "Collect sources") || strings.Contains(rail, "Done") { + t.Fatal(rail) } - if strings.Contains(rail+railHint(a, 9), mergeWordAborted) { - t.Fatalf("the rail read the engine's own word for a stopped node:\n%s", rail) - } - - // A node whose branch CAME HOME keeps its row: the column is the session's - // record of its own work now, and "where did that task go" is the commonest - // question asked of it. What it stops saying is anything more than its name. - drive(t, a, streamEventMsg{gen: a.gen, ev: update(8, "Mix audio", session.TaskDone, session.TaskNotice{ - Elapsed: 8 * time.Second, Merge: mergeWordMerged, - })}) - railOpenAll(a) - if !strings.Contains(plain(strings.Join(a.railRows(12), "\n")), "Mix audio") { - t.Fatal("a merged node left the roster's record") + if card := a.doneCardFor(9); card == nil || card.branch != "task/collect" { + t.Fatal("incomplete result lost") } - if !a.railShowing() { - t.Fatal("the rail left while two kept branches were still on it") + drive(t, a, streamEventMsg{gen: a.gen, ev: update(8, "Mix audio", session.TaskDone, session.TaskNotice{Merge: mergeWordMerged})}) + if rail := plain(strings.Join(a.railRows(20), "\n")); strings.Contains(rail, "Mix audio") { + t.Fatal("completed task remained on active sidebar") } - // The two kept branches are what remains, and dealing with them is the - // person's business — this test only owns the empty case, so it drops them - // the way /new does. The column does NOT leave with them: the place is - // permanent, and an emptied column is back to saying what it is for. a.dropTasks() if !a.railShowing() { - t.Fatal("/new took the column down with the nodes") - } - if rail := plain(strings.Join(a.railRows(10), "\n")); strings.Contains(rail, "no tasks yet") { - t.Fatalf("the emptied column announces its emptiness:\n%s", rail) + t.Fatal("clearing tasks removed the sidebar navigation") } } -// A NODE'S END IS HISTORY, so it lands in the transcript — once, however many -// lanes carried it. The de-dup is (id, state), because an update raised inside a -// turn arrives on the turn's stream AND on the standing subscription. -// -// WHAT LANDS IS A CARD (taskdone.go), not the one dim sentence this surface used -// to leave, and every fact the sentence carried is asserted here in the card's -// own grammar: the outcome and the elapsed on the head, the engine's own words -// quoted underneath. -// -// The landings are SEPARATED BY A REPLY because a contiguous run of more than -// two cards rolls up into one object (taskdone.go's [doneRollupFloor]), and what -// this test owns is the wording of a card rather than the shape of a batch. func TestALandedNodeWritesOneCardWhateverLaneCarriedIt(t *testing.T) { a, agent, _ := taskApp(t) - // say is the reply that closes one landing off from the next. - say := func(text string) { - drive(t, a, streamEventMsg{gen: a.gen, ev: session.Event{Kind: session.EventTextDelta, Text: text}}, frameMsg{}) - } - done := update(7, "Fix the nil-map crash", session.TaskDone, session.TaskNotice{ - Elapsed: 130 * time.Second, Merge: mergeWordMerged, - }) - // The same event, on both lanes, exactly as internal/session emits it. + done := update(7, "Fix the nil-map crash", session.TaskDone, session.TaskNotice{Elapsed: 130 * time.Second, Merge: mergeWordMerged, Report: "Fixed parser", Result: "Final answer"}) cmd := a.watchTasks() agent.updates <- done drive(t, a, append(runCmd(cmd), streamEventMsg{gen: a.gen, ev: done})...) - - text := taskText(a) - // THE NAME IS WHOLE AND THE SPAN IS A FACT OF ITS OWN, both since 2026-09-03: - // taskident.go's [taskTitleOf] no longer cuts a title to three words before - // any width is known, and taskdone.go's [app.doneTail] joins the span to the - // state word with the row's own separator rather than with a bare space. - want := "Fix the nil-map crash · " + doneWord + " · " + taskSpanWord(130*time.Second) + " · " + mergeWordMerged - if strings.Count(text, want) != 1 { - t.Fatalf("the transcript holds %d copies of %q:\n%s", strings.Count(text, want), want, text) - } - - // A failure says why, in the report's first line, and it says it in the - // node's own words — which is why they are in quotes. - say("looking at the next one.") - drive(t, a, streamEventMsg{gen: a.gen, ev: update(9, "Collect sources", session.TaskFailed, session.TaskNotice{ - Elapsed: 4 * time.Second, Report: "the tests did not build\nsee the log", - })}) - a.openDone(a.doneEntryFor(9)) - for _, want := range []string{ - // `failed` is deleted as a landing's word: the head reads `incomplete` and - // the row under it says why, in the engine's own sentence for a fault - // (docs/design/task-states/DESIGN.md). - "Collect sources · " + taskIncompleteState + " · " + taskSpanWord(4*time.Second), - "a fault: the tests did not build", - } { - if !strings.Contains(taskText(a), want) { - t.Fatalf("the failure card does not carry %q:\n%s", want, taskText(a)) + count := 0 + for _, entry := range a.entries { + if entry.done != nil && entry.done.id == 7 { + count++ } } - a.openDone(a.doneEntryFor(9)) - if strings.Contains(taskText(a), "see the log") { - t.Fatalf("the collapsed card leaked the rest of the report:\n%s", taskText(a)) + if count != 1 { + t.Fatalf("duplicate delivery retained %d landings", count) } - - // A NODE THAT KEPT ITS BRANCH SAYS SO AS A FACT AND NOT AS A STATE. The head - // carries the word, the file count and `branch kept · `; the row under - // it carries the engine's own account of what went wrong, and the fused - // `stopped — branch kept` is gone from the card altogether - // (docs/design/task-states/DESIGN.md). - say("and the audio.") - drive(t, a, streamEventMsg{gen: a.gen, ev: update(11, "Mix audio", session.TaskFailed, session.TaskNotice{ - Elapsed: 90 * time.Second, Report: "stopped: 40 steps and no finish", - Merge: mergeWordAborted, Branch: "task/mix", - })}) - a.openDone(a.doneEntryFor(11)) - for _, want := range []string{ - "Mix audio · " + taskIncompleteState + " · " + taskSpanWord(90*time.Second), - "branch · task/mix", - "stopped: 40 steps and no finish", - } { - if !strings.Contains(taskText(a), want) { - t.Fatalf("the kept-branch card does not carry %q:\n%s", want, taskText(a)) - } + card := a.doneCardFor(7) + if card == nil || card.result != "Final answer" || card.report != "Fixed parser" || card.span != 130*time.Second || !card.hidden() { + t.Fatalf("lost landing facts: %+v", card) } - for _, never := range []string{mergeWordAborted, "stopped — branch kept"} { - if strings.Contains(taskText(a), never) { - t.Fatalf("the card says %q, which is not a card's word any more:\n%s", never, taskText(a)) - } + if rows := a.doneRows(card, 120, false); len(rows) != 0 { + t.Fatal("duplicate completion occupied chat") } - // And a node that ended with nothing to say still says something: an ending - // this surface was told nothing about is a fault, in the engine's bare word - // for one, rather than an empty row. - say("and the titles.") - drive(t, a, streamEventMsg{gen: a.gen, ev: update(12, "Render titles", session.TaskFailed, session.TaskNotice{ - Elapsed: 3 * time.Second, - })}) - if !strings.Contains(taskText(a), "Render titles · "+taskIncompleteState) { - t.Fatalf("a silent failure does not land as %q:\n%s", taskIncompleteState, taskText(a)) + for _, id := range []uint64{9, 11, 12} { + drive(t, a, streamEventMsg{gen: a.gen, ev: update(id, "Incomplete task", session.TaskFailed, session.TaskNotice{Report: "tests did not build", Branch: "task/kept"})}) + if card := a.doneCardFor(id); card == nil || card.report != "tests did not build" || card.branch != "task/kept" || !card.hidden() { + t.Fatalf("lost incomplete result: %+v", card) + } } } -// THE RAIL IS TREE-READY. v1's graph has no edges, so nothing draws one — but a -// node whose prerequisites are unmet says so in v1's own sentence, from the -// DependsOn the row model already stores. func TestTheRailNamesWhatABlockedNodeWaitsOn(t *testing.T) { a, _, _ := taskApp(t) drive(t, a, @@ -3782,7 +3609,7 @@ func TestTheRailIsChargedAgainstTheConversationOnly(t *testing.T) { // column (sidecol.go). name := "Fix the nil-map crash" top := a.bodyTop() - if tc.rail && !strings.Contains(lines[top], sideTasksWord+" 1") { + if tc.rail && !strings.Contains(lines[top], sideTasksWord) { t.Fatalf("at %d columns the column does not open with its header:\n%q", tc.width, lines[top]) } if tc.rail && (!strings.Contains(lines[top+1], railHeadWords[railRunning]) || !strings.Contains(lines[top+2], name)) { diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 85b8599526..eeb3e7bd93 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -132,6 +132,7 @@ var commands = []command{ // looking, /compact is one of them, and a row inserted above it would push // the daily command behind a scroll (deliverables_test.go pins exactly // that). So it lands as close to its pair as the law allows (home.go). + {name: "processes", desc: "open this conversation's processes and logs"}, {name: "dismiss", desc: "hide settled task notifications here"}, {name: "dismiss", args: "undo", desc: "restore dismissed task notifications"}, {name: "home", desc: "every project and conversation on this machine"}, diff --git a/internal/tui3/completion_cleanup_regression_test.go b/internal/tui3/completion_cleanup_regression_test.go new file mode 100644 index 0000000000..e1f048e9b4 --- /dev/null +++ b/internal/tui3/completion_cleanup_regression_test.go @@ -0,0 +1,88 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Quiet landings must retain the result rather than clearing the entry that +// existing task and record doors use to find it. +func TestQuietCompletionPreservesDataAcrossRepeatedUpdates(t *testing.T) { + a, _, _ := taskApp(t) + for _, state := range []session.TaskState{session.TaskDone, session.TaskFailed} { + drive(t, a, streamEventMsg{gen: a.gen, ev: update(73, "Preserved result", state, session.TaskNotice{Report: "Landing account", Result: "Actual deliverable", CostUSD: 0.17})}) + card := a.doneCardAt(a.doneEntryFor(73)) + if card == nil || card.result != "Actual deliverable" || card.report != "Landing account" { + t.Fatalf("completion lost its data: %+v", card) + } + if rows := a.doneRows(card, 100, false); len(rows) != 0 { + t.Fatalf("routine completion occupies %d rows: %v", len(rows), rows) + } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(73, "Preserved result", state, session.TaskNotice{Report: "Landing account", Result: "Actual deliverable", CostUSD: 0.19})}) + if got := taskText(a); strings.Contains(got, "Preserved result") || strings.Contains(got, "Landing account") { + t.Fatalf("metadata refresh raised a completion again: %s", got) + } + } +} + +func TestQuietCompletionCannotHideNewPendingDecision(t *testing.T) { + a, _, _ := taskApp(t) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(74, "Review parser", session.TaskFailed, session.TaskNotice{Report: "Partial result", Result: "Parser implementation"})}) + if card := a.doneCardAt(a.doneEntryFor(74)); card == nil || !card.hidden() { + t.Fatalf("routine failure not quiet: %+v", card) + } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(74, "Review parser", session.TaskFailed, session.TaskNotice{Report: "Check failed", Result: "Parser implementation", ResultHeld: true})}) + card := a.doneCardAt(a.doneEntryFor(74)) + if card == nil || card.hidden() || card.status.Tier != session.TaskTierYourCall { + t.Fatalf("new decision hidden: %+v", card) + } + if got := taskText(a); !strings.Contains(got, "Review parser") || !strings.Contains(got, card.status.Ask.Reason) { + t.Fatalf("decision not visible with reason: %s", got) + } + if a.dismissDone(a.doneEntryFor(74)) { + t.Fatal("pending decision can be dismissed") + } +} + +func TestCrewLifecycleDoesNotCreateIndependentTranscriptReceipts(t *testing.T) { + a, _, _ := taskApp(t) + count := len(a.entries) + crew := &crewroute.Decision{Class: crewroute.Bugfix, EstUSD: 0.02, Crew: []crewroute.Pick{{Seat: crewroute.Worker, Model: "deepseek/deepseek-v4.1-flash", Provider: "openrouter"}}} + for _, state := range []session.TaskState{session.TaskRunning, session.TaskDone, session.TaskDone} { + a.sayTaskCrew(session.TaskNotice{ID: 75, State: state, Crew: crew, CostUSD: 0.018}) + } + if len(a.entries) != count { + t.Fatalf("crew lifecycle added %d transcript receipts", len(a.entries)-count) + } +} + +// Notices can be the only surviving result or a work-stopping explanation, so +// grouping completion receipts must never blanket-filter their event kind. +func TestQuietCompletionPreservesSubstantiveNotices(t *testing.T) { + a, _, _ := taskApp(t) + for _, text := range []string{"The time limit stopped this task; its partial output is saved.", "Fallback result: the comparison favours the parser implementation.", "Reminder: review the parser experiment."} { + a.taskEvent(session.Event{Kind: session.EventNotice, Text: text}) + if got := taskText(a); !strings.Contains(got, text) { + t.Fatalf("substantive notice was hidden: %q\n%s", text, got) + } + } +} + +func TestQuietCompletionSidebarContainsOnlyActiveWorkAndDecisions(t *testing.T) { + a, _, _ := taskApp(t) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(76, "Finished parser experiment", session.TaskDone, session.TaskNotice{Report: "Completed result"})}) + got := plain(strings.Join(a.railRows(20), "\n")) + for _, hidden := range []string{"Finished parser experiment", "Done 1", "Ended 1", "Tasks 1"} { + if strings.Contains(got, hidden) { + t.Fatalf("completed work still occupies the sidebar (%q):\n%s", hidden, got) + } + } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(77, "Choose parser result", session.TaskUnverified, unverifiedNotice("needs review"))}) + got = plain(strings.Join(a.railRows(20), "\n")) + if !strings.Contains(got, "Choose parser result") { + t.Fatalf("pending decision missing from sidebar:\n%s", got) + } +} diff --git a/internal/tui3/completion_projection_test.go b/internal/tui3/completion_projection_test.go new file mode 100644 index 0000000000..83e42b2954 --- /dev/null +++ b/internal/tui3/completion_projection_test.go @@ -0,0 +1,50 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// Detail formatting remains a unit concern even though ordinary results no +// longer have a clickable receipt in the transcript. Inspect a snapshot rather +// than making the real conversation show a quiet card just for these checks. +func retainedLandingText(t *testing.T, a *app, id uint64) string { + t.Helper() + card := a.doneCardFor(id) + if card == nil { + t.Fatalf("task %d lost its retained result", id) + } + snapshot := *card + snapshot.quiet, snapshot.dismissed, snapshot.open = false, false, true + return plainOf(a.doneRows(&snapshot, a.bodyWidth(), false)) +} + +func TestRuntimeReceiptProjectionKeepsRequestedAnswersAndAlerts(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.entries, _ = a.replayBlocks([]session.DisplayEntry{ + {Role: "user", Text: "Check the report"}, + {Role: "aside", Text: "task 9 done: report", RoutineCompletion: true}, + {Role: "assistant", Text: "The report is ready.", Answer: true}, + {Role: "aside", Text: "Your requested watch matched."}, + {Role: "user", Text: "task 9 done: report"}, + }, chatReplay(0)) + a.touch() + text := taskText(a) + for _, want := range []string{"The report is ready."} { + if !strings.Contains(text, want) { + t.Fatalf("presentation hid substantive content %q: %s", want, text) + } + } + retainedAlert := false + for _, entry := range a.entries { + retainedAlert = retainedAlert || entry.text == "Your requested watch matched." + } + if !retainedAlert { + t.Fatal("runtime receipt projection removed the requested alert from its existing work disclosure") + } + if strings.Count(text, "task 9 done: report") != 1 { + t.Fatalf("runtime receipt leaked or person's quote disappeared: %s", text) + } +} diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index b1799b9fed..19fd0abbed 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -275,13 +275,8 @@ func crewPinMark(linear bool) string { return tokens.Plain.Glyph(tokens.GPinned) } -// sayTaskCrew keeps a routed task's crew line in the thread: ONE line, said -// when the task starts with the estimate, REWRITTEN IN PLACE while the task -// goes — a seat that failed to start and moved to its fallback — and MOVED TO -// THE END when it lands, with what it cost beside the estimate and the one -// door to asking again harder. -// Two lines for one crew read as two crews; the second said nothing the first -// could not carry. +// sayTaskCrew remembers routing facts without adding another transcript block. +// Tasks owns the run details, including the crew and the retry action. func (a *app) sayTaskCrew(notice session.TaskNotice) { if notice.Crew == nil { return @@ -336,29 +331,8 @@ func (a *app) sayTaskCrew(notice session.TaskNotice) { if text == said.text { return } - switch { - case said.text == "": - a.feed.noteWritten(text, false, facts) - case said.landed: - // THE LANDED LINE MOVES WITH THE CARD. Rewriting its old estimate in - // place would leave the actual cost far above the answer. If a turn is - // still speaking, its boundary moves both rows past that answer, beyond - // the work that closes under a chip. - a.feed.moveNote(said.text, text, facts) - case !a.feed.renote(said.text, text, facts): - a.feed.noteWritten(text, false, facts) - } - if said.landed { - // The final crew line is addressed to the person. A note left as - // ordinary work can disappear under a closed fold even when the task - // landed after its proposing turn had already ended. - for i := len(a.entries) - 1; i >= 0; i-- { - if a.entries[i].kind == entryNote && a.entries[i].text == text { - a.entries[i].told = true - break - } - } - } + // Crew facts belong to the task result, never a second transcript receipt. + said.text, said.facts = text, facts a.crewSaid[notice.ID] = said } diff --git a/internal/tui3/crew_test.go b/internal/tui3/crew_test.go index 9d0fe4bcd4..6bb975ea87 100644 --- a/internal/tui3/crew_test.go +++ b/internal/tui3/crew_test.go @@ -151,7 +151,7 @@ func TestCrewSegment(t *testing.T) { // A ROUTED TASK SAYS ITS CREW when it starts, with the estimate, and when it // lands, with the actual beside the estimate and the door to asking again. -func TestARoutedTaskSaysItsCrewTwice(t *testing.T) { +func TestARoutedTaskKeepsItsCrewWithoutTranscriptReceipts(t *testing.T) { a, _ := sheetApp(t) crew := &crewroute.Decision{Class: crewroute.Bugfix, EstUSD: 0.02, Crew: []crewroute.Pick{ {Seat: crewroute.Worker, Model: "z-ai/glm-5.3-flash", Provider: "openrouter"}, @@ -160,7 +160,7 @@ func TestARoutedTaskSaysItsCrewTwice(t *testing.T) { }} a.sayTaskCrew(session.TaskNotice{ID: 7, State: session.TaskRunning, Crew: crew}) a.sayTaskCrew(session.TaskNotice{ID: 7, State: session.TaskRunning, Crew: crew}) - started := lastNote(t, a) + started := a.crewSaid[7].text if !strings.Contains(started, "task 7 crew · bugfix · worker glm-5.3-flash (openrouter)") || !strings.Contains(started, "est $0.020") { t.Fatalf("the start line reads %q", started) } @@ -173,11 +173,11 @@ func TestARoutedTaskSaysItsCrewTwice(t *testing.T) { count++ } } - if count != 1 { - t.Fatalf("the start line was said %d times", count) + if count != 0 { + t.Fatalf("crew details leaked %d transcript receipts", count) } a.sayTaskCrew(session.TaskNotice{ID: 7, State: session.TaskDone, Crew: crew, CostUSD: 0.018}) - if landed := lastNote(t, a); !strings.Contains(landed, "$0.018 (est $0.020)") || !strings.Contains(landed, "/redo stronger") { + if landed := a.crewSaid[7].text; !strings.Contains(landed, "$0.018 (est $0.020)") || !strings.Contains(landed, "/redo stronger") { t.Fatalf("the landing line reads %q", landed) } } @@ -189,7 +189,7 @@ func TestAStoppedTaskCrewLineDoesNotOfferRedo(t *testing.T) { }} a.sayTaskCrew(session.TaskNotice{ID: 8, State: session.TaskRunning, Crew: crew}) a.sayTaskCrew(session.TaskNotice{ID: 8, State: session.TaskFailed, Stopped: true, Crew: crew}) - line := lastNote(t, a) + line := a.crewSaid[8].text if !strings.Contains(line, "task 8 crew · stopped") || strings.Contains(line, "/redo stronger") { t.Fatalf("stopped crew line = %q", line) } @@ -271,7 +271,7 @@ func TestAStoppedTaskLeadsWithItsOneAction(t *testing.T) { {Seat: crewroute.Checker, Model: "moonshotai/kimi-k3", Provider: "openrouter"}, }} a.sayTaskCrew(session.TaskNotice{ID: 3, State: session.TaskFailed, Crew: crew}) - line := lastNote(t, a) + line := a.crewSaid[3].text if !strings.HasPrefix(line, "task 3 crew · failed — add credit on openrouter to continue · ") { t.Errorf("the stopped line reads %q", line) } @@ -292,8 +292,8 @@ func TestANewConversationsTaskSaysItsCrew(t *testing.T) { a.dropTasks() before := len(a.entries) a.sayTaskCrew(session.TaskNotice{ID: 1, State: session.TaskRunning, Crew: crew}) - if len(a.entries) == before || !strings.Contains(lastNote(t, a), "task 1 crew · bugfix") { - t.Fatalf("the new conversation's task 1 said no crew line") + if len(a.entries) != before || !strings.Contains(a.crewSaid[1].text, "task 1 crew · bugfix") { + t.Fatalf("the new conversation's crew facts were lost or added a transcript receipt") } } @@ -327,7 +327,7 @@ func TestCrewMoneyDrawsNoZero(t *testing.T) { // stronger` where nobody was looking (the fresh-install check saw a 22-minute // task land with no crew line in view). The landing moves the one line to the // end of the thread — still one line per task, never two. -func TestTheLandingCrewLineIsSaidWhereTheTaskLands(t *testing.T) { +func TestTheLandingCrewFactsDoNotDisplaceTheConversation(t *testing.T) { a, _ := sheetApp(t) crew := &crewroute.Decision{Class: crewroute.Bugfix, EstUSD: 0.013, Crew: []crewroute.Pick{ {Seat: crewroute.Worker, Model: "z-ai/glm-5.3-flash", Provider: "openrouter"}, @@ -340,16 +340,15 @@ func TestTheLandingCrewLineIsSaidWhereTheTaskLands(t *testing.T) { } a.sayTaskCrew(session.TaskNotice{ID: 2, State: session.TaskDone, Crew: crew, CostUSD: 0.004, Merge: "kept", Branch: "task/fix"}) last := a.entries[len(a.entries)-1] - if last.kind != entryNote || !strings.Contains(last.text, "task 2 crew · ") || !strings.Contains(last.text, "$0.004 (est $0.013) · not right? /redo stronger") { - t.Fatalf("the thread ends on %q, not the landing crew line", last.text) + if last.text != "an answer to is my code sent anywhere that logs it?" { + t.Fatalf("crew receipt displaced the conversation: %q", last.text) + } + if !strings.Contains(a.crewSaid[2].text, "$0.004 (est $0.013)") { + t.Fatal("completion spending was lost") } - count := 0 for _, e := range a.entries { if strings.Contains(e.text, "task 2 crew") { - count++ + t.Fatal("crew receipt appeared in the conversation") } } - if count != 1 { - t.Fatalf("the task's crew is said %d times, want once", count) - } } diff --git a/internal/tui3/jobpage.go b/internal/tui3/jobpage.go index 02413002da..5d2b79e68e 100644 --- a/internal/tui3/jobpage.go +++ b/internal/tui3/jobpage.go @@ -85,7 +85,7 @@ const jobPageFoot = 3 // jobPageHit is what one row of the page answers to a click. It is the card's // own two-edge bargain ([taskCardHit]): the head and the foot are the way // back, and everything between them is read. -type jobPageHit uint8 +type jobPageHit int const ( jobPageHitNone jobPageHit = iota @@ -179,6 +179,9 @@ func (a *app) jobPageKeyPress(msg tea.KeyPressMsg) (tea.Cmd, bool) { // map ([app.taskCardKey]): esc is one layer, the letters that are verbs on this // page, and the scroll the card already taught. func (a *app) jobPageKey(key string) tea.Cmd { + if a.jobPage == processListPage { + return a.processListKey(key) + } job := a.jobPageJob() if job == nil { a.closeJobPage() @@ -189,6 +192,7 @@ func (a *app) jobPageKey(key string) tea.Cmd { a.closeJobPage() case "m": a.jobPagePutInMessage(*job) + a.jobList = false a.closeJobPage() case "c": return a.jobPageCopyPath(*job) @@ -247,16 +251,25 @@ func (a *app) jobPageScroll(delta int) { // jobPagePress resolves a click on the page. Its edges are the way back and // its body is read, which is [app.taskCardPress]'s own shape. -func (a *app) jobPagePress(x, y int) { +func (a *app) jobPagePress(x, y int) tea.Cmd { width, height := a.size() _, hits, _, _ := a.jobPageFrame(width, height) if y < 0 || y >= len(hits) { - return + return nil + } + if a.jobPage == processListPage && hits[y] >= 1000 { + at := int(hits[y]) - 1000 + rows := a.processRows() + if at < len(rows) { + a.jobCursor = at + return a.openJobPage(rows[at].ID) + } } if !hits[y].back() { - return + return nil } a.closeJobPage() + return nil } // ── the three verbs ───────────────────────────────────────────────────────── @@ -332,6 +345,9 @@ func jobPageHandle(id int) string { return jobPageHandleLead + itoa(id) } // The caret is reported as (0, 0) and never moves, because nothing on this // page is typed into. func (a *app) jobPageFrame(width, height int) ([]string, []jobPageHit, int, int) { + if a.jobPage == processListPage { + return a.processListFrame(width, height) + } pal := a.pal lines := make([]string, 0, height) hits := make([]jobPageHit, 0, height) diff --git a/internal/tui3/jobsection.go b/internal/tui3/jobsection.go index 4631f5e9db..0b4ae4610a 100644 --- a/internal/tui3/jobsection.go +++ b/internal/tui3/jobsection.go @@ -19,7 +19,7 @@ import ( // jobSection is this conversation's jobs as the column draws them, or nil // when there are none — which is the emptiness law, not an empty heading. func (a *app) jobSection(width, room int) []railLine { - return jobSectionRows(a.jobsLive(), a.jobsSettled(), a.jobsOpen, a.now(), width, room, a.pal) + return jobSectionRows(a.jobsLive(), nil, a.jobsOpen, a.now(), width, room, a.pal) } // jobSectionMin is how many rows the jobs section will spend before it has @@ -28,7 +28,7 @@ func (a *app) jobSection(width, room int) []railLine { // ([app.marginRows]), because live work outranks the furniture around it, // which is the same trade the roster's own live head makes. func (a *app) jobSectionMin() int { - if len(a.jobs) == 0 { + if a.jobsRunning() == 0 { return 0 } if !a.jobsOpen { diff --git a/internal/tui3/jobstate.go b/internal/tui3/jobstate.go index 1c8825a3f2..7f375e3d78 100644 --- a/internal/tui3/jobstate.go +++ b/internal/tui3/jobstate.go @@ -116,6 +116,7 @@ func (a *app) dropJobs() { a.jobs = nil a.jobsOpen = false a.closeJobPage() + a.jobList, a.jobPage = false, 0 } // ── THE SEAM BETWEEN THE COLUMN AND THE PAGE ──────────────────────────────── @@ -146,6 +147,11 @@ func (a *app) showJobPage(id int) bool { // with it: a log tail and a scroll position kept past the page they belong to // are what the NEXT page would open showing (jobpage.go's [jobDraw]). func (a *app) closeJobPage() { + if a.jobList && a.jobPage != processListPage { + a.jobPage, a.jobDraw = processListPage, nil + return + } + a.jobList = false a.jobPage = 0 a.jobDraw = nil } diff --git a/internal/tui3/landinside_test.go b/internal/tui3/landinside_test.go index f0119284fd..f27ae02a82 100644 --- a/internal/tui3/landinside_test.go +++ b/internal/tui3/landinside_test.go @@ -26,41 +26,23 @@ func landingUpdate1556(id uint64, title string, state session.TaskState) session }) } +// A landing keeps its durable facts while the meaningful answer is the only +// completion content occupying the conversation. func assertLandingAfterProse1556(t *testing.T, a *app, title, prose string, id uint64, crewRequired bool) { t.Helper() view := taskText(a) - card := "◆ " + title - crew := "task " + itoa(int(id)) + " crew · " - t.Run("card follows final prose once", func(t *testing.T) { - for _, want := range []string{prose, card} { - if !strings.Contains(view, want) { - t.Fatalf("the settled conversation is missing %q:\n%s", want, view) - } - } - if got := strings.Count(view, card); got != 1 { - t.Fatalf("the landing has %d cards, want one:\n%s", got, view) - } - if strings.Index(view, card) <= strings.Index(view, prose) { - t.Fatalf("the card did not move after the turn's final prose:\n%s", view) - } - }) - if crewRequired { - t.Run("crew follows final prose once", func(t *testing.T) { - for _, want := range []string{crew} { - if !strings.Contains(view, want) { - t.Fatalf("the settled conversation is missing %q:\n%s", want, view) - } - } - if !strings.Contains(strings.Join(strings.Fields(view), " "), "not right? /redo stronger") { - t.Fatalf("the crew line lost its redo action:\n%s", view) - } - if got := strings.Count(view, crew); got != 1 || strings.Index(view, crew) <= strings.Index(view, prose) { - t.Fatalf("the crew line appears %d times or precedes the final prose:\n%s", got, view) - } - }) - if strings.Index(view, crew) >= strings.Index(view, card) { - t.Fatalf("the crew line no longer precedes its card:\n%s", view) - } + if !strings.Contains(view, prose) { + t.Fatalf("meaningful answer disappeared: %s", view) + } + if strings.Contains(view, "◆ "+title) || strings.Contains(view, "task "+itoa(int(id))+" crew · ") { + t.Fatalf("routine completion duplicated the answer: %s", view) + } + card := a.doneCardFor(id) + if card == nil || card.title != title || !card.hidden() { + t.Fatalf("quiet landing lost its retained identity: %+v", card) + } + if crewRequired && (a.tasks[id] == nil || a.tasks[id].crew == nil) { + t.Fatal("quiet landing lost crew details") } } @@ -90,16 +72,12 @@ func TestATaskThatLandsInsideItsTurnGetsItsCardAtTheTurnsEnd(t *testing.T) { const later = "Count words in store.py" drive(t, a, taskEventMsg{gen: a.taskGen, ev: landingUpdate1556(3, later, session.TaskRunning)}) drive(t, a, taskEventMsg{gen: a.taskGen, ev: landingUpdate1556(3, later, session.TaskDone)}) - view := taskText(a) - first, second := "◆ "+title, "◆ "+later - if strings.Count(view, first) != 1 || strings.Count(view, second) != 1 || - strings.Index(view, first) >= strings.Index(view, second) || - strings.Index(view, second) <= strings.Index(view, prose) || - strings.Count(view, "task 2 crew · ") != 1 || strings.Count(view, "task 3 crew · ") != 1 || - strings.Index(view, "task 3 crew · ") <= strings.Index(view, prose) || - strings.Index(view, "task 3 crew · ") >= strings.Index(view, second) { - t.Fatalf("the two tasks lost their one-card landing order:\n%s", view) + assertLandingAfterProse1556(t, a, title, prose, 2, true) + assertLandingAfterProse1556(t, a, later, prose, 3, true) + if a.doneEntryFor(2) >= a.doneEntryFor(3) { + t.Fatal("retained landing order changed") } + }) } diff --git a/internal/tui3/nested_landing_accept_test.go b/internal/tui3/nested_landing_accept_test.go index 345c7bb865..bf3f2dac61 100644 --- a/internal/tui3/nested_landing_accept_test.go +++ b/internal/tui3/nested_landing_accept_test.go @@ -56,12 +56,14 @@ func TestANestedLandingAcceptWritesTheTookLine(t *testing.T) { Parent: 1, Elapsed: 42 * time.Second, Merge: mergeWordMerged, Report: took + "\nfinished, but needs your look — nobody could check it in 5m0s", })}) - // Open the compact batch to inspect its retained acceptance receipt. - a.toggleDoneAt(a.doneEntryFor(1)) - a.toggleDoneAt(a.doneEntryFor(2)) - if text := taskText(a); !strings.Contains(text, took) { - t.Fatalf("the conversation does not say the part was decided:\n%s", text) + // Acceptance remains durable without raising another completion receipt. + if card := a.doneCardFor(2); card == nil || !strings.Contains(card.report, took) || !card.hidden() { + t.Fatalf("acceptance was not retained quietly: %+v", card) } + if text := taskText(a); strings.Contains(text, took) { + t.Fatalf("acceptance raised a routine receipt: %s", text) + } + card := a.doneCardFor(2) if card == nil || card.status.State != session.TaskDone { t.Fatalf("the decided part left its latest card at %+v", card) diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index 5900478346..ca8b99fd4a 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -1134,6 +1134,10 @@ func (p *tasksPlace) enter(a *app) tea.Cmd { } entry := item.entry node := a.taskSheetNodeFor(&entry) + if node != nil && node.crew != nil && node.state != session.TaskRunning && node.state != session.TaskQueued { + entry = a.currentTaskEntry(entry) + return a.taskSheetInside(&entry) + } if node == nil { return a.taskSheetInside(&entry) } @@ -1226,8 +1230,7 @@ func (p *tasksPlace) window(a *app, key string) bool { // always answered on the first press. func (a *app) taskSheetPress(x, y int) tea.Cmd { if a.taskSheet.detailOn { - a.taskCardPress(x, y) - return nil + return a.taskCardPress(x, y) } // THE FRAME IS BUILT ONCE AND BOTH HALVES OF THE HIT MAP COME OUT OF IT. A // press used to lay the whole place out twice — once for the pane's verbs and diff --git a/internal/tui3/planrail.go b/internal/tui3/planrail.go index 844d647b66..e698e41d49 100644 --- a/internal/tui3/planrail.go +++ b/internal/tui3/planrail.go @@ -272,3 +272,20 @@ func (a *app) planRailRoot(twig *planTwig, width int) []railLine { out := []railLine{{text: text, entry: -1, plan: twig.row.ID, head: true}} return append(out, a.planRailLines(twig.kids, 1, width)...) } + +// The sidebar keeps only work still running or waiting for a decision; history stays in Tasks. +func activePlanForest(forest []*planTwig) []*planTwig { + var out []*planTwig + for _, twig := range forest { + kids := activePlanForest(twig.kids) + state := planNodeState(twig.row) + if state == session.TaskDone || state == session.TaskFailed { + out = append(out, kids...) + continue + } + kept := *twig + kept.kids = kids + out = append(out, &kept) + } + return out +} diff --git a/internal/tui3/processes.go b/internal/tui3/processes.go new file mode 100644 index 0000000000..74ea1ba42f --- /dev/null +++ b/internal/tui3/processes.go @@ -0,0 +1,99 @@ +package tui3 + +import ( + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Processes are inspected on demand; completed logs never occupy the work sidebar. +const processListPage = -1 + +func (a *app) showProcesses(arg string) tea.Cmd { + if arg = strings.TrimSpace(arg); arg != "" { + id, err := strconv.Atoi(arg) + if err != nil || a.jobAt(id) == nil { + a.note("use /processes or /processes for a process in this conversation") + return nil + } + a.jobList = true + return a.openJobPage(id) + } + a.jobList, a.jobPage, a.jobCursor = true, processListPage, 0 + a.jobHover = -1 + a.jobDraw = nil + a.touch() + return nil +} + +func (a *app) processRows() []session.JobNotice { + rows := make([]session.JobNotice, 0, len(a.jobs)) + for i := len(a.jobs) - 1; i >= 0; i-- { + rows = append(rows, a.jobs[i]) + } + return rows +} + +func (a *app) processListKey(key string) tea.Cmd { + rows := a.processRows() + a.jobHover = -1 + switch key { + case "esc", "left": + a.closeJobPage() + case "up", "k", "ctrl+p": + a.jobCursor = max(0, a.jobCursor-1) + case "down", "j", "ctrl+n": + a.jobCursor = min(max(0, len(rows)-1), a.jobCursor+1) + case "home", "g": + a.jobCursor = 0 + case "end", "G": + a.jobCursor = max(0, len(rows)-1) + case "enter", "right": + if a.jobCursor >= 0 && a.jobCursor < len(rows) { + return a.openJobPage(rows[a.jobCursor].ID) + } + } + a.touch() + return nil +} + +// The row index is encoded in its hit so a click uses the exact frame that was drawn. +func (a *app) processListFrame(width, height int) ([]string, []jobPageHit, int, int) { + if height < 1 || width < 1 { + return nil, nil, 0, 0 + } + var lines []string + var hits []jobPageHit + add := func(text string, hit jobPageHit) { lines = append(lines, text); hits = append(hits, hit) } + title := "Processes · this conversation" + back := "esc back" + if width >= len(title)+len(back)+2 { + title += strings.Repeat(" ", width-len(title)-len(back)) + back + } + add(a.pal.ink(fit(title, width)), jobPageHitHead) + add(a.pal.dim(rule(width)), jobPageHitNone) + rows := a.processRows() + room := max(1, height-4) + top := clampTop(a.jobCursor-room+1, len(rows), room) + if len(rows) == 0 { + add(a.pal.dim(fit("Processes and their logs appear here when you start background work.", width)), jobPageHitNone) + } + for i := top; i < len(rows) && i < top+room; i++ { + text := " " + jobRowLine(rows[i], a.now(), max(1, width-2), a.pal) + if i == a.jobCursor { + text = a.pal.selected(text, 0) + } else if i == a.jobHover { + text = a.pal.cursor(text, 0) + } + add(text, jobPageHit(1000+i)) + } + add(a.pal.dim(rule(width)), jobPageHitNone) + add(paintHint(hintFit("↑↓ choose · enter open log · esc back", width), a.pal, a.pal.dim), jobPageHitFoot) + if len(lines) > height { + lines = lines[:height] + hits = hits[:height] + } + return lines, hits, 0, 0 +} diff --git a/internal/tui3/processes_test.go b/internal/tui3/processes_test.go new file mode 100644 index 0000000000..eebbb706bf --- /dev/null +++ b/internal/tui3/processes_test.go @@ -0,0 +1,69 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestProcessesKeepsFinishedLogsOffSidebarAndOpensExactClickedResult(t *testing.T) { + a, _, _ := taskApp(t) + a.width, a.height = 110, 32 + first := jobLogFile(t, "compiler output for first process") + second := jobLogFile(t, "tests passed for second process") + a.jobUpdate(jobNotice(3, session.JobDone, "compile", first)) + a.jobUpdate(jobNotice(9, session.JobDone, "test", second)) + if lines := a.jobSection(32, 20); len(lines) != 0 || a.jobSectionMin() != 0 { + t.Fatal("completed process still occupies sidebar") + } + a.showProcesses("") + rows, hits, _, _ := a.jobPageFrame(110, 32) + if text := plain(strings.Join(rows, "\n")); !strings.Contains(text, "Processes") || !strings.Contains(text, "9 · done") { + t.Fatal(text) + } + at := -1 + for i, hit := range hits { + if hit == 1001 { + at = i + } + } + if at < 0 { + t.Fatal("first process has no mouse target") + } + cmd := a.jobPagePress(8, at) + if a.jobPage != 3 || cmd == nil { + t.Fatalf("clicked row opened %d rather than process 3", a.jobPage) + } + takeJobLog(t, a, cmd) + rows, _, _, _ = a.jobPageFrame(110, 32) + if text := plain(strings.Join(rows, "\n")); !strings.Contains(text, "compiler output for first process") || strings.Contains(text, "tests passed for second process") { + t.Fatal(text) + } + a.jobPageKey("esc") + if a.jobPage != processListPage { + t.Fatal("Escape did not return to process list") + } + a.jobPageKey("esc") + if a.jobPageOpen() || a.jobList { + t.Fatal("Escape did not return to conversation") + } +} + +func TestProcessesKeyboardAndConversationSwitchPreserveOwnership(t *testing.T) { + a, _, _ := taskApp(t) + a.jobUpdate(jobNotice(3, session.JobDone, "compile", jobLogFile(t, "output"))) + a.showProcesses("") + if cmd := a.jobPageKey("enter"); a.jobPage != 3 || cmd == nil { + t.Fatal("Enter did not open process log") + } + a.dropJobs() + if a.jobPageOpen() || a.jobList || len(a.processRows()) != 0 { + t.Fatal("process page survived conversation switch") + } + a.showProcesses("") + rows, _, _, _ := a.jobPageFrame(80, 24) + if !strings.Contains(plain(strings.Join(rows, "\n")), "when you start background work") { + t.Fatal("empty processes page has no explanation") + } +} diff --git a/internal/tui3/programbadge_test.go b/internal/tui3/programbadge_test.go index b6ba26365f..0dad65670d 100644 --- a/internal/tui3/programbadge_test.go +++ b/internal/tui3/programbadge_test.go @@ -649,7 +649,7 @@ func TestAProgramsLandedCardSaysWhatItCost(t *testing.T) { } done := a.entries[at].done done.open = true - card := plain(strings.Join(a.doneRows(done, 120, false), "\n")) + card := retainedLandingText(t, a, 7) if !strings.Contains(card, "$2.30") { t.Fatalf("the program's landed card names no price:\n%s", card) } diff --git a/internal/tui3/quicktask_test.go b/internal/tui3/quicktask_test.go index 01b6229dd7..9313410d93 100644 --- a/internal/tui3/quicktask_test.go +++ b/internal/tui3/quicktask_test.go @@ -113,9 +113,8 @@ func TestAQuickLandingShowsItsAnswerAndNoBranchOrMergeRow(t *testing.T) { notice.Result = "keys.go and keys_test.go agree; parse.go is the odd one out." notice.ResultWhole = notice.Result drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(7, "compare the four files", session.TaskDone, notice)}) - clickHit(t, a, hitDone) - text := taskText(a) + text := retainedLandingText(t, a, 7) if !strings.Contains(text, "parse.go is the odd one out.") { t.Fatalf("the open card does not carry the worker's last message:\n%s", text) } @@ -153,9 +152,8 @@ func TestAQuickLandingThatWroteFilesNamesThem(t *testing.T) { notice.Result = "the note is written." notice.Changed = []string{"notes/summary.md"} drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(7, "write the summary", session.TaskDone, notice)}) - clickHit(t, a, hitDone) - text := taskText(a) + text := retainedLandingText(t, a, 7) if !strings.Contains(text, doneChangedLabel+"notes/summary.md") { t.Fatalf("the card never names the file it wrote:\n%s", text) } diff --git a/internal/tui3/railclick_test.go b/internal/tui3/railclick_test.go index 319c7e0252..6c00fa63ee 100644 --- a/internal/tui3/railclick_test.go +++ b/internal/tui3/railclick_test.go @@ -423,7 +423,7 @@ func TestTheColumnLeadsWithStateAndSpendsNoCellOnIdentity(t *testing.T) { // the indent under its heading, one state glyph, and the air after it. // That is what lets the column be read downward as one column of states. want := ansi.StringWidth(railSeam) + 4 - for _, title := range []string{"Collect the fixture", "Ship the streaming", "Cut the goldens"} { + for _, title := range []string{"Write the tree walker", "Ship the streaming", "Cut the goldens"} { if got := leadCells(t, mustRailRow(t, a, title), title); got != want { t.Fatalf("the row leads in %d cells, want %d:\n%q", got, want, mustRailRow(t, a, title)) } diff --git a/internal/tui3/raildone1556_test.go b/internal/tui3/raildone1556_test.go index 5e27a3b4b4..0056cf1024 100644 --- a/internal/tui3/raildone1556_test.go +++ b/internal/tui3/raildone1556_test.go @@ -46,86 +46,43 @@ func doneAttemptRows1556(lines []string) []int { func TestDoneOpenDrawsEveryRowItsHeadingCounts(t *testing.T) { for _, width := range []int{110, 140, 180} { - for _, wide := range []bool{false, true} { - if width == 110 && wide { - continue - } - t.Run(fmt.Sprintf("width=%d/wide=%t", width, wide), func(t *testing.T) { - a := doneColumn1556(t, width) - if wide { - drive(t, a, altT()) - drive(t, a, key(railWidenChord)) - } - railClick(t, a, a.bodyWidth()+3, railHeadY(t, a, railDone)) - lines := railText(a, a.viewHeight()) - view := strings.Join(lines, "\n") - if !strings.Contains(view, "Done 2 "+glyphOpen) { - t.Fatalf("the open group lost its count:\n%s", view) - } - rows := doneAttemptRows1556(lines) - if len(rows) != 2 { - t.Fatalf("Done 2 draws %d attempt rows, want two:\n%s", len(rows), view) - } - stopped, done := a.taskStateMark(a.tasks[1]), a.taskStateMark(a.tasks[2]) - if stopped == done { - t.Fatalf("the stopped and done marks are the same: %q", stopped) - } - for _, mark := range []string{stopped, done} { - seen := false - for _, at := range rows { - row := lines[at] - if strings.Contains(row, mark+" ") { - seen = true - if !strings.Contains(row, "[senior-dev]") && !strings.Contains(row, "[sd]") { - t.Fatalf("attempt lost its role badge: %q", row) - } - } - } - if !seen { - t.Fatalf("no attempt wears state mark %q:\n%s", mark, view) - } + t.Run(fmt.Sprint(width), func(t *testing.T) { + a := doneColumn1556(t, width) + for _, open := range []bool{false, true} { + a.side.open[railDone] = open + view := strings.Join(railText(a, a.viewHeight()), "\n") + if strings.Contains(view, "Done 2") || strings.Contains(view, "rename Load") { + t.Fatalf("completed attempts occupy sidebar: %s", view) } - head, foot := -1, len(lines) - for i, line := range lines { - if strings.Contains(line, "Done 2 "+glyphOpen) { - head = i - } - if strings.Contains(line, "+ /task") { - foot = i + for _, id := range []uint64{1, 2} { + if a.doneCardAt(a.doneEntryFor(id)) == nil { + t.Fatalf("result %d lost", id) } } - if head < 0 || rows[0] <= head || rows[1] >= foot { - t.Fatalf("both attempts must stand under Done:\n%s", view) - } - }) - } + } + }) } } func TestAFoldedGroupFoldsTheRunsItsRowsCarry(t *testing.T) { a := doneColumn1556(t, 180) - lines := railText(a, a.viewHeight()) - if view := strings.Join(lines, "\n"); !strings.Contains(view, "Done 2 "+glyphShut) || len(doneAttemptRows1556(lines)) != 0 { - t.Fatalf("folded Done shows an attempt or loses its count:\n%s", view) + a.side.open[railDone] = true + if view := strings.Join(railText(a, a.viewHeight()), "\n"); strings.Contains(view, "rename Load") || strings.Contains(view, "Done 2") { + t.Fatalf("opening obsolete fold raised completed work: %s", view) } - railClick(t, a, a.bodyWidth()+3, railHeadY(t, a, railDone)) - if lines = railText(a, a.viewHeight()); len(doneAttemptRows1556(lines)) != 2 { - t.Fatalf("opening Done does not reveal both attempts:\n%s", strings.Join(lines, "\n")) + if len(a.tasks) != 2 { + t.Fatal("completed results lost") } } func TestAStoppedRowKeepsItsRoleBadge(t *testing.T) { - a, _, _ := taskApp(t) - a.width, a.height = 180, 40 - drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(1, "rename LoadConfig", session.TaskRunning, - session.TaskNotice{Program: "senior-dev"})}) - drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(1, "rename LoadConfig", session.TaskFailed, - session.TaskNotice{Program: "senior-dev", Stopped: true, Ending: session.TaskEndingStopped})}) - railClick(t, a, a.bodyWidth()+3, railHeadY(t, a, railDone)) - row, ok := railRowFor(a, a.viewHeight(), "rename LoadConfig") - if !ok || !strings.Contains(row, "[senior-dev]") { - t.Fatalf("the stopped row lost its worker badge in the drawn column:\n%s", - strings.Join(railText(a, a.viewHeight()), "\n")) + a := doneColumn1556(t, 180) + card := a.doneCardAt(a.doneEntryFor(1)) + if card == nil || card.program != "senior-dev" || card.status.State != session.TaskFailed { + t.Fatalf("stopped result lost identity: %+v", card) + } + if _, ok := railRowFor(a, a.viewHeight(), "rename LoadConfig"); ok { + t.Fatal("stopped result occupies sidebar") } } @@ -190,27 +147,20 @@ func TestANodeNamingAPartCarriesItsRun(t *testing.T) { func TestDoneFoldKeepsItsCountAndOtherGroups(t *testing.T) { a, _, _ := taskApp(t) - a.width, a.height = 180, 40 - drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(1, "finished work", session.TaskDone, session.TaskNotice{})}) - drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(2, "running work", session.TaskRunning, session.TaskNotice{})}) - drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(3, "queued work", session.TaskQueued, session.TaskNotice{})}) - drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(4, "waiting work", session.TaskQueued, - session.TaskNotice{DependsOn: []uint64{2}})}) - drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(5, "failed work", session.TaskRunning, session.TaskNotice{})}) - drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(5, "failed work", session.TaskFailed, - session.TaskNotice{Ending: session.TaskEndingError})}) - view := strings.Join(railText(a, a.viewHeight()), "\n") - for _, want := range []string{"Done 1 " + glyphShut, "Running 1", "running work", "Queued 1 " + glyphShut, - "Waiting 1 " + glyphShut, "failed work incomplete"} { - if !strings.Contains(view, want) { - t.Fatalf("folded Done changed another group or its count; missing %q:\n%s", want, view) + a.taskUpdate(update(1, "finished work", session.TaskDone, session.TaskNotice{Result: "saved answer"})) + a.taskUpdate(update(2, "running work", session.TaskRunning, session.TaskNotice{})) + a.taskUpdate(update(3, "queued work", session.TaskQueued, session.TaskNotice{})) + for _, open := range []bool{false, true} { + a.side.open[railDone] = open + view := strings.Join(railText(a, a.viewHeight()), "\n") + if strings.Contains(view, "finished work") || strings.Contains(view, "Done 1") { + t.Fatalf("finished history occupies sidebar: %s", view) + } + if !strings.Contains(view, "running work") || !strings.Contains(view, "Queued 1") { + t.Fatalf("active work lost: %s", view) } } - if strings.Contains(view, "finished work") { - t.Fatalf("folded Done still draws its row:\n%s", view) - } - railClick(t, a, a.bodyWidth()+3, railHeadY(t, a, railDone)) - if view = strings.Join(railText(a, a.viewHeight()), "\n"); !strings.Contains(view, "finished work") || !strings.Contains(view, "Done 1 "+glyphOpen) { - t.Fatalf("opening Done did not reveal its counted row:\n%s", view) + if card := a.doneCardAt(a.doneEntryFor(1)); card == nil || card.result != "saved answer" { + t.Fatal("finished result lost") } } diff --git a/internal/tui3/railview_test.go b/internal/tui3/railview_test.go index 72c1069b6b..5036c33fac 100644 --- a/internal/tui3/railview_test.go +++ b/internal/tui3/railview_test.go @@ -78,18 +78,15 @@ func railLanded(a *app, n int) { func TestALandedRowSpendsOneLineOnTheColumn(t *testing.T) { a, _, _ := taskApp(t) railLanded(a, 13) - text := strings.Join(railText(a, 20), "\n") for i := 1; i <= 13; i++ { - if !strings.Contains(text, railBuild(i)) { - t.Fatalf("landed job %d is off a twenty-row column:\n%s", i, text) + if strings.Contains(text, railBuild(i)) { + t.Fatalf("completed task occupies sidebar: %s", text) + } + card := a.doneCardAt(a.doneEntryFor(uint64(i))) + if card == nil || card.cost != railCostOf(i) { + t.Fatalf("result facts lost for %d", i) } - } - // AND THE HISTORY UNDER THEM IS NOT DRAWN. The log path is what a settled job - // used to spend its second row on; it is the hint line's now (see below), and - // a column that still drew it would not have fitted the thirteen rows above. - if strings.Contains(text, railReportOf(1)) { - t.Fatalf("a landed row still spends a line on its own history:\n%s", text) } } @@ -134,23 +131,12 @@ func TestAQueuedRowStillSaysWhatItWaitsOn(t *testing.T) { func TestALandedRowGivesItsBlockToTheHintLine(t *testing.T) { a, _, _ := taskApp(t) railLanded(a, 3) - hint := railHint(a, 2) - if !strings.Contains(hint, dollars(railCostOf(2))) { - t.Fatalf("the hint over a landed row does not say what it cost: %q", hint) - } - if strings.Contains(hint, dollars(railCostOf(1))) || strings.Contains(hint, dollars(railCostOf(3))) { - t.Fatalf("one row's hint carried its neighbours': %q", hint) - } - // AND THE POINTER READS THE SAME HINT, on the row the pointer is on. - _, y := marginLine(t, a, func(l railLine) bool { - return strings.Contains(plain(l.text), railBuild(2)) - }) - drive(t, a, tea.MouseMotionMsg{X: a.railLeft() + 3, Y: y}) - if a.hot.kind != hoverRail || a.hot.id != 2 { - t.Fatalf("the pointer on the row resolved to %+v", a.hot) + if _, ok := railRowFor(a, 20, railBuild(2)); ok { + t.Fatal("completed task remains on sidebar") } - if got := a.sideHoverWords(); got != hint { - t.Fatalf("the pointer's hint %q is not the row's %q", got, hint) + card := a.doneCardAt(a.doneEntryFor(2)) + if card == nil || card.cost != railCostOf(2) || card.merge != mergeWordMerged { + t.Fatalf("completed task details lost: %+v", card) } } @@ -160,11 +146,11 @@ func TestALandedRowGivesItsBlockToTheHintLine(t *testing.T) { // right under it with no spacer between. func TestTheHeaderStaysWhileTheRosterScrolls(t *testing.T) { a, _, _ := taskApp(t) - railLanded(a, 40) + railActive(a, 40) a.railScroll(12) rows := railText(a, 20) - if len(rows) < 2 || !strings.Contains(rows[0], sideTasksWord+" 40") || + if len(rows) < 2 || !strings.Contains(rows[0], sideTasksWord) || strings.TrimSpace(strings.TrimPrefix(rows[1], "│")) == "" { t.Fatalf("the header moved while scrolling:\n%s", strings.Join(rows, "\n")) } @@ -176,7 +162,7 @@ func TestTheHeaderStaysWhileTheRosterScrolls(t *testing.T) { // beside it, which is the pointer landing on one list and acting on another. func TestTheWheelOverTheColumnScrollsTheColumn(t *testing.T) { a, _, _ := taskApp(t) - railLanded(a, 60) + railActive(a, 60) drive(t, a, frameMsg{}) was, wasOffset := a.railTop, a.offset @@ -202,7 +188,7 @@ func TestTheWheelOverTheColumnScrollsTheColumn(t *testing.T) { // whole frame's wheel would have replaced one wrong answer with another. func TestTheWheelBesideTheColumnStillScrollsTheConversation(t *testing.T) { a, _, _ := taskApp(t) - railLanded(a, 60) + railActive(a, 60) for i := 0; i < 30; i++ { typeLine(t, a, "line "+itoa(i)) } @@ -223,7 +209,7 @@ func TestTheWheelBesideTheColumnStillScrollsTheConversation(t *testing.T) { // an offset nudged out from under it would be undone by the next layout. func TestTheWheelWalksTheCursorWhileTheColumnHoldsTheKeyboard(t *testing.T) { a, _, _ := taskApp(t) - railLanded(a, 60) + railActive(a, 60) a.railTake(true) was := a.railWhere.id @@ -232,3 +218,9 @@ func TestTheWheelWalksTheCursorWhileTheColumnHoldsTheKeyboard(t *testing.T) { t.Fatalf("the wheel left the cursor on node %d", was) } } + +func railActive(a *app, n int) { + for i := 1; i <= n; i++ { + a.taskUpdate(update(uint64(i), railBuild(i), session.TaskRunning, session.TaskNotice{})) + } +} diff --git a/internal/tui3/senior_dev_landing_fold_test.go b/internal/tui3/senior_dev_landing_fold_test.go index d9c19da82f..e0bd452f95 100644 --- a/internal/tui3/senior_dev_landing_fold_test.go +++ b/internal/tui3/senior_dev_landing_fold_test.go @@ -8,9 +8,8 @@ import ( "github.com/Agent-Field/codeaf/internal/session" ) -// The standing task lane is the engine road: its landing arrives while the -// chat's wake turn is being drawn. The first frame and the settled reply must -// both show the program's card without opening the wake turn's work fold. +// The engine landing retains its result while the wake answer owns the chat. +// Neither delivery order nor an open task room raises a duplicate receipt. func TestSeniorDevEngineLandingStandsOutsideWakeFold(t *testing.T) { for _, roomOpen := range []bool{false, true} { a := newTestApp(&fakeAgent{model: "m"}) @@ -30,16 +29,15 @@ func TestSeniorDevEngineLandingStandsOutsideWakeFold(t *testing.T) { if roomOpen { a.closeRoom() } - // The compact receipt identifies the task; its explanation remains inspectable. + // The program's account remains in its task while the answer owns chat. cardInfo := a.doneCardFor(7) - cardInfo.open = true - if detail := strings.Join(a.doneRows(cardInfo, a.width, false), "\n"); !strings.Contains(detail, "senior-dev's ending went to the chat") { - t.Fatalf("program landing lost its retained explanation: %s", detail) + if cardInfo == nil || cardInfo.report != "submitted a change" || !cardInfo.hidden() { + t.Fatalf("landing lost its quiet account: %+v", cardInfo) } - cardInfo.open = false + first := taskText(a) - if !strings.Contains(first, "Repair the parser") { - t.Fatalf("room open %t: the first conversation frame hid the landing:\n%s", roomOpen, first) + if strings.Contains(first, "Repair the parser") { + t.Fatalf("room open %t: the first conversation frame duplicated the landing:\n%s", roomOpen, first) } a.entries = append(a.entries, entry{kind: entryTool, tool: "read", turn: 2, status: toolOK}, @@ -49,7 +47,7 @@ func TestSeniorDevEngineLandingStandsOutsideWakeFold(t *testing.T) { card := strings.Index(settled, "Repair the parser") fold := strings.LastIndex(settled, "worked") answer := strings.Index(settled, "The change is on its branch.") - if card < 0 || fold < 0 || answer < 0 || !(card < fold && fold < answer) { + if card >= 0 || fold < 0 || answer < 0 || fold >= answer { t.Fatalf("room open %t: the card, folded wake work and answer are out of order:\n%s", roomOpen, settled) } if strings.Contains(settled, "reading the ending") || strings.Contains(settled, "read") { @@ -62,14 +60,14 @@ func TestSeniorDevEngineLandingStandsOutsideWakeFold(t *testing.T) { reopened.touch() again := taskText(reopened) if card, fold, answer := strings.Index(again, "Repair the parser"), - strings.LastIndex(again, "worked"), strings.Index(again, "The change is on its branch."); card < 0 || fold < 0 || answer < 0 || !(card < fold && fold < answer) { + strings.LastIndex(again, "worked"), strings.Index(again, "The change is on its branch."); card >= 0 || fold < 0 || answer < 0 || fold >= answer { t.Fatalf("room open %t: reopened conversation changed the landing order:\n%s", roomOpen, again) } } } // The direct session event lane used by --no-host carries the same program -// landing and must draw the card before the reply on that road too. +// landing and must retain it quietly on that road too. func TestSeniorDevNoHostLandingStandsOutsideWakeFold(t *testing.T) { a := newTestApp(&fakeAgent{model: "m"}) a.width, a.height = 110, 40 @@ -88,13 +86,13 @@ func TestSeniorDevNoHostLandingStandsOutsideWakeFold(t *testing.T) { a.touch() text := taskText(a) if card, fold, answer := strings.Index(text, "Repair the parser"), - strings.LastIndex(text, "worked"), strings.Index(text, "The change is on its branch."); card < 0 || fold < 0 || answer < 0 || !(card < fold && fold < answer) { + strings.LastIndex(text, "worked"), strings.Index(text, "The change is on its branch."); card >= 0 || fold < 0 || answer < 0 || fold >= answer { t.Fatalf("direct session lane hid the landing or wake fold:\n%s", text) } } // The engine can deliver the settled wake reply before its task subscription -// sends the landing. That order must still leave a visible, durable card. +// sends the landing. That order must retain the result without another row. func TestSeniorDevLandingAfterWakeReplyStaysVisible(t *testing.T) { a := newTestApp(&fakeAgent{model: "m"}) a.width, a.height = 110, 40 @@ -110,7 +108,7 @@ func TestSeniorDevLandingAfterWakeReplyStaysVisible(t *testing.T) { session.TaskNotice{Program: "senior-dev"})}) a.Update(taskEventMsg{gen: a.taskGen, ev: update(7, "Repair the parser", session.TaskDone, session.TaskNotice{Program: "senior-dev", Report: "submitted a change"})}) - if got := taskText(a); !strings.Contains(got, "Repair the parser") { + if got := taskText(a); strings.Contains(got, "Repair the parser") { t.Fatalf("landing behind the settled wake disappeared:\n%s", got) } reopened := newTestApp(&fakeAgent{model: "m"}) @@ -118,13 +116,13 @@ func TestSeniorDevLandingAfterWakeReplyStaysVisible(t *testing.T) { reopened.workMode = config.WorkFold reopened.entries = append([]entry(nil), a.entries...) reopened.touch() - if got := taskText(reopened); !strings.Contains(got, "Repair the parser") { + if got := taskText(reopened); strings.Contains(got, "Repair the parser") { t.Fatalf("reopening hid a landing delivered after the wake:\n%s", got) } } -// An ordinary task still writes its landing after the starting turn and before -// the later chat turn, as it did before the program card needed a fold boundary. +// An ordinary task retains its original event position without rendering a +// completion receipt between the conversation's answers. func TestOrdinaryTaskLandingKeepsItsConversationPosition(t *testing.T) { a := newTestApp(&fakeAgent{model: "m"}) a.width, a.height = 110, 40 @@ -146,7 +144,7 @@ func TestOrdinaryTaskLandingKeepsItsConversationPosition(t *testing.T) { text := taskText(a) card := strings.Index(text, "Repair the parser") answer := strings.Index(text, "The task is done.") - if card < 0 || answer < 0 || card >= answer { + if card >= 0 || answer < 0 { t.Fatalf("ordinary card is not before the later answer:\n%s", text) } } diff --git a/internal/tui3/sidecol.go b/internal/tui3/sidecol.go index c497c59422..5c00cc7f01 100644 --- a/internal/tui3/sidecol.go +++ b/internal/tui3/sidecol.go @@ -154,6 +154,8 @@ const ( sideActHide // sideActTeams opens the teams page, where a packet is answered. sideActTeams + // sideActTasks opens the existing task history on demand. + sideActTasks ) // sideAct is what a press does. @@ -332,16 +334,23 @@ func (a *app) sideHeadRow(width int) (string, *sideRow) { // always dim and a count of new rows (fresh) is always ink, so what arrived // behind the other word is still read. word := func(label, count string, fresh bool, current bool, act sideAct, hint string) { - text := label + " " + count + text := label + if count != "" { + text += " " + count + } if fresh { text += " " + sideNewWord } var painted string + shownLabel := label + if count != "" { + shownLabel += " " + } switch { case current: - painted = a.pal.bold(a.pal.ink(label + " ")) + painted = a.pal.bold(a.pal.ink(shownLabel)) default: - painted = a.pal.dim(label + " ") + painted = a.pal.dim(shownLabel) } switch { case count == "0": @@ -364,12 +373,14 @@ func (a *app) sideHeadRow(width int) (string, *sideRow) { used += w } team := kind != sideKindPlain - tasksAct, trafficAct := sideAct{}, sideAct{} + tasksAct, trafficAct := sideAct{kind: sideActTasks}, sideAct{} if team { - tasksAct = sideAct{kind: sideActView, view: sideTasks} + if view != sideTasks { + tasksAct = sideAct{kind: sideActView, view: sideTasks} + } trafficAct = sideAct{kind: sideActView, view: sideTraffic} } - word(sideTasksWord, itoa(len(a.taskOrder)), false, view == sideTasks, tasksAct, "Show this chat's tasks"+hintSegment+"click") + word(sideTasksWord, "", false, view == sideTasks, tasksAct, "Open task results and history"+hintSegment+"click") if team { n, fresh := a.sideTrafficCount(t, kind, handle) // WITH THE TRAFFIC IN FRONT NOTHING IN IT IS NEW TO THE WORD: the `new` @@ -534,6 +545,9 @@ func (a *app) sideBand() []sideBandItem { if word := status.RowWord(); word != "" { words += railSep + word } + if action := node.actionNeeded(); action != "" { + words += railSep + action + } // A RUN HELD AT ITS GATE WEARS THE GATE'S MARK here as in the list // ([app.railTreeGlyph]). mark := a.taskStateMark(node) @@ -543,17 +557,7 @@ func (a *app) sideBand() []sideBandItem { out = append(out, sideBandItem{key: "task/" + itoa(int(node.id)), ask: true, mark: mark, words: words, act: sideAct{kind: sideActOpen, id: node.id}}) } - for _, node := range members[railDone] { - if !a.sideBandFails(node) { - continue - } - words := node.title - if word := a.taskStatus(node).Word; word != "" { - words += " " + word - } - out = append(out, sideBandItem{key: "fail/" + itoa(int(node.id)), mark: a.taskStateMark(node), words: words, - act: sideAct{kind: sideActOpen, id: node.id}}) - } + return out } @@ -834,6 +838,9 @@ func (a *app) sideDo(act sideAct) tea.Cmd { a.sideSetView(act.view) case sideActHide: a.sideToggle() + case sideActTasks: + a.railTake(false) + return a.showPage(pageTasks) case sideActTeams: a.railTake(false) return a.showPage(pageTeams) diff --git a/internal/tui3/sidecol_test.go b/internal/tui3/sidecol_test.go index 06880c384c..b5e78f31d9 100644 --- a/internal/tui3/sidecol_test.go +++ b/internal/tui3/sidecol_test.go @@ -89,84 +89,30 @@ func sideDrawn(a *app) ([]string, []string) { // and nothing in the band is drawn again in the list under it. func TestTheBandCarriesAsksInAmberAndFailuresInInk(t *testing.T) { a := sideBandFixture(t) - keys, raw := sideDrawn(a) - var band []int - for i, k := range keys { - if k != "" && k != sideHeadKey { - band = append(band, i) - } - } - want := []string{"task/2", "task/1", "fail/5", "band/more"} - if len(band) != len(want) { - t.Fatalf("the band drew %v, want %v:\n%s", keys, want, strings.Join(railText(a, a.viewHeight()), "\n")) + band := a.sideBand() + if len(band) != 2 || band[0].key != "task/2" || band[1].key != "task/1" { + t.Fatalf("band contains routine failures: %+v", band) } - for i, at := range band { - if keys[at] != want[i] { - t.Fatalf("band row %d is %q, want %q (all %v)", i, keys[at], want[i], keys) + for _, item := range band { + if !item.ask { + t.Fatal("decision lost attention") } } - if more := plain(raw[band[3]]); !strings.Contains(more, "+2 more") { - t.Fatalf("the band's last row is %q, want +2 more", more) - } - // THE PAINT. The palette's warn ink is on the asks and on nothing else. - warn := a.pal.ask("Q") - warn = warn[:strings.Index(warn, "Q")] - if warn == "" { - t.Fatal("the test palette paints no warn ink, so this test would prove nothing") - } - for i, at := range band[:3] { - amber := strings.Contains(raw[at], warn) - if ask := i < 2; amber != ask { - t.Fatalf("band row %q amber=%v, want %v:\n%q", keys[at], amber, ask, raw[at]) + text := strings.Join(railText(a, a.viewHeight()), "\n") + for _, hidden := range []string{"Cut the goldens", "Read the law", "Done 1"} { + if strings.Contains(text, hidden) { + t.Fatalf("completed history occupies sidebar: %s", text) } } - if !strings.Contains(plain(raw[band[2]]), a.taskStateMark(a.tasks[5])+" Cut the goldens 5") { - t.Fatalf("the failure row is %q", plain(raw[band[2]])) - } - // A RULE CLOSES THE BAND, and the list starts under it. - if rule := plain(raw[band[3]+1]); !strings.Contains(rule, "────") { - t.Fatalf("no rule under the band: %q", rule) - } - // NOT REPEATED BELOW. - railOpenAll(a) - for _, e := range a.railEntries() { - if e.node != nil && e.node.id <= 5 { - t.Fatalf("band item %d is drawn again in the list", e.node.id) + for _, id := range []uint64{3, 4, 5, 7} { + if a.doneCardAt(a.doneEntryFor(id)) == nil { + t.Fatalf("finished result %d lost", id) } } - // `+2 more` SHOWS THEM ALL, and `fewer` folds them back. - x, y := sideRowOn(t, a, "band/more") - railClick(t, a, x, y) - if n := len(a.sideBand()); n != 5 { - t.Fatalf("the band holds %d items", n) - } - sideRowOn(t, a, "fail/3") - if more := plain(strings.Join(railText(a, a.viewHeight()), "\n")); !strings.Contains(more, "fewer") { - t.Fatalf("the opened band offers no way back:\n%s", more) - } - x, y = sideRowOn(t, a, "band/more") - railClick(t, a, x, y) - if a.side.bandAll { - t.Fatal("`fewer` did not fold the band back") - } - // OPENING A FAILURE IS LOOKING AT IT: it leaves the band and joins the - // finished work. - x, y = sideRowOn(t, a, "fail/5") + x, y := sideRowOn(t, a, "task/2") railClick(t, a, x, y) - if !a.roomOpen() || a.room.id != 5 { - t.Fatalf("a press on the failure opened %d", roomID(a)) - } - for _, item := range a.sideBand() { - if item.key == "fail/5" { - t.Fatal("the failure the person opened is still in the band") - } - } - listed := false - for _, e := range a.railEntries() { - listed = listed || (e.node != nil && e.node.id == 5) - } - if !listed { - t.Fatal("the failure the person opened is not in the list") + if !a.roomOpen() || a.room.id != 2 { + t.Fatal("decision action no longer opens owning task") } } @@ -174,16 +120,18 @@ func TestTheBandCarriesAsksInAmberAndFailuresInInk(t *testing.T) { // band only counts what it cannot draw from the fifth item on. func TestTheBandDrawsFourItemsRatherThanCountOne(t *testing.T) { a := sideBandFixture(t) - a.side.acked = map[uint64]bool{3: true} + for _, id := range []uint64{3, 4} { + a.taskUpdate(update(id, "Choose result "+itoa(int(id)), session.TaskUnverified, session.TaskNotice{Merge: mergeWordAborted, Branch: "task/result"})) + } keys, _ := sideDrawn(a) got := strings.Join(keys, " ") - for _, want := range []string{"task/2", "task/1", "fail/5", "fail/4"} { + for _, want := range []string{"task/4", "task/3", "task/2", "task/1"} { if !strings.Contains(got, want) { - t.Fatalf("the band lost %q: %v", want, keys) + t.Fatalf("decision missing %s: %v", want, keys) } } if strings.Contains(got, "band/more") { - t.Fatalf("four items were cut to three and a count: %v", keys) + t.Fatalf("four decisions collapsed into a count: %v", keys) } } @@ -206,7 +154,7 @@ func TestAnEmptyBandIsGoneAndAPlainTerminalReadsItsMarks(t *testing.T) { b := sideBandFixture(t) b.linear = true text := strings.Join(railText(b, b.viewHeight()), "\n") - for _, want := range []string{b.taskStateMark(b.tasks[2]) + " Port the parser 2", b.taskStateMark(b.tasks[5]) + " Cut the goldens 5", "----"} { + for _, want := range []string{b.taskStateMark(b.tasks[2]) + " Port the parser 2", "----"} { if !strings.Contains(text, want) { t.Fatalf("the plain terminal's band is missing %q:\n%s", want, text) } @@ -304,7 +252,7 @@ func TestTheHoverGroundIsTheClickTarget(t *testing.T) { } } // A plain chat's one word is not a door: there is nothing to switch to. - if len(row.doors) != 1 || row.doors[0].act.kind != sideActHide { + if len(row.doors) != 2 || row.doors[0].act.kind != sideActTasks || row.doors[1].act.kind != sideActHide { t.Fatalf("a plain chat's header has doors %+v", row.doors) } } diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 58661eb794..287ad49782 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -2,6 +2,7 @@ package tui3 import ( "path/filepath" + "reflect" "strconv" "strings" "time" @@ -10,6 +11,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) @@ -170,7 +172,9 @@ type taskNode struct { // (session's TaskNotice.Model). Empty means nobody said — a scripted agent, // an older engine — and every row that draws it draws nothing instead, the // way the spend does. - model string + model string + // crew is the routed run information disclosed on the task result page. + crew *crewroute.Decision nextModel string thinking string state session.TaskState @@ -2503,7 +2507,7 @@ func (a *app) railGroupOf(node *taskNode) railGroup { // a queued node behind a full machine is never filed as work in flight. status := a.taskStatus(node) switch { - case status.Attention: + case status.Attention || node.actionNeeded() != "": // A parent's running is a FOLD, not a mute. While the parent lives it is // the one being asked, so the top of the family is what a person reads // first — but the demand stays visible here, because filing it under @@ -2529,6 +2533,14 @@ func (a *app) railGroupOf(node *taskNode) railGroup { return railDone } +// actionNeeded names a routed stop whose remedy requires the person's intervention. +func (node *taskNode) actionNeeded() string { + if node == nil || node.crew == nil || node.state != session.TaskFailed || node.stopped { + return "" + } + return strings.TrimSpace(node.crew.Stopped) +} + // taskAwaitsPerson reports whether this node's only remaining step is a // PERSON'S. It is one half of the demand the roster counts; the other is edits // nobody brought home ([session.TaskStatus.ChangesUnlanded]), and both name work @@ -2697,7 +2709,7 @@ func (a *app) railKin() (kids map[string][]*taskNode, byKey map[string]*taskNode // band: work waiting on the person, and a failure they have not opened, are // the band's (sidecol.go). func (a *app) railListed(node *taskNode, g railGroup) bool { - return g != railAttention && !(g == railDone && a.sideBandFails(node)) + return g != railAttention && g != railDone } // railEntries is the roster's row model: each group that has anything in it, @@ -3279,7 +3291,7 @@ func (a *app) railDrawnView(height int) ([]railLine, int) { // THE TREES COME OUT OF THE READING THE PLACE ALREADY HOLDS, never out of the // store: this is a frame, and a frame never reads the disk. The reading is // refreshed on the paint clock ([tasksPlace.regroup]). - forest := a.tasksFiltered().planRailForest(a.taskSheet.mine.plan) + forest := activePlanForest(a.tasksFiltered().planRailForest(a.taskSheet.mine.plan)) if len(forest) == 0 { return view, focus } @@ -5192,7 +5204,8 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { notice.Decider == node.decider && notice.NextModel == node.nextModel && notice.Thinking == node.thinking && (notice.Brief == "" || notice.Brief == node.brief) && (notice.Acceptance == "" || notice.Acceptance == node.acceptance) && - (notice.Program == "" || notice.Program == node.program)) { + (notice.Program == "" || notice.Program == node.program) && + (notice.Crew == nil || reflect.DeepEqual(notice.Crew, node.crew))) { return nil } } @@ -5352,6 +5365,10 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { if model := strings.TrimSpace(notice.Model); model != "" { node.model = model } + if notice.Crew != nil { + crew := *notice.Crew + node.crew = &crew + } node.nextModel = strings.TrimSpace(notice.NextModel) node.thinking = notice.Thinking if len(notice.Changed) > 0 { @@ -5444,9 +5461,7 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { a.landedCard(node) } var pilot tea.Cmd - // A ROUTED TASK SAYS ITS CREW TWICE: which crew it got and the estimate - // when it starts, and what it cost beside the estimate when it lands - // (crew.go's [app.sayTaskCrew], once per end per task). + // Crew facts remain with the task rather than producing transcript receipts. a.sayTaskCrew(*notice) switch notice.State { case session.TaskRunning: diff --git a/internal/tui3/taskcost_test.go b/internal/tui3/taskcost_test.go index ae07175b85..f696634233 100644 --- a/internal/tui3/taskcost_test.go +++ b/internal/tui3/taskcost_test.go @@ -34,16 +34,16 @@ func TestTheLandedCardSaysWhatTheWorkCostWhenSomebodyPricedIt(t *testing.T) { if strings.Contains(taskText(a), dollars(0.75)) { t.Fatalf("the collapsed card recites the price:\n%s", taskText(a)) } - clickHit(t, a, hitDone) - if !strings.Contains(taskText(a), dollars(0.75)) { - t.Fatalf("the opened card does not say what the work cost:\n%s", taskText(a)) + + if !strings.Contains(retainedLandingText(t, a, 7), dollars(0.75)) { + t.Fatalf("the opened card does not say what the work cost:\n%s", retainedLandingText(t, a, 7)) } // AND IT SITS ON THE MODEL'S OWN ROW, which is what changed when the three // stacked fact rows became one ([app.doneFactsRow]). The price wears no label // any more: on a row of whole facts joined by ` · `, `$0.75` is the only thing // that can be a dollar figure, and `cost · ` in front of it was a noun saying // what the glyph already said. - text := taskText(a) + text := retainedLandingText(t, a, 7) if !strings.Contains(text, doneModelLabel+"openai/gpt-5"+railSep+dollars(0.75)) { t.Fatalf("the price is not beside the model that earned it:\n%s", text) } @@ -64,9 +64,8 @@ func TestACardNobodyPricedDrawsNoCostRowAtAll(t *testing.T) { session.TaskDone, session.TaskNotice{ Model: "openai/gpt-5", Report: "the guard is in", Merge: mergeWordMerged, })}) - clickHit(t, a, hitDone) - text := taskText(a) + text := retainedLandingText(t, a, 7) if strings.Contains(text, "$") || strings.Contains(text, dollars(0)) { t.Fatalf("an unpriced node was given a figure:\n%s", text) } @@ -90,10 +89,9 @@ func TestTheCardsPriceIsTheNodesReconciledFigureAndNotTheRawNotice(t *testing.T) advance(2 * time.Minute) drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(7, "Fix the nil-map crash", session.TaskDone, session.TaskNotice{Report: "the guard is in", Merge: mergeWordMerged})}) - clickHit(t, a, hitDone) - if !strings.Contains(taskText(a), dollars(0.30)) { - t.Fatalf("the card dropped the spend the pilot counted:\n%s", taskText(a)) + if !strings.Contains(retainedLandingText(t, a, 7), dollars(0.30)) { + t.Fatalf("the card dropped the spend the pilot counted:\n%s", retainedLandingText(t, a, 7)) } } @@ -132,7 +130,7 @@ func TestTheCardCountsFilesAndInventsNoLineNumbers(t *testing.T) { Changed: []string{"internal/session/loop.go", "internal/session/loop_test.go"}, })}) - text := taskText(a) + text := retainedLandingText(t, a, 7) if !strings.Contains(text, "2"+doneFilesSuffix) { t.Fatalf("the card does not count the files the node wrote:\n%s", text) } diff --git a/internal/tui3/taskcrewdetails.go b/internal/tui3/taskcrewdetails.go new file mode 100644 index 0000000000..46c620e2f6 --- /dev/null +++ b/internal/tui3/taskcrewdetails.go @@ -0,0 +1,85 @@ +package tui3 + +import ( + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/session" +) + +const taskStrongerWord = "r run again with a stronger crew" + +// Crew facts belong to the task people open, not to another transcript receipt. +func (a *app) taskCrewRows(entry session.TaskIndexEntry, width int) []string { + entry = a.currentTaskEntry(entry) + if entry.Crew == nil { + return nil + } + var rows []string + if stopped := strings.TrimSpace(entry.Crew.Stopped); stopped != "" { + for _, line := range wrap(stopped, width) { + rows = append(rows, a.pal.warn(line)) + } + } + for _, line := range wrap("crew · "+a.crewLine(entry.Crew, entry.Cost), width) { + rows = append(rows, a.pal.dim(line)) + } + return rows +} + +// A stronger run addresses the selected task in its owning conversation, never the latest task elsewhere. +func (a *app) taskCanRedoStronger(entry session.TaskIndexEntry) bool { + if entry.SessionID == "" || entry.SessionID != a.taskSheetSelfID() || entry.Program != "" || a.taskSheet.awayOwner.on { + return false + } + node := a.taskSheetNodeFor(&entry) + crew := entry.Crew + if node != nil && node.crew != nil { + crew = node.crew + } + if crew == nil || strings.TrimSpace(crew.Stopped) != "" || entry.Live() { + return false + } + _, ok := a.agent.(redoAgent) + return ok +} + +func (a *app) redoTaskStronger(entry session.TaskIndexEntry) tea.Cmd { + if !a.taskCanRedoStronger(entry) { + return nil + } + id, err := strconv.ParseUint(entry.ID, 10, 64) + if err != nil || id == 0 { + return nil + } + door := a.agent.(redoAgent) + ctx, conv := a.ctx, a.taskBriefConv() + a.closeTaskRecord() + a.closeTaskSheet() + return a.offLoop(func() func(bool) tea.Cmd { + next, title, err := door.RedoStronger(ctx, id) + return foldTaskStarted(taskStartedMsg{kind: "single", id: strconv.FormatUint(next, 10), title: title, err: err, conv: conv}) + }) +} + +const taskResultOutputWord = "o open full output" + +// The result keeps the transcript's existing room one deliberate action away. +func (a *app) taskResultHasOutput(entry session.TaskIndexEntry) bool { + return a.taskSheetNodeFor(&entry) != nil +} + +func (a *app) openTaskResultOutput(entry session.TaskIndexEntry) tea.Cmd { + node := a.taskSheetNodeFor(&entry) + if node == nil { + return nil + } + a.closeTaskSheet() + if node.run != "" { + a.openOrchRoom(node.run, node.node) + } else { + a.openRoomFor(node.id, node.title) + } + return a.takeRoomPump() +} diff --git a/internal/tui3/taskcrewdetails_test.go b/internal/tui3/taskcrewdetails_test.go new file mode 100644 index 0000000000..b463ac2d50 --- /dev/null +++ b/internal/tui3/taskcrewdetails_test.go @@ -0,0 +1,157 @@ +package tui3 + +import ( + "context" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/charmbracelet/x/ansi" +) + +type selectedCrewAgent struct { + *fakeAgent + selected uint64 +} + +func (f *selectedCrewAgent) RedoStronger(_ context.Context, id uint64) (uint64, string, error) { + f.selected = id + return 9, "stronger", nil +} + +func TestTaskResultKeepsCrewAndTargetsSelectedStrongerRun(t *testing.T) { + a, _, entry := retryFixture(t) + f := &selectedCrewAgent{fakeAgent: &fakeAgent{}} + a.agent = f + crew := &crewroute.Decision{EstUSD: .52, Crew: []crewroute.Pick{{Seat: crewroute.Worker, Model: "worker-model"}, {Seat: crewroute.Planner, Model: "planner-model"}, {Seat: crewroute.Checker, Model: "checker-model"}}} + a.tasks[7].crew = crew + entry.Crew = crew + a.taskSheet.detail = entry + lines, hits, _, _ := a.taskCardFrame(120, 40) + text := ansi.Strip(strings.Join(lines, "\n")) + for _, want := range []string{"worker-model", "planner-model", "checker-model", "0.520", taskStrongerWord} { + if !strings.Contains(text, want) { + t.Fatalf("missing %q: %s", want, text) + } + } + var click = -1 + for y, hit := range hits { + if hit == taskCardHitStronger { + click = y + break + } + } + if click < 0 { + t.Fatal("stronger action has no pointer target") + } + runCmd(a.taskCardPress(4, click)) + if f.selected != 7 { + t.Fatalf("stronger run targeted %d, want selected 7", f.selected) + } +} + +func TestStrongerTaskActionNeverTargetsAnotherConversationOrBlockedCrew(t *testing.T) { + a, _, entry := retryFixture(t) + a.agent = &selectedCrewAgent{fakeAgent: &fakeAgent{}} + entry.Crew = &crewroute.Decision{} + if !a.taskCanRedoStronger(entry) { + t.Fatal("own ended task unavailable") + } + entry.SessionID = "another-conversation" + if a.taskCanRedoStronger(entry) || a.redoTaskStronger(entry) != nil { + t.Fatal("foreign task could rerun locally") + } + entry.SessionID = "retry-session" + entry.Crew.Stopped = "add credits" + if a.taskCanRedoStronger(entry) { + t.Fatal("stronger action offered despite credit stop") + } + entry.Crew.Stopped = "" + entry.Status = string(session.TaskRunning) + if a.taskCanRedoStronger(entry) { + t.Fatal("running task offered stronger rerun") + } +} + +func TestActionableCrewStopStaysVisibleUntilResolved(t *testing.T) { + a, _, entry := retryFixture(t) + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 7, Title: "Repair export", State: session.TaskFailed, Crew: &crewroute.Decision{Stopped: "add credits to continue"}}}) + node := a.tasks[7] + // This is a provider stop, not the person's intentional stop from the fixture. + node.stopped = false + node.ending = "" + a.landedCard(node) + card := a.doneCardAt(a.doneEntryFor(7)) + if card == nil || card.hidden() { + t.Fatalf("actionable stop hidden: %+v", card) + } + if a.dismissDone(a.doneEntryFor(7)) { + t.Fatal("actionable stop can be dismissed") + } + text := ansi.Strip(strings.Join(a.doneRows(card, 120, false), "\n")) + if !strings.Contains(text, "add credits to continue") { + t.Fatalf("remedy missing: %s", text) + } + if a.taskCanRedoStronger(entry) { + t.Fatal("stronger retry offered for missing credit") + } + node.crew = nil + a.landedCard(node) + card = a.doneCardAt(a.doneEntryFor(7)) + if !card.hidden() { + t.Fatal("ordinary failure raised attention") + } +} + +func TestCompletedCrewTaskOpensResultThroughTasksAndKeepsFullOutputDoor(t *testing.T) { + a, _, entry := retryFixture(t) + a.height = 40 + a.agent = &selectedCrewAgent{fakeAgent: &fakeAgent{}} + a.tasks[7].crew = &crewroute.Decision{EstUSD: .52, Crew: []crewroute.Pick{{Seat: crewroute.Worker, Model: "worker-model"}}} + a.tasks[7].produced = "Actual selected task deliverable" + a.tasks[7].cost = .31 + a.tasks[7].changed = []string{"export.go"} + a.closeTaskRecord() + a.showPage(pageTasks) + a.taskSheet.regroup(a) + a.taskSheet.cursor = a.tasksSettle(0) + found := false + for i := 0; i < 20; i++ { + if item, ok := a.taskSheetCurrent(); ok && item.entry.ID == entry.ID { + found = true + break + } + a.taskSheetMove(1) + } + if !found { + t.Fatal("completed task unavailable in Tasks history") + } + a.taskSheet.enter(a) + if !a.taskSheet.detailOn || a.roomOpen() { + t.Fatal("completed routed task bypassed its result page") + } + rows, hits, _, _ := a.taskCardFrame(120, 40) + text := ansi.Strip(strings.Join(rows, "\n")) + for _, want := range []string{"Actual selected task deliverable", "worker-model", "$0.31", "1 file changed", taskStrongerWord, taskResultOutputWord} { + if !strings.Contains(text, want) { + t.Fatalf("result lost %q: %s", want, text) + } + } + outputRow := -1 + for y, hit := range hits { + if hit == taskCardHitOutput { + outputRow = y + break + } + } + if outputRow < 0 { + t.Fatal("full output action has no mouse target") + } + _, rooms, _ := roomApp(t) + a.agent = rooms + a.taskCardPress(4, outputRow) + if !a.roomOpen() || a.room.id != 7 { + t.Fatal("full output action failed to open selected transcript") + } +} diff --git a/internal/tui3/taskdismiss.go b/internal/tui3/taskdismiss.go index 462f0acb5b..3d1a90190f 100644 --- a/internal/tui3/taskdismiss.go +++ b/internal/tui3/taskdismiss.go @@ -5,7 +5,7 @@ import "github.com/Agent-Field/codeaf/internal/session" // Dismissal is presentation state. It never stops, deletes or accepts work. func (a *app) dismissDone(i int) bool { card := a.doneCardAt(i) - if card == nil || card.dismissed || card.status.Tier == session.TaskTierYourCall { + if card == nil || card.dismissed || (card.status.Tier == session.TaskTierYourCall || card.attention != "") { return false } card.dismissed = true diff --git a/internal/tui3/taskdismiss_test.go b/internal/tui3/taskdismiss_test.go index a1f16bb486..72ae15c1ef 100644 --- a/internal/tui3/taskdismiss_test.go +++ b/internal/tui3/taskdismiss_test.go @@ -26,14 +26,9 @@ func TestNotificationBatchFoldsAndKeepsPendingDecisionVisible(t *testing.T) { } d := deck{entries: a.entries} rows := a.doneCluster(d, nil, 0, len(d.entries), 100) - if len(rows) != 1 { - t.Fatalf("batch has %d rows, want one", len(rows)) + if len(rows) != 0 { + t.Fatalf("routine completions took %d transcript rows", len(rows)) } - a.openDone(rows[0].entry) - if rows = a.doneCluster(d, nil, 0, len(d.entries), 100); len(rows) <= 3 { - t.Fatalf("batch did not expand: %v", rows) - } - a.openDone(rows[0].entry) drive(t, a, streamEventMsg{gen: a.gen, ev: update(4, "Needs decision", session.TaskUnverified, unverifiedNotice("needs review"))}) d = deck{entries: a.entries} rows = a.doneCluster(d, nil, 0, len(d.entries), 100) @@ -75,6 +70,12 @@ func TestDeleteDismissesSelectedNotificationAndNavigationSkipsHiddenCards(t *tes for i := 1; i <= 3; i++ { drive(t, a, streamEventMsg{gen: a.gen, ev: update(uint64(i), "Completed work", session.TaskDone, session.TaskNotice{})}) } + // Explicitly visible legacy notifications still support manual dismissal. + for i := range a.entries { + if a.entries[i].done != nil { + a.entries[i].done.quiet = false + } + } first := a.doneEntryFor(1) if head := plain(a.doneHead(a.doneCardAt(first), 120, true)); !strings.Contains(head, "delete dismiss") { t.Fatalf("selected notification has no dismissal hint: %s", head) diff --git a/internal/tui3/taskdone.go b/internal/tui3/taskdone.go index e2c0135613..293d9b8e2d 100644 --- a/internal/tui3/taskdone.go +++ b/internal/tui3/taskdone.go @@ -99,6 +99,10 @@ type taskDone struct { open bool // Dismissal only affects this window; the task record stays in Sessions. dismissed bool + // quiet keeps routine state changes in Tasks while retaining the landing data. + quiet bool + // attention is a concrete remedy, such as adding credit, rather than a routine failure. + attention string // gut is how many columns of the READING GUTTER this card's rows already // carry (gutter.go), for the reason the proposal's own spans carry one // (task.go's [taskCard]). @@ -226,6 +230,8 @@ func (a *app) landedCard(node *taskNode) { model: node.model, cost: node.spent(), } + card.attention = node.actionNeeded() + card.quiet = card.status.Tier != session.TaskTierYourCall && card.attention == "" if card.span == 0 && !node.began.IsZero() { card.span = a.now().Sub(node.began) } @@ -255,9 +261,13 @@ func (a *app) landedCard(node *taskNode) { // The streaming block closes so this card is its own block. If the task // lands during that turn, its boundary moves the card past the final answer: // a settled work fold would otherwise close over a card left inside it. - a.closeLive() + if !card.quiet { + a.closeLive() + } a.entries = append(a.entries, entry{kind: entryDone, turn: a.turn, done: card}) - a.follow() + if !card.quiet { + a.follow() + } a.touch() } @@ -281,6 +291,9 @@ func (a *app) sayTurnLandings() { return } for _, id := range a.turnLandings { + if card := a.doneCardFor(id); card != nil && card.quiet { + continue + } if said, ok := a.crewSaid[id]; ok && said.landed { for i := len(a.entries) - 1; i >= 0; i-- { if a.entries[i].kind == entryNote && a.entries[i].text == said.text { @@ -366,14 +379,21 @@ func (a *app) handedBackCard(fresh *taskDone) bool { return false } card.status = fresh.status + card.quiet = fresh.quiet + card.attention = fresh.attention // A fresh decision must be visible even when its task state did not move. - if card.status.Tier == session.TaskTierYourCall { + if card.status.Tier == session.TaskTierYourCall || card.attention != "" { card.dismissed = false } a.settleTouched(card) return true } +// hidden applies the same presentation policy to live and restored landings. +func (card *taskDone) hidden() bool { + return card.dismissed || card.quiet +} + // doneCardAt is the card one entry draws, or nil. func (a *app) doneCardAt(i int) *taskDone { if i < 0 || i >= len(a.entries) || a.entries[i].kind != entryDone { @@ -423,7 +443,7 @@ func (a *app) openDone(i int) bool { func (a *app) doneCluster(d deck, out []row, from, to, width int) []row { visible := 0 for i := from; i < to; i++ { - if card := d.entries[i].done; card != nil && !card.dismissed && !supersededIn(d, i, to) { + if card := d.entries[i].done; card != nil && !card.hidden() && !supersededIn(d, i, to) { visible++ } } @@ -432,7 +452,7 @@ func (a *app) doneCluster(d deck, out []row, from, to, width int) []row { } for i := from; i < to; i++ { card := d.entries[i].done - if card == nil || card.dismissed || supersededIn(d, i, to) { + if card == nil || card.hidden() || supersededIn(d, i, to) { continue } for _, text := range a.doneRows(card, width, a.selected(i)) { @@ -448,10 +468,15 @@ const doneRollupFloor = 1 // doneRows draws one compact notification. Only an open card or pending // decision needs more than its headline. func (a *app) doneRows(card *taskDone, width int, sel bool) []string { - if card == nil || card.dismissed || width < 8 { + if card == nil || card.hidden() || width < 8 { return nil } out := []string{a.doneHead(card, width, sel)} + if card.attention != "" { + for _, line := range wrap(card.attention, width-2) { + out = append(out, " "+a.pal.warn(line)) + } + } if card.open || card.status.Tier == session.TaskTierYourCall { if line := a.doneUnder(card, width); line != "" { out = append(out, line) @@ -1161,7 +1186,7 @@ func capFieldMark(lines []string, mark string) []string { func (a *app) rollupRows(d deck, out []row, from, to, width int) []row { first := -1 for i := from; i < to; i++ { - if card := d.entries[i].done; card != nil && !card.dismissed && !supersededIn(d, i, to) { + if card := d.entries[i].done; card != nil && !card.hidden() && !supersededIn(d, i, to) { first = i break } @@ -1177,7 +1202,7 @@ func (a *app) rollupRows(d deck, out []row, from, to, width int) []row { out = append(out, row{text: head, entry: first, hit: hitDone}) for i := from; i < to; i++ { card := d.entries[i].done - if card == nil || card.dismissed || supersededIn(d, i, to) { + if card == nil || card.hidden() || supersededIn(d, i, to) { continue } // A decision is never hidden inside a folded batch. @@ -1209,7 +1234,7 @@ func (a *app) rollupHead(d deck, from, to, width int) string { var first, last time.Time for i := from; i < to; i++ { card := d.entries[i].done - if card == nil || card.dismissed || supersededIn(d, i, to) { + if card == nil || card.hidden() || supersededIn(d, i, to) { continue } count++ diff --git a/internal/tui3/taskending_test.go b/internal/tui3/taskending_test.go index 1cd7b43219..07f7d6df89 100644 --- a/internal/tui3/taskending_test.go +++ b/internal/tui3/taskending_test.go @@ -192,11 +192,11 @@ func TestAWorkerThatWouldNotWriteItsNotesSaysSoOnTheRailAndInTheRoom(t *testing. // THE CARD SAYS THE SAME THING ON TWO ROWS, which is its own law: the word and // the branch on the head, the reason under it, and nothing fused // (docs/design/task-states/DESIGN.md). - if compact := taskText(a); !strings.Contains(compact, taskBranchKept) { + if compact := retainedLandingText(t, a, 7); !strings.Contains(compact, "branch · task/parser") { t.Fatalf("compact receipt lost its kept branch: %s", compact) } a.toggleDoneAt(a.doneEntryFor(7)) - card := taskText(a) + card := retainedLandingText(t, a, 7) for _, want := range []string{ " · " + taskIncompleteState + " · ", "would not write its notes down", "branch · task/parser", } { @@ -206,7 +206,7 @@ func TestAWorkerThatWouldNotWriteItsNotesSaysSoOnTheRailAndInTheRoom(t *testing. } // The landing card keeps the card lane's own sentence for the same ending // until that lane lands. - if room := taskText(a); !strings.Contains(room, "would not write its notes down") { + if room := retainedLandingText(t, a, 7); !strings.Contains(room, "would not write its notes down") { t.Fatalf("the room does not say why the worker stopped:\n%s", room) } // AND IT IS THE OVER-TIER CELL, DIM. Nobody found anything wrong with the diff --git a/internal/tui3/taskgroundword_test.go b/internal/tui3/taskgroundword_test.go index aef401975d..99773f72bc 100644 --- a/internal/tui3/taskgroundword_test.go +++ b/internal/tui3/taskgroundword_test.go @@ -112,8 +112,8 @@ func TestTheLandingNoteNamesTheCopyTheWorkCameHomeFrom(t *testing.T) { Rung: tc.rung, Mode: tc.mode, Report: "the guard is in", Branch: "task/fix-the-nil-map-crash-9c1a2f", Merge: "merged", })}) - clickHit(t, a, hitDone) - text := taskText(a) + + text := retainedLandingText(t, a, 7) if !strings.Contains(text, tc.label+" · task/fix-the-nil-map-crash-9c1a2f") { t.Fatalf("the landing note does not label the branch %q:\n%s", tc.label, text) } @@ -136,8 +136,8 @@ func TestALandingNoteWithNoRungToNameSaysOnlyBranch(t *testing.T) { session.TaskDone, session.TaskNotice{ Report: "the guard is in", Branch: "task/fix-the-nil-map-crash-9c1a2f", Merge: "merged", })}) - clickHit(t, a, hitDone) - text := taskText(a) + + text := retainedLandingText(t, a, 7) if !strings.Contains(text, doneBranchLabel+"task/fix-the-nil-map-crash-9c1a2f") { t.Fatalf("the landing note did not fall back to %q:\n%s", doneBranchLabel, text) } diff --git a/internal/tui3/taskmodel_test.go b/internal/tui3/taskmodel_test.go index fb152e83d1..5be5f06800 100644 --- a/internal/tui3/taskmodel_test.go +++ b/internal/tui3/taskmodel_test.go @@ -160,8 +160,8 @@ func TestTheModelFollowsTheNodeOntoTheRailAndTheLandedCard(t *testing.T) { if strings.Contains(taskText(a), "openai/gpt-5") { t.Fatalf("the collapsed card recites the model:\n%s", taskText(a)) } - clickHit(t, a, hitDone) - if !strings.Contains(taskText(a), doneModelLabel+"openai/gpt-5") { - t.Fatalf("the opened card does not say what ran the work:\n%s", taskText(a)) + + if !strings.Contains(retainedLandingText(t, a, 7), doneModelLabel+"openai/gpt-5") { + t.Fatalf("the opened card does not say what ran the work:\n%s", retainedLandingText(t, a, 7)) } } diff --git a/internal/tui3/taskrecord.go b/internal/tui3/taskrecord.go index dbfd3dbdbe..921027a727 100644 --- a/internal/tui3/taskrecord.go +++ b/internal/tui3/taskrecord.go @@ -183,6 +183,8 @@ const ( // foot's place at that width, so it is NOT one of the two edges [back] // answers for — a thumb on that band is aiming at a chip, not at the door. taskCardHitMention + taskCardHitStronger + taskCardHitOutput ) // back reports whether a row of the card is the way back to the list. @@ -434,6 +436,10 @@ func (a *app) taskCardKey(key string) tea.Cmd { return cmd } switch key { + case "o": + return a.openTaskResultOutput(entry) + case "r": + return a.redoTaskStronger(entry) case "enter": return a.retryTask(entry) case "esc", "left": @@ -500,21 +506,28 @@ func (a *app) taskCardScroll(delta int) { // taskCardPress resolves a click on the card. Its edges are the way back and // its body is read, which is [app.expandPress]'s own shape. -func (a *app) taskCardPress(x, y int) { +func (a *app) taskCardPress(x, y int) tea.Cmd { width, height := a.size() _, hits, _, _ := a.taskCardFrame(width, height) if y < 0 || y >= len(hits) { - return + return nil + } + if hits[y] == taskCardHitOutput { + return a.openTaskResultOutput(a.taskSheet.detail) + } + if hits[y] == taskCardHitStronger { + return a.redoTaskStronger(a.taskSheet.detail) } // phone lane: the foot is two bands rather than one way out (taskphone.go). if hits[y] == taskCardHitMention { a.taskCardBarPress(x) - return + return nil } if !hits[y].back() { - return + return nil } a.closeTaskRecord() + return nil } // taskCardHitAt is that hit-test with nothing done about it: which of the card's @@ -572,6 +585,14 @@ func (a *app) taskCardFrame(width, height int) ([]string, []taskCardHit, int, in head := len(lines) room := height - head - taskCardFoot + stronger := a.taskCanRedoStronger(entry) && width-2 >= len(taskStrongerWord) + if stronger { + room-- + } + output := a.taskResultHasOutput(entry) && width-2 >= len(taskResultOutputWord) + if output { + room-- + } if room < 1 { room = 1 } @@ -601,6 +622,12 @@ func (a *app) taskCardFrame(width, height int) ([]string, []taskCardHit, int, in for i := 0; i < drawn; i++ { add(" "+body[a.taskSheet.detailTop+i], taskCardHitNone) } + if stronger { + add(" "+pal.underline(taskStrongerWord), taskCardHitStronger) + } + if output { + add(" "+pal.underline(taskResultOutputWord), taskCardHitOutput) + } add(pal.dim(rule(width)), taskCardHitNone) // THE PAGE ENTER LANDS ON FROM A `needs you` ROW SHOWS THE QUESTION IT @@ -980,6 +1007,13 @@ func (a *app) taskCardBody(entry session.TaskIndexEntry, width int) []string { // THE OUTCOME IS THE SENTENCE THE PERSON CAME FOR, so it is the first thing // under the state and it is drawn in ink rather than in the dim every other // fact here wears. + if node := a.taskSheetNodeFor(&entry); node != nil && node.crew != nil && strings.TrimSpace(node.produced) != "" { + var result []string + for _, line := range wrap(node.produced, width) { + result = append(result, pal.ink(line)) + } + bands = append(bands, result) + } if outcome := strings.TrimSpace(entry.Outcome); outcome != "" { var said []string for _, wrapped := range wrap(outcome, width) { @@ -988,6 +1022,8 @@ func (a *app) taskCardBody(entry session.TaskIndexEntry, width int) []string { bands = append(bands, said) } + bands = append(bands, a.taskCrewRows(entry, width)) + var facts []string // WHERE IT CAME OUT OF, because the page a person opens to learn MORE may // not know less than the row they opened it from. The row carries the diff --git a/internal/tui3/taskresult_test.go b/internal/tui3/taskresult_test.go index 10c25f8d66..b29eb694f5 100644 --- a/internal/tui3/taskresult_test.go +++ b/internal/tui3/taskresult_test.go @@ -5,7 +5,6 @@ import ( "testing" "time" - tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/session" @@ -62,9 +61,8 @@ func conflictedStoryNotice() session.TaskNotice { // count. func TestAnOpenCardReadsTheAnswerAsProseAndNotAsMarkdownSource(t *testing.T) { a := doneResultApp(t, 80, session.TaskDone, conflictedStoryNotice()) - clickHit(t, a, hitDone) - text := taskText(a) + text := retainedLandingText(t, a, 7) for _, want := range []string{"The Salt Road", "Chapter one", "The cart came over the ridge at dawn."} { if !strings.Contains(text, want) { t.Fatalf("the open card does not say %q:\n%s", want, text) @@ -147,9 +145,8 @@ func TestAnUndeliveredLandingLeadsTheCardInTheWarnHue(t *testing.T) { // under the sentence that says where the work is, rather than beside it. func TestTheLandingsOwnDetailStaysOnTheCardUnderTheHeadline(t *testing.T) { a := doneResultApp(t, 80, session.TaskDone, conflictedStoryNotice()) - clickHit(t, a, hitDone) - text := taskText(a) + text := retainedLandingText(t, a, 7) for _, want := range []string{ "would be overwritten", "you took this as done", @@ -169,20 +166,13 @@ func TestTheLandingsOwnDetailStaysOnTheCardUnderTheHeadline(t *testing.T) { // A compact card keeps its account inside; opening draws that account once. func TestAnOpenCardDropsTheQuotedPreviewItIsAboutToPrintInFull(t *testing.T) { a := doneResultApp(t, 160, session.TaskDone, conflictedStoryNotice()) - - collapsed := taskText(a) - if strings.Contains(collapsed, "did not merge cleanly") { - t.Fatalf("the collapsed card leaked its detail:\n%s", collapsed) - } - clickHit(t, a, hitDone) - open := taskText(a) - if strings.Contains(open, `"its branch`) { - t.Fatalf("the open card still quotes the sentence it prints in full:\n%s", open) + before := taskText(a) + detail := retainedLandingText(t, a, 7) + if strings.Contains(detail, `"its branch`) || !strings.Contains(detail, "did not merge cleanly") { + t.Fatalf("retained detail lost its diagnostic or repeated its preview: %s", detail) } - // Closing it restores the compact notification unchanged. - clickHit(t, a, hitDone) - if again := taskText(a); again != collapsed { - t.Fatalf("closing the card did not restore it:\nwas:\n%s\nnow:\n%s", collapsed, again) + if taskText(a) != before || strings.Contains(before, "did not merge cleanly") { + t.Fatal("inspecting a retained result polluted the conversation") } } @@ -245,9 +235,8 @@ func TestACutAnswerNamesWhereTheWholeOfItIs(t *testing.T) { Result: "Sixteen machines answer for this model.", ResultCut: true, ResultWhole: "/tmp/journal/node-7-result.txt", }) - clickHit(t, a, hitDone) - text := taskText(a) + text := retainedLandingText(t, a, 7) if !strings.Contains(text, doneMoreAt+"/tmp/journal/node-7-result.txt") { t.Fatalf("the card does not say where the rest of the answer is:\n%s", text) } @@ -261,9 +250,8 @@ func TestACutAnswerWithNowhereToPointSaysSo(t *testing.T) { Merge: mergeWordMerged, Report: "the survey is written", Result: "Sixteen machines answer for this model.", ResultCut: true, }) - clickHit(t, a, hitDone) - text := taskText(a) + text := retainedLandingText(t, a, 7) if !strings.Contains(text, doneMoreGone) { t.Fatalf("the card is silent about an answer it only has the start of:\n%s", text) } @@ -369,34 +357,16 @@ func TestAnUndeliveredLandingWithNoResultSplitsItsReportOnce(t *testing.T) { // click anywhere in it — the answer's rows included — which is the mechanic the // expansion has always been behind, and the one a new block of rows is easiest // to drop out of. -func TestEveryRowOfAnOpenCardFoldsItBackOnAClick(t *testing.T) { +func TestRoutineResultLeavesNoTranscriptClickTargets(t *testing.T) { a := doneResultApp(t, 160, session.TaskDone, conflictedStoryNotice()) - clickHit(t, a, hitDone) - card := a.doneCardAt(len(a.entries) - 1) - if card == nil || !card.open { - t.Fatal("the click did not open the card") - } - - // The answer's own row answers to the card, and pressing it closes it. body, _ := a.window(a.width, a.viewHeight()) - found := false - for i, r := range body { - if !strings.Contains(plain(r.text), "The cart came over the ridge at dawn.") { - continue - } - found = true - if r.hit != hitDone { - t.Fatalf("the answer's row is not part of the card it is inside:\n%q", plain(r.text)) + for _, row := range body { + if row.hit == hitDone { + t.Fatal("routine result left a transcript click target") } - drive(t, a, tea.MouseClickMsg{Y: a.bodyTop() + i, Button: tea.MouseLeft}) - drive(t, a, tea.MouseReleaseMsg{Y: a.bodyTop() + i, Button: tea.MouseLeft}) - break } - if !found { - t.Fatalf("the open card drew no answer to press:\n%s", taskText(a)) - } - if card.open { - t.Fatal("a click on the answer did not fold the card back") + if text := retainedLandingText(t, a, 7); !strings.Contains(text, "The cart came over the ridge at dawn.") { + t.Fatalf("quiet result lost its deliverable: %s", text) } } @@ -444,8 +414,8 @@ func TestExpandedAnswerDoesNotRepeatTheComposedReportPreview(t *testing.T) { notice := conflictedStoryNotice() notice.Report += "\n" + strings.TrimSpace(notice.Result) a := doneResultApp(t, 80, session.TaskDone, notice) - clickHit(t, a, hitDone) - text := taskText(a) + + text := retainedLandingText(t, a, 7) if strings.Count(text, "The Salt Road") != 1 || !strings.Contains(text, "did not merge cleanly") { t.Fatalf("answer must appear once with its delivery diagnostic:\n%s", text) } diff --git a/internal/tui3/taskretry.go b/internal/tui3/taskretry.go index 5adb9d4894..b1299d927d 100644 --- a/internal/tui3/taskretry.go +++ b/internal/tui3/taskretry.go @@ -90,6 +90,39 @@ func (a *app) taskRetried(msg taskRetriedMsg) { // The live graph overrides a saved landing while preserving its transcript and identity. func (a *app) currentTaskEntry(entry session.TaskIndexEntry) session.TaskIndexEntry { node := a.taskSheetNodeFor(&entry) + if node != nil && node.crew != nil { + entry.Crew = node.crew + } + if node != nil && node.crew != nil { + entry.Status, entry.Ending = string(node.state), node.ending + entry.Parent = node.parent + if node.report != "" { + entry.Outcome = firstProseLine(node.report) + } + if node.spent() > 0 { + entry.Cost = node.spent() + } + if node.model != "" { + entry.Model = node.model + } + if node.branch != "" && node.merge != mergeWordMerged { + entry.Branch = node.branch + } + if node.where != "" { + entry.Where = node.where + } + if len(node.changed) > 0 { + entry.Files = append([]string(nil), node.changed...) + entry.FilesChanged = len(node.changed) + } + if !node.started.IsZero() { + entry.StartedAt = node.started + } + if !node.ended.IsZero() { + entry.EndedAt = node.ended + } + entry.DurationMS = node.ranFor().Milliseconds() + } if node == nil || !node.retried { return entry } diff --git a/internal/tui3/taskretry_test.go b/internal/tui3/taskretry_test.go index b60f5d9f53..7567c0aa06 100644 --- a/internal/tui3/taskretry_test.go +++ b/internal/tui3/taskretry_test.go @@ -71,7 +71,7 @@ func TestTaskRetryEnterUpdatesEveryViewWithoutAnotherTask(t *testing.T) { t.Fatal(text) } painted := ansi.Strip(strings.Join(a.doneRows(a.entries[at].done, 120, false), "\n")) - if strings.Contains(painted, "old failure") || !strings.Contains(painted, a.taskStatus(node).Word) { + if painted != "" { t.Fatal(painted) } if a.entries[at].done.status.Word != a.taskStatus(node).Word { diff --git a/internal/tui3/tasktier_test.go b/internal/tui3/tasktier_test.go index 18fe5269c1..d82ad30be5 100644 --- a/internal/tui3/tasktier_test.go +++ b/internal/tui3/tasktier_test.go @@ -245,7 +245,7 @@ func TestTheColumnFilesYourCallFirstThenMovingThenOver(t *testing.T) { streamEventMsg{gen: a.gen, ev: update(5, "Port the parser", session.TaskUnverified, session.TaskNotice{Merge: mergeWordAborted, Branch: "task/parser"})}, ) band := a.sideBand() - if len(band) != 2 || band[0].key != "task/5" || !band[0].ask || band[1].key != "fail/4" || band[1].ask { + if len(band) != 1 || band[0].key != "task/5" || !band[0].ask { t.Fatalf("the band is not the question in amber then the failure in ink: %+v", band) } railOpenAll(a) @@ -255,8 +255,8 @@ func TestTheColumnFilesYourCallFirstThenMovingThenOver(t *testing.T) { order = append(order, e.node.id) } } - if len(order) != 3 || order[0] != 3 || order[1] != 2 || order[2] != 1 { - t.Fatalf("the list is %v, want running 3, queued 2, done 1, and nothing the band holds", order) + if len(order) != 2 || order[0] != 3 || order[1] != 2 { + t.Fatalf("the list is %v, want running 3 and queued 2; decisions stay in their band and finished results stay in history", order) } } diff --git a/internal/tui3/taskview_test.go b/internal/tui3/taskview_test.go index 002ff9b843..b985a510e1 100644 --- a/internal/tui3/taskview_test.go +++ b/internal/tui3/taskview_test.go @@ -791,7 +791,7 @@ func TestSidebarHeaderStaysWhileRunningWorkScrolls(t *testing.T) { a, _, _ := taskApp(t) a.taskUpdate(update(1, "Ship the port", session.TaskRunning, session.TaskNotice{})) for i := 2; i <= 60; i++ { - a.taskUpdate(update(uint64(i), "landed "+itoa(i), session.TaskDone, + a.taskUpdate(update(uint64(i), "running "+itoa(i), session.TaskRunning, session.TaskNotice{Merge: mergeWordMerged})) } railOpenAll(a) diff --git a/internal/tui3/teamcard.go b/internal/tui3/teamcard.go index ca789d53d1..51d4bde771 100644 --- a/internal/tui3/teamcard.go +++ b/internal/tui3/teamcard.go @@ -235,6 +235,8 @@ func asideShapeOf(e session.DisplayEntry) asideShape { switch { case len(e.Team) > 0 || strings.HasPrefix(text, teamAsideLead): return asideTeam + case e.RoutineCompletion: + return asideHidden case session.TeamWakeNote(text): return asideHidden } diff --git a/internal/tui3/workfold.go b/internal/tui3/workfold.go index 0b9d1495e3..1160c640a2 100644 --- a/internal/tui3/workfold.go +++ b/internal/tui3/workfold.go @@ -129,7 +129,7 @@ func housekeepingFold(es []entry, f workfold) bool { } case entryCompact, entryTeam, entryDivider: case entryDone: - if e.done == nil || !e.done.dismissed { + if e.done == nil || !e.done.hidden() { return false } default: @@ -532,7 +532,7 @@ func countWork(es []entry, from, to int, f *workfold) { if e.kind == entryUser || e.kind == entryDivider || e.kind == entrySteer { continue } - if e.kind == entryDone && e.done != nil && e.done.dismissed { + if e.kind == entryDone && e.done != nil && e.done.hidden() { continue } if f.start < 0 { diff --git a/internal/tui3/worktab_test.go b/internal/tui3/worktab_test.go index b43fe5ebf8..ef73d57a58 100644 --- a/internal/tui3/worktab_test.go +++ b/internal/tui3/worktab_test.go @@ -109,13 +109,14 @@ func TestWorkTabEscReturnsToConversationAndLandingCardRemains(t *testing.T) { fake.editRows(func(rows []session.PlanTaskRow) { rows[0].Status, rows[1].Status = "done", "done" }) a.workTabStable() a.workTabStable() - if got := taskText(a); !strings.Contains(got, "Root") { - t.Fatalf("self-closing work tab hid the compact landing: %s", got) + if got := taskText(a); strings.Contains(got, "the importer landed") { + t.Fatalf("closing work tab raised a completion receipt: %s", got) } - a.toggleDoneAt(a.doneEntryFor(7)) - if got := plain(func() string { s, _, _ := a.frame(); return s }()); !strings.Contains(got, "Root") || !strings.Contains(got, "the importer landed") { - t.Fatalf("self-closing work tab removed the landing card:\n%s", got) + card := a.doneCardFor(7) + if card == nil || card.title != "Root" || card.report != "the importer landed" || !card.hidden() { + t.Fatalf("closing work tab lost retained result: %+v", card) } + } func TestWorkTabKeepsTheStripSwitchKey(t *testing.T) { From eceff2001219f144db2755684258a319cedc43c8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 11:13:30 -0400 Subject: [PATCH 02/13] docs: restore precise manual answers for task and process controls --- internal/manual/chat/how-tasks-run.md | 10 ++++++++++ internal/manual/chat/keys.md | 17 +++++++++++++++++ internal/manual/chat/models-and-cost.md | 8 ++++++++ .../manual/chat/running-from-the-terminal.md | 11 +++++++++++ internal/tui3/jobsection.go | 16 ++++++++-------- 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index b35790fbe8..326b8f4e13 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3406,3 +3406,13 @@ a failed observation, never evidence that a page was read. A child reads the overall ask for requirements it owns. Delegation instructions for the parent remain the parent's coordination work; a child repeats them only when its own work order assigns coordination. + +## Where does my task work + +An ordinary code task works in its own copy of the repository the work is about, +on its own branch, so you can continue editing your working copy. Its task page +and history record show the resolved folder. A quick task instead works in the +conversation's own folder, with file reservations rather than a separate branch. +If the request explicitly names a plain folder outside a repository, that exact +folder is used. The resolved place is a fact on the task, not guessed from your +terminal's current folder after the task has started. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index e27f953b43..20e69b8d46 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -3706,3 +3706,20 @@ Press `alt+k` (`opt+k` on macOS) to open the chats menu from a conversation. It lists the same open tabs as Home, in the tab strip's order. Select one and press Enter. Closed conversations are behind the menu's fold; Home also keeps up to three recently closed conversations dimmed below the open list. + +## How do I make this one chat think harder + +Press `alt+e` (`opt+e` on a Mac) or click the effort word beside the model above +the message box. It cycles `auto`, `low`, `medium`, `high`, `xhigh`, `max`, then +back to `auto`, without changing your draft. `/effort high` sets a rung directly. +This changes the current conversation's effort; it does not move other chats or +a task that has already started. + +## How do I stop a task from the chat without opening it + +With an empty message box, press `x` when exactly one stoppable task row is +visible. With more than one, press `alt+t` (`opt+t` on a Mac), select the task +in the roster, and press `x`. Neither route opens the task room. The stop card +opens on `keep going`: choose `stop it` and press Enter to confirm, or click +that answer. Escape cancels the card. A half-typed message is preserved; with +text in the box, an ordinary `x` types the letter instead. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index bb053a2dbc..5de3eba558 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -4285,3 +4285,11 @@ the earlier call and its result are carried as readable conversation text. The model can discuss that result, but it cannot make a new tool call itself. Switching back to a tool-capable model restores the original tool-call history on its request. + +## Where are the logs + +The model-call log is `~/.codeaf/logs/calls.jsonl`, under `CODEAF_PROFILE_DIR` if +you moved the profile. Run `codeaf logs` to read recent calls, `codeaf logs --follow` +to follow them, or `codeaf logs --path` to print the exact path. Each call records +its model, timing, token use and cost. Background process output is separate: open +`/processes` in the conversation and choose a process to read its log. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index b6c91fa103..9f3e746012 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -1352,3 +1352,14 @@ its final totals. With no receipt outstanding, it adds no receipt wait to the ending. Otherwise it waits at most the provider's existing receipt deadline and keeps the figures already received when a receipt cannot be settled. An incomplete receipt therefore reports the work's paid calls as well as its failure reason. + +## What background processes are still running + +Run `codeaf services` in the terminal to list long-running services started here +and not yet stopped, with each name, status, age, health check and log path. +`codeaf services stop ` stops a named service. If none are running, the +command says `nothing is being kept running.` + +For the current chat's background commands, watches or renders, use `/processes` +in that chat. Click a process to open its log. The sidebar shows only running +processes; finished logs remain available on demand without an ended count. diff --git a/internal/tui3/jobsection.go b/internal/tui3/jobsection.go index 0b4ae4610a..5be6dfcbf0 100644 --- a/internal/tui3/jobsection.go +++ b/internal/tui3/jobsection.go @@ -14,17 +14,17 @@ import ( // // IT DRAWS NOTHING ITSELF. A string a person sees is formatted from the // notice's fields at the width the column has, in jobsview.go, so a test can -// pin the four collapsed shapes with no window and no terminal. +// pin the running-work presentation with no window and no terminal. -// jobSection is this conversation's jobs as the column draws them, or nil -// when there are none — which is the emptiness law, not an empty heading. +// jobSection shows only running processes. Finished logs stay available through +// the on-demand Processes list, without a sidebar history count. func (a *app) jobSection(width, room int) []railLine { return jobSectionRows(a.jobsLive(), nil, a.jobsOpen, a.now(), width, room, a.pal) } -// jobSectionMin is how many rows the jobs section will spend before it has -// drawn a single finished job: the blank and the label, and — when it is -// open — every running job. The automations section gives this up first +// jobSectionMin reserves the blank and the label, plus every running process +// when expanded. Finished processes consume no sidebar space. The automations +// section gives this up first // ([app.marginRows]), because live work outranks the furniture around it, // which is the same trade the roster's own live head makes. func (a *app) jobSectionMin() int { @@ -38,8 +38,8 @@ func (a *app) jobSectionMin() int { } // toggleJobs opens the section or shuts it. Collapsed is the default -// ([app.jobsOpen] is false until somebody asks), and history is a count on -// the label until they do. +// ([app.jobsOpen] is false until somebody asks). Both states show live work +// only; process history is opened explicitly through /processes. func (a *app) toggleJobs() { a.jobsOpen = !a.jobsOpen // THE CURSOR NEVER STAYS ON A ROW THE FOLD TOOK AWAY, which is the From 57d33cc87f5f9a3be4a635b4615d73ae3252699f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 11:13:55 -0400 Subject: [PATCH 03/13] chat: open routed task output through its canonical task page --- internal/tui3/taskcrewdetails.go | 7 +---- internal/tui3/taskcrewdetails_test.go | 40 +++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/internal/tui3/taskcrewdetails.go b/internal/tui3/taskcrewdetails.go index 46c620e2f6..a9fce28ec5 100644 --- a/internal/tui3/taskcrewdetails.go +++ b/internal/tui3/taskcrewdetails.go @@ -76,10 +76,5 @@ func (a *app) openTaskResultOutput(entry session.TaskIndexEntry) tea.Cmd { return nil } a.closeTaskSheet() - if node.run != "" { - a.openOrchRoom(node.run, node.node) - } else { - a.openRoomFor(node.id, node.title) - } - return a.takeRoomPump() + return a.openRailRoom(node) } diff --git a/internal/tui3/taskcrewdetails_test.go b/internal/tui3/taskcrewdetails_test.go index b463ac2d50..a3462846ea 100644 --- a/internal/tui3/taskcrewdetails_test.go +++ b/internal/tui3/taskcrewdetails_test.go @@ -107,7 +107,8 @@ func TestActionableCrewStopStaysVisibleUntilResolved(t *testing.T) { func TestCompletedCrewTaskOpensResultThroughTasksAndKeepsFullOutputDoor(t *testing.T) { a, _, entry := retryFixture(t) a.height = 40 - a.agent = &selectedCrewAgent{fakeAgent: &fakeAgent{}} + _, rooms, _ := roomApp(t) + a.agent = &selectedCrewRoomAgent{roomFake: rooms} a.tasks[7].crew = &crewroute.Decision{EstUSD: .52, Crew: []crewroute.Pick{{Seat: crewroute.Worker, Model: "worker-model"}}} a.tasks[7].produced = "Actual selected task deliverable" a.tasks[7].cost = .31 @@ -148,10 +149,43 @@ func TestCompletedCrewTaskOpensResultThroughTasksAndKeepsFullOutputDoor(t *testi if outputRow < 0 { t.Fatal("full output action has no mouse target") } - _, rooms, _ := roomApp(t) - a.agent = rooms a.taskCardPress(4, outputRow) if !a.roomOpen() || a.room.id != 7 { t.Fatal("full output action failed to open selected transcript") } } + +// The same agent exposes both result actions throughout layout and the click. +type selectedCrewRoomAgent struct{ *roomFake } + +func (f *selectedCrewRoomAgent) RedoStronger(_ context.Context, _ uint64) (uint64, string, error) { + return 9, "stronger", nil +} + +func TestTaskResultOutputUsesTheRunsCanonicalPlanPage(t *testing.T) { + a, fake := railTaskPageApp(t, true) + a.width, a.height = 120, 40 + a.tasks[2].state = session.TaskDone + a.tasks[2].planTask = "2" + entry := session.TaskIndexEntry{ID: "2", Title: a.tasks[2].title, Status: string(session.TaskDone), SessionID: a.taskSheetSelfID()} + a.taskSheet.detail, a.taskSheet.detailOn = entry, true + rows, hits, _, _ := a.taskCardFrame(120, 40) + at := -1 + for y, hit := range hits { + if hit == taskCardHitOutput { + at = y + break + } + } + if at < 0 { + t.Fatalf("result lacks full output target: %v", rows) + } + cmd := a.taskCardPress(4, at) + drive(t, a, runCmd(cmd)...) + if fake.pages.Load() != 1 || a.roomPlan() == nil || a.roomPlan().id != "2" { + t.Fatalf("full output bypassed canonical run page: reads=%d room=%+v", fake.pages.Load(), a.roomPlan()) + } + if !strings.Contains(planRoomText(t, a), "replace the parser") { + t.Fatal("selected run output disappeared") + } +} From dfba5d6adc834d00d7dc098feb9afd64fded68d9 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 11:14:28 -0400 Subject: [PATCH 04/13] session: preserve human quotations of completion payloads --- internal/session/agent.go | 1 + internal/session/completion_presentation_test.go | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/session/agent.go b/internal/session/agent.go index 6601095d36..895e5803d9 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -2661,6 +2661,7 @@ func (a *Agent) recordUserLocked(user userMessage) { mark = &messagePresentation{Audience: "human"} } else { mark.Audience = "human" + mark.RoutineCompletion = false } a.presentation.remember(user.message, mark) } diff --git a/internal/session/completion_presentation_test.go b/internal/session/completion_presentation_test.go index b070ac2754..5ec993d4b7 100644 --- a/internal/session/completion_presentation_test.go +++ b/internal/session/completion_presentation_test.go @@ -18,7 +18,9 @@ func TestCompletionPresentationSurvivesCompactionAndReopenWithoutChangingContext agent.mu.Lock() agent.recordUserLocked(routine) // Equal words typed by the person are a separate occurrence, not a receipt. - agent.recordUserLocked(userText(text)) + quoted := userText(text) + quoted.message = agent.messages[len(agent.messages)-1] + agent.recordUserLocked(quoted) alert := wakeNote("Your requested watch matched: release is ready") alert.authored = true agent.recordUserLocked(alert) From b123646a21902cb5d211f83b5107b145c7c7c570 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 11:17:03 -0400 Subject: [PATCH 05/13] test: document only active sidebar group headings --- internal/tui3/tallyrows_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/tui3/tallyrows_test.go b/internal/tui3/tallyrows_test.go index d674aabb66..2bb41bd3e9 100644 --- a/internal/tui3/tallyrows_test.go +++ b/internal/tui3/tallyrows_test.go @@ -255,15 +255,14 @@ func TestTheManualQuotesBothTasksOpeningHeadingsExactly(t *testing.T) { } // THE GROUP HEADINGS THE MANUAL QUOTES ARE BUILT FROM THE ROSTER'S OWN WORD -// TABLE. The column's footer of counts is gone and its headings carry the -// counts now (`Running 4`, `Done 6 ▸`); the words moved once before and four +// TABLE. Only active groups remain in the column; completed results live in +// Tasks rather than a Done heading. The words moved once before and four // passages went on teaching the words it had dropped, so the next move of the // table fails here rather than in front of somebody reading the page. func TestTheManualQuotesTheRosterGroupsOwnWords(t *testing.T) { wants := []string{ railHeadWords[railRunning] + " " + itoa(4), railHeadWords[railIdle] + " " + itoa(3) + " " + glyphShut, - railHeadWords[railDone] + " " + itoa(6) + " " + glyphShut, } for _, want := range wants { if !manual.Chat().Mentions(want) { From 0ac7b50fd4303c4b3dde25bbcb4c2c268b85d83c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 11:19:55 -0400 Subject: [PATCH 06/13] chat: show identical task result and outcome prose once --- internal/tui3/taskcrewdetails_test.go | 24 ++++++++++++++++++++++++ internal/tui3/taskrecord.go | 5 ++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/internal/tui3/taskcrewdetails_test.go b/internal/tui3/taskcrewdetails_test.go index a3462846ea..e6f26ce75a 100644 --- a/internal/tui3/taskcrewdetails_test.go +++ b/internal/tui3/taskcrewdetails_test.go @@ -189,3 +189,27 @@ func TestTaskResultOutputUsesTheRunsCanonicalPlanPage(t *testing.T) { t.Fatal("selected run output disappeared") } } + +func TestSelectedTaskResultSaysIdenticalOutcomeOnceAndKeepsDifferentAccount(t *testing.T) { + const result = "The parser now handles an empty map. The regression test passes." + for _, outcome := range []string{result, "The changes remain on the task branch for your review."} { + t.Run(outcome, func(t *testing.T) { + a, _, entry := retryFixture(t) + node := a.tasks[7] + node.crew = &crewroute.Decision{} + node.produced, node.report = result, outcome + entry.Outcome = outcome + a.taskSheet.detail = entry + text := ansi.Strip(strings.Join(a.taskCardBody(a.currentTaskEntry(entry), 120), "\n")) + if count := strings.Count(text, result); count != 1 { + t.Fatalf("selected result repeated %d times: %s", count, text) + } + if !strings.Contains(text, outcome) { + t.Fatalf("distinct landing account lost: %s", text) + } + if node.produced != result || node.report != outcome || entry.Outcome != outcome { + t.Fatal("presentation changed original result fields") + } + }) + } +} diff --git a/internal/tui3/taskrecord.go b/internal/tui3/taskrecord.go index 921027a727..179cbc0338 100644 --- a/internal/tui3/taskrecord.go +++ b/internal/tui3/taskrecord.go @@ -1007,14 +1007,17 @@ func (a *app) taskCardBody(entry session.TaskIndexEntry, width int) []string { // THE OUTCOME IS THE SENTENCE THE PERSON CAME FOR, so it is the first thing // under the state and it is drawn in ink rather than in the dim every other // fact here wears. + displayedResult := "" if node := a.taskSheetNodeFor(&entry); node != nil && node.crew != nil && strings.TrimSpace(node.produced) != "" { + displayedResult = strings.TrimSpace(node.produced) var result []string for _, line := range wrap(node.produced, width) { result = append(result, pal.ink(line)) } bands = append(bands, result) } - if outcome := strings.TrimSpace(entry.Outcome); outcome != "" { + // Identical result and landing prose needs one paragraph; both original fields remain intact. + if outcome := strings.TrimSpace(entry.Outcome); outcome != "" && outcome != displayedResult { var said []string for _, wrapped := range wrap(outcome, width) { said = append(said, pal.ink(wrapped)) From 0ec78fda726194c4e04133cdd572e11a3487d248 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 11:23:08 -0400 Subject: [PATCH 07/13] test: verify active roster and on-demand process history --- internal/tui3/bundle_test.go | 50 ++++++++++----------- internal/tui3/depthfade_test.go | 4 +- internal/tui3/homefate_test.go | 9 ++++ internal/tui3/homeslash.go | 2 +- internal/tui3/jobsreopen_test.go | 29 ++++++++---- internal/tui3/railgroups_test.go | 76 ++++++++++++-------------------- 6 files changed, 85 insertions(+), 85 deletions(-) diff --git a/internal/tui3/bundle_test.go b/internal/tui3/bundle_test.go index 61c731faca..0c22e9c784 100644 --- a/internal/tui3/bundle_test.go +++ b/internal/tui3/bundle_test.go @@ -3684,15 +3684,13 @@ func TestTheRosterGroupsItsWorkByStateOneLineEach(t *testing.T) { // everything else one folded row. What needs the person is the band's, above // them, and is not under any of them. want := []string{ - sideTasksWord + " 6", + sideTasksWord, a.taskStateMark(a.tasks[4]) + " Render titles", - a.taskStateMark(a.tasks[6]) + " Trim silence", strings.Repeat(a.linearMark("─", "-"), 8), railHeadWords[railRunning] + " 1", "Fix the nil-map crash", railHeadWords[railIdle] + " 1 " + glyphShut, railHeadWords[railParked] + " 1 " + glyphShut, - railHeadWords[railDone] + " 1 " + glyphShut, } for i, w := range want { if i >= len(rows) || !strings.Contains(rows[i], w) { @@ -3702,11 +3700,11 @@ func TestTheRosterGroupsItsWorkByStateOneLineEach(t *testing.T) { rail := strings.Join(rows, "\n") // THE RUNNING WORK'S HEADING WEARS NO MARK: it does not fold, and a mark is // a promise that a press does something. - if strings.Contains(rows[4], glyphShut) || strings.Contains(rows[4], glyphOpen) { + if strings.Contains(rows[3], glyphShut) || strings.Contains(rows[3], glyphOpen) { t.Fatalf("the running heading wears a fold mark: %q", rows[4]) } // ONE GLYPH OPENS A TASK ROW, and it is the state; no identity ◆, no id. - if lead := railSeam + " " + plain(a.railTreeGlyph(a.tasks[2])) + " Fix the nil-map"; !strings.HasPrefix(rows[5], lead) { + if lead := railSeam + " " + plain(a.railTreeGlyph(a.tasks[2])) + " Fix the nil-map"; !strings.HasPrefix(rows[4], lead) { t.Fatalf("the task row does not lead with its state: %q, want %q", rows[5], lead) } if strings.Contains(rail, "#2") || strings.Contains(rail, plain(a.taskMark(identFor(2)))+" Fix") { @@ -3810,22 +3808,21 @@ func TestTheRostersCursorFollowsANodeThatChangesUrgency(t *testing.T) { if a.railWhere.id != 1 { t.Fatalf("the cursor is on %+v, want the second running node", a.railWhere) } - // It lands in the done group, which is folded: the cursor follows it to the - // heading standing for it rather than jumping to the top of the column. - drive(t, a, streamEventMsg{gen: a.gen, ev: update(1, "Collect sources", session.TaskDone, - session.TaskNotice{Merge: mergeWordMerged})}) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(1, "Collect sources", session.TaskDone, session.TaskNotice{Merge: mergeWordMerged})}) entries := a.railEntries() at := a.railFocusIndex(entries) - if at < 0 || !entries[at].head || entries[at].group != railDone { - t.Fatalf("the cursor did not follow the node to its group: %d in %+v", at, entries) + if at < 0 || entries[at].group != railRunning { + t.Fatalf("focus did not remain with active work: %d %+v", at, entries) } - // And opened, the cursor is on the node itself. - a.sideToggleGroup(railDone) - entries = a.railEntries() - at = a.railFocusIndex(entries) - if at < 0 || entries[at].node == nil || entries[at].node.id != 1 { - t.Fatalf("the cursor is not on the landed node: %d in %+v", at, entries) + for _, entry := range entries { + if entry.node != nil && entry.node.id == 1 { + t.Fatal("finished row retained sidebar focus") + } + } + if a.doneCardFor(1) == nil { + t.Fatal("finished task result lost") } + } func TestTheRosterWindowsHundredsOfNodesAroundItsFocus(t *testing.T) { @@ -3862,7 +3859,7 @@ func TestTheRosterWindowsHundredsOfNodesAroundItsFocus(t *testing.T) { t.Fatalf("the first task did not scroll with the list:\n%s", rail) } // And the header still counts the whole roster rather than the window. - if rows := railText(a, 12); !strings.Contains(rows[0], sideTasksWord+" 300") || !strings.Contains(rows[0], sideHideKey) { + if rows := railText(a, 12); !strings.Contains(rows[0], sideTasksWord) || !strings.Contains(rows[0], sideHideKey) { t.Fatalf("the header scrolled away or counts the window: %v", rows) } } @@ -3943,20 +3940,21 @@ func TestAPlainFailureIsFiledAsNewsAndNotAsADemand(t *testing.T) { fails = append(fails, item.key) } } - if strings.Join(asks, " ") != "task/5 task/4" || strings.Join(fails, " ") != "fail/6 fail/3 fail/2" { + if strings.Join(asks, " ") != "task/5 task/4" || len(fails) != 0 { t.Fatalf("the band holds asks %v and failures %v", asks, fails) } rail := rosterText(a, 24) - for _, want := range []string{"+2 more", railHeadWords[railDone] + " 1"} { - if !strings.Contains(rail, want) { - t.Fatalf("the roster is missing %q:\n%s", want, rail) + for _, gone := range []string{"Collect sources", "Trim silence", "Mix audio", "Write the auth", "Done", "+2 more"} { + if strings.Contains(rail, gone) { + t.Fatalf("routine history leaked into active sidebar: %q\n%s", gone, rail) } } - a.sideToggleGroup(railDone) - rail = rosterText(a, 24) - if !strings.Contains(rail, "Collect sources") || strings.Count(rail, "Render titles")+strings.Count(rail, "Port the parser") != 1 { - t.Fatalf("an item of the band is drawn twice, or the clean merge is missing:\n%s", rail) + for _, want := range []string{"Cut the trailer", "Port the parser"} { + if strings.Count(rail, want) != 1 { + t.Fatalf("decision missing or duplicated: %s", rail) + } } + } // ── THE ROOM: A TASK IS A PLACE ───────────────────────────────────────────── diff --git a/internal/tui3/depthfade_test.go b/internal/tui3/depthfade_test.go index 40fda2d7f9..04710c799a 100644 --- a/internal/tui3/depthfade_test.go +++ b/internal/tui3/depthfade_test.go @@ -266,9 +266,9 @@ func fadeColumnApp(t *testing.T, count int) *app { a, _, _ := taskApp(t) a.width, a.height = 100, 24 for i := 0; i < count; i++ { - a.taskUpdate(update(uint64(i+1), "The "+fadeWord(i)+" errand", session.TaskDone, session.TaskNotice{})) + a.taskUpdate(update(uint64(i+1), "The "+fadeWord(i)+" errand", session.TaskRunning, session.TaskNotice{})) } - // The finished work opened, so it is longer than the frame. + // Active work exceeds the frame; completed history is absent from this sidebar. railOpenAll(a) a.paints = 0 return a diff --git a/internal/tui3/homefate_test.go b/internal/tui3/homefate_test.go index 94b5259556..5c3d6d00ae 100644 --- a/internal/tui3/homefate_test.go +++ b/internal/tui3/homefate_test.go @@ -422,3 +422,12 @@ func TestNewAtHomeSaysItWasTheConversationBehindHome(t *testing.T) { t.Fatalf("/new said %q, want %q", a.home.msg, homeFreshBehindWord) } } + +// Process logs are conversation-owned, so home must not inspect a hidden chat. +func TestProcessesAtHomeNeedsTheTargetConversation(t *testing.T) { + for _, arg := range []string{"", "7"} { + if got := homeFate("processes", arg); got != fateNeedsChat { + t.Fatalf("/processes %s targets hidden conversation: %q", arg, got) + } + } +} diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index ff397df8e2..b35c640e3c 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -209,7 +209,7 @@ func homeFate(word, rest string) string { case "land", "workspace", "dismiss", "delete": return fateBehind case "files", "permissions", "connect", "harness", "subharness", "skill", - "autonomy", "copy", "select", "rewind", "compact", "export", "drafts", "manual", "folder": + "autonomy", "copy", "select", "rewind", "compact", "export", "drafts", "manual", "folder", "processes": // /manual IS HERE SINCE 2026-09-22 and not among the answers: it is a // turn of a conversation now (manualcmd.go), and a turn needs one. As // an answer it printed the pages into the conversation BEHIND home, diff --git a/internal/tui3/jobsreopen_test.go b/internal/tui3/jobsreopen_test.go index 85be4d3ada..7db2b228f3 100644 --- a/internal/tui3/jobsreopen_test.go +++ b/internal/tui3/jobsreopen_test.go @@ -59,20 +59,31 @@ func TestAReopenedConversationStillShowsItsJobs(t *testing.T) { len(jobs), len(a.jobs)) } - // AND THE SECTION IS THE DOOR. Kept jobs that no surface draws would be the - // same defect one layer down, so the pin goes all the way to the lines the - // column paints. - a.jobsOpen = true - var drawn []string - for _, line := range a.jobSection(48, 20) { - drawn = append(drawn, plain(line.text)) + if rows := a.jobSection(48, 20); len(rows) != 0 { + t.Fatal("restored completed jobs appeared in sidebar") } - text := strings.Join(drawn, "\n") + a.showProcesses("") + rows, hits, _, _ := a.processListFrame(100, 24) + text := strings.Join(rows, "\n") for _, job := range jobs { if !strings.Contains(text, job.Name) { - t.Fatalf("the jobs section drew:\n%s\nand it should name the restored job %q", text, job.Name) + t.Fatalf("on-demand process history lost %q: %s", job.Name, text) } } + at := -1 + for i, hit := range hits { + if hit == 1000 { + at = i + } + } + if at < 0 { + t.Fatal("restored process has no click target") + } + a.width, a.height = 100, 24 + if cmd := a.jobPagePress(8, at); cmd == nil || a.jobPage != 3 { + t.Fatal("restored process log door did not open selected process") + } + } // TestAJobReplayedOnTheStandingLaneOpensItsPage closes the sentence row 1 of the diff --git a/internal/tui3/railgroups_test.go b/internal/tui3/railgroups_test.go index 9323a02513..60cabcc9d6 100644 --- a/internal/tui3/railgroups_test.go +++ b/internal/tui3/railgroups_test.go @@ -130,12 +130,11 @@ func TestTheRosterIsOneLinePerTaskUnderItsGroups(t *testing.T) { "│ " + plain(spin) + " Write the tree", "│ " + plain(spin) + " Ship the port", "│ " + railHeadWords[idle] + " 2 " + glyphShut, - "│ Done 1 " + glyphShut, } if len(rows) < len(want)+1 { t.Fatalf("the roster drew %d rows:\n%s", len(rows), strings.Join(rows, "\n")) } - if !strings.HasPrefix(rows[0], "│ "+sideTasksWord+" 5") || !strings.HasSuffix(strings.TrimRight(rows[0], " "), sideHideKey) { + if !strings.HasPrefix(rows[0], "│ "+sideTasksWord) || !strings.HasSuffix(strings.TrimRight(rows[0], " "), sideHideKey) { t.Fatalf("the header is %q", rows[0]) } for i, w := range want { @@ -166,65 +165,51 @@ func TestAFoldIsRememberedForTheSession(t *testing.T) { a, _, _ := roomApp(t) railRun(a) a.height = 30 - if _, ok := railRowFor(a, a.viewHeight(), "Read the law"); ok { - t.Fatalf("the finished work came up open:\n%s", strings.Join(railText(a, a.viewHeight()), "\n")) - } x := a.bodyWidth() + ansi.StringWidth(railSeam) + 2 - railClick(t, a, x, railHeadY(t, a, railDone)) - if a.roomOpen() { - t.Fatalf("a press on a heading opened a room: id=%d", roomID(a)) - } - if _, ok := railRowFor(a, a.viewHeight(), "Read the law"); !ok { - t.Fatalf("a press on the heading did not open the group:\n%s", strings.Join(railText(a, a.viewHeight()), "\n")) + if _, ok := railRowFor(a, a.viewHeight(), "Cut the goldens"); ok { + t.Fatal("queued group opened itself") } - if !strings.Contains(mustRailRow(t, a, "Done 1"), glyphOpen) { - t.Fatalf("the open group does not wear ▾: %q", mustRailRow(t, a, "Done 1")) + railClick(t, a, x, railHeadY(t, a, railIdle)) + if a.roomOpen() { + t.Fatal("group heading opened room") } - a.taskUpdate(update(2, "Read the law", session.TaskDone, session.TaskNotice{Merge: mergeWordMerged})) - a.taskUpdate(update(6, "Port the loader", session.TaskDone, session.TaskNotice{Merge: mergeWordMerged})) - for _, title := range []string{"Read the law", "Port the loader"} { + for _, title := range []string{"Cut the goldens", "Wire the seam"} { if _, ok := railRowFor(a, a.viewHeight(), title); !ok { - t.Fatalf("the group the person opened closed itself on an update:\n%s", strings.Join(railText(a, a.viewHeight()), "\n")) + t.Fatal("opened queued group lost row") } } - railClick(t, a, x, railHeadY(t, a, railDone)) - if _, ok := railRowFor(a, a.viewHeight(), "Read the law"); ok { - t.Fatalf("a second press did not fold the group:\n%s", strings.Join(railText(a, a.viewHeight()), "\n")) - } - // THE RUNNING WORK DOES NOT FOLD, and its heading says so by wearing no - // mark and taking no press. - run := mustRailRow(t, a, "Running ") - if strings.Contains(run, glyphOpen) || strings.Contains(run, glyphShut) { - t.Fatalf("the running heading wears a fold it does not have: %q", run) + a.taskUpdate(update(6, "Port the loader", session.TaskQueued, session.TaskNotice{})) + if _, ok := railRowFor(a, a.viewHeight(), "Port the loader"); !ok { + t.Fatal("group closed after update") } - railClick(t, a, x, railHeadY(t, a, railRunning)) - if _, ok := railRowFor(a, a.viewHeight(), "Ship the port"); !ok { - t.Fatalf("a press folded the running work:\n%s", strings.Join(railText(a, a.viewHeight()), "\n")) + railClick(t, a, x, railHeadY(t, a, railIdle)) + if _, ok := railRowFor(a, a.viewHeight(), "Cut the goldens"); ok { + t.Fatal("second click did not fold group") } - // AND THE KEYBOARD REACHES THE SAME FOLD: enter on a heading. drive(t, a, altT()) - a.railWhere = railSpot{group: int(railDone) + 1} + a.railWhere = railSpot{group: int(railIdle) + 1} drive(t, a, key("enter")) - if _, ok := railRowFor(a, a.viewHeight(), "Read the law"); !ok { - t.Fatalf("enter on the heading did not open the group:\n%s", strings.Join(railText(a, a.viewHeight()), "\n")) + if _, ok := railRowFor(a, a.viewHeight(), "Cut the goldens"); !ok { + t.Fatal("keyboard did not open queued group") } - if a.roomOpen() { - t.Fatal("enter on a heading opened a room") + if _, ok := railRowFor(a, a.viewHeight(), "Read the law"); ok { + t.Fatal("completion appeared in active roster") + } + run := mustRailRow(t, a, "Running ") + if strings.Contains(run, glyphOpen) || strings.Contains(run, glyphShut) { + t.Fatal("running heading folds") } } -// THE HEADING'S HOVER GROUND IS ITS PRESS. The pointer on a folded heading -// lights it and names what a press does; the pointer on the running heading -// lights nothing, because a press there does nothing. func TestAHeadingLightsOnlyWhereItFolds(t *testing.T) { a, _, _ := taskApp(t) railRun(a) x := a.bodyWidth() + ansi.StringWidth(railSeam) + 2 - a.setHover(x, railHeadY(t, a, railDone)) - if a.hot.kind != hoverRailGroup || railGroup(a.hot.index) != railDone { + a.setHover(x, railHeadY(t, a, railIdle)) + if a.hot.kind != hoverRailGroup || railGroup(a.hot.index) != railIdle { t.Fatalf("the pointer on the Done heading resolved to %+v", a.hot) } - if got := a.sideHoverWords(); !strings.Contains(got, "Show what is done") { + if got := a.sideHoverWords(); !strings.Contains(got, "Show what is queued") { t.Fatalf("the hint over the folded heading reads %q", got) } a.setHover(x, railHeadY(t, a, railRunning)) @@ -303,7 +288,7 @@ func TestTheFullscreenRosterDrawsTheSameGroups(t *testing.T) { } rows := railText(a, a.viewHeight()) body := strings.Join(rows, "\n") - for _, want := range []string{sideTasksWord + " 5", "Running 2", "Ship the port", "Done 1"} { + for _, want := range []string{sideTasksWord, "Running 2", "Ship the port"} { if !strings.Contains(body, want) { t.Fatalf("the fullscreen roster is missing %q:\n%s", want, body) } @@ -316,10 +301,7 @@ func TestTheFullscreenRosterDrawsTheSameGroups(t *testing.T) { t.Fatalf("row %d is %d cells wide on an %d-column frame:\n%q", i, w, a.width, row) } } - a.railWhere = railSpot{group: int(railDone) + 1} - drive(t, a, key("enter")) - if !strings.Contains(strings.Join(railText(a, a.viewHeight()), "\n"), "Read the law") { - t.Fatalf("enter on the heading did not open the group over the body:\n%s", - strings.Join(railText(a, a.viewHeight()), "\n")) + if strings.Contains(body, "Done") || strings.Contains(body, "Read the law") { + t.Fatalf("history appeared in active roster: %s", body) } } From f275467f36387bb7d6ecef1b84ce903b94be756f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 11:23:19 -0400 Subject: [PATCH 08/13] chat: verify active task navigation and contextual sidebar actions --- internal/tui3/c263_rail_plan_test.go | 29 +++++++++++++++++++++++++--- internal/tui3/managercolumn_test.go | 20 +++++++++++-------- internal/tui3/sidecol.go | 4 +++- internal/tui3/taskview_test.go | 9 ++++++--- internal/tui3/teamtraffic_test.go | 6 +++--- 5 files changed, 50 insertions(+), 18 deletions(-) diff --git a/internal/tui3/c263_rail_plan_test.go b/internal/tui3/c263_rail_plan_test.go index 14a0398a46..21c8ca3e3e 100644 --- a/internal/tui3/c263_rail_plan_test.go +++ b/internal/tui3/c263_rail_plan_test.go @@ -37,16 +37,27 @@ func TestRailPlanDrawsEveryPartAsATaskRow(t *testing.T) { } got := plain(strings.Join(a.railRows(a.viewHeight()), "\n")) for _, word := range []string{"rewrite the auth", "implement handler", "schema migration", - "integration tests", "old fixture", "old helper"} { + "integration tests"} { if !strings.Contains(got, word) { t.Fatalf("the rail lacks %q:\n%s", word, got) } } - for _, gone := range []string{"2 done", "2/4", "$ git grep"} { + for _, gone := range []string{"2 done", "2/4", "$ git grep", "old fixture", "old helper"} { if strings.Contains(got, gone) { t.Fatalf("the rail still draws the run renderer's %q:\n%s", gone, got) } } + for _, id := range []string{"done-a", "done-b"} { + retained := false + for _, row := range a.taskSheet.mine.plan { + if row.ID == id && row.Status == "done" { + retained = true + } + } + if !retained { + t.Fatalf("completed plan history %s was discarded", id) + } + } for _, row := range a.railRows(a.viewHeight()) { if ansi.StringWidth(row) > a.railWidth() { t.Fatalf("rail row is %d cells in a %d-cell rail: %q", ansi.StringWidth(row), a.railWidth(), row) @@ -124,11 +135,23 @@ func TestARunAndANodeFamilyDrawTheSameShapeOnTheRail(t *testing.T) { if got, want := railShape(lines[run]), railShape(lines[node]); got != want { t.Fatalf("the run's row is shaped\n%q\nand the node's row\n%q\n\n%s", got, want, strings.Join(lines, "\n")) } + if strings.Contains(strings.Join(lines, "\n"), "Kid one B") { + t.Fatal("completed part occupies sidebar") + } + retained := false + for _, row := range a.taskSheet.mine.plan { + if row.ID == "t-a" && row.Status == "done" { + retained = true + } + } + if !retained { + t.Fatal("completed part removed from task history") + } spinner := tokens.Spinner(0) if !strings.Contains(lines[run], spinner+" Bravo work") { t.Fatalf("the run's row does not wear the working spinner %q:\n%s", spinner, lines[run]) } - for _, part := range []string{"Kid one B", "Kid two B", "Kid six B"} { + for _, part := range []string{"Kid two B", "Kid six B"} { if at(part) <= run { t.Fatalf("the part %q is not its own row under the run:\n%s", part, strings.Join(lines, "\n")) } diff --git a/internal/tui3/managercolumn_test.go b/internal/tui3/managercolumn_test.go index 949a531fcc..8e5613f3fb 100644 --- a/internal/tui3/managercolumn_test.go +++ b/internal/tui3/managercolumn_test.go @@ -53,21 +53,20 @@ func sideWordAt(t *testing.T, a *app, view int) (int, int) { } // WITH THE MANAGER IN FRONT THE ONE COLUMN OPENS ON THE TRAFFIC, and its -// header offers the manager's own tasks as the other word, with its count, -// even at zero. A press on the word lays the tasks in the same column at the +// header offers the manager's own tasks as the other word without a count. A press on the word lays the tasks in the same column at the // same width with the header where it was, and a press on the Traffic word // takes it back. There is no second column and no special case. func TestManagerColumnOpensOnTheTrafficAndOffersItsTasks(t *testing.T) { a := managerColumnApp(t) rows := railLines(t, a) - head := railRowOf(rows, sideTasksWord+" 0"+sideWordSep+sideTrafficWord) + head := railRowOf(rows, sideTasksWord+sideWordSep+sideTrafficWord) if head < 0 || a.sideView() != sideTraffic { t.Fatalf("the manager's column does not open on the Traffic with both words:\n%s", strings.Join(rows, "\n")) } managerTasks(a, 2) rows = railLines(t, a) - if railRowOf(rows, sideTasksWord+" 2"+sideWordSep) != head || railRowOf(rows, "Task 1") >= 0 { - t.Fatalf("the Tasks word does not count the work behind it:\n%s", strings.Join(rows, "\n")) + if railRowOf(rows, sideTasksWord+sideWordSep) != head || railRowOf(rows, "Task 1") >= 0 { + t.Fatalf("the Tasks heading moved or exposed inactive work:\n%s", strings.Join(rows, "\n")) } body, cols := a.bodyWidth(), a.railWidth() @@ -76,7 +75,7 @@ func TestManagerColumnOpensOnTheTrafficAndOffersItsTasks(t *testing.T) { if a.hot.kind != hoverSide || a.hot.key != sideHeadKey || a.hot.index < 0 { t.Fatalf("the Tasks word does not answer the pointer: %+v", a.hot) } - if words := a.dockHoverWords(); !strings.Contains(words, "tasks") || !strings.Contains(words, "click") { + if words := a.dockHoverWords(); words != "Show active tasks"+hintSegment+"click" { t.Fatalf("the Tasks word's hint says %q", words) } a.dropHover() @@ -85,9 +84,14 @@ func TestManagerColumnOpensOnTheTrafficAndOffersItsTasks(t *testing.T) { t.Fatal("a press on Tasks did not bring the tasks to the front") } rows = railLines(t, a) - if a.bodyWidth() != body || a.railWidth() != cols || railRowOf(rows, sideTasksWord+" 2") != head || railRowOf(rows, "Task 1") <= head { + if a.bodyWidth() != body || a.railWidth() != cols || railRowOf(rows, sideTasksWord) != head || railRowOf(rows, "Task 1") <= head { t.Fatalf("the tasks are not in the same column under the same header (body %d->%d, cols %d->%d):\n%s", body, a.bodyWidth(), cols, a.railWidth(), strings.Join(rows, "\n")) } + a.setHover(x, y) + if words := a.dockHoverWords(); words != "Open task results and history"+hintSegment+"click" { + t.Fatalf("active Tasks heading names the wrong action: %q", words) + } + a.dropHover() x, y = sideWordAt(t, a, sideTraffic) sideClick(t, a, x, y) if a.sideView() != sideTraffic || a.bodyWidth() != body { @@ -135,7 +139,7 @@ func TestTheColumnsWordIsRememberedPerKindOfChat(t *testing.T) { } plainChat.sideSetView(sideTraffic) rows := railLines(t, plainChat) - if plainChat.sideView() != sideTasks || railRowOf(rows, sideTrafficWord) >= 0 || railRowOf(rows, sideTasksWord+" 1") < 0 { + if plainChat.sideView() != sideTasks || railRowOf(rows, sideTrafficWord) >= 0 || railRowOf(rows, sideTasksWord) < 0 { t.Fatalf("a chat in no team offers a Traffic:\n%s", strings.Join(rows, "\n")) } } diff --git a/internal/tui3/sidecol.go b/internal/tui3/sidecol.go index 5c00cc7f01..29b7614103 100644 --- a/internal/tui3/sidecol.go +++ b/internal/tui3/sidecol.go @@ -374,13 +374,15 @@ func (a *app) sideHeadRow(width int) (string, *sideRow) { } team := kind != sideKindPlain tasksAct, trafficAct := sideAct{kind: sideActTasks}, sideAct{} + tasksHint := "Open task results and history" if team { if view != sideTasks { tasksAct = sideAct{kind: sideActView, view: sideTasks} + tasksHint = "Show active tasks" } trafficAct = sideAct{kind: sideActView, view: sideTraffic} } - word(sideTasksWord, "", false, view == sideTasks, tasksAct, "Open task results and history"+hintSegment+"click") + word(sideTasksWord, "", false, view == sideTasks, tasksAct, tasksHint+hintSegment+"click") if team { n, fresh := a.sideTrafficCount(t, kind, handle) // WITH THE TRAFFIC IN FRONT NOTHING IN IT IS NEW TO THE WORD: the `new` diff --git a/internal/tui3/taskview_test.go b/internal/tui3/taskview_test.go index b985a510e1..dc51bc4427 100644 --- a/internal/tui3/taskview_test.go +++ b/internal/tui3/taskview_test.go @@ -1071,8 +1071,11 @@ func TestTheRostersCursorStopsAtTheLastTaskOfThisConversation(t *testing.T) { for i := 0; i < 20; i++ { drive(t, a, key("down")) } - if a.railWhere.id != 2 { - t.Fatalf("the walk did not clamp on this conversation's last task: %+v", a.railWhere) + if a.railWhere.id != 1 { + t.Fatalf("the walk did not clamp on this conversation's last active task: %+v", a.railWhere) + } + if card := a.doneCardAt(a.doneEntryFor(2)); card == nil || card.status.State != session.TaskDone { + t.Fatal("completed result lost while removed from sidebar") } // AND enter DOWN THERE IS STILL A ROOM'S DOOR AND NEVER A RECORD CARD'S. Every // row of this column is a node this conversation holds, so there is no row for @@ -1082,7 +1085,7 @@ func TestTheRostersCursorStopsAtTheLastTaskOfThisConversation(t *testing.T) { if a.taskSheet.detailOn { t.Fatal("a row of this conversation's work opened a record card") } - if a.railWhere.id != 2 { + if a.railWhere.id != 1 { t.Fatalf("enter moved the cursor off this conversation's work: %+v", a.railWhere) } } diff --git a/internal/tui3/teamtraffic_test.go b/internal/tui3/teamtraffic_test.go index b3a47865c8..01eada5582 100644 --- a/internal/tui3/teamtraffic_test.go +++ b/internal/tui3/teamtraffic_test.go @@ -77,7 +77,7 @@ func trafficReadNow(t *testing.T, a *app) { // THE COLUMN IS THE CACHE, BESIDE THE MANAGER, AND IT OPENS ON THE TRAFFIC. // With the manager in front on a wide frame the right of the body is the side -// column, its header `Tasks 0 · Traffic N` with the Traffic in front; the +// column, its header `Tasks · Traffic N` with the Traffic in front; the // directive is a row of work, the note that answers nothing is General's; the // person's own lines are not drawn; the conversation is narrowed by exactly // the column; the composer says the words go to the manager; and a handle @@ -107,7 +107,7 @@ func TestTrafficColumnBesideTheManager(t *testing.T) { if cols != sideColsFor(a.width) || a.bodyWidth() != a.width-cols { t.Fatalf("the column takes %d columns and leaves the body %d of %d", cols, a.bodyWidth(), a.width) } - head := railRowOf(rows, sideTasksWord+" 0"+sideWordSep+sideTrafficWord) + head := railRowOf(rows, sideTasksWord+sideWordSep+sideTrafficWord) general := railRowOf(rows, "General") work := railRowOf(rows, "take the scope") note := railRowOf(rows, "@"+price+" → ") @@ -196,7 +196,7 @@ func TestTrafficColumnStandsAt110(t *testing.T) { t.Fatal("the column does not stand at 110") } rows := railLines(t, a) - if railRowOf(rows, sideTasksWord+" 0"+sideWordSep+sideTrafficWord) < 0 { + if railRowOf(rows, sideTasksWord+sideWordSep+sideTrafficWord) < 0 { t.Fatalf("the header does not carry both words:\n%s", strings.Join(rows, "\n")) } } From 14cf74da8b1a48098674f5a51b18a10b7d1787ec Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 11:23:33 -0400 Subject: [PATCH 09/13] test: inspect retained task results without completed sidebar receipts --- internal/tui3/roomrecord_test.go | 4 ++-- internal/tui3/taskending_test.go | 10 +++++----- internal/tui3/taskresult_test.go | 4 +++- internal/tui3/taskstamp_test.go | 2 +- internal/tui3/taskstrip_test.go | 2 +- internal/tui3/unverified_test.go | 6 +++--- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/internal/tui3/roomrecord_test.go b/internal/tui3/roomrecord_test.go index 7840369910..154eadaacc 100644 --- a/internal/tui3/roomrecord_test.go +++ b/internal/tui3/roomrecord_test.go @@ -29,7 +29,7 @@ func TestALandedRoomFallsBackToTheReportOnTheRecord(t *testing.T) { Elapsed: 2 * time.Minute, })}) close(agent.lane(7)) - clickRailNode(t, a, 7) + drive(t, a, runCmd(a.openRailRoom(a.tasks[7]))...) text := roomText(a) if !strings.Contains(text, "Added the guard in parseRow") { @@ -54,7 +54,7 @@ func TestARefusedDoorDoesNotCountAsATranscript(t *testing.T) { agent.journal = "" drive(t, a, streamEventMsg{gen: a.gen, ev: update(4, "Port the parser", session.TaskDone, session.TaskNotice{Report: "Added the guard in parseRow."})}) - clickRailNode(t, a, 4) + drive(t, a, runCmd(a.openRailRoom(a.tasks[4]))...) if room := a.room; room != nil { for _, e := range room.entries { diff --git a/internal/tui3/taskending_test.go b/internal/tui3/taskending_test.go index 07f7d6df89..2162e567ff 100644 --- a/internal/tui3/taskending_test.go +++ b/internal/tui3/taskending_test.go @@ -12,9 +12,9 @@ import ( // row, and the hint line over it, which is where the finished report went when // every row became one line. func endedRailText(a *app) string { - railOpenAll(a) - rows := plain(strings.Join(a.railRows(12), "\n")) + " " + railHint(a, 7) - return strings.Join(strings.Fields(strings.ReplaceAll(rows, "│", " ")), " ") + node := a.tasks[7] + rows := a.railEntryRow(railEntry{node: node, group: a.railGroupOf(node)}, 120) + " " + strings.Join(a.railUnder(node, 120), " ") + return strings.Join(strings.Fields(plain(rows)), " ") } func endedNotice(ending session.TaskEnding, report string) session.TaskNotice { @@ -53,11 +53,11 @@ func TestAHaltedNodeSaysWhyAndIsNotCalledStopped(t *testing.T) { // reason and a source-control fact in one phrase; the head carries the word // and the branch and the row under it carries why // (docs/design/task-states/DESIGN.md). - if compact := taskText(a); !strings.Contains(compact, " · "+taskBranchKept+" · task/parser") { + if compact := retainedLandingText(t, a, 7); !strings.Contains(compact, "branch · task/parser") { t.Fatalf("compact receipt lost its kept branch: %s", compact) } a.toggleDoneAt(a.doneEntryFor(7)) - text := taskText(a) + text := retainedLandingText(t, a, 7) for _, want := range []string{ " · " + taskIncompleteState + " · ", "branch · task/parser", diff --git a/internal/tui3/taskresult_test.go b/internal/tui3/taskresult_test.go index b29eb694f5..0891155a7f 100644 --- a/internal/tui3/taskresult_test.go +++ b/internal/tui3/taskresult_test.go @@ -388,7 +388,9 @@ func TestAnOpenCardHoldsItsColumnAgainstLongPathsAndWideCharacters(t *testing.T) t.Fatalf("width %d: the landing drew no card", width) } card.open = true - rows := a.doneRows(card, width, false) + snapshot := *card + snapshot.quiet = false + rows := a.doneRows(&snapshot, width, false) if len(rows) < 2 { t.Fatalf("width %d: the open card drew %d rows", width, len(rows)) } diff --git a/internal/tui3/taskstamp_test.go b/internal/tui3/taskstamp_test.go index 0ec36c0405..45ffba4921 100644 --- a/internal/tui3/taskstamp_test.go +++ b/internal/tui3/taskstamp_test.go @@ -149,7 +149,7 @@ func TestACompletionCardDrawsTheRecordsOwnSpan(t *testing.T) { t.Fatal("the restored task did not draw a completion card") } card.open = true - text := taskText(a) + text := retainedLandingText(t, a, 14) want := doneSpanLabel + "14:02 → 14:14" if !strings.Contains(text, want) { t.Fatalf("the completion card does not draw the record's span %q:\n%s", want, text) diff --git a/internal/tui3/taskstrip_test.go b/internal/tui3/taskstrip_test.go index 6162347fa6..40d776f1d5 100644 --- a/internal/tui3/taskstrip_test.go +++ b/internal/tui3/taskstrip_test.go @@ -433,7 +433,7 @@ func TestAPartThatAskedHereLandsItsAnswerHereToo(t *testing.T) { } card.open = true a.touch() - if text := taskText(a); !strings.Contains(text, took) { + if text := retainedLandingText(t, a, 2); !strings.Contains(text, took) { t.Fatalf("the conversation does not say the part was decided:\n%s", text) } } diff --git a/internal/tui3/unverified_test.go b/internal/tui3/unverified_test.go index 384cc1df93..6d46bf74e8 100644 --- a/internal/tui3/unverified_test.go +++ b/internal/tui3/unverified_test.go @@ -139,7 +139,7 @@ func TestARollupWithAnUnverifiedNodeStopsSayingDone(t *testing.T) { unverifiedNotice("finished, but needs your look — nobody could say either way"))}, ) text := taskText(a) - if !strings.Contains(text, glyphAsk+" 3"+doneRollupMix) { + if !strings.Contains(text, "Port the parser") || !strings.Contains(text, askCheckReason) { t.Fatalf("the rollup head does not carry the question:\n%s", text) } if strings.Contains(text, doneRollupWord) { @@ -167,7 +167,7 @@ func TestARollupCountsADecidedNodeOnceByItsNewestCard(t *testing.T) { })}, ) text := taskText(a) - if !strings.Contains(text, "2"+doneRollupWord) { + if strings.Contains(text, doneRollupWord) || a.doneCardFor(1) == nil || a.doneCardFor(2) == nil { t.Fatalf("the rollup does not count two tasks done:\n%s", text) } if strings.Contains(text, glyphAsk) { @@ -175,7 +175,7 @@ func TestARollupCountsADecidedNodeOnceByItsNewestCard(t *testing.T) { } // Settled batches keep their task list behind the expansion. a.openDone(a.doneEntryFor(1)) - text = taskText(a) + text = retainedLandingText(t, a, 2) if n := strings.Count(text, "Port the parser"); n != 1 { t.Fatalf("the decided node is drawn %d times in its batch, want once:\n%s", n, text) } From df466cca0519dd6c2c8a7c64f7e551019fa2358f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 11:23:37 -0400 Subject: [PATCH 10/13] test: preserve navigation and results with quiet completions --- internal/tui3/programtab_test.go | 32 +++++++++- internal/tui3/railclick_test.go | 6 +- .../tui3/senior_dev_chat_receipts_test.go | 58 +++++++++---------- internal/tui3/taskcardhost_test.go | 9 ++- 4 files changed, 66 insertions(+), 39 deletions(-) diff --git a/internal/tui3/programtab_test.go b/internal/tui3/programtab_test.go index f94317573e..d22acd3296 100644 --- a/internal/tui3/programtab_test.go +++ b/internal/tui3/programtab_test.go @@ -54,14 +54,42 @@ func newProgramTabLab(t *testing.T, status string) *tabLab { return lab } +// Finished program output is opened from history; active rows keep their direct +// sidebar door. Both gestures reach the same canonical stored program page. +func programTabHistoryDoor(t *testing.T, l *tabLab) bool { + t.Helper() + if l.a.tasks[7].state != session.TaskDone { + return false + } + l.a.showPage(pageTasks) + l.a.taskSheet.regroup(l.a) + l.a.taskSheet.cursor = l.a.tasksSettle(0) + for i := 0; i < 20; i++ { + if item, ok := l.a.taskSheetCurrent(); ok && (item.entry.ID == "7" || item.plan != nil && item.plan.ID == "7") { + drive(t, l.a, runCmd(l.a.taskSheet.enter(l.a))...) + return true + } + l.a.taskSheetMove(1) + } + t.Fatal("finished program unavailable in task history") + return true +} + // programTabDoors is every door onto the program's task a test can drive // through the loop, each named for what a person does. var programTabDoors = []struct { name string open func(t *testing.T, l *tabLab) }{ - {"a press on its rail row", func(t *testing.T, l *tabLab) { clickRail(t, l.a, 0) }}, - {"enter on its rail row", func(t *testing.T, l *tabLab) { + {"a press on active row or task history", func(t *testing.T, l *tabLab) { + if !programTabHistoryDoor(t, l) { + clickRail(t, l.a, 0) + } + }}, + {"enter on active row or task history", func(t *testing.T, l *tabLab) { + if programTabHistoryDoor(t, l) { + return + } l.a.side.open[railDone] = true l.a.railWhere = railSpot{id: 7} l.a.railHold = true diff --git a/internal/tui3/railclick_test.go b/internal/tui3/railclick_test.go index 6c00fa63ee..cc8d7e685e 100644 --- a/internal/tui3/railclick_test.go +++ b/internal/tui3/railclick_test.go @@ -120,7 +120,7 @@ func TestTheStateCellOpensTheTask(t *testing.T) { strings.Contains(drawn, " "+glyphShut) { t.Fatalf("a task row drew a disclosure:\n%s", drawn) } - for _, id := range []uint64{1, 9} { + for _, id := range []uint64{1, 3} { a.closeRoom() railClick(t, a, glyph, railRowY(t, a, id)) if !a.roomOpen() || a.room.id != id { @@ -268,7 +268,7 @@ func TestEveryVisibleRowOfTheColumnOpensTheTaskItDraws(t *testing.T) { a, _, _ := roomApp(t) railFamily(a) seam := a.bodyWidth() + ansi.StringWidth(railSeam) - for _, id := range []uint64{1, 3, 4, 5, 2, 9, 6} { + for _, id := range []uint64{1, 3, 4, 5, 6} { for _, at := range []int{0, 4, a.railRoom() - 1} { a.closeRoom() var y int @@ -301,7 +301,7 @@ func TestPressingAnotherRowWhileARoomIsOpenSwitchesToIt(t *testing.T) { if !a.roomStandingOn(a.tasks[1]) { t.Fatal("the roster does not mark the row the reader walked through") } - for _, next := range []uint64{3, 9, 4, 1} { + for _, next := range []uint64{3, 5, 4, 1} { // THE ROW IS FOUND WITH THE PAGE STILL OPEN, which is the case this is // about: the room pins a header above the body, so the roster's rows are // not where they were before anybody walked in (view.go's diff --git a/internal/tui3/senior_dev_chat_receipts_test.go b/internal/tui3/senior_dev_chat_receipts_test.go index 502d20fb47..1cce2f9854 100644 --- a/internal/tui3/senior_dev_chat_receipts_test.go +++ b/internal/tui3/senior_dev_chat_receipts_test.go @@ -79,23 +79,22 @@ func TestSeniorDevEndedCardNamesProgramOnBothChatRoads(t *testing.T) { if card == nil || card.program != "senior-dev" { t.Fatalf("the landing lost the program identity: %+v", card) } - if got := taskText(a); !strings.Contains(got, "Repair the parser [senior-dev]") { - t.Fatalf("the compact landing has no badge:\n%s", got) + if got := taskText(a); strings.Contains(got, "Repair the parser [senior-dev]") { + t.Fatalf("program completion duplicated chat: %s", got) } - if rows := a.doneRows(card, 160, false); len(rows) != 1 { - t.Fatalf("the compact landing stopped being one row: %v", rows) + if !card.hidden() || card.report != "submitted a change" { + t.Fatalf("quiet program ending lost account: %+v", card) } - card.open = true - if got := plain(strings.Join(a.doneRows(card, 160, false), "\n")); !strings.Contains(got, "senior-dev's ending went to the chat") || !strings.Contains(got, "submitted a change") { - t.Fatalf("the opened program card lost its ending:\n%s", got) + if rows := a.doneRows(card, 160, false); len(rows) != 0 { + t.Fatalf("quiet program consumes rows: %v", rows) } - card.open = false + a.turn = 2 a.entries = append(a.entries, entry{kind: entryTool, tool: "read", status: toolOK, turn: 2}, entry{kind: entryAssistant, text: "Its work is on its branch.", settled: true, turn: 2}) a.touch() - if got := taskText(a); !strings.Contains(got, "Repair the parser [senior-dev]") || !strings.Contains(got, "worked") { + if got := taskText(a); strings.Contains(got, "Repair the parser [senior-dev]") || !strings.Contains(got, "Its work is on its branch.") || !strings.Contains(got, "worked") { t.Fatalf("the wake reply hid the program's badge:\n%s", got) } }) @@ -149,16 +148,15 @@ func seniorDevBatchApp(t *testing.T, road, title string) *app { if first < 0 || a.doneEntryFor(8) != first+1 { t.Fatalf("the landings are not adjacent: %+v", a.entries) } - if got := taskText(a); !strings.Contains(got, "2 tasks done") || strings.Contains(got, title) { - t.Fatalf("the settled batch is not compact:\n%s", got) + if got := taskText(a); strings.Contains(got, "tasks done") || strings.Contains(got, title) { + t.Fatalf("batch leaked routine receipts: %s", got) } - // Use the key the real conversation advertises, rather than setting open - // or drawing either card through doneRows directly. - a.sel = first - drive(t, a, key("ctrl+o")) - if !a.doneCardFor(7).open || a.doneCardFor(8).open { - t.Fatal("expanding the batch changed the ordinary card's own open state") + for _, id := range []uint64{7, 8} { + if card := a.doneCardFor(id); card == nil || !card.hidden() { + t.Fatalf("quiet batch lost record %d: %+v", id, card) + } } + return a } @@ -166,7 +164,7 @@ func TestSeniorDevBatchKeepsProgramBadgeOnBothChatRoads(t *testing.T) { for _, road := range []string{"engine", "no-host"} { t.Run(road, func(t *testing.T) { a := seniorDevBatchApp(t, road, "Repair the parser") - if got := taskText(a); !strings.Contains(got, "Repair the parser [senior-dev]") || !strings.Contains(got, "Read the guide") { + if got := taskText(a); strings.Contains(got, "Repair the parser [senior-dev]") || strings.Contains(got, "Read the guide") { t.Fatalf("the expanded batch lost the program identity or its ordinary sibling:\n%s", got) } a.doneCardFor(7).title = strings.Repeat("Repair the parser ", 8) @@ -176,7 +174,7 @@ func TestSeniorDevBatchKeepsProgramBadgeOnBothChatRoads(t *testing.T) { }{{160, "[senior-dev]"}, {40, "[sd]"}, {24, "[sd]"}} { a.width = tc.width a.touch() - if got := taskText(a); !strings.Contains(got, tc.badge) { + if got := taskText(a); strings.Contains(got, tc.badge) { t.Fatalf("width %d: the expanded batch cut off its badge:\n%s", tc.width, got) } // Check the row's painter and width as well as the whole surface. @@ -199,23 +197,19 @@ func TestSeniorDevBatchOpenedCardRetainsEndingOnBothChatRoads(t *testing.T) { a := seniorDevBatchApp(t, road, "Repair the parser") assertEnding := func() { t.Helper() - got := taskText(a) - for _, want := range []string{"senior-dev's ending went to the chat", "submitted the parser change"} { - if !strings.Contains(got, want) { - t.Fatalf("the opened card in the batch lost %q:\n%s", want, got) - } + card := a.doneCardFor(7) + if card == nil || card.program != "senior-dev" || card.report != "submitted the parser change" || !card.hidden() { + t.Fatalf("quiet program lost ending: %+v", card) } - if strings.Contains(got, "the guide was read") { - t.Fatalf("opening senior-dev's card opened its sibling's output:\n%s", got) + if sibling := a.doneCardFor(8); sibling == nil || sibling.report != "the guide was read" { + t.Fatal("quiet batch lost sibling output") + } + if got := taskText(a); strings.Contains(got, "submitted the parser change") || strings.Contains(got, "the guide was read") { + t.Fatalf("batch outputs duplicated chat: %s", got) } } assertEnding() - drive(t, a, key("ctrl+o")) - if got := taskText(a); strings.Contains(got, "submitted the parser change") || !strings.Contains(got, "2 tasks done") { - t.Fatalf("closing the batch did not restore its compact row:\n%s", got) - } - drive(t, a, key("ctrl+o")) - assertEnding() + a.turn = 2 a.entries = append(a.entries, entry{kind: entryTool, tool: "read", status: toolOK, turn: 2}, diff --git a/internal/tui3/taskcardhost_test.go b/internal/tui3/taskcardhost_test.go index c46e41863d..8b5c5e4464 100644 --- a/internal/tui3/taskcardhost_test.go +++ b/internal/tui3/taskcardhost_test.go @@ -168,8 +168,13 @@ func TestAHostedRosterListsTheFarConversationsTasks(t *testing.T) { t.Fatalf("the far row did not become a roster node: %#v", node) } railOpenAll(a) - if entries := a.railEntries(); len(entries) != 2 || !entries[0].head || entries[1].node == nil || entries[1].node.id != 9 { - t.Fatalf("the hosted roster stayed empty: %#v", entries) + if entries := a.railEntries(); len(entries) != 0 { + t.Fatalf("completed remote work leaked into sidebar: %#v", entries) + } + // The same hosted record remains available through the Tasks history door. + rows := a.taskSheetOwnRows() + if len(rows) != 1 || rows[0].ID != "9" || rows[0].Title != entry.Title { + t.Fatalf("quiet remote task lost its history: %#v", rows) } } From 06f55a180c2e90fccc7449e7ccb5bafaf38e6b0a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 12:07:43 -0400 Subject: [PATCH 11/13] Group completion history under work disclosures and notify out-of-view results --- .../unreleased/1893-quiet-completions.md | 7 +- internal/manual/chat/tasks.md | 28 +- internal/session/agent.go | 21 +- .../belt_completion_presentation_test.go | 92 ++++ internal/session/completion_presentation.go | 47 ++ .../session/completion_presentation_test.go | 100 +++++ internal/session/presentation.go | 7 + internal/session/task_run_belt.go | 33 +- internal/tui3/app.go | 39 +- internal/tui3/completiontoast.go | 268 ++++++++++++ internal/tui3/completiontoast_async_test.go | 80 ++++ internal/tui3/completiontoast_test.go | 184 ++++++++ internal/tui3/input.go | 7 + internal/tui3/jobstate.go | 8 + internal/tui3/keeper.go | 31 +- internal/tui3/keptwork.go | 1 + internal/tui3/livesteps.go | 11 +- internal/tui3/notify.go | 3 + internal/tui3/render.go | 53 ++- internal/tui3/replay.go | 6 + internal/tui3/replaycompletion.go | 181 ++++++++ internal/tui3/replaycompletion_test.go | 239 +++++++++++ internal/tui3/task.go | 17 + internal/tui3/teamcard.go | 2 - internal/tui3/view.go | 8 + internal/tui3/workcompletion.go | 402 ++++++++++++++++++ internal/tui3/workcompletion_test.go | 281 ++++++++++++ internal/tui3/workfold.go | 56 ++- 28 files changed, 2163 insertions(+), 49 deletions(-) create mode 100644 internal/session/belt_completion_presentation_test.go create mode 100644 internal/tui3/completiontoast.go create mode 100644 internal/tui3/completiontoast_async_test.go create mode 100644 internal/tui3/completiontoast_test.go create mode 100644 internal/tui3/replaycompletion.go create mode 100644 internal/tui3/replaycompletion_test.go create mode 100644 internal/tui3/workcompletion.go create mode 100644 internal/tui3/workcompletion_test.go diff --git a/docs/changes/unreleased/1893-quiet-completions.md b/docs/changes/unreleased/1893-quiet-completions.md index cb27baba94..e080cafa04 100644 --- a/docs/changes/unreleased/1893-quiet-completions.md +++ b/docs/changes/unreleased/1893-quiet-completions.md @@ -1,11 +1,12 @@ --- kind: changed -title: task results stay on demand instead of filling the conversation +title: task and process endings group inside worked disclosures pr: 1893 surface: [chat, engine, docs] invalidates: - - "Task endings and crew receipts used to accumulate in chat and return on tab switches. Routine completions now remain in task results, while decisions and actionable stops stay visible." + - "Task endings and crew receipts used to accumulate in chat and return on tab switches. Routine completions now settle under their original request’s worked disclosure with links to results and logs, while decisions and actionable stops stay visible." - "The sidebar used to list completed tasks and processes. It now shows active work and decisions; click Tasks for retained results or use /processes for process history and logs." - "Crew models, estimated and actual spend, and the stronger retry action used to live in a separate transcript receipt. They now live with the selected task result, including restored routed runs." - - "Runtime completion notes used to replay as extra conversation rows. New receipts carry their presentation audience through compaction and reopen; exact standalone older receipts are projected quietly without rewriting their journal." + - "Runtime completion notes used to replay as extra conversation rows. New receipts carry their presentation audience through compaction and reopen; exact standalone older receipts are grouped without rewriting their journal; uncertain ownership stays in a background-work disclosure and ambiguous mixed messages remain visible." + - "A focused terminal previously had no temporary completion signal for work out of sight. A six-second coalesced notification now offers View work and dismiss using existing screen space, without replaying notifications for restored history." --- diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 6c2fe1d826..53778a29f8 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -1827,14 +1827,26 @@ the roster or `jobs kill` is your decision and is drawn as `stopped`, with the b ## Where did my task's result go when it finished while codeaf was still answering -A task finishing updates its existing row in Tasks. Routine `done`, `incomplete` and -`stopped` results do not add completion cards or crew receipts to the conversation, -including when you switch tabs or reopen an older conversation. Nothing is deleted -from the journal. The answer codeaf owes your question still appears in chat. - -Open the task from the roster or Tasks to read its result, report, files, branch, -models and spending. Process logs remain in Processes. A pending `your call` decision -stays visible with its reason and working answer controls until it is resolved. +Routine task endings, crew receipts and process exits settle inside the original +request's collapsed **worked** disclosure. Click the disclosure, or press `ctrl+e`, +to inspect its grouped work. Click a task row for its result or a process row for +its log. The answer codeaf owes your question still appears directly in chat. + +If a completion happens out of sight while the terminal is focused, a temporary +notification uses existing space above the input, or a page's footer. **View work** +(`alt+j`) opens the originating work group; the cross dismisses it. Completions for +one group coalesce and the notification disappears after six seconds. Watching the +work update in place does not raise a duplicate notification. Reopening a conversation +or switching tabs does not announce old completions again. + +Recognizable older standalone receipts also group on reopening, without rewriting +or deleting the journal. When the original request cannot be established, a collapsed +background-work group retains the receipt instead of assigning it to a later request. +Ambiguous mixed messages and requested alerts remain visible. A pending `your call` +decision stays visible with its reason and working answer controls until resolved. + +Tasks still retains each result, report, files, branch, models and spending. +Process logs also remain available through `/processes`. ## How work lands — merged, branch kept, in your own folder, or conflicted diff --git a/internal/session/agent.go b/internal/session/agent.go index 895e5803d9..c9534281b9 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1197,6 +1197,8 @@ type userMessage struct { // completionOnly distinguishes task/job delivery receipts from requested // watch alerts and mixed background batches. It never changes model delivery. completionOnly bool + // attention is a requested alert, independent of its model-facing wake. + attention bool // wake marks a note the model OWES AN ANSWER FOR: a task's completion // (task_run.go's reportTaskNode), a background job's exit (jobs.go's reap), @@ -2647,7 +2649,7 @@ func (a *Agent) recordLocked(message ai.Message) { // must not write. Everything the model and the tools produce is text and goes // through recordLocked exactly as before. func (a *Agent) recordUserLocked(user userMessage) { - if !user.authored || user.completionOnly { + if !user.authored || user.completionOnly || user.attention { // Presentation follows this occurrence, not equal words a person might // later quote. The model still receives the complete original payload. mark := a.presentation.of(user.message) @@ -2656,12 +2658,13 @@ func (a *Agent) recordUserLocked(user userMessage) { a.presentation = &presentationIndex{} } if user.authored { - mark = &messagePresentation{Audience: "operational", RoutineCompletion: true} + mark = &messagePresentation{Audience: "operational", RoutineCompletion: user.completionOnly, Attention: user.attention, CompletionTasks: append([]TaskReplyTag(nil), user.replyTags...)} } else if mark == nil { mark = &messagePresentation{Audience: "human"} } else { mark.Audience = "human" mark.RoutineCompletion = false + mark.Attention = false } a.presentation.remember(user.message, mark) } @@ -3406,6 +3409,7 @@ func batchSessionNotes(notes []userMessage) userMessage { order := make([]string, 0, len(notes)) parts := make([]string, 0, len(notes)) completionOnly := len(notes) > 0 + hasCompletion, attention := false, false var ( wake bool otherResults bool @@ -3415,6 +3419,8 @@ func batchSessionNotes(notes []userMessage) userMessage { ) for _, note := range notes { completionOnly = completionOnly && note.completionOnly + hasCompletion = hasCompletion || note.completionOnly + attention = attention || note.attention wake = wake || note.wake otherResults = otherResults || note.otherResults nonTaskNews = nonTaskNews || note.nonTaskNews || len(note.replyTags) == 0 @@ -3461,6 +3467,7 @@ func batchSessionNotes(notes []userMessage) userMessage { return userMessage{ message: textMessage("user", text), completionOnly: completionOnly, + attention: attention || hasCompletion && !completionOnly, replyTags: tags, otherResults: otherResults, nonTaskNews: nonTaskNews, @@ -3576,6 +3583,7 @@ func (a *Agent) enqueueWatchNote(name, text string, fired bool) { if fired { note := wakeNote(text) note.otherResults = true + note.attention = true a.enqueueNote(note) return } @@ -4641,6 +4649,8 @@ type DisplayEntry struct { // RoutineCompletion is a delivery receipt whose full result belongs to // its task or process. It is retained as data rather than another chat row. RoutineCompletion bool + // CompletionTasks keeps the receipt's original task identities independently of the later answer's attribution. + CompletionTasks []TaskReplyTag // Answer marks a completed tool-free response or an explicit human update. This // boundary survives replay so a later response cannot demote its message. Answer bool @@ -4912,6 +4922,10 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese } interrupted, explicitlyHuman := false, false routineCompletion := false + attention := false + if mark := presentation.of(msg); mark != nil { + attention = mark.Attention + } if mark := presentation.of(msg); mark != nil && msg.Role == "user" && mark.RoutineCompletion { role, routineCompletion = "aside", true } @@ -4941,8 +4955,9 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese entries = append(entries, DisplayEntry{ Role: role, RoutineCompletion: routineCompletion, + CompletionTasks: completionTaskTags(journal, msg, routineCompletion, presentation), Answer: role == "assistant" && !interrupted && (len(msg.ToolCalls) == 0 || update), - Addressed: role == "assistant" && (update || interrupted && explicitlyHuman), + Addressed: role == "aside" && attention || role == "assistant" && (update || interrupted && explicitlyHuman), Interrupted: interrupted, Text: displayText, ImageRefs: journal.imageRefs(msg), diff --git a/internal/session/belt_completion_presentation_test.go b/internal/session/belt_completion_presentation_test.go new file mode 100644 index 0000000000..f4c8520e77 --- /dev/null +++ b/internal/session/belt_completion_presentation_test.go @@ -0,0 +1,92 @@ +package session + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/roles" +) + +func TestPlainBeltLandingReceiptRetainsExactTaskIdentityAcrossReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + completer := &scriptedCompleter{} + a, _ := newTestAgent(t, completer, func(c *Config) { c.SessionFile = path }) + store, err := plandb.Open(filepath.Join(t.TempDir(), planStoreFilename), "the run", "1", "Parser", "repair parser") + if err != nil { + t.Fatal(err) + } + defer store.Close() + run := &beltRun{store: store, root: store.RootID(), row: 9, title: "Parser", brief: "repair parser"} + summary := RunSummary{Outcome: beltRunOutcomeDone, Result: "The parser handles an empty map."} + original := beltRunOutcomeNote(store, run.root, summary, RunLanding{}, a.beltRunSpan(run)) + a.deliverBeltRunLanding(run, summary, RunLanding{}) + assert := func(entries []DisplayEntry) { + t.Helper() + if len(entries) != 1 || !entries[0].RoutineCompletion || entries[0].Text != original || len(entries[0].CompletionTasks) != 1 { + t.Fatalf("plain belt receipt has no grouping provenance: %#v", entries) + } + tag := entries[0].CompletionTasks[0] + if tag.ID != 9 || tag.Title != "Parser" || tag.Request != "repair parser" || tag.State != TaskDone { + t.Fatalf("run identity changed: %+v", tag) + } + } + assert(a.Transcript()) + if completer.requests() != 0 { + t.Fatal("plain /task landing started a new model turn") + } + if err := a.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + saved, err := replaySessionFile(path) + if err != nil { + t.Fatal(err) + } + assert(shapeEntries(saved.messages, &sessionFile{presentation: saved.presentation, notes: saved.notes, replyTags: saved.replyTags})) + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("presentation rewrote landing journal") + } +} + +func TestOwedBeltLandingGroupsReceiptButKeepsTheActualAnswerVisible(t *testing.T) { + completer := &scriptedCompleter{steps: []step{finalText("The parser handles an empty map.")}} + a, _ := newTestAgent(t, completer, func(c *Config) { + c.AskConsent = false + c.RolesSource = tierSettings(map[string]string{roles.TierKey(roles.TierLow): "test/cheap-model"}) + }) + store, err := plandb.Open(filepath.Join(t.TempDir(), planStoreFilename), "run", "1", "Parser", "repair parser") + if err != nil { + t.Fatal(err) + } + defer store.Close() + question := "What did the repair find?" + if _, err := store.Revise(store.RootID(), plandb.TaskPatch{Question: &question}); err != nil { + t.Fatal(err) + } + run := &beltRun{store: store, root: store.RootID(), row: 9, title: "Parser", brief: "repair parser"} + a.deliverBeltRunLanding(run, RunSummary{Outcome: beltRunOutcomeDone, Result: "Root output preserved."}, RunLanding{}) + beltRunWaitFor(t, "the actual owed landing answer", func() bool { a.mu.Lock(); defer a.mu.Unlock(); return !a.running && completer.requests() >= 1 }) + receipt, answer := false, false + for _, e := range a.Transcript() { + if e.RoutineCompletion { + receipt = true + if len(e.CompletionTasks) != 1 || e.CompletionTasks[0].ID != 9 || e.CompletionTasks[0].Request != question || !strings.Contains(e.Text, "Root output preserved.") { + t.Fatalf("owed receipt lost source or result: %+v", e) + } + } + if e.Role == "assistant" && e.Answer && strings.Contains(e.Text, "The parser handles an empty map.") { + answer = true + } + } + if !receipt || !answer || completer.requests() != 1 { + t.Fatalf("owed wake/answer changed: receipt=%v answer=%v calls=%d", receipt, answer, completer.requests()) + } +} diff --git a/internal/session/completion_presentation.go b/internal/session/completion_presentation.go index bcdf8a1d48..62a33e77c2 100644 --- a/internal/session/completion_presentation.go +++ b/internal/session/completion_presentation.go @@ -1,6 +1,7 @@ package session import ( + "github.com/Agent-Field/agentfield/sdk/go/ai" "strconv" "strings" ) @@ -13,6 +14,17 @@ func legacyRoutineCompletion(entry sessionEntry) bool { return false } text := entry.Content + // Old crew receipts were authored single-line records. A person's quoted text has no Note mark. + if !strings.ContainsAny(text, "\r\n") { + head, body, ok := strings.Cut(text, " crew · ") + fields := strings.Fields(head) + if ok && len(fields) == 2 && fields[0] == "task" && strings.Contains(body, " · worker ") && strings.Contains(body, " · checker ") { + id, err := strconv.ParseUint(fields[1], 10, 64) + if err == nil && id > 0 { + return true + } + } + } if strings.HasPrefix(text, "while you worked:") { return false } @@ -46,3 +58,38 @@ func legacyRoutineCompletion(entry sessionEntry) bool { } return false } + +// Receipt provenance is carried separately so inspecting it cannot consume the answer's own tags. +func completionTaskTags(journal *sessionFile, message ai.Message, routine bool, presentation *presentationIndex) []TaskReplyTag { + if routine { + if mark := presentation.of(message); mark != nil && len(mark.CompletionTasks) > 0 { + return append([]TaskReplyTag(nil), mark.CompletionTasks...) + } + } + if !routine || journal == nil { + return nil + } + return journal.taskReplyTags(message) +} + +// Older mixed batches cannot prove that a requested alert was absent, so their complete content stays visible. +func legacyAttentionReceipt(entry sessionEntry) bool { + if entry.Role != "user" || !entry.Note || len(entry.ToolCalls) != 0 || legacyRoutineCompletion(entry) { + return false + } + text := strings.TrimSpace(entry.Content) + if strings.HasPrefix(text, "while you worked:") { + return true + } + return LegacyWatchAlert(text) +} + +// LegacyWatchAlert recognizes only the reserved terminal watch records, not periodic watch ticks. +func LegacyWatchAlert(text string) bool { + head, _, _ := strings.Cut(strings.TrimSpace(text), "\n") + if !strings.HasPrefix(head, "watch ") { + return false + } + _, summary, ok := strings.Cut(head, ": ") + return ok && (summary == "until matched" || strings.HasPrefix(summary, "quiet for ") || strings.HasPrefix(summary, "stopped: the command failed ")) +} diff --git a/internal/session/completion_presentation_test.go b/internal/session/completion_presentation_test.go index 5ec993d4b7..083ec50648 100644 --- a/internal/session/completion_presentation_test.go +++ b/internal/session/completion_presentation_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/provider" ) func TestCompletionPresentationSurvivesCompactionAndReopenWithoutChangingContext(t *testing.T) { @@ -115,3 +116,102 @@ func TestLegacyCompletionPresentationRequiresStandaloneRuntimeProvenance(t *test }) } } + +func TestCompletionGroupingRetainsReceiptSourcesAcrossReopenWithoutEditingJournal(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.SessionFile = path }) + tag := TaskReplyTag{ID: 9, Title: "Build report", Request: "Keep the original request", State: TaskDone, Revision: 2} + receipt := wakeNote("exact retained runtime delivery payload") + receipt.authored, receipt.completionOnly = true, true + receipt.replyTags = []TaskReplyTag{tag} + agent.mu.Lock() + agent.recordUserLocked(receipt) + agent.mu.Unlock() + agent.recordAssistant(textMessage("assistant", "The requested report is ready."), provider.MessageReasoning{}) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + saved, err := replaySessionFile(path) + if err != nil { + t.Fatal(err) + } + entries := shapeEntries(saved.messages, &sessionFile{presentation: saved.presentation, notes: saved.notes, replyTags: saved.replyTags}) + if len(entries) != 2 || !entries[0].RoutineCompletion || len(entries[0].CompletionTasks) != 1 || entries[0].CompletionTasks[0] != tag || entries[0].Text != receipt.text() { + t.Fatalf("grouped receipt lost exact source facts: %#v", entries) + } + if len(entries[0].ReplyTags) != 0 || len(entries[1].ReplyTags) != 1 || entries[1].ReplyTags[0] != tag { + t.Fatal("receipt grouping consumed answer attribution") + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("grouping rewrote existing journal") + } +} + +func TestLegacyCrewGroupingRequiresRuntimeAuthorshipAndCompleteReceipt(t *testing.T) { + crew := "task 7 crew · bugfix · worker model-a · planner model-b · checker model-c · $0.310 (est $0.520)" + for _, tc := range []struct { + text string + note, want bool + }{{crew, true, true}, {crew, false, false}, {"An example: " + crew, true, false}, {crew + "\nRequested alert: check deployment", true, false}, {"task 7 crew · user supplied prose", true, false}} { + if got := legacyRoutineCompletion(sessionEntry{Role: "user", Note: tc.note, Content: tc.text}); got != tc.want { + t.Fatalf("receipt=%q authored=%v grouped=%v", tc.text, tc.note, got) + } + } +} + +func TestRequestedWatchAttentionSurvivesBatchAndReopenWithoutExposingHousekeeping(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.SessionFile = path }) + agent.mu.Lock() + agent.opened = false + agent.mu.Unlock() + agent.enqueueWatchNote("release", "watch release: until matched\nREADY", true) + agent.mu.Lock() + if len(agent.steering) != 1 || !agent.steering[0].attention { + agent.mu.Unlock() + t.Fatal("watch firing has no typed attention") + } + alert := agent.steering[0] + batch := batchSessionNotes([]userMessage{jobNote("job 3 exited 0"), alert}) + agent.recordUserLocked(batch) + agent.recordUserLocked(userMessage{message: textMessage("user", "provider retry bookkeeping"), authored: true}) + agent.mu.Unlock() + if !batch.attention || batch.completionOnly { + t.Fatal("mixed requested alert became routine completion") + } + assert := func(entries []DisplayEntry) { + t.Helper() + if len(entries) != 2 || !entries[0].Addressed || entries[0].RoutineCompletion || entries[1].Addressed { + t.Fatalf("alert or housekeeping audience changed: %#v", entries) + } + } + assert(agent.Transcript()) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + saved, err := replaySessionFile(path) + if err != nil { + t.Fatal(err) + } + assert(shapeEntries(saved.messages, &sessionFile{presentation: saved.presentation, notes: saved.notes, replyTags: saved.replyTags})) +} + +func TestLegacyAttentionProtectsTerminalWatchAndAmbiguousMixedOnly(t *testing.T) { + for _, tc := range []struct { + text string + want bool + }{ + {"watch release: until matched\nREADY", true}, {"watch build: quiet for 2m\nPASS", true}, {"while you worked:\n\ntask receipt\n\nwatch alert", true}, + {"watch release: tick 9\nREADY", false}, {"provider retry bookkeeping", false}, {helpersNoteOpening + "\n" + helpersNoteBody, false}, + } { + mark := legacyPresentation(sessionEntry{Role: "user", Note: true, Content: tc.text}) + if got := mark != nil && mark.Attention; got != tc.want { + t.Fatalf("legacy attention=%v for %q", got, tc.text) + } + } +} diff --git a/internal/session/presentation.go b/internal/session/presentation.go index 463f4f654d..670ff89454 100644 --- a/internal/session/presentation.go +++ b/internal/session/presentation.go @@ -27,6 +27,10 @@ type messagePresentation struct { // RoutineCompletion is the runtime's delivery receipt, not the answer // the person asked for. Its owner keeps the report and output available. RoutineCompletion bool `json:"routineCompletion,omitempty"` + // Attention preserves a requested alert or an ambiguous mixed delivery outside routine work folds. + Attention bool `json:"attention,omitempty"` + // CompletionTasks preserves source identity in memory-only conversations; journals keep these as replyTags. + CompletionTasks []TaskReplyTag `json:"-"` } // Message identity follows its immutable content allocation, as reasoning repair @@ -119,6 +123,9 @@ func interruptedPresentation(text string) *messagePresentation { // text and mixed replies remain untouched. New records always write an explicit // audience, so even these exact words can be deliberately addressed to a person. func legacyPresentation(entry sessionEntry) *messagePresentation { + if legacyAttentionReceipt(entry) { + return &messagePresentation{Audience: "operational", Attention: true} + } if legacyRoutineCompletion(entry) { return &messagePresentation{Audience: "operational", RoutineCompletion: true} } diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index ea3fbe14fa..18592f5d96 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -2117,9 +2117,13 @@ func (a *Agent) deliverBeltRunLanding(run *beltRun, summary RunSummary, landing a.accept(delivery{origin: fromRuntime, kind: msgResult, note: a.programLandingNote(run, summary, line)}) return } - if task := run.store.Task(run.root); landingOwesAnswer(task) { + task := run.store.Task(run.root) + tags := plainBeltLandingReplyTags(run, task, summary) + if landingOwesAnswer(task) { document := owedLandingDocument(task, line) note := wakeNote(document.text()) + note.completionOnly = true + note.replyTags = tags note.landingQuestion, note.landingOutcome = document.landingQuestion, document.landingOutcome note.batch = false note.settle, note.settleCeiling = true, owedLandingCallCeiling() @@ -2129,12 +2133,39 @@ func (a *Agent) deliverBeltRunLanding(run *beltRun, summary RunSummary, landing return } note := userText(line) + note.completionOnly = true + note.replyTags = tags note.authored = true a.mu.Lock() a.recordUserLocked(note) a.mu.Unlock() } +// Plain run receipts retain their task identity while the original delivery and answer obligations stay unchanged. +func plainBeltLandingReplyTags(run *beltRun, task *plandb.Task, summary RunSummary) []TaskReplyTag { + id := run.row + if id == 0 { + id, _ = strconv.ParseUint(strings.TrimPrefix(run.root, "t-"), 10, 64) + } + if id == 0 { + return nil + } + title, request := run.title, run.brief + if task != nil { + if title == "" { + title = task.Title + } + if strings.TrimSpace(task.Question) != "" { + request = task.Question + } + } + state := TaskDone + if summary.Outcome != beltRunOutcomeDone { + state = TaskFailed + } + return []TaskReplyTag{{ID: id, Title: title, Request: request, State: state}} +} + // recordProgramLimit gives the open surface the same authored line the // conversation keeps. The model wake may be refused by this very limit, so // the standing lane must paint it without waiting for another turn. diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 65c9d5fe46..a35fa80aca 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -605,8 +605,10 @@ type entry struct { // else (taskdone.go). Like [entry.card] it is a POINTER, because the card // carries the one piece of state a person can change about it — whether its // full context is showing — and the row and that state must never disagree. - done *taskDone - harness *harnessCard + // completion is operational history disclosed under the request that owns it. + completion *workCompletion + done *taskDone + harness *harnessCard // auto is the automation card or line this entry draws, for kind // entryAutomation and nothing else (automation.go). It is a POINTER for // [entry.card]'s reason: the answer lane holds the same card, and the row @@ -1389,7 +1391,10 @@ type app struct { // same bargain again, for a chip that is right-aligned and so knows its own // columns only once the frame has chosen a width (jumpchip.go's // [app.jumpChip] and [app.jumpPress]). - jumpSpan hudSpan + jumpSpan hudSpan + completionToast completionToast + completionToastY int + pendingCompletionReveal *completionToast // steerDoor is where the waiting message's `→ steers it in` clause was last // drawn, in columns, or the zero span on a frame that did not draw it — the // same bargain jumpSpan makes, for the same reason: the line that lays the @@ -3595,7 +3600,11 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if alarm := a.chatTipAlarm(); alarm != nil { cmd = tea.Batch(cmd, alarm) } + cmd = tea.Batch(cmd, a.applyCompletionReveal()) a.chatTipCount() + if alarm := a.completionToastAlarm(); alarm != nil { + cmd = tea.Batch(cmd, alarm) + } // AND A CLOCK SOMEBODY STOPPED IS TOLD TO THE ENGINE HERE, from a command // rather than from inside the key routine that took the key // (questionhold.go's [app.takeQuestionHolds] says why it is this line). @@ -3656,6 +3665,11 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // not survive has news, and a person who presses a key gets it rather than // waiting for whatever repaints next. a.takeLinkNotice() + // An explicit gesture after View replaces its pending navigation intent. + switch msg.(type) { + case tea.KeyPressMsg, tea.PasteMsg, tea.MouseClickMsg, tea.MouseWheelMsg: + a.pendingCompletionReveal = nil + } // THE TEAMS PAGE TAKES WHAT IS ITS OWN AND HANDS THE REST TO THE MANAGER'S // CONVERSATION it hosts (teamspagehost.go). One comparison on every other // place. @@ -3663,6 +3677,11 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { return a, cmd } switch msg := msg.(type) { + case completionToastExpiredMsg: + if msg.serial == a.completionToast.serial { + a.dismissCompletionToast() + } + return a, nil case tea.WindowSizeMsg: // A zero size is a terminal that could not say — a headless boot, a // window being born. Keeping the last known size draws something; @@ -3791,6 +3810,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if taken { return a, flushed } + if msg.String() == completionToastKey && a.completionToastAllowed() { + return a, tea.Batch(flushed, a.viewCompletionToast()) + } a.dismissProjectPaste(msg) if a.pasteEdit.open && msg.String() != "ctrl+c" { return a, tea.Batch(flushed, a.pasteEditorKey(msg)) @@ -4425,6 +4447,11 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // and a clock restarted first would have taken the tip row — and the // cross being pressed — off that layout (notice.go's [app.chatTipPress]). defer a.stirred() + if msg.Mouse().Button == tea.MouseLeft { + if cmd, took := a.completionToastPress(msg.Mouse().X, msg.Mouse().Y); took { + return a, cmd + } + } // THE TEAM SWITCHER OWNS THE PRESS WHILE IT IS UP, as a menu does: // its rows answer, and a press off it only puts it away (teammenu.go). if a.teamMenu.on && msg.Mouse().Button == tea.MouseLeft { @@ -7538,6 +7565,10 @@ func (a *app) press(x, y int) (cmd tea.Cmd) { a.toggleCap(r.turn) case hitWorkFold: a.toggleWorkfold(r.turn) + case hitWorkCompletion: + return a.openWorkCompletion(r.completion) + case hitWorkReceiptDetails: + a.toggleWorkCompletionDetails(r.completion) case hitMore: a.showAll(r.entry) case hitThread: @@ -7710,7 +7741,7 @@ func (a *app) selectTool(delta int) bool { // make the room a mouse-only place. The walk is also what gives ctrl+o // something to act on: the key a card names is spent on the SELECTED card // (taskdone.go's [app.openDone]). - if r.hit != hitTool && r.hit != hitCaption && r.hit != hitTask && r.hit != hitDone && r.hit != hitHarness { + if r.hit != hitTool && r.hit != hitCaption && r.hit != hitTask && r.hit != hitDone && r.hit != hitHarness && r.hit != hitWorkCompletion { continue } key := r.entry diff --git a/internal/tui3/completiontoast.go b/internal/tui3/completiontoast.go new file mode 100644 index 0000000000..0b021e4b19 --- /dev/null +++ b/internal/tui3/completiontoast.go @@ -0,0 +1,268 @@ +package tui3 + +import ( + tea "charm.land/bubbletea/v2" + "fmt" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/charmbracelet/x/ansi" + "strings" + "time" +) + +const completionToastDuration = 6 * time.Second +const completionToastKey = "alt+j" + +type completionToast struct { + file string + turn int + task uint64 + process int + count, incomplete int + until time.Time + serial, armed uint64 + view, dismiss hudSpan +} +type completionToastExpiredMsg struct{ serial uint64 } +type heldCompletion struct { + task uint64 + process int + incomplete bool +} + +// Callers must observe the same item's running-to-settled edge. Replay is quiet. +func (a *app) queueCompletionToast(file string, turn int, task uint64, process int, incomplete bool) { + if !a.focused { + return + } + t := &a.completionToast + if t.file != file || t.turn != turn || !a.now().Before(t.until) { + serial := t.serial + *t = completionToast{file: file, turn: turn, serial: serial} + } + t.task, t.process = task, process + t.count++ + if incomplete { + t.incomplete++ + } + t.serial++ + t.until = a.now().Add(completionToastDuration) + a.touch() +} +func (a *app) completionToastActive() bool { + return a.focused && a.completionToast.count > 0 && a.now().Before(a.completionToast.until) +} +func (a *app) completionToastAlarm() tea.Cmd { + t := &a.completionToast + if !a.completionToastActive() || t.armed == t.serial { + return nil + } + t.armed = t.serial + serial, wait := t.serial, t.until.Sub(a.now()) + return tea.Tick(wait, func(time.Time) tea.Msg { return completionToastExpiredMsg{serial} }) +} +func (a *app) dismissCompletionToast() { + a.completionToast.count = 0 + a.completionToast.serial++ + a.touch() +} + +// Borrow existing clearance or footer; never add a row or move the input. +func (a *app) completionToastLine(width int) string { + t := &a.completionToast + t.view, t.dismiss = hudSpan{}, hudSpan{} + if !a.completionToastActive() || width < 20 { + return "" + } + label := fmt.Sprintf("%d work item finished", t.count) + if t.count != 1 { + label = fmt.Sprintf("%d work items finished", t.count) + } + if t.incomplete > 0 { + label += fmt.Sprintf(" · %d incomplete", t.incomplete) + } + if t.turn == -1 { + label = "Reply ready" + } + action, end := "View work · "+completionToastKey, " · ×" + room := width - ansi.StringWidth(action+end) - 4 + if room < 8 { + action = "View" + room = width - ansi.StringWidth(action+end) - 4 + } + label = ansi.Truncate(label, max(0, room), "…") + prefix := " " + label + " · " + t.view = hudSpan{from: ansi.StringWidth(prefix), to: ansi.StringWidth(prefix + action)} + t.dismiss = hudSpan{from: t.view.to + 3, to: t.view.to + 4} + return fit(a.pal.dim(prefix)+a.pal.accent(action)+a.pal.dim(end), width) +} +func (a *app) completionToastAllowed() bool { + if !a.completionToastActive() || a.setup.open || a.rew.on || a.awaitingTask() || a.overlayHeight() > 0 { + return false + } + if _, up := a.questionDialog(a.width); up { + return false + } + return true +} +func (a *app) completionToastOver(frame string) string { + a.completionToastY = -1 + if !a.completionToastAllowed() { + return frame + } + lines := strings.Split(frame, "\n") + if len(lines) < 2 { + return frame + } + y := len(lines) - 1 + if !a.pageShowing() && !a.jobPageOpen() && !a.roomOpen() && !a.copy.on && !a.railFull() && a.welcomeHeight() == 0 && a.footClearance() > 0 { + _, marks, _, _ := a.chrome(a.width) + for i, m := range marks { + if m.kind == chromeCompletionToast { + y = len(lines) - len(marks) + i + break + } + } + } + line := a.completionToastLine(a.width) + if line == "" || y < 0 || y >= len(lines) { + return frame + } + lines[y] = line + a.completionToastY = y + return strings.Join(lines, "\n") +} +func (a *app) completionToastPress(x, y int) (tea.Cmd, bool) { + if !a.completionToastAllowed() || y != a.completionToastY { + return nil, false + } + if a.completionToast.dismiss.holds(x) { + a.dismissCompletionToast() + return nil, true + } + if a.completionToast.view.holds(x) { + return a.viewCompletionToast(), true + } + return nil, false +} +func (a *app) viewCompletionToast() tea.Cmd { + t := a.completionToast + a.dismissCompletionToast() + var cmd tea.Cmd + if a.convKey(t.file) != a.convKey(a.file) { + var owned bool + cmd, owned = a.bringForward(t.file) + if !owned { + return cmd + } + } + cmd = tea.Batch(cmd, a.showPage(pageNone)) + a.closeJobPage() + a.closeRoom() + a.pendingCompletionReveal = &t + return tea.Batch(cmd, a.applyCompletionReveal()) +} + +// A hosted tab loads its journal asynchronously. Keep the person's explicit +// navigation until that reading and its standing roster have arrived. +func (a *app) applyCompletionReveal() tea.Cmd { + t := a.pendingCompletionReveal + if t == nil { + return nil + } + if a.convKey(t.file) != a.convKey(a.file) { + a.pendingCompletionReveal = nil + return nil + } + if a.hostReplayLoading || a.hostReplayWaiting { + return nil + } + if t.turn == -1 { + a.toLatest() + a.pendingCompletionReveal = nil + return nil + } + if a.revealCompletionIdentity(t.task, t.process) { + a.pendingCompletionReveal = nil + return nil + } + turn, owned := t.turn, t.turn > 0 + if t.task != 0 { + turn, owned = a.workCompletionTurn(t.task) + } + if t.process != 0 { + turn, owned = a.workProcessTurn(t.process) + } + if owned { + a.revealWorkCompletion(turn) + for _, r := range a.visible(a.bodyWidth()) { + if r.hit == hitWorkFold && r.turn == turn { + a.pendingCompletionReveal = nil + break + } + } + } + return nil +} + +func (w *behindWatch) takeCompletions() []heldCompletion { + w.workMu.Lock() + defer w.workMu.Unlock() + result := w.completions + w.completions = nil + return result +} +func taskCompletionState(s session.TaskState) bool { + return s == session.TaskDone || s == session.TaskFailed +} + +func (a *app) workTurnVisible(turn int) bool { + if a.pageShowing() || a.roomOpen() || a.jobPageOpen() || a.copy.on || a.railFull() { + return false + } + rows := a.visible(a.bodyWidth()) + start := a.offsetFor(len(rows), a.viewHeight()) + for i := start; i < min(len(rows), start+a.viewHeight()); i++ { + r := rows[i] + if r.hit == hitWorkFold && r.turn == turn { + return true + } + if r.entry >= 0 && r.entry < len(a.entries) && a.entries[r.entry].turn == turn { + return true + } + } + return false +} + +// A visible answer needs no second signal. A reply outside this view gets the +// same temporary door, without duplicating a task completion already announced. +func (a *app) queueReplyToast(file string) { + if a.completionToastActive() && a.completionToast.file == file { + return + } + a.queueCompletionToast(file, -1, 0, 0, false) +} + +func (a *app) completionIdentityVisible(task uint64, job int, turn int, owned bool) bool { + if owned { + return a.workTurnVisible(turn) + } + if a.pageShowing() || a.roomOpen() || a.jobPageOpen() || a.copy.on || a.railFull() { + return false + } + keys := map[int]bool{} + for _, f := range a.deckFolds(a.bodyDeck()) { + for _, c := range f.completions { + if task != 0 && c.task == task || job != 0 && c.job == job { + keys[f.key] = true + } + } + } + rows := a.visible(a.bodyWidth()) + start := a.offsetFor(len(rows), a.viewHeight()) + for i := start; i < min(len(rows), start+a.viewHeight()); i++ { + if rows[i].hit == hitWorkFold && keys[rows[i].turn] { + return true + } + } + return false +} diff --git a/internal/tui3/completiontoast_async_test.go b/internal/tui3/completiontoast_async_test.go new file mode 100644 index 0000000000..9de03d0852 --- /dev/null +++ b/internal/tui3/completiontoast_async_test.go @@ -0,0 +1,80 @@ +package tui3 + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/session" +) + +func asyncCompletionView(t *testing.T) (*app, []tea.Msg, string) { + t.Helper() + target := t.TempDir() + "/owner/transcript.jsonl" + host := replayTestAgent(nil) + for i := 0; i < 24; i++ { + host.entries = append(host.entries, session.DisplayEntry{Role: "user", Text: "Recorded request " + itoa(i)}, session.DisplayEntry{Role: "assistant", Text: "Recorded answer " + itoa(i), Answer: true}) + } + a := toastFixture() + a.width, a.height = 120, 24 + a.behind = map[string]*kept{a.convKey(target): {conv: Conversation{Agent: host, SessionFile: target}, side: &aside{}}} + a.queueCompletionToast(target, 0, 9, 0, false) + cmd := a.viewCompletionToast() + if a.file != target || !a.hostReplayLoading || a.pendingCompletionReveal == nil { + t.Fatal("View did not retain navigation while hosted journal loads") + } + // Execute the real AttachReplay command but deliberately hold its UI fold. + messages := runCmd(cmd) + if !a.hostReplayLoading || a.pendingCompletionReveal == nil { + t.Fatal("request disappeared before asynchronous journal fold") + } + return a, messages, target +} + +func TestCompletionToastViewSurvivesAsyncJournalThenRoster(t *testing.T) { + a, messages, target := asyncCompletionView(t) + drive(t, a, messages...) + if a.file != target || a.hostReplayLoading { + t.Fatal("hosted replay did not finish in owning conversation") + } + if a.pendingCompletionReveal == nil { + t.Fatal("View was discarded before delayed task roster arrived") + } + drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(9, "Delayed restored task", session.TaskDone, session.TaskNotice{Result: "Retained result"})}) + if a.pendingCompletionReveal != nil { + t.Fatal("View intent remained pending after matching work arrived") + } + found := false + for _, r := range a.visible(a.bodyWidth()) { + if r.hit == hitWorkCompletion && r.completion.task == 9 { + found = true + } + } + if !found { + t.Fatal("matching delayed result did not open in its disclosure") + } + if a.offset <= 0 { + t.Fatal("View did not scroll to delayed work below hosted journal") + } + // Later unrelated frames must not replay the explicit navigation after success. + a.offset, a.stick = 0, false + drive(t, a, frameMsg{}) + if a.offset != 0 || a.pendingCompletionReveal != nil { + t.Fatal("later frame stole scrolling after successful View") + } +} + +func TestCompletionToastAsyncViewCancelsWhenUserChangesConversation(t *testing.T) { + a, messages, _ := asyncCompletionView(t) + a.detachConversation() + other := t.TempDir() + "/other/transcript.jsonl" + drive(t, a, runCmd(a.attachConversation(Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: other}, nil))...) + drive(t, a, messages...) + if a.file != other || a.pendingCompletionReveal != nil { + t.Fatal("old View intent survived navigation to another conversation") + } + a.offset, a.stick = 0, false + drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(9, "Different chat same task number", session.TaskDone, session.TaskNotice{})}) + if a.offset != 0 || a.pendingCompletionReveal != nil { + t.Fatal("old task identity changed the new conversation's scroll") + } +} diff --git a/internal/tui3/completiontoast_test.go b/internal/tui3/completiontoast_test.go new file mode 100644 index 0000000000..3a059889a7 --- /dev/null +++ b/internal/tui3/completiontoast_test.go @@ -0,0 +1,184 @@ +package tui3 + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/charmbracelet/x/ansi" +) + +func toastFixture() *app { + a := newTestApp(&fakeAgent{model: "m"}) + a.file = "/tmp/toast/session/transcript.jsonl" + a.focused = true + a.entries = foldFixture() + a.turn = 1 + a.closeHome() + a.dismissWelcome() + return a +} +func TestCompletionToastCoalescesExpiresAndDoesNotMoveComposer(t *testing.T) { + a := toastFixture() + now := time.Unix(100, 0) + a.clock = func() time.Time { return now } + before, _, _, _ := a.chrome(a.width) + a.queueCompletionToast(a.file, 1, 7, 0, false) + first := a.completionToast.serial + a.queueCompletionToast(a.file, 1, 8, 0, true) + frame, _, _ := a.frame() + if !strings.Contains(ansi.Strip(frame), "2 work items finished") { + t.Fatalf("missing burst toast:\n%s", ansi.Strip(frame)) + } + after, _, _, _ := a.chrome(a.width) + if len(before) != len(after) { + t.Fatal("toast moved composer") + } + a.route(completionToastExpiredMsg{first}) + if !a.completionToastActive() { + t.Fatal("old timer dismissed newer completion") + } + now = now.Add(completionToastDuration) + if a.completionToastActive() { + t.Fatal("toast outlived six seconds") + } + frame, _, _ = a.frame() + if strings.Contains(ansi.Strip(frame), "View work") { + t.Fatal("expired toast still drawn") + } +} +func TestCompletionToastViewClickOpensOwningFoldAndPreservesDraft(t *testing.T) { + a := toastFixture() + a.input.setText("keep this draft") + a.queueCompletionToast(a.file, 1, 0, 0, false) + a.frame() + x, y := a.completionToast.view.from, a.completionToastY + _, took := a.completionToastPress(x, y) + if !took || !a.workOpen[1] { + t.Fatalf("View click did not open originating work: took=%v open=%v", took, a.workOpen) + } + if a.input.String() != "keep this draft" { + t.Fatal("toast changed draft") + } + if a.completionToastActive() { + t.Fatal("View did not dismiss toast") + } +} +func TestCompletionToastDismissClickAndUnfocusedSilence(t *testing.T) { + a := toastFixture() + a.queueCompletionToast(a.file, 1, 0, 0, false) + a.frame() + if _, took := a.completionToastPress(a.completionToast.dismiss.from, a.completionToastY); !took { + t.Fatal("dismiss target missed") + } + if a.completionToastActive() { + t.Fatal("dismiss failed") + } + a.focused = false + a.queueCompletionToast(a.file, 1, 1, 0, false) + if a.completionToast.count != 0 { + t.Fatal("unfocused terminal queued in-app toast") + } +} +func TestHeldCompletionOnlySignalsObservedEdgesAndDeduplicates(t *testing.T) { + w := &behindWatch{} + terminal := &session.TaskNotice{ID: 7, State: session.TaskDone} + w.noteTask(terminal) + if len(w.takeCompletions()) != 0 { + t.Fatal("old task replay raised toast") + } + w.noteTask(&session.TaskNotice{ID: 7, State: session.TaskRunning}) + w.noteTask(terminal) + w.noteTask(terminal) + got := w.takeCompletions() + if len(got) != 1 || got[0].task != 7 { + t.Fatalf("task edge duplicated/lost: %+v", got) + } + w.noteJob(&session.JobNotice{ID: 4, State: session.JobDone}) + if len(w.takeCompletions()) != 0 { + t.Fatal("old process replay raised toast") + } + w.noteJob(&session.JobNotice{ID: 4, State: session.JobRunning}) + w.noteJob(&session.JobNotice{ID: 4, State: session.JobFailed}) + w.noteJob(&session.JobNotice{ID: 4, State: session.JobFailed}) + got = w.takeCompletions() + if len(got) != 1 || got[0].process != 4 || !got[0].incomplete { + t.Fatalf("process edge duplicated/lost: %+v", got) + } +} +func TestCompletionToastWidthNeverOverflows(t *testing.T) { + a := toastFixture() + a.queueCompletionToast(a.file, 1, 1, 0, true) + for _, width := range []int{20, 24, 32, 60, 120} { + line := a.completionToastLine(width) + if ansi.StringWidth(line) > width || a.completionToast.dismiss.to > width { + t.Fatalf("toast actions clipped at width %d: %q", width, ansi.Strip(line)) + } + } +} + +func TestLiveCompletionToastOnlyWhenOriginalWorkIsOutOfSight(t *testing.T) { + for _, away := range []bool{false, true} { + a := toastFixture() + if away { + a.showPage(pageSettings) + } + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 7, Title: "Check parser", State: session.TaskRunning}}) + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 7, Title: "Check parser", State: session.TaskDone}}) + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 7, Title: "Check parser", State: session.TaskDone}}) + if away && a.completionToast.count != 1 { + t.Fatalf("offscreen completion not signalled exactly once: %+v", a.completionToast) + } + if !away && a.completionToast.count != 0 { + t.Fatal("watched work raised redundant toast") + } + } + a := toastFixture() + a.showPage(pageSettings) + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 7, Title: "Old task", State: session.TaskDone}}) + if a.completionToast.count != 0 { + t.Fatal("restored terminal event raised toast") + } +} + +func TestReplyToastDoesNotDuplicateTaskSignalAndViewPreservesDraft(t *testing.T) { + a := toastFixture() + a.queueCompletionToast(a.file, 1, 7, 0, false) + a.queueReplyToast(a.file) + if a.completionToast.count != 1 || a.completionToast.task != 7 { + t.Fatal("reply duplicated task completion signal") + } + a.dismissCompletionToast() + a.input.setText("draft stays") + a.queueReplyToast(a.file) + frame, _, _ := a.frame() + if !strings.Contains(ansi.Strip(frame), "Reply ready") { + t.Fatal("reply completion not labelled") + } + a.viewCompletionToast() + if a.input.String() != "draft stays" || !a.stick { + t.Fatal("View reply changed draft or failed to reveal latest answer") + } +} + +func TestHeldDecisionAndCreditStopDoNotRaiseRoutineCompletionToast(t *testing.T) { + a := toastFixture() + file := "/tmp/asking/session/transcript.jsonl" + w := &behindWatch{} + held := &kept{conv: Conversation{Agent: askingAgent{&fakeAgent{model: "m"}}, SessionFile: file}, watch: w} + a.behind = map[string]*kept{a.convKey(file): held} + w.noteTask(&session.TaskNotice{ID: 7, State: session.TaskRunning}) + w.noteTask(&session.TaskNotice{ID: 7, State: session.TaskDone}) + a.behindStir(behindStirMsg{key: a.convKey(file)}) + if a.completionToastActive() { + t.Fatal("persistent question got routine finished toast") + } + w = &behindWatch{} + w.noteTask(&session.TaskNotice{ID: 8, State: session.TaskRunning}) + w.noteTask(&session.TaskNotice{ID: 8, State: session.TaskFailed, Crew: &crewroute.Decision{Stopped: "add credits to continue"}}) + if len(w.takeCompletions()) != 0 { + t.Fatal("actionable credit stop became routine completion") + } +} diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 47b5a01f32..ef20c7e79c 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -968,6 +968,10 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { // A SELECTED PROPOSAL OWNS IT TOO, for the other half of the same reason: // a click on that card opens the node's room now, so the brief keeps the // key rather than losing both gestures (task.go's [app.openCard]). + if c := a.selectedWorkCompletion(); c != nil && c.text != "" { + a.toggleWorkCompletionDetails(c) + return nil + } if a.openDone(a.sel) || a.openCard(a.sel) { return nil } @@ -1482,6 +1486,9 @@ func (a *app) enterLine() tea.Cmd { a.toggleCap(key) return nil } + if c := a.selectedWorkCompletion(); c != nil { + return a.openWorkCompletion(c) + } a.openTool(a.sel) return nil } diff --git a/internal/tui3/jobstate.go b/internal/tui3/jobstate.go index 7f375e3d78..bfb2b7a98a 100644 --- a/internal/tui3/jobstate.go +++ b/internal/tui3/jobstate.go @@ -46,6 +46,7 @@ func (a *app) jobUpdate(notice session.JobNotice) bool { if notice.ID == 0 || notice.Kind == session.JobKindTask { return false } + a.recordWorkProcess(notice) for at, held := range a.jobs { if held.ID != notice.ID { continue @@ -53,7 +54,14 @@ func (a *app) jobUpdate(notice session.JobNotice) bool { if held == notice { return false } + wasLive := !held.Over() a.jobs[at] = notice + if wasLive && notice.Over() { + turn, owned := a.workProcessTurn(notice.ID) + if owned && !a.workTurnVisible(turn) { + a.queueCompletionToast(a.file, turn, 0, notice.ID, notice.State == session.JobFailed) + } + } return true } // OLDEST FIRST, AND THE ORDER NEVER CHANGES AGAIN. A job's place in this diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index d03d5ae326..7e5f7a9897 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -251,9 +251,10 @@ type behindWatch struct { // cancellation snapshot. Painting reads only the atomic working flag. live map[uint64]struct{} // The surface snapshots only this conversation’s replayed cancellation IDs. - workMu sync.Mutex - jobs map[int]struct{} - jobbing atomic.Bool + workMu sync.Mutex + jobs map[int]struct{} + completions []heldCompletion + jobbing atomic.Bool // notices are the lines this conversation said to the person while it was // out of sight that NO RECORD CARRIES — memory's notices // ([session.Event.Unrecorded]). A turn's events and the transcript are @@ -327,6 +328,11 @@ func (w *behindWatch) noteTask(notice *session.TaskNotice) bool { } w.workMu.Lock() defer w.workMu.Unlock() + _, wasLive := w.live[notice.ID] + completed := wasLive && taskCompletionState(notice.State) && notice.Parent == 0 + if completed && (notice.Crew == nil || strings.TrimSpace(notice.Crew.Stopped) == "") { + w.completions = append(w.completions, heldCompletion{task: notice.ID, incomplete: notice.State == session.TaskFailed}) + } switch notice.State { case session.TaskRunning, session.TaskQueued: if w.live == nil { @@ -338,7 +344,7 @@ func (w *behindWatch) noteTask(notice *session.TaskNotice) bool { default: return false } - return w.tasking.Swap(len(w.live) > 0) != (len(w.live) > 0) + return w.tasking.Swap(len(w.live) > 0) != (len(w.live) > 0) || completed } // stir asks the surface to look, unless it has already been asked. @@ -711,6 +717,15 @@ func (a *app) behindStir(note behindStirMsg) tea.Cmd { return tea.Batch(next, a.takeOverKept(note.key, held)) } landed := held.watch.took() + requiresPerson := needsPerson(held.conv.Agent) + for _, completion := range held.watch.takeCompletions() { + if !requiresPerson { + a.queueCompletionToast(held.conv.SessionFile, 0, completion.task, completion.process, completion.incomplete) + } + } + if requiresPerson && a.completionToast.file == held.conv.SessionFile { + a.dismissCompletionToast() + } var parked tea.Cmd continued := false if landed && held.side != nil && len(held.side.parks) > 0 { @@ -725,14 +740,18 @@ func (a *app) behindStir(note behindStirMsg) tea.Cmd { a.touch() var banner tea.Cmd switch { - case needsPerson(held.conv.Agent): + case requiresPerson: // A QUESTION IN A CONVERSATION NOBODY IS LOOKING AT. The presence file // says `waiting on you` to every other window on the machine and to // home; this is what says it to the person sitting here, who is looking // at a different conversation in the same terminal. banner = a.notifyBehind(held, notifyAskWord) case landed && !continued: - banner = a.notifyBehind(held, notifyDoneWord) + if a.focused { + a.queueReplyToast(held.conv.SessionFile) + } else { + banner = a.notifyBehind(held, notifyDoneWord) + } } // NO FINISHED BANNER FOR AN ANSWER THAT IMMEDIATELY CONTINUES. The finished // counter still advances because the tab truthfully reports how many turns diff --git a/internal/tui3/keptwork.go b/internal/tui3/keptwork.go index c7f393c631..24e39a7358 100644 --- a/internal/tui3/keptwork.go +++ b/internal/tui3/keptwork.go @@ -20,6 +20,7 @@ func (w *behindWatch) noteJob(notice *session.JobNotice) bool { } if was && notice.Over() { w.finished.Add(1) + w.completions = append(w.completions, heldCompletion{process: notice.ID, incomplete: notice.State == session.JobFailed}) } return w.jobbing.Swap(len(w.jobs) > 0) != (len(w.jobs) > 0) || was && notice.Over() } diff --git a/internal/tui3/livesteps.go b/internal/tui3/livesteps.go index 33b2878a6e..7e841557ac 100644 --- a/internal/tui3/livesteps.go +++ b/internal/tui3/livesteps.go @@ -103,6 +103,7 @@ const liveWorkWord = "working" // so the run ends there and a new one opens under it — which leaves their // sentence standing between two compact blocks, where they said it. type liveWork struct { + completions []workCompletion // key is the disclosure control; turn remains the transcript identity. key int // turn owns tool windows and Ctrl+O; room phase disclosure keys do not. @@ -416,7 +417,7 @@ func (a *app) liveStepBlock(w liveWork, width int, d deck) []row { } out = append(out, row{text: drawn, entry: -1, hit: hitWorkFold, turn: w.key, activity: w.pending}) } - return out + return a.liveCompletionSummary(w, out, width) } // Newest first, one whole step at a time, until the budget is spent. type step struct { @@ -523,7 +524,7 @@ func (a *app) liveStepBlock(w liveWork, width int, d deck) []row { out = append(out, row{text: drawn, entry: -1, hit: hitWorkFold, turn: w.key, activity: (s.live && at == 0) || (waiting && ((inline && i == len(s.lines)-1) || (!inline && i == 0))), inlineWait: waiting && ((inline && i == len(s.lines)-1) || (!inline && i == 0))}) } } - return out + return a.liveCompletionSummary(w, out, width) } // liveWorkDoor is the way back out of an opened window, and it is drawn only @@ -538,7 +539,11 @@ func (a *app) liveStepBlock(w liveWork, width int, d deck) []row { // (tokencol.go states the one rule both obey). func (a *app) liveWorkDoor(w liveWork, width int, d deck) row { mark := a.linearMark("▾ ", "v ") - text := a.pal.dim(mark + liveWorkWord + " · ctrl+e") + label := mark + liveWorkWord + if len(w.completions) > 0 { + label += railSep + itoa(len(w.completions)) + " finished" + } + text := a.pal.dim(label + " · ctrl+e") room := width - workIndentCols(width) - actionGutter if room > 0 && w.last { text += a.turnTokenSuffix(d, ansi.StringWidth(text), room) diff --git a/internal/tui3/notify.go b/internal/tui3/notify.go index ab0ccc903a..42961948d6 100644 --- a/internal/tui3/notify.go +++ b/internal/tui3/notify.go @@ -79,6 +79,9 @@ func (a *app) notifyBody() string { // construction not the one being read. func (a *app) notifyDone() tea.Cmd { if a.focused { + if !a.workTurnVisible(a.turn) { + a.queueReplyToast(a.file) + } return nil } return tea.Raw(a.pingSeq(notifyTitle, a.notifyBody())) diff --git a/internal/tui3/render.go b/internal/tui3/render.go index f0b4a3e203..5d31a96566 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -34,12 +34,14 @@ import ( type hitKind uint8 const ( - hitNone hitKind = iota - hitTool // a tool call: click expands that call inline - hitFold // the "N earlier tool calls" line: click expands the turn - hitCaption // one step heading: click opens that step's calls - hitWorkFold // one completed turn's folded machinery - hitMore // the "… N more lines" foot of a capped expansion: click lifts the cap + hitNone hitKind = iota + hitTool // a tool call: click expands that call inline + hitFold // the "N earlier tool calls" line: click expands the turn + hitCaption // one step heading: click opens that step's calls + hitWorkFold // one completed turn's folded machinery + hitWorkReceiptDetails // original receipt details inside a work disclosure + hitWorkCompletion // a task result or process log nested in its work disclosure + hitMore // the "… N more lines" foot of a capped expansion: click lifts the cap // hitBrief is the door under a node's folded instruction (brieffold.go): // click opens the rest of it, click again folds it back. It is a hit of its // own rather than another hitMore because the two answer the same gesture @@ -84,10 +86,11 @@ const ( // the mouse hit-tests row.entry, and ↑/↓ walk the same list. Two sources of // truth for "which entry is this row" is how a click lands on the wrong call. type row struct { - text string - entry int // index into app.entries; -1 for a blank or the fold line - hit hitKind - turn int // the turn a fold line folds + text string + entry int // index into app.entries; -1 for a blank or the fold line + hit hitKind + turn int // the turn a fold line folds + completion *workCompletion // activity says this row already carries the running turn's sign of life. // The footer reads the actual drawing so an opened or absent compact block // cannot suppress the only remaining indication of work. @@ -389,6 +392,14 @@ func (a *app) deckRows(d deck, width int) ([]row, bool) { // tense earlier. The lens chooses live compactness independently of its // settled fold policy, and each page owns its disclosure key. lives := deriveLiveWork(d) + if d.lens.foldPast == foldTurns { + for at, w := range lives { + if w.last { + w.completions = a.workCompletionsFor(d, w.turn) + lives[at] = w + } + } + } // The compact work window already owns the activity indication. Keep // the anchored logo for the initial waiting state, not as a second // animated row stranded above a reply after work resumes. @@ -397,7 +408,20 @@ func (a *app) deckRows(d deck, width int) ([]row, bool) { } for i := 0; i < len(es); i++ { e := &es[i] - if f, ok := folds[i]; ok { + if f, ok := folds[i]; ok && f.virtual { + if wasUser || wasBlock { + gap() + } + out = append(out, row{text: a.pal.dim(a.workfoldLabel(d, f)), entry: -1, hit: hitWorkFold, turn: f.key}) + if a.workFoldOpen(d, f.key) { + out = append(out, a.completionRows(f.completions, width)...) + } + wasCluster, wasBlock, wasUser, wasNote = true, false, false, false + } + if e.completion != nil { + continue + } + if f, ok := folds[i]; ok && !f.virtual { // THE CHIP IS NAMED BY ITS OWN KEY AND NOT BY THE TURN // (workfold.go's [workfold.key]): a room's page is one turn holding a // chip per phase, and a row that carried the turn would make every @@ -449,6 +473,7 @@ func (a *app) deckRows(d deck, width int) ([]row, bool) { } out = append(out, a.disclosedEntryRows(d, at, width)...) } + out = append(out, a.completionRows(f.completions, width)...) i = f.answer - 1 wasCluster, wasBlock, wasUser, wasNote = true, false, false, false continue @@ -542,6 +567,9 @@ func (a *app) deckRows(d deck, width int) ([]row, bool) { out = append(out, a.toolRows(d, call, call == toolsTo-1, width)...) } } + if w.last { + out = append(out, a.completionRows(w.completions, width)...) + } wasCluster, wasBlock, wasUser, wasNote = true, false, false, false i = w.end - 1 continue @@ -903,6 +931,9 @@ func (a *app) captionSpanBody(d deck, c caption, width int) []row { // disclosedEntryRows preserves each retained entry's interaction when the // surrounding work is opened, including landed-card doors and assistant links. func (a *app) disclosedEntryRows(d deck, at, width int) []row { + if d.entries[at].completion != nil { + return nil + } e := &d.entries[at] // The caption owns its lifted narration, including the disclosed remainder. if e.kind == entryAssistant && e.capHead { diff --git a/internal/tui3/replay.go b/internal/tui3/replay.go index ef92aad3ba..6d77c34b8d 100644 --- a/internal/tui3/replay.go +++ b/internal/tui3/replay.go @@ -710,6 +710,7 @@ func (a *app) replayBlocks(entries []session.DisplayEntry, shape replayShape) ([ continue // a step that only called tools; its calls follow } if len(e.ReplyTags) > 0 { + associateReplayCompletionSources(blocks, e.ReplyTags) blocks = append(blocks, taskReplySourceEntry(e.ReplyTags, turn)) } blocks = append(blocks, entry{ @@ -811,6 +812,10 @@ func (a *app) replayBlocks(entries []session.DisplayEntry, shape replayShape) ([ case asideHidden: continue } + if e.RoutineCompletion { + blocks = append(blocks, replayCompletionEntries(e, blocks, turn)...) + continue + } // A LINE THE SESSION WROTE GOES IN THE SESSION'S OWN LANE — the dim // "· " row this surface says everything of its own in ([feed.note]) — // and NOT above a "›" as though somebody had typed it. @@ -826,6 +831,7 @@ func (a *app) replayBlocks(entries []session.DisplayEntry, shape replayShape) ([ // draws exactly as it always did. blocks = append(blocks, entry{ kind: entryNote, text: text, turn: turn, cut: e.Interrupted, + told: e.Addressed || session.LegacyWatchAlert(text) || strings.HasPrefix(text, "while you worked:"), }) } } diff --git a/internal/tui3/replaycompletion.go b/internal/tui3/replaycompletion.go new file mode 100644 index 0000000000..1a4601a765 --- /dev/null +++ b/internal/tui3/replaycompletion.go @@ -0,0 +1,181 @@ +package tui3 + +import ( + "crypto/sha256" + "fmt" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// Replay keeps runtime receipts as inspectable work without rewriting their journal. +func replayCompletionEntries(e session.DisplayEntry, blocks []entry, turn int) []entry { + if !e.RoutineCompletion { + return nil + } + if len(e.CompletionTasks) > 0 { + out := make([]entry, 0, len(e.CompletionTasks)) + for _, tag := range e.CompletionTasks { + facts := session.TaskFacts{State: tag.State} + if tag.Stopped { + facts.Ending = session.TaskEndingStopped + } + state := session.ProjectTask(facts).Word + c := &workCompletion{owner: replayCompletionOwner(blocks, tag.ID, 0, tag.Request), task: tag.ID, title: tag.Title, state: state, text: e.Text, key: fmt.Sprintf("task/%d/%d", tag.ID, tag.Revision)} + out = append(out, entry{kind: entryNote, turn: turn, text: e.Text, completion: c}) + } + return out + } + task, job, title, state := replayCompletionIdentity(e.Text) + c := &workCompletion{owner: replayCompletionOwner(blocks, task, job, ""), task: task, job: job, title: title, state: state, text: e.Text} + switch { + case task > 0: + c.key = fmt.Sprintf("task/%d", task) + case job > 0: + c.key = fmt.Sprintf("job/%d", job) + default: + c.key = fmt.Sprintf("receipt/%x", sha256.Sum256([]byte(e.Text))) + } + return []entry{{kind: entryNote, turn: turn, text: e.Text, completion: c}} +} + +// Only an exact original request, proposal id or reserved process-start receipt proves ownership. +func replayCompletionOwner(blocks []entry, task uint64, job int, request string) int { + proven, requestTurn := 0, 0 + proofFound, proofAmbiguous, requestFound, requestAmbiguous := false, false, false, false + for i := range blocks { + e := &blocks[i] + proof := task > 0 && e.kind == entryTask && e.card != nil && e.card.id == task + proof = proof || job > 0 && e.kind == entryTool && e.tool == "jobs" && strings.HasPrefix(e.detail.Output, fmt.Sprintf("job %d started; log at ", job)) + if proof { + if proofFound && proven != e.turn { + proofAmbiguous = true + } + proven, proofFound = e.turn, true + } + if request != "" && e.kind == entryUser && strings.TrimSpace(e.text) == strings.TrimSpace(request) { + if requestFound && requestTurn != e.turn { + requestAmbiguous = true + } + requestTurn, requestFound = e.turn, true + } + } + if proofFound { + if proofAmbiguous { + return 0 + } + return proven + } + if requestFound && !requestAmbiguous { + return requestTurn + } + return 0 +} + +// These identities are read only after the session has proved the occurrence is a runtime receipt. +func replayCompletionIdentity(text string) (task uint64, job int, title, state string) { + text = strings.TrimPrefix(strings.TrimSpace(text), "while you worked: ") + for _, line := range strings.Split(text, "\n") { + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + id, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil || id == 0 { + continue + } + switch fields[0] { + case "task": + task = id + if fields[2] == "crew" { + return task, 0, "Task " + fields[1], "" + } + _, rest, ok := strings.Cut(line, ": ") + if ok { + title, _, _ = strings.Cut(rest, " · transcript ") + } + state = strings.TrimSuffix(fields[2], ":") + return task, 0, title, state + case "job": + if fields[2] == "exited" || fields[2] == "finished" { + return 0, int(id), "Process " + fields[1], "finished" + } + } + } + return 0, 0, "Background work", "" +} + +// Older task notes can acquire their exact source from the answer that consumed them. +func associateReplayCompletionSources(blocks []entry, tags []session.TaskReplyTag) { + for _, tag := range tags { + for i := len(blocks) - 1; i >= 0; i-- { + if blocks[i].kind == entryAssistant { + answeredBefore := false + for _, prior := range blocks[i].replyTags { + answeredBefore = answeredBefore || prior.ID == tag.ID + } + if answeredBefore { + break + } + } + c := blocks[i].completion + if c == nil || c.task != tag.ID { + continue + } + wanted := fmt.Sprintf("task/%d/%d", tag.ID, tag.Revision) + if c.key != "task/"+strconv.FormatUint(tag.ID, 10) && c.key != wanted { + continue + } + c.key = wanted + if c.owner == 0 { + c.owner = replayCompletionOwner(blocks[:i], tag.ID, 0, tag.Request) + } + if strings.TrimSpace(tag.Title) != "" { + c.title = tag.Title + } + } + } +} + +// A toast can reopen a retained receipt's actual disclosure even when old history cannot prove its admission turn. +func (a *app) workCompletionReplayTurn(task uint64) (int, bool) { + for i := len(a.entries) - 1; i >= 0; i-- { + e := &a.entries[i] + if c := e.completion; c != nil && c.task == task { + if c.owner > 0 { + return c.owner, true + } + if e.turn > 0 { + return e.turn, true + } + } + } + return 0, false +} + +// Identity finds the actual disclosure, including old background groups with a synthetic key. +func (a *app) revealCompletionIdentity(task uint64, job int) bool { + key, latest, found := 0, -1, false + for _, fold := range a.deckFolds(a.bodyDeck()) { + for _, c := range fold.completions { + matches := task != 0 && c.task == task || job != 0 && c.job == job + if matches && fold.start >= latest { + key, latest, found = fold.key, fold.start, true + } + } + } + if !found { + return false + } + a.openWorkfold(key) + a.touch() + for i, row := range a.visible(a.bodyWidth()) { + if row.hit == hitWorkFold && row.turn == key { + a.offset = max(0, i-a.viewHeight()/3) + a.stick = false + return true + } + } + return false +} diff --git a/internal/tui3/replaycompletion_test.go b/internal/tui3/replaycompletion_test.go new file mode 100644 index 0000000000..182880a030 --- /dev/null +++ b/internal/tui3/replaycompletion_test.go @@ -0,0 +1,239 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func historicalCompletionFixture() []session.DisplayEntry { + task := session.TaskReplyTag{ID: 9, Title: "Check parser", Request: "Check the parser", State: session.TaskDone, Revision: 1} + return []session.DisplayEntry{ + {Role: "user", Text: "Check the parser"}, + {Role: "assistant", Text: "The parser check is running.", Answer: true}, + {Role: "user", Text: "Build the manual"}, + {Role: "tool", Tool: "jobs", CallID: "job-start", Output: "job 3 started; log at /tmp/manual.log", Answered: true}, + {Role: "assistant", Text: "The manual build is running.", Answer: true}, + {Role: "user", Text: "Keep this new discussion separate"}, + {Role: "aside", Text: "EXACT TASK RECEIPT", RoutineCompletion: true, CompletionTasks: []session.TaskReplyTag{task}}, + {Role: "aside", Text: "task 9 crew · bugfix · worker a · planner b · checker c · $0.310", RoutineCompletion: true}, + {Role: "assistant", Text: "The parser handles the empty map.", Answer: true, ReplyTags: []session.TaskReplyTag{task}}, + {Role: "aside", Text: "job 3 exited 0: manual ready", RoutineCompletion: true}, + {Role: "aside", Text: "while you worked:\n\nambiguous task receipt\n\nRequested watch matched"}, + {Role: "aside", Text: "Requested reminder: review the parser"}, + {Role: "user", Text: "EXACT TASK RECEIPT"}, + } +} + +func TestHistoricalCompletionGroupingKeepsOriginalOwnersAndExactPayloads(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.entries, _ = a.replayBlocks(historicalCompletionFixture(), chatReplay(0)) + tasks, jobs := 0, 0 + reminder, mixed, quote := false, false, false + for _, e := range a.entries { + if c := e.completion; c != nil { + if c.task == 9 { + tasks++ + if c.owner != 1 { + t.Fatalf("task attached to later request: %+v", c) + } + } + if c.job == 3 { + jobs++ + if c.owner != 2 { + t.Fatalf("process attached to later request: %+v", c) + } + } + if c.text != e.text { + t.Fatal("replay lost exact receipt payload") + } + } + reminder = reminder || e.text == "Requested reminder: review the parser" + mixed = mixed || strings.Contains(e.text, "ambiguous task receipt") && e.completion == nil + quote = quote || e.kind == entryUser && e.text == "EXACT TASK RECEIPT" && e.completion == nil + } + if tasks != 2 || jobs != 1 || !reminder || !mixed || !quote { + t.Fatalf("lost receipt or meaningful occurrence: tasks=%d jobs=%d reminder=%v mixed=%v quote=%v", tasks, jobs, reminder, mixed, quote) + } +} + +func TestHistoricalCompletionOwnershipNeverGuessesAcrossRequests(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.entries, _ = a.replayBlocks([]session.DisplayEntry{{Role: "user", Text: "Unrelated new request"}, {Role: "aside", Text: "task 37 done: older report · transcript file:///tmp/report", RoutineCompletion: true}}, chatReplay(0)) + c := a.entries[len(a.entries)-1].completion + if c == nil || c.task != 37 || c.owner != 0 { + t.Fatalf("unknown old work invented a request owner: %+v", c) + } +} + +func TestHistoricalCompletionGroupingSurvivesReopenAndTabReturn(t *testing.T) { + first := &fakeAgent{model: "m", past: historicalCompletionFixture()} + a := newTestApp(first) + a.file = t.TempDir() + "/first.jsonl" + a.entries, _ = a.replayBlocks(first.past, chatReplay(0)) + check := func() { + t.Helper() + tasks, jobs := 0, 0 + for _, e := range a.entries { + if c := e.completion; c != nil { + if c.task == 9 && c.owner == 1 { + tasks++ + } + if c.job == 3 && c.owner == 2 { + jobs++ + } + } + } + if tasks != 2 || jobs != 1 { + t.Fatalf("tab/reopen duplicated or lost grouped receipts: %d %d", tasks, jobs) + } + } + check() + original, side := a.front(), a.detachConversation() + drain(t, a, a.attachConversation(Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: t.TempDir() + "/second.jsonl"}, nil)) + for _, e := range a.entries { + if e.completion != nil { + t.Fatal("another conversation inherited grouped work") + } + } + a.detachConversation() + drain(t, a, a.attachConversation(original, side)) + check() + reopened := newTestApp(&fakeAgent{model: "m", past: historicalCompletionFixture()}) + reopened.entries, _ = reopened.replayBlocks(historicalCompletionFixture(), chatReplay(0)) + a.entries = reopened.entries + check() +} + +func TestLegacyTaskAndCrewReceiptsAcquireTheSameOriginalRequest(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + tag := session.TaskReplyTag{ID: 9, Title: "Parser", Request: "Check parser", State: session.TaskDone, Revision: 1} + a.entries, _ = a.replayBlocks([]session.DisplayEntry{ + {Role: "user", Text: "Check parser"}, {Role: "assistant", Text: "Working on it.", Answer: true}, + {Role: "user", Text: "A different request"}, + {Role: "aside", Text: "task 9 done: parser ready · transcript file:///tmp/parser", RoutineCompletion: true}, + {Role: "aside", Text: "task 9 crew · bugfix · worker a · checker c", RoutineCompletion: true}, + {Role: "assistant", Text: "The parser passes.", Answer: true, ReplyTags: []session.TaskReplyTag{tag}}, + }, chatReplay(0)) + receipts := 0 + for _, e := range a.entries { + if c := e.completion; c != nil { + receipts++ + if c.owner != 1 || c.key != "task/9/1" { + t.Fatalf("old receipt not grouped with original request: %+v", c) + } + } + } + if receipts != 2 { + t.Fatalf("legacy receipts lost: %d", receipts) + } + if turn, ok := a.workCompletionReplayTurn(9); !ok || turn != 1 { + t.Fatalf("toast view lost original disclosure: %d %v", turn, ok) + } +} + +func TestLegacyToastViewUsesRetainedLocalDisclosureWithoutInventingOwner(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.entries, _ = a.replayBlocks([]session.DisplayEntry{{Role: "user", Text: "Current request"}, {Role: "aside", Text: "task 37 done: older report · transcript file:///tmp/report", RoutineCompletion: true}}, chatReplay(0)) + if turn, ok := a.workCompletionReplayTurn(37); !ok || turn != 1 { + t.Fatalf("retained background disclosure cannot be revealed: %d %v", turn, ok) + } + if a.entries[1].completion.owner != 0 { + t.Fatal("fallback invented original admission ownership") + } + if _, ok := a.workCompletionReplayTurn(38); ok { + t.Fatal("toast invented a missing work disclosure") + } +} + +func TestToastIdentityRevealsUnknownBackgroundFoldRatherThanWrongRequest(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.width, a.height = 120, 40 + a.entries, _ = a.replayBlocks([]session.DisplayEntry{{Role: "user", Text: "Unrelated request"}, {Role: "aside", Text: "task 37 done: older report · transcript file:///tmp/report", RoutineCompletion: true}}, chatReplay(0)) + a.touch() + var key int + found := false + for _, f := range a.deckFolds(a.bodyDeck()) { + if f.background && len(f.completions) == 1 && f.completions[0].task == 37 { + key, found = f.key, true + } + } + if !found || key >= 0 { + t.Fatalf("unknown work has no separate background disclosure: key=%d found=%v", key, found) + } + if !a.revealCompletionIdentity(37, 0) || !a.workOpen[key] { + t.Fatal("toast View failed to reveal actual retained background fold") + } + if a.workOpen[1] { + t.Fatal("toast opened unrelated request instead of background disclosure") + } + if a.revealCompletionIdentity(38, 0) { + t.Fatal("toast View invented a missing receipt") + } +} + +func TestHistoricalTaskAttemptsKeepTheirOwnIdentityAndRequest(t *testing.T) { + first := session.TaskReplyTag{ID: 9, Title: "First parser attempt", Request: "First request", State: session.TaskFailed, Revision: 1} + second := session.TaskReplyTag{ID: 9, Title: "Second parser attempt", Request: "Second request", State: session.TaskDone, Revision: 2} + a := newTestApp(&fakeAgent{model: "m"}) + a.entries, _ = a.replayBlocks([]session.DisplayEntry{ + {Role: "user", Text: first.Request}, {Role: "aside", Text: "First attempt details", RoutineCompletion: true, CompletionTasks: []session.TaskReplyTag{first}}, + {Role: "user", Text: second.Request}, {Role: "aside", Text: "Second attempt details", RoutineCompletion: true, CompletionTasks: []session.TaskReplyTag{second}}, + {Role: "assistant", Text: "The second attempt passes.", ReplyTags: []session.TaskReplyTag{second}, Answer: true}, + }, chatReplay(0)) + for _, e := range a.entries { + if c := e.completion; c != nil { + switch c.text { + case "First attempt details": + if c.key != "task/9/1" || c.owner != 1 || c.title != first.Title { + t.Fatalf("old attempt inherited later result identity: %+v", c) + } + case "Second attempt details": + if c.key != "task/9/2" || c.owner != 2 { + t.Fatalf("new attempt lost identity: %+v", c) + } + } + } + } +} + +func TestReopenedRequestedAlertsAndAmbiguousMixedMessagesRemainVisibleOutsideWork(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + a.width, a.height = 140, 50 + const watch = "watch release: until matched" + const mixed = "while you worked:\n\nThe task ended.\n\nRequested alert: review release" + a.entries, _ = a.replayBlocks([]session.DisplayEntry{ + {Role: "user", Text: "Check release"}, + {Role: "tool", Tool: "read", CallID: "read-1", Output: "private operational output", Answered: true}, + {Role: "aside", Text: "provider retry bookkeeping"}, + {Role: "aside", Text: watch + "\nREADY", Addressed: true}, + {Role: "aside", Text: mixed}, + {Role: "aside", Text: "Requested reminder: review parser", Addressed: true}, + {Role: "assistant", Text: "The release is ready.", Answer: true}, + }, chatReplay(0)) + a.touch() + visible := taskText(a) + for _, want := range []string{watch, "Requested alert: review release", "Requested reminder: review parser", "The release is ready."} { + if !strings.Contains(visible, want) { + t.Fatalf("requested alert hidden inside routine work: missing %q\n%s", want, visible) + } + } + if strings.Contains(visible, "provider retry bookkeeping") { + t.Fatalf("attention exposed unrelated housekeeping: %s", visible) + } +} + +func TestRepeatedOriginalRequestDoesNotReassignHistoricalTask(t *testing.T) { + a := newTestApp(&fakeAgent{model: "m"}) + tag := session.TaskReplyTag{ID: 9, Title: "Parser", Request: "Check parser", State: session.TaskDone} + a.entries, _ = a.replayBlocks([]session.DisplayEntry{{Role: "user", Text: "Check parser"}, {Role: "assistant", Text: "Working.", Answer: true}, {Role: "user", Text: "Check parser"}, {Role: "aside", Text: "task receipt", RoutineCompletion: true, CompletionTasks: []session.TaskReplyTag{tag}}}, chatReplay(0)) + c := a.entries[len(a.entries)-1].completion + if c == nil || c.owner != 0 { + t.Fatalf("ambiguous repeated request assigned to wrong occurrence: %+v", c) + } + blocks := []entry{{kind: entryUser, text: "Check parser", turn: 1}, {kind: entryTask, turn: 1, card: &taskCard{id: 9}}, {kind: entryUser, text: "Check parser", turn: 2}} + if owner := replayCompletionOwner(blocks, 9, 0, "Check parser"); owner != 1 { + t.Fatalf("exact proposal identity lost to ambiguous words: %d", owner) + } +} diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 287ad49782..1f2b59df72 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -150,6 +150,9 @@ func (c *taskCard) settled() bool { return c.verdict != "" } // elapsed clock counts on the frame tick instead of freezing at whatever the // last update happened to say. Everything else is the notice, kept. type taskNode struct { + // workTurn records the request that admitted this live work, independently of completion time. + workTurn int + workOwned bool // retried keeps the conversation card in its original position across attempts. retried bool id uint64 @@ -5209,6 +5212,10 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { return nil } } + wasLive := false + if old := a.tasks[notice.ID]; old != nil { + wasLive = old.state == session.TaskRunning || old.state == session.TaskQueued + } if a.taskSeen == nil { a.taskSeen = map[uint64]session.TaskState{} } @@ -5461,6 +5468,9 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { a.landedCard(node) } var pilot tea.Cmd + if !node.workOwned { + node.workTurn, node.workOwned = a.workCompletionTurn(node.id) + } // Crew facts remain with the task rather than producing transcript receipts. a.sayTaskCrew(*notice) switch notice.State { @@ -5512,6 +5522,13 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { } } a.touch() + if wasLive && taskCompletionState(notice.State) && node.parent == "" { + if turn, owned := a.workCompletionTurn(node.id); !a.completionIdentityVisible(node.id, 0, turn, owned) { + if card := a.doneCardFor(node.id); card == nil || card.quiet { + a.queueCompletionToast(a.file, turn, node.id, 0, notice.State == session.TaskFailed) + } + } + } if node.retried && a.taskSheet.detailOn && a.taskSheetNodeFor(&a.taskSheet.detail) == node && notice.State != session.TaskRunning && notice.State != session.TaskQueued { a.taskSheet.detail = a.currentTaskEntry(a.taskSheet.detail) diff --git a/internal/tui3/teamcard.go b/internal/tui3/teamcard.go index 51d4bde771..ca789d53d1 100644 --- a/internal/tui3/teamcard.go +++ b/internal/tui3/teamcard.go @@ -235,8 +235,6 @@ func asideShapeOf(e session.DisplayEntry) asideShape { switch { case len(e.Team) > 0 || strings.HasPrefix(text, teamAsideLead): return asideTeam - case e.RoutineCompletion: - return asideHidden case session.TeamWakeNote(text): return asideHidden } diff --git a/internal/tui3/view.go b/internal/tui3/view.go index c99125b239..d0e2f05274 100644 --- a/internal/tui3/view.go +++ b/internal/tui3/view.go @@ -146,6 +146,7 @@ const ( // is EMPTY apart from the chip, and the chip is right-aligned, so a press on // it is a question about the column as well as the row (jumpchip.go). chromeJump + chromeCompletionToast // chromeLegend is the rule between the transcript and the box. Its right // end carries the hint slot, and the one thing in that slot a person can // press is the door home (home.go's [app.homeDoorPress]). @@ -291,6 +292,7 @@ func (a *app) frame() (string, int, int) { body = a.teamMembershipOver(body) body = a.teamCreateOver(body) body = a.conversationDeleteOver(body) + body = a.completionToastOver(body) return norm.NFC.String(body), caretX, caretY } @@ -705,7 +707,13 @@ func (a *app) chrome(width int) ([]string, []chromeRow, int, int) { // addGap spends one row of the ladder, and hands it to the chip if the chip // has not been placed yet. THE TIP DOES NOT RIDE THIS ROW: it stands at the // keys row's right end, over the project (footswap.go's [app.hintRow]). + toastPlaced := false addGap := func() { + if a.completionToastAllowed() && !toastPlaced { + toastPlaced = true + add("", chromeRow{kind: chromeCompletionToast}) + return + } if chip != "" && !jumped { jumped = true add(chip, chromeRow{kind: chromeJump}) diff --git a/internal/tui3/workcompletion.go b/internal/tui3/workcompletion.go new file mode 100644 index 0000000000..6ec7da10e0 --- /dev/null +++ b/internal/tui3/workcompletion.go @@ -0,0 +1,402 @@ +package tui3 + +import ( + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/charmbracelet/x/ansi" +) + +// workCompletion retains a runtime receipt without making another conversational answer. +// owner is an established request turn; zero leaves historical ownership unproven. +type workCompletion struct { + at int + owner int + key string + task uint64 + job int + title, state, text string +} + +// workCompletionTurn reads admission provenance, never the time the work happened to finish. +func (a *app) workCompletionTurn(id uint64) (int, bool) { + return a.workCompletionParentTurn(id, map[uint64]bool{}) +} + +func (a *app) workCompletionParentTurn(id uint64, seen map[uint64]bool) (int, bool) { + if seen[id] { + return 0, false + } + seen[id] = true + for _, e := range a.entries { + if e.kind == entryTask && e.card != nil && e.card.id == id { + return e.turn, true + } + if e.completion != nil && e.completion.task == id && e.completion.owner != 0 { + return e.completion.owner, true + } + } + if node := a.tasks[id]; node != nil { + if parent, err := strconv.ParseUint(node.parent, 10, 64); err == nil && parent != id { + if owner, ok := a.workCompletionParentTurn(parent, seen); ok { + return owner, true + } + } + if node.workOwned { + return node.workTurn, true + } + } + return 0, false +} + +// recordWorkProcess holds one process identity through its live and finished states. +func (a *app) recordWorkProcess(notice session.JobNotice) { + if notice.Kind == session.JobKindTask || notice.ID == 0 { + return + } + for i := range a.entries { + c := a.entries[i].completion + if c == nil || c.job != notice.ID { + continue + } + c.title, c.state = notice.Label(), string(notice.State) + return + } + // Restored terminal rows have no admission provenance; replay provides it when known. + if notice.Over() { + return + } + a.entries = append(a.entries, entry{kind: entryNote, turn: a.turn, completion: &workCompletion{owner: a.turn, key: "job/" + itoa(notice.ID), job: notice.ID, title: notice.Label(), state: string(notice.State)}}) +} + +func (a *app) openWorkCompletion(c *workCompletion) tea.Cmd { + if c == nil { + return nil + } + if c.job != 0 { + return a.openJobPage(c.job) + } + if c.task != 0 { + id := strconv.FormatUint(c.task, 10) + for _, entry := range a.taskSheetOwnRows() { + if entry.ID == id && a.taskSheetOwnsEntry(&entry) { + return a.openTaskRecord(&entry) + } + } + if node := a.tasks[c.task]; node != nil { + return a.openTaskRecord(&session.TaskIndexEntry{ID: id, Title: node.label, Label: node.label, Status: string(node.state), SessionID: a.taskSheetSelfID(), Crew: node.crew, Cost: node.spent(), Model: node.model}) + } + } + return nil +} + +// revealWorkCompletion is the toast's door into the existing request disclosure. +func (a *app) revealWorkCompletion(turn int) { + a.openWorkfold(turn) + a.touch() + rows := a.visible(a.bodyWidth()) + for i, r := range rows { + if r.hit == hitWorkFold && r.turn == turn { + a.offset = max(0, i-a.viewHeight()/3) + a.stick = false + return + } + } +} + +func workCompletionKey(c workCompletion) string { + if c.task != 0 && c.key == "task/"+strconv.FormatUint(c.task, 10)+"/0" { + return "task/" + strconv.FormatUint(c.task, 10) + } + if c.key != "" { + return c.key + } + if c.task != 0 { + return "task/" + strconv.FormatUint(c.task, 10) + } + if c.job != 0 { + return "job/" + itoa(c.job) + } + return c.text +} + +func (a *app) completionRows(cs []workCompletion, width int) []row { + var out []row + for i := range cs { + c := cs[i] + title := strings.TrimSpace(c.title) + if title == "" { + title = firstLine(c.text) + } + if title == "" { + continue + } + word := c.state + if word == "" { + word = "finished" + } + if c.task != 0 && word == string(session.TaskFailed) { + word = session.ProjectTask(session.TaskFacts{State: session.TaskFailed}).Word + } + action := "" + door := false + if c.task != 0 { + door = a.tasks[c.task] != nil + for _, saved := range a.comp.tasks { + if saved.ID == strconv.FormatUint(c.task, 10) && a.taskSheetOwnsEntry(&saved) { + door = true + break + } + } + if door { + action = "open result" + } + } + if c.job != 0 { + door = a.jobAt(c.job) != nil + if door { + action = "open log" + } + } + hit := hitNone + if door { + hit = hitWorkCompletion + } + tail := railSep + word + if action != "" { + tail += railSep + action + } + name := fit(title, max(1, width-2-ansi.StringWidth(tail))) + line := " " + a.pal.ink(name) + a.pal.dim(railSep+word) + if action != "" { + line += a.pal.dim(railSep) + a.pal.accent(action) + } + out = append(out, row{text: fitPainted(line, width), entry: c.at, hit: hit, completion: &c}) + if door && c.text != "" { + open := c.at >= 0 && c.at < len(a.entries) && a.entries[c.at].open + arrow := "▸" + if open { + arrow = "▾" + } + if a.pal.linear { + arrow = ">" + if open { + arrow = "v" + } + } + word := arrow + " details · ctrl+o" + out = append(out, row{text: a.pal.dim(" " + word), entry: c.at, hit: hitWorkReceiptDetails, completion: &c}) + if open { + for _, raw := range strings.Split(c.text, "\n") { + for _, wrapped := range wrap(raw, max(1, width-6)) { + out = append(out, row{text: a.pal.muted(" " + wrapped), entry: c.at, completion: &c}) + } + } + } + } + if !door && c.text != "" { + for _, raw := range strings.Split(c.text, "\n") { + for _, wrapped := range wrap(raw, max(1, width-2)) { + out = append(out, row{text: a.pal.dim(" " + wrapped), entry: c.at, completion: &c}) + } + } + } + } + return out +} + +func (a *app) workTaskTurn(id uint64) (int, bool) { return a.workCompletionTurn(id) } +func (a *app) workProcessTurn(id int) (int, bool) { + for _, e := range a.entries { + if c := e.completion; c != nil && c.job == id { + return c.owner, c.owner != 0 + } + } + return 0, false +} + +// withWorkCompletions augments one existing disclosure, never the answer it surrounds. +func (a *app) withWorkCompletions(d deck, folds map[int]workfold) map[int]workfold { + if d.lens.foldPast != foldTurns { + return folds + } + type group struct { + owner, anchor int + unknown bool + items []workCompletion + keys map[string]int + } + var groups []*group + byTurn := map[int]*group{} + unknown := map[int]*group{} + unknownIdentity := map[string]*group{} + add := func(c workCompletion, at int) { + c.at = at + if c.owner == 0 && c.task != 0 { + if owner, ok := a.workCompletionTurn(c.task); ok && owner != 0 { + c.owner = owner + } + } + if c.state == string(session.JobRunning) || c.state == string(session.TaskQueued) { + return + } + var g *group + if c.owner != 0 { + g = byTurn[c.owner] + } else { + g = unknownIdentity[workCompletionKey(c)] + if g == nil { + g = unknown[d.entries[at].turn] + } + } + if g == nil { + g = &group{owner: c.owner, anchor: at, unknown: c.owner == 0, keys: map[string]int{}} + if c.owner != 0 { + byTurn[c.owner] = g + } else { + unknown[d.entries[at].turn] = g + } + groups = append(groups, g) + } + key := workCompletionKey(c) + if c.owner == 0 { + unknownIdentity[key] = g + } + if prev, ok := g.keys[key]; ok { + old := &g.items[prev] + if c.text != "" && !strings.Contains(old.text, c.text) { + if old.text != "" { + old.text += "\n" + } + old.text += c.text + } + if c.title != "" { + old.title = c.title + } + if c.state != "" { + old.state = c.state + } + if c.task != 0 { + old.task = c.task + } + if c.job != 0 { + old.job = c.job + } + return + } + g.keys[key] = len(g.items) + g.items = append(g.items, c) + } + latestTaskKey := map[uint64]string{} + for _, e := range d.entries { + if c := e.completion; c != nil && c.task != 0 { + latestTaskKey[c.task] = workCompletionKey(*c) + } + } + for i, e := range d.entries { + if e.completion != nil { + add(*e.completion, i) + } + if e.done == nil || !e.done.quiet { + continue + } + owner, ok := a.workCompletionTurn(e.done.id) + if !ok { + owner = 0 + } + key := latestTaskKey[e.done.id] + if key == "" { + key = "task/" + strconv.FormatUint(e.done.id, 10) + } + text := "" + if node := a.tasks[e.done.id]; node != nil && node.crew != nil { + text = "crew · " + a.crewLine(node.crew, node.spent()) + } + add(workCompletion{owner: owner, key: key, task: e.done.id, title: e.done.title, state: e.done.status.Word, text: text}, i) + } + if len(groups) == 0 { + return folds + } + if folds == nil { + folds = map[int]workfold{} + } + for _, g := range groups { + if !g.unknown && g.owner == d.runningTurn && d.runningTurn != 0 { + continue + } + at := -1 + if !g.unknown { + for index, f := range folds { + if f.turn == g.owner && (at < 0 || index < at) { + at = index + } + } + } + if at >= 0 { + f := folds[at] + f.completions = g.items + folds[at] = f + continue + } + anchor := g.anchor + key := g.owner + if g.unknown { + key = -1000000000 - anchor + } else { + for i, e := range d.entries { + if e.turn == g.owner { + anchor = i + if groupBreaks(&e) { + anchor = i + 1 + } + break + } + } + } + if anchor >= len(d.entries) { + anchor = len(d.entries) - 1 + } + if _, exists := folds[anchor]; exists { + continue + } + folds[anchor] = workfold{key: key, turn: g.owner, start: anchor, answer: anchor, virtual: true, background: g.unknown, completions: g.items} + } + return folds +} + +// Running work uses the same receipt projection without creating a second Worked chip. +func (a *app) workCompletionsFor(d deck, turn int) []workCompletion { + d.runningTurn = 0 + for _, f := range a.withWorkCompletions(d, nil) { + if f.turn == turn && !f.background { + return f.completions + } + } + return nil +} + +func (a *app) selectedWorkCompletion() *workCompletion { + for _, r := range a.visible(a.bodyWidth()) { + if r.entry == a.sel && r.hit == hitWorkCompletion { + return r.completion + } + } + return nil +} + +func (a *app) liveCompletionSummary(w liveWork, rows []row, width int) []row { + if len(w.completions) > 0 && len(rows) > 0 { + rows[0].text = fitPainted(rows[0].text+a.pal.dim(railSep+itoa(len(w.completions))+" finished"), max(1, width-workIndentCols(width)-actionGutter)) + } + return rows +} + +func (a *app) toggleWorkCompletionDetails(c *workCompletion) { + if c == nil || c.at < 0 || c.at >= len(a.entries) { + return + } + a.entries[c.at].open = !a.entries[c.at].open + a.touch() +} diff --git a/internal/tui3/workcompletion_test.go b/internal/tui3/workcompletion_test.go new file mode 100644 index 0000000000..deea5ac2b5 --- /dev/null +++ b/internal/tui3/workcompletion_test.go @@ -0,0 +1,281 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" +) + +func completionFoldApp(t *testing.T) *app { + t.Helper() + a, _, _ := taskApp(t) + a.width, a.height = 200, 80 + a.entries, a.workMode = foldFixture(), config.WorkFold + a.turn = 1 + // The fixture models a real recorded task proposal, not an unrecorded slash command. + a.entries = append(a.entries, entry{kind: entryTask, turn: 1, card: &taskCard{id: 7, title: "Recorded task", verdict: taskClockWord}}) + return a +} + +func TestLateTaskUpdatesOriginalWorkedGroupAndItsResultDoor(t *testing.T) { + a := completionFoldApp(t) + a.taskUpdate(update(7, "Compile parser", session.TaskRunning, session.TaskNotice{})) + a.turn = 2 + a.entries = append(a.entries, entry{kind: entryUser, turn: 2, text: "Discuss a different issue"}, entry{kind: entryAssistant, turn: 2, text: "The new answer remains visible.", settled: true}) + a.taskUpdate(update(7, "Compile parser", session.TaskDone, session.TaskNotice{Report: "Compiled successfully", Result: "Original deliverable"})) + for _, f := range a.deckFolds(a.conversation()) { + if len(f.completions) > 0 && f.turn != 1 { + t.Fatal("completion attached to newer question") + } + } + collapsed := strings.Join(plainRows(a), "\n") + if strings.Contains(collapsed, "Compile parser") || !strings.Contains(collapsed, "The new answer remains visible.") { + t.Fatal(collapsed) + } + a.openWorkfold(1) + a.touch() + rows := a.visible(a.bodyWidth()) + at := -1 + for i, r := range rows { + if r.hit == hitWorkCompletion && r.completion.task == 7 { + if at >= 0 { + t.Fatal("duplicate task result rows") + } + at = i + } + } + if at < 0 { + t.Fatal("expanded original work has no result door") + } + a.stick = false + a.offset = 0 + a.press(8, a.bodyTop()+at) + if !a.taskSheet.detailOn || a.taskSheet.detail.ID != "7" { + t.Fatal("result click did not open exact task") + } +} + +func TestWorkingGroupKeepsCompletionsWhileOriginalAnswerStreams(t *testing.T) { + a := completionFoldApp(t) + a.state = stateWorking + a.live = len(a.entries) - 1 + a.entries[a.live].settled = false + a.taskUpdate(update(7, "Background compile", session.TaskRunning, session.TaskNotice{})) + a.taskUpdate(update(7, "Background compile", session.TaskDone, session.TaskNotice{Result: "Compiler output"})) + a.touch() + if got := strings.Join(plainRows(a), "\n"); !strings.Contains(got, "finished") || strings.Contains(got, "Background compile") { + t.Fatal(got) + } + a.openWorkfold(1) + a.touch() + found := false + for _, r := range a.visible(a.bodyWidth()) { + if r.hit == hitWorkCompletion && r.completion.task == 7 { + found = true + } + } + if !found { + t.Fatal("ongoing Working disclosure lost completed task") + } +} + +func TestWorkedGroupRetainsOriginalReceiptAlongsideCanonicalResult(t *testing.T) { + a := completionFoldApp(t) + a.taskUpdate(update(7, "Compile parser", session.TaskRunning, session.TaskNotice{})) + a.taskUpdate(update(7, "Compile parser", session.TaskDone, session.TaskNotice{})) + raw := "original checker alpha · paid $0.123\noriginal result report" + a.entries = append(a.entries, entry{kind: entryNote, turn: 2, completion: &workCompletion{owner: 1, key: "task/7/0", task: 7, title: "Compile parser", state: "done", text: raw}}) + a.openWorkfold(1) + a.touch() + var details *workCompletion + count := 0 + for _, r := range a.visible(a.bodyWidth()) { + if r.hit == hitWorkCompletion && r.completion.task == 7 { + count++ + } + if r.hit == hitWorkReceiptDetails { + details = r.completion + } + } + if count != 1 || details == nil { + t.Fatalf("receipt/snapshot dedup lost details: count %d details %+v", count, details) + } + a.toggleWorkCompletionDetails(details) + if text := strings.Join(plainRows(a), "\n"); !strings.Contains(text, "paid $0.123") || !strings.Contains(text, "original result report") { + t.Fatal(text) + } +} + +func TestUnknownReceiptKeepsFullTextInsideLocalBackgroundDisclosure(t *testing.T) { + a := completionFoldApp(t) + raw := "runtime receipt with no proven owner\ncomplete original result retained" + a.entries = append(a.entries, entry{kind: entryNote, turn: 2, text: raw, completion: &workCompletion{key: "receipt/unknown", title: "Background work", text: raw}}) + if got := strings.Join(plainRows(a), "\n"); strings.Contains(got, "complete original result retained") { + t.Fatal("unknown receipt leaked outside disclosure") + } + key := 0 + for _, f := range a.deckFolds(a.conversation()) { + if f.background { + key = f.key + } + } + if key == 0 { + t.Fatal("unknown receipt has no inspectable local disclosure") + } + a.openWorkfold(key) + a.touch() + if got := strings.Join(plainRows(a), "\n"); !strings.Contains(got, "complete original result retained") { + t.Fatal(got) + } +} + +func TestProcessCompletionUpdatesOriginalGroupAndKeepsLogDoor(t *testing.T) { + a := completionFoldApp(t) + job := jobNotice(3, session.JobRunning, "compile", jobLogFile(t, "compiler log retained")) + a.jobUpdate(job) + a.turn = 2 + a.entries = append(a.entries, entry{kind: entryUser, turn: 2, text: "Another question"}, entry{kind: entryAssistant, turn: 2, text: "Another answer", settled: true}) + job.State = session.JobDone + a.jobUpdate(job) + a.openWorkfold(1) + a.touch() + var result *workCompletion + for _, r := range a.visible(a.bodyWidth()) { + if r.hit == hitWorkCompletion && r.completion.job == 3 { + result = r.completion + } + } + if result == nil || result.owner != 1 { + t.Fatal("process result left original request") + } + cmd := a.openWorkCompletion(result) + if cmd == nil || a.jobPage != 3 { + t.Fatal("process result lost log door") + } + takeJobLog(t, a, cmd) + if rows, _, _, _ := a.jobPageFrame(120, 40); !strings.Contains(plain(strings.Join(rows, "\n")), "compiler log retained") { + t.Fatal("opened wrong log") + } +} + +func TestWorkedGroupKeepsTaskRevisionsDistinctAndMergesLatestSnapshot(t *testing.T) { + a := completionFoldApp(t) + a.taskUpdate(update(7, "Compile parser", session.TaskRunning, session.TaskNotice{})) + a.taskUpdate(update(7, "Compile parser", session.TaskDone, session.TaskNotice{})) + for _, revision := range []int{1, 2} { + a.entries = append(a.entries, entry{kind: entryNote, turn: 2, completion: &workCompletion{owner: 1, key: "task/7/" + itoa(revision), task: 7, title: "Compile parser", state: "done", text: "recorded revision " + itoa(revision)}}) + } + count := 0 + for _, f := range a.deckFolds(a.conversation()) { + for _, c := range f.completions { + if c.task == 7 { + count++ + if c.key == "task/7" { + t.Fatal("snapshot duplicated latest recorded revision") + } + } + } + } + if count != 2 { + t.Fatalf("recorded revisions became %d task rows, want two", count) + } +} + +func TestTaskPartInheritsOriginalParentRequestRatherThanCurrentChat(t *testing.T) { + a := completionFoldApp(t) + a.taskUpdate(update(7, "Parent task", session.TaskRunning, session.TaskNotice{})) + a.turn = 2 + a.taskUpdate(update(8, "Later child", session.TaskRunning, session.TaskNotice{Parent: 7})) + if owner, ok := a.workCompletionTurn(8); !ok || owner != 1 { + t.Fatalf("child was attributed to newer chat turn: %d %v", owner, ok) + } +} + +func TestRestoredManualTaskWithoutReceiptKeepsLocalResultDisclosure(t *testing.T) { + a := completionFoldApp(t) + a.turn = 2 + a.taskUpdate(update(9, "Manually started task", session.TaskDone, session.TaskNotice{Report: "Restored manual task report", Result: "Restored manual output"})) + key := 0 + count := 0 + for _, f := range a.deckFolds(a.conversation()) { + for _, c := range f.completions { + if c.task == 9 { + count++ + key = f.key + if !f.background { + t.Fatal("restore invented original user ownership") + } + } + } + } + if count != 1 || key == 0 { + t.Fatalf("manual restored completion vanished: rows %d key %d", count, key) + } + a.openWorkfold(key) + a.touch() + var result *workCompletion + for _, r := range a.visible(a.bodyWidth()) { + if r.hit == hitWorkCompletion && r.completion.task == 9 { + result = r.completion + } + } + if result == nil { + t.Fatal("manual restored task lost clickable result") + } + a.openWorkCompletion(result) + if !a.taskSheet.detailOn || a.taskSheet.detail.ID != "9" { + t.Fatal("manual restored result door opened wrong task") + } +} + +func TestUnrecordedManualTaskDoesNotClaimPreviousUserRequest(t *testing.T) { + a := completionFoldApp(t) + a.taskUpdate(update(99, "Manual task after prior reply", session.TaskRunning, session.TaskNotice{})) + if _, owned := a.workCompletionTurn(99); owned { + t.Fatal("manual slash task claimed unrelated previous request") + } + a.taskUpdate(update(99, "Manual task after prior reply", session.TaskDone, session.TaskNotice{})) + found := false + for _, f := range a.deckFolds(a.conversation()) { + for _, c := range f.completions { + if c.task == 99 { + found = true + if !f.background { + t.Fatal("manual task grouped into unrelated user request") + } + } + } + } + if !found { + t.Fatal("manual task lost local completion disclosure") + } +} + +func TestLatestWorkShortcutUsesPositionForNegativeBackgroundKeys(t *testing.T) { + a := completionFoldApp(t) + raw := "retained background completion details" + a.entries = append(a.entries, entry{kind: entryNote, turn: 2, completion: &workCompletion{key: "receipt/latest", title: "Background work", text: raw}}) + latest := 0 + for _, f := range a.deckFolds(a.conversation()) { + if f.background { + latest = f.key + } + } + if latest >= 0 { + t.Fatal("fixture has no negative background disclosure") + } + if !a.toggleLatestWorkfold() || !a.workOpen[latest] { + t.Fatal("ctrl+e did not open newest background disclosure") + } + if a.workOpen[1] { + t.Fatal("ctrl+e opened older positive-key work instead") + } + if got := strings.Join(plainRows(a), "\n"); !strings.Contains(got, raw) { + t.Fatal("newest background details stayed hidden") + } + if !a.toggleLatestWorkfold() || a.workOpen[latest] { + t.Fatal("second ctrl+e did not close same newest disclosure") + } +} diff --git a/internal/tui3/workfold.go b/internal/tui3/workfold.go index 1160c640a2..f6459776bc 100644 --- a/internal/tui3/workfold.go +++ b/internal/tui3/workfold.go @@ -16,6 +16,10 @@ import ( // same entries derives the same fold, while a person's expansion dies with the // window that owns it. type workfold struct { + // completions remain children of their original request even when they arrive later. + completions []workCompletion + virtual bool + background bool // key is THE CHIP'S OWN NAME — what [deck.workOpen] is keyed by, what the // chip's row carries, and what a click and `ctrl+e` name when they open it. // @@ -105,12 +109,15 @@ func (a *app) deckFolds(d deck) map[int]workfold { } } } - return folds + return a.withWorkCompletions(d, folds) } // housekeepingFold is retained operational bookkeeping, not another phase of // work. Its entries share the turn's disclosure without drawing a second chip. func housekeepingFold(es []entry, f workfold) bool { + if len(f.completions) > 0 { + return false + } if f.start >= f.answer { return false } @@ -529,7 +536,7 @@ func countWork(es []entry, from, to int, f *workfold) { var began, ended time.Time for i := from; i < to; i++ { e := &es[i] - if e.kind == entryUser || e.kind == entryDivider || e.kind == entrySteer { + if e.completion != nil || e.kind == entryUser || e.kind == entryDivider || e.kind == entrySteer { continue } if e.kind == entryDone && e.done != nil && e.done.hidden() { @@ -689,7 +696,7 @@ func (a *app) workfoldLabel(d deck, f workfold) string { // one, so a chip in there that consulted the map would quote the time the // conversation's first turn took as the time this node's first phase took — // a figure about somebody else's work, said with confidence. - if d.lens.receipts == receiptsInline { + if d.lens.receipts == receiptsInline && !f.background { if stamp, ok := a.stamps[f.turn]; ok { took = stamp.took } @@ -701,6 +708,9 @@ func (a *app) workfoldLabel(d deck, f workfold) string { arrow = "▾" } parts := []string{arrow + " worked"} + if f.background { + parts[0] = arrow + " background work" + } if f.stopped { parts[0] = arrow + " stopped by you" if word := tookWord(took); word != "" { @@ -730,6 +740,31 @@ func (a *app) workfoldLabel(d deck, f workfold) string { if f.failures > 0 { parts = append(parts, a.linearMark(a.icon(tokens.GFailed), "x")+" "+itoa(f.failures)+" failed") } + if len(f.completions) > 0 { + tasks, processes := 0, 0 + for _, c := range f.completions { + if c.task != 0 { + tasks++ + } + if c.job != 0 { + processes++ + } + } + if tasks > 0 { + word := " tasks" + if tasks == 1 { + word = " task" + } + parts = append(parts, itoa(tasks)+word) + } + if processes > 0 { + word := " processes" + if processes == 1 { + word = " process" + } + parts = append(parts, itoa(processes)+word) + } + } parts = append(parts, "ctrl+e") return strings.Join(parts, " · ") } @@ -756,7 +791,7 @@ func rowIsWork(r row, es []entry, folds map[int]workfold) bool { if r.text == "" { return false } - if r.hit == hitWorkFold || r.hit == hitFold || r.hit == hitCaption || r.hit == hitTool || r.hit == hitMore { + if r.completion != nil || r.hit == hitWorkFold || r.hit == hitFold || r.hit == hitCaption || r.hit == hitTool || r.hit == hitMore { return true } return workEntry(es, folds, r.entry) @@ -781,10 +816,15 @@ func (a *app) toggleLatestWorkfold() bool { a.setWorkOpen(d, key, !showing) return true } - latest, found := 0, false - for _, f := range a.deckFolds(d) { - if !found || f.key > latest { - latest, found = f.key, true + latest, latestAt, found := 0, -1, false + for at, f := range a.deckFolds(d) { + // Keys identify disclosures; their position determines which is newest. + // Background history has negative keys and phase folds have local keys. + if housekeepingFold(d.entries, f) && !a.workFoldOpen(d, f.key) { + continue + } + if !found || at > latestAt { + latest, latestAt, found = f.key, at, true } } if !found { From c0d562b0678e7025d1ece085dd847cea57722e9c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 12:10:22 -0400 Subject: [PATCH 12/13] Preserve keyboard access to folded compaction details --- internal/tui3/workfold.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/tui3/workfold.go b/internal/tui3/workfold.go index f6459776bc..68171b256e 100644 --- a/internal/tui3/workfold.go +++ b/internal/tui3/workfold.go @@ -820,9 +820,6 @@ func (a *app) toggleLatestWorkfold() bool { for at, f := range a.deckFolds(d) { // Keys identify disclosures; their position determines which is newest. // Background history has negative keys and phase folds have local keys. - if housekeepingFold(d.entries, f) && !a.workFoldOpen(d, f.key) { - continue - } if !found || at > latestAt { latest, latestAt, found = f.key, at, true } From cfee1d978e82515f4ae68b06e973d7b120544202 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 12:16:37 -0400 Subject: [PATCH 13/13] Fit grouped work in narrow terminals and verify completion notification edges --- internal/tui3/render.go | 10 +++--- internal/tui3/tabsignal_test.go | 50 +++++++++++++++++++++------- internal/tui3/workcompletion.go | 4 +++ internal/tui3/workfold_width_test.go | 40 ++++++++++++++++++++++ 4 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 internal/tui3/workfold_width_test.go diff --git a/internal/tui3/render.go b/internal/tui3/render.go index 5d31a96566..ce23bafd2d 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -412,9 +412,9 @@ func (a *app) deckRows(d deck, width int) ([]row, bool) { if wasUser || wasBlock { gap() } - out = append(out, row{text: a.pal.dim(a.workfoldLabel(d, f)), entry: -1, hit: hitWorkFold, turn: f.key}) + out = append(out, row{text: a.pal.dim(fit(a.workfoldLabel(d, f), max(0, width-workIndentCols(width)))), entry: -1, hit: hitWorkFold, turn: f.key}) if a.workFoldOpen(d, f.key) { - out = append(out, a.completionRows(f.completions, width)...) + out = append(out, a.completionRows(f.completions, max(0, width-workIndentCols(width)))...) } wasCluster, wasBlock, wasUser, wasNote = true, false, false, false } @@ -442,7 +442,7 @@ func (a *app) deckRows(d deck, width int) ([]row, bool) { // the pass used to sniff for a row that already began with two spaces // so as not to move this one twice, and the sniff caught every other // row that happened to open on two spaces as well (see the pass). - out = append(out, row{text: a.pal.dim(a.workfoldLabel(d, f)), entry: -1, hit: hitWorkFold, turn: f.key}) + out = append(out, row{text: a.pal.dim(fit(a.workfoldLabel(d, f), max(0, width-workIndentCols(width)))), entry: -1, hit: hitWorkFold, turn: f.key}) if !open { i = f.answer - 1 wasCluster, wasBlock, wasUser, wasNote = false, false, false, false @@ -473,7 +473,7 @@ func (a *app) deckRows(d deck, width int) ([]row, bool) { } out = append(out, a.disclosedEntryRows(d, at, width)...) } - out = append(out, a.completionRows(f.completions, width)...) + out = append(out, a.completionRows(f.completions, max(0, width-workIndentCols(width)))...) i = f.answer - 1 wasCluster, wasBlock, wasUser, wasNote = true, false, false, false continue @@ -568,7 +568,7 @@ func (a *app) deckRows(d deck, width int) ([]row, bool) { } } if w.last { - out = append(out, a.completionRows(w.completions, width)...) + out = append(out, a.completionRows(w.completions, max(0, width-workIndentCols(width)))...) } wasCluster, wasBlock, wasUser, wasNote = true, false, false, false i = w.end - 1 diff --git a/internal/tui3/tabsignal_test.go b/internal/tui3/tabsignal_test.go index 9272194fb5..4d6106f415 100644 --- a/internal/tui3/tabsignal_test.go +++ b/internal/tui3/tabsignal_test.go @@ -384,31 +384,57 @@ func TestATerminalNoticeClearsTheCachedWork(t *testing.T) { } } -// THE EDGE IS THE CONVERSATION'S, NOT THE NODE'S. A graph publishing a node a -// second must not wake the surface a second, and the last node settling is the -// only clearing that counts. -func TestOnlyTheConversationsOwnEdgeIsWorthAStir(t *testing.T) { +// The conversation's working edge and an observed root completion each earn a stir. +// Duplicate, child and unseen historical terminal notices do not announce new work. +func TestConversationWorkEdgesAndObservedRootCompletionsAreWorthAStir(t *testing.T) { _, watch, _ := signalLab(t) if !watch.noteTask(signalRunning(21).Task) { - t.Fatal("the first node did not raise the edge") + t.Fatal("first live node did not raise working edge") } if watch.noteTask(signalRunning(21).Task) { - t.Fatal("the same node saying `running` again raised a second edge") + t.Fatal("duplicate running event raised another edge") } if watch.noteTask(signalRunning(22).Task) { - t.Fatal("a second node starting raised an edge on a conversation already working") + t.Fatal("another live node changed already-working state") } - if watch.noteTask(signalSettled(21, session.TaskDone).Task) { - t.Fatal("one node of two ending cleared the conversation") + if !watch.noteTask(signalSettled(21, session.TaskDone).Task) { + t.Fatal("observed root completion did not stir for its offscreen notification") } if !watch.tasking.Load() { - t.Fatal("a conversation with one node left running stopped claiming work") + t.Fatal("another root still runs but work state cleared") + } + got := watch.takeCompletions() + if len(got) != 1 || got[0].task != 21 { + t.Fatalf("root completion missing or duplicated: %+v", got) + } + if watch.noteTask(signalSettled(21, session.TaskDone).Task) || len(watch.takeCompletions()) != 0 { + t.Fatal("duplicate settled event announced completion again") + } + if watch.noteTask(signalSettled(99, session.TaskDone).Task) || len(watch.takeCompletions()) != 0 { + t.Fatal("unseen historical terminal event announced new completion") + } + child := signalRunning(23).Task + child.Parent = 22 + if watch.noteTask(child) { + t.Fatal("child admission stirred an already-working conversation") + } + child = signalSettled(23, session.TaskDone).Task + child.Parent = 22 + if watch.noteTask(child) || len(watch.takeCompletions()) != 0 { + t.Fatal("child completion stirred or announced separately from its parent") + } + if !watch.tasking.Load() { + t.Fatal("child completion cleared still-running parent") } if !watch.noteTask(signalSettled(22, session.TaskDone).Task) { - t.Fatal("the last node ending did not raise the edge") + t.Fatal("last root completion did not stir") } if watch.tasking.Load() { - t.Fatal("every node ended and the conversation still claims work") + t.Fatal("all roots ended but working state remained") + } + got = watch.takeCompletions() + if len(got) != 1 || got[0].task != 22 { + t.Fatalf("last root completion lost or duplicated: %+v", got) } } diff --git a/internal/tui3/workcompletion.go b/internal/tui3/workcompletion.go index 6ec7da10e0..14f75767e1 100644 --- a/internal/tui3/workcompletion.go +++ b/internal/tui3/workcompletion.go @@ -204,6 +204,10 @@ func (a *app) completionRows(cs []workCompletion, width int) []row { } } } + // Every child pays its own nesting before the shared work indentation. + for i := range out { + out[i].text = fitPainted(out[i].text, width) + } return out } diff --git a/internal/tui3/workfold_width_test.go b/internal/tui3/workfold_width_test.go new file mode 100644 index 0000000000..3178f5ae14 --- /dev/null +++ b/internal/tui3/workfold_width_test.go @@ -0,0 +1,40 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" + "github.com/charmbracelet/x/ansi" +) + +func TestCompletionDisclosureFitsNarrowBodyClosedOpenAndWithDetails(t *testing.T) { + a := completionFoldApp(t) + a.turn = 2 + a.taskUpdate(update(9, "Restored manual task with a long title", session.TaskDone, session.TaskNotice{})) + raw := "Original checker and spending details remain available.\n" + strings.Repeat("wide界 ", 12) + a.entries = append(a.entries, entry{kind: entryNote, turn: 2, completion: &workCompletion{key: "task/9/0", task: 9, title: "Restored manual task with a long title", state: "done", text: raw}}) + for _, width := range []int{11, 40, 80, 120} { + for _, open := range []bool{false, true} { + for _, f := range a.deckFolds(a.conversation()) { + a.setWorkOpen(a.conversation(), f.key, open) + } + for i := range a.entries { + a.entries[i].open = open + } + rows, _ := a.deckRows(a.conversation(), width) + found := false + for _, r := range rows { + if r.hit == hitWorkFold || r.completion != nil { + found = true + if got := ansi.StringWidth(r.text); got > width { + t.Fatalf("completion at width %d open %v draws %d cells: %q", width, open, got, ansi.Strip(r.text)) + } + } + } + if !found { + t.Fatal("fixture lost its work disclosure") + } + } + } +}