diff --git a/cmd/aforge-demo-home/seed_work.go b/cmd/aforge-demo-home/seed_work.go
index 34a4470a48..76e1080e20 100644
--- a/cmd/aforge-demo-home/seed_work.go
+++ b/cmd/aforge-demo-home/seed_work.go
@@ -174,6 +174,56 @@ var demoTasks = []demoTask{
DurationMS: 7 * 60 * 1000,
},
},
+ // AND A GROUP OF LANDINGS NOBODY HAS CHECKED, ACROSS THREE PROJECTS. Home's
+ // `needs you` panel holds them as its `to check` group (homepanel_needs.go),
+ // and a fixture with ONE of them draws a group of one — which is the shape a
+ // group line, a count, a clause and a fold all fail to be tested by. These
+ // are deliberately unlike each other: one wrote seventeen files, one wrote
+ // none, one has a name long enough to be cut, and one is old enough to age
+ // onto the fold rather than onto a row.
+ {
+ project: firstProjectName, talk: "Reading the Ledger", ago: 26 * time.Hour,
+ entry: session.TaskIndexEntry{
+ ID: "1", Title: "Take the fortnight's spend apart by model", Status: string(session.TaskUnverified),
+ Outcome: "Opus is four fifths of the fortnight and nearly all of it is one conversation; the split is in the page.",
+ Files: []string{"docs/design/spend/fortnight.md"},
+ FilesChanged: 1, Cost: 0.19, Model: "anthropic/claude-sonnet-4", Tokens: 24_400,
+ DurationMS: 5 * 60 * 1000,
+ },
+ },
+ {
+ project: firstProjectName, talk: "The Manual's Missing Page", ago: 30 * time.Hour,
+ entry: session.TaskIndexEntry{
+ ID: "1", Title: "Write the page about reading a task, and every question it should answer",
+ Status: string(session.TaskUnverified),
+ Outcome: "Seventeen sections, each under two thousand characters, and every probe in the table reaches one of them.",
+ Files: []string{
+ "internal/manual/chat/reading-a-task-page.md", "internal/manual/chat/tasks.md",
+ "internal/manual/chat/home.md", "internal/manual/chat/keys.md",
+ },
+ FilesChanged: 17, Cost: 0.61, Model: "anthropic/claude-opus-4.1", Tokens: 88_200,
+ DurationMS: 19 * 60 * 1000,
+ },
+ },
+ {
+ project: "pricing-site", talk: "Pricing Research", ago: 7 * time.Hour,
+ entry: session.TaskIndexEntry{
+ ID: "2", Title: "Read what the four nearest competitors charge", Status: string(session.TaskUnverified),
+ Outcome: "Three of the four price per seat and one per workspace; nobody could check the figures against a source.",
+ Cost: 0.08, Model: "anthropic/claude-sonnet-4", Tokens: 11_300,
+ DurationMS: 3 * 60 * 1000,
+ },
+ },
+ {
+ project: "infra", talk: "Rotating the Deploy Key", ago: 4 * 24 * time.Hour,
+ entry: session.TaskIndexEntry{
+ ID: "1", Title: "Rotate the deploy key and re-fasten the runners", Status: string(session.TaskUnverified),
+ Outcome: "The key is rotated and both runners took it; the third host was not reachable to prove.",
+ Files: []string{"terraform/runners.tf", "runbooks/deploy-key.md"},
+ FilesChanged: 2, Cost: 0.24, Model: "anthropic/claude-sonnet-4", Tokens: 31_100,
+ DurationMS: 8 * 60 * 1000,
+ },
+ },
}
// writeTaskIndex appends every row to its own project's index and answers how
diff --git a/docs/changes/unreleased/900-home-needs-groups.md b/docs/changes/unreleased/900-home-needs-groups.md
new file mode 100644
index 0000000000..13d29425fb
--- /dev/null
+++ b/docs/changes/unreleased/900-home-needs-groups.md
@@ -0,0 +1,27 @@
+---
+kind: changed
+title: home's `needs you` is two groups — the questions that stopped something, then what to check
+pr: 900
+surface: [chat]
+invalidates:
+ - "`needs you` drew a conversation's question, a watch and a landed task as ONE flat list ordered oldest-first. It is TWO GROUPS on one panel: the live questions first (a conversation stopped on a question or a consent card, a watch that needs somebody), longest wait first, and under them a dim group line `to check · N` with the clause `finished, nobody has checked it` at its right, holding every task whose call is the person's, NEWEST first. A live question is always above a landing, however old the landing is."
+ - "Every landing on home carried the sub-line `landed unchecked · enter to look` (`needsUncheckedWord` in `internal/tui3/placeprose.go`). That constant is DELETED and no row says it. A landing is ONE line — its name and `N files · age` — and only the row under the cursor grows a second line, which is the report's first sentence with the task's own two answers at its right."
+ - "A landing on `needs you` wore the amber `?` mark. It wears NONE. The mark means a thing has stopped and will not move until somebody answers it, and a landing has already finished; law 8's `two marks` is unchanged and the `?` is now only on a stopped conversation or watch."
+ - "The heading `needs you · N` counted every row including the landings. It counts the QUESTIONS only; the landings are counted on the group's own line. A frame holding only landings draws `needs you` with no number at all, which is the emptiness law applied to a heading."
+ - "A digit on home answered THE TOP question of `needs you`, always. It answers THE ONE ROW OF THE FRAME THAT IS DRAWING ITS ANSWERS: the row under the cursor when that row can take one, and the top answerable row otherwise. Drawing and routing read the same function (`app.homeAnswerAt`), so the chip on the screen and the key a person presses can never be two different rows."
+ - "A landed task could not be answered from home at all — `homeRowAnswers` gated the chips on a live session's presence question, which a `to check` row by construction does not have. A landing on `to check` now draws its own two answers in the TASK'S OWN WORDS (`session.TaskAsk.Yes`/`.No`) and takes them. THE KEYS ON HOME ARE `1` AND `2`, not the `[a]`/`[n]` the landing card, the task's room and its record take: a bare letter on home always types (the box promises `type to search or start something new` and the promise has no asterisk), and the drawn digit is the one printable exception that screen makes. The digit is mapped back to `session.LandingYesKey`/`LandingNoKey` before the answer leaves the surface, so the engine sees the same answer it has always seen."
+ - "A conversation whose presence says `waiting on you · your call on
` with no question object was drawn as a question on `needs you`, above the `to check` row for the same piece of work — one landing on two rows of one panel. `internal/session`'s `Agent.waitingOnPerson` reads a pending decision last and has no question object to write for it, so this is the ordinary shape of any live conversation sitting on a landed task. The conversation's row is dropped and the landing's row stands: it has the work's own name, its files and its answers."
+ - "`since you left` listed every landing since the look stamp, including the ones `needs you` was showing as your call. It DROPS a landing that the `to check` group is drawing, and shows it again as an ordinary line once that landing has been answered or has aged out of the group."
+ - "The pulse's `N want you` counted conversations, watches and errands and never a landed task, so it disagreed with `needs you · N` under it. It is the SUM OF BOTH GROUPS now, counted through the panel's own reading of a landing (`needsCallOf`, `needsAged`) rather than through a second test. The tab bar's `tasks` number is unrelated and unchanged — `placeCounts.ChangedIn` is `how many things moved since you last looked at this place`, and that seam is still nil."
+ - "A panel squeezed by taking rows off the bottom until it reached its floor, and there was no such thing as a group inside a panel. `homePanelRows.group` is a run of rows at the FOOT of a panel's list drawn under a dim line of its own; because they are last, the ordinary bottom-up cut spends them first, and when none is left the group's line goes and the panel's own fold names the group — `4 to check · 1 older · tasks`. `homeGridPanel.shrink` is now a plain walk down from the natural height rather than the old `least - 2` arithmetic."
+ - "The demo home (`cmd/aforge-demo-home`) had exactly ONE `unverified` row, which draws a `to check` group of one. It has five, across three projects, deliberately unlike each other: seventeen files, no files at all, a name long enough to be cut, and one old enough to age onto the fold instead of onto a row."
+---
+
+The note on the panel was that the difference between `needs you` and `to check` is not
+obvious, and the fix is one dim clause on the group's line rather than a label on every row.
+Seven words, once, in the place a heading's clause already lives — and the cursor row's
+`1 accept 2 not right` says what checking one actually means.
+
+Two things the tmux drive found that unit tests had not: the same landing drawn on two rows
+of one panel whenever its conversation is live, and that `a`/`n` cannot be answer keys on a
+screen whose foot promises that typing starts a conversation.
diff --git a/docs/design/home-mission-control/DESIGN.md b/docs/design/home-mission-control/DESIGN.md
index ff8fd07030..7b05c704e3 100644
--- a/docs/design/home-mission-control/DESIGN.md
+++ b/docs/design/home-mission-control/DESIGN.md
@@ -60,13 +60,29 @@ own keys, each a door to the place that owns it. The chat list is one panel.
5. **A short terminal squeezes in priority order.** `next up` and `spend` give
way first, then `since you left`, then `running`; `needs you` and `where you
were` shrink last. A squeezed panel keeps its heading and `N more · `.
+ A GROUP INSIDE A PANEL folds before the panel gives up a row above it: the
+ rows of `needs you`'s `to check` group sit at the foot of the panel's list, so
+ the ordinary bottom-up cut spends them first, and when none is left the
+ group's line goes and the fold names it — `8 to check · tasks` (#884).
6. **Preselect the previous thing.** On double-space the cursor is on the chat
you were in before this one (this window's own stack, `chattabs.go`
`tabList`). Enter is a switch in two keys; esc goes back.
-7. **Digits answer the top question from anywhere on home.** No cursor move.
- The answers are drawn on the row so the key is never a guess.
+7. **One row of the frame draws its answers, and the key answers it from
+ anywhere on home.** No cursor move. That row is the one under the cursor when
+ it can take an answer and the top answerable row otherwise (#884), so a digit
+ works on a frame nobody has walked and a landing somebody HAS walked onto is
+ the row the key means. The answers are drawn on the row so the key is never a
+ guess. THE WORDS ARE THE QUESTION'S OWN EVERYWHERE; the KEYS are too wherever
+ the question's own keys are digits, which is every card a conversation stops
+ on. The one exception is a landing in `to check`, whose keys are `[a]`/`[n]`
+ on its card, in its room and on its record and are `1`/`2` here — because a
+ bare letter on home types (home.go: the foot promises "type to search or start
+ something new" and the promise has no asterisk), and the drawn digit is the
+ one printable exception this screen makes. The digit is mapped back to
+ `session.LandingYesKey`/`LandingNoKey` before the answer leaves the surface.
8. **Two marks, one accent, one hint line.** `GlyphNeedsHuman` in `hueWarn` for
- a person, the single spinner cell for work. Nothing else wears a glyph or a
+ a person — a conversation or a watch that has STOPPED, never a landing on
+ `to check`, which has already finished — the single spinner cell for work. Nothing else wears a glyph or a
colour. Money is a number. Key hints live on the foot and the alt map, never
inside a panel.
9. **Nothing grows past its budget.** Every panel folds inside itself with
diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md
index 4757715ca9..7fa75d212b 100644
--- a/internal/manual/chat/home.md
+++ b/internal/manual/chat/home.md
@@ -17,12 +17,17 @@ answering one question you would ask walking up to a colleague's desk:
? Clever Bet Prediction Model 6h npm run dev · pricing-site · a background job
the 2024 season only, or all three? enter
since you left · 12h
- where you were Spark Fleet Ssh Audit · 2 hosts up, 1 not
- Understanding Hash Tables here made apartments-minto-street.md
- explain open addressing vs chaining
- Understanding Bloom Filters 11h spend today $0.14 of $20
- Locate Recent Sandbox Task infra 1d ▁▂▁▃▅▂▁▁▇▃▂▅▂▁ 14 days $34.10 · loudest mon $9.20
- 70 more · type to find one opus 63% · 3 chats and 1 task today
+ to check · 3 finished, nobody has checked it Spark Fleet Ssh Audit · 2 hosts up, 1 not
+ tier-B subs 3 files · 1d made apartments-minto-street.md
+ built the tier-B flow 1 accept 2 not right
+ package release radar 1d spend today $0.14 of $20
+ 1 more · 15 older · tasks ▁▂▁▃▅▂▁▁▇▃▂▅▂▁ 14 days $34.10 · loudest mon $9.20
+ opus 63% · 3 chats and 1 task today
+ where you were
+ Understanding Hash Tables here next up
+ explain open addressing vs chaining the 6am repo watch in 20h
+ Understanding Bloom Filters 11h
+ 70 more · type to find one
projects next up
~/aforge-v2 12 chats · 1 running master the 6am repo watch in 20h
@@ -50,7 +55,7 @@ from 170, where `projects` and `spend` take the third.
| Panel | What a row is | `enter` on a row | Dim line when it holds nothing |
| --- | --- | --- | --- |
-| `needs you` | a question waiting on a person, anywhere | opens where it was asked | `questions from any chat or task land here · a digit answers them` |
+| `needs you` | a question waiting on a person, anywhere, and under them the `to check` group of work that landed | opens where it was asked | `questions from any chat or task land here · a digit answers them` |
| `where you were` | a conversation | opens it | `your conversations · what you type below starts one` |
| `projects` | a folder with conversations | starts a new chat there | never empty — the folder this window opened in is always a row |
| `running` | a task, a background job, a watch firing | brings its conversation here | `work you send off with /task runs here on its own` |
@@ -66,39 +71,95 @@ while a number still draws nothing at zero — never `$0.00`, never `0 tasks`.
## What needs me — the needs you panel, the ? rows at the top of home
-**The top of the left column, and its rows wear the amber `?`.** A row is there when
+**The top of the left column.** The panel holds **two groups**: the questions that have
+stopped something, then the work that landed and has not been checked.
+
+**The first group is the questions, and only they wear the amber `?`.** A row is there when
something has stopped and cannot go on without you, from any project:
- a conversation stopped on a question — a command to approve, a task to approve, a card
about something standing;
-- a task the record marks as `your call` — work nobody could check reads
- `landed unchecked · enter to look`, and `enter` opens the conversation on the task's
- record, where its two answers are;
- a reminder, watch or rule that stopped and wants an answer.
-Each row is **two lines**: the title with **how long it has waited** at the right, and under
-it what it is asking, in the question's own words. The longest wait is at the top. The
-heading counts the rows: `needs you · 2`.
+Each is **two lines**: the title with **how long it has waited** at the right, and under it
+what it is asking, in the question's own words. **The longest wait is at the top**, and the
+heading counts these rows and no others: `needs you · 2`. A frame where nothing is stopped
+draws the heading with no number at all.
**A permission question is repeated exactly and nothing is added to it.** The session that
is stopped writes one sentence — `needs your ok to run ` and the tool's name — and the row
is that sentence, whole. A question with a paragraph shows only its first line.
-**Only a task's call ages.** A `your call` is a row for two days after it landed; after that
-the panel's last line counts it instead — `3 older · tasks` — and `enter` there opens the
-tasks place, where every one of them still is. A question a conversation is stopped on, and
-a watch that needs somebody, never age off home.
+**A question stands above every landing**, however old the landing is: a stopped
+conversation is costing you something and a landing is not.
Four rows show, eight in a tall window, then `N more · tasks`. Amber is spent on the `?`
mark and on nothing else; a machine with nothing waiting has no accent on it at all.
-## Answer from home — a digit answers the top question from anywhere
+## to check — the group of landings nobody has checked
+
+**`to check` is every task whose call is yours: work that finished, where nobody could say
+whether it is right.** It is a dim line inside the `needs you` panel — `to check · 8` at the
+left, and at the right the one clause that says what the group is:
+
+```
+ to check · 8 finished, nobody has checked it
+ tier-B subs 3 files · 1d
+ package release radar 1d
+```
+
+The line is drawn only while the group has rows, and the rows are **newest first** — the
+opposite of the questions above them, because nothing here is waiting and the freshest
+landing is the one still in your head.
+
+**A landing is one line at rest**: its name, its file count and how long ago. A landing
+with no files to its name draws nothing where the count would be, never `0 files`.
+
+**The row under the cursor grows a second line** — the first sentence of what the work came
+to, and its two answers at the right:
-**The top question that has answers draws them on its own row** — `1 allow once 2 always
-3 deny`, three at most, then `enter` — and **a digit answers it from anywhere on home**,
+```
+ tier-B subs 3 files · 1d
+ built the tier-B subscription flow 1 accept 2 not right
+```
+
+The two words are the task's own — `accept` and `not right` for work nobody could check,
+which is every row that reaches this group. **The two keys are `1` and `2`
+here and `[a]`/`[n]` everywhere else**, and that is deliberate: a bare letter on home always
+types, because the box under it promises "type to search or start something new" and the
+promise has no asterisk. A digit drawn on the row is the one printable exception home makes,
+so a landing takes `1` and `2` on this screen and its own letters on its card, in its room
+and on its record. `enter` opens the conversation on the task's record.
+
+**A landing wears no mark.** The `?` means something has stopped and will not move; this
+work has already finished.
+
+**Only a landing ages.** A `your call` is a row for two days after it landed; after that the
+panel's last line counts it instead — `3 older · tasks` — and `enter` there opens the tasks
+place, where every one of them still is. A question a conversation is stopped on, and a
+watch that needs somebody, never age off home.
+
+**A landing on `to check` is not repeated by `since you left`.** It goes back to that panel
+as an ordinary line once it has been answered or has aged out of the group.
+
+**On a short frame the whole group folds to one line before any question gives way** —
+`8 to check · tasks`, which opens the tasks place.
+
+## Answer from home — a digit answers the question that is drawing its answers
+
+**Exactly one row of the frame draws its answers** — `1 allow once 2 always 3 deny`,
+three at most, then `enter` — and **the key answers that row from anywhere on home**,
wherever the cursor is. Every other row says `enter` instead, so one `1` is never on the
-screen twice with two meanings. The answers are that question's own, on its own keys:
-there is no home-specific `y`/`n` translation anywhere on this screen.
+screen twice with two meanings. The words are that question's own and nothing here is put
+into a vocabulary of home's; there is no `y`/`n` anywhere on this screen.
+
+**The keys are the question's own wherever those keys are digits**, which is every card a
+conversation stops on. The one place they are not is a landing in `to check`: its keys are
+`[a]`/`[n]` everywhere else and `1`/`2` here, because a bare letter on home types.
+
+**Which row is it?** The row under the cursor when that row can take an answer, and the top
+answerable row otherwise. So a digit works on a frame you have not walked, and once you have
+walked onto a landing in `to check` its `1`/`2` are the keys that are live.
Every kind of question can be answered this way: the whole question is in the file home
reads, and the answer goes back through the one door that knows which part of the engine
@@ -118,9 +179,10 @@ With something typed in the box a digit is a character going into it, never an a
## What opens when I press a landed row on home — I clicked a needs you row and it opened the chat
**A task's row opens its conversation with the task's record in front.** `enter` on a
-`your call` row in `needs you` opens the conversation that ran the work, on the task's
-record, where the question and its two answers are — whatever project it belongs to, with
-the conversation you were in left running behind it.
+`to check` row opens the conversation that ran the work, on the task's record, where the
+question, its whole report and its two answers are — whatever project it belongs to, with
+the conversation you were in left running behind it. You do not have to open it to answer:
+`1` and `2` on the row itself send the same two answers.
A conversation's own question opens that conversation, where the question is waiting above
the box. Work that landed also reaches home through **the `since you left` panel**: a line
@@ -142,6 +204,10 @@ Three ways to settle it, and they are the same door:
whatever that row is asking (`accept`/`not right`, `resolve it`/`drop it`), plus
`[s] tell it`, which opens the task's page rather than answering, and a dimmer
`[d] let aforge decide this one`;
+- **home** — walk onto its row in `to check` and press `1` or `2`. The conversation applies
+ it on its own beat; if it is not running, the answer waits on its doorstep and is applied
+ the next time that conversation runs, and the row says
+ `answered · waiting for it to pick that up` meanwhile;
- **the task's room** — enter on its roster row opens it, and the same chips stand at the
foot of the page; `a`, `n`, `s` and `d` over an empty box answer it with nothing
selected;
@@ -370,7 +436,7 @@ instead, **the foot names the chord that still reaches them** — `ctrl+o open f
`ctrl+e pause` on a standing order — and the rest are on their chords too: `ctrl+y` copy
path, `ctrl+e` put away. `ctrl+o` on a `projects` row opens that project's folder.
-A digit answers the top question in `needs you` wherever you are standing. `enter` acts on
+A digit answers the one row of `needs you` drawing its answers, wherever you are standing. `enter` acts on
the row under the cursor. `alt+.` draws the map.
## Why is the needs you heading highlighted, why is one project name darker than the others
diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md
index c2afc76933..c047fe2222 100644
--- a/internal/manual/chat/keys.md
+++ b/internal/manual/chat/keys.md
@@ -1984,7 +1984,7 @@ what each holds), and its keys are a small grammar:
| --- | --- |
| `↑` / `↓` (`ctrl+p` / `ctrl+n`) | walk the column you are in, from one panel into the next; `↑` off the top of a column reaches **the tab bar** (*The tab bar is a row the cursor can stand on*) |
| `←` / `→` | cross to the next column, onto the row nearest the one you left — only into a column with a row to stand on |
-| a digit | answers **the top question in `needs you`**, from anywhere on home, with no cursor move — its answers are drawn on its own row, `1 allow once 2 always 3 deny` |
+| a digit, or a question's own key | answers **the one row of `needs you` that is drawing its answers**, from anywhere on home, with no cursor move — the row under the cursor when it can take one, the top answerable row otherwise. A question's chips are `1 allow once 2 always 3 deny`; a landing in `to check` offers `1 accept 2 not right`, its own `[a]`/`[n]` being letters and letters always type on home |
| `enter` | acts on the row under the cursor: a conversation opens, a project row starts a new chat in that folder, a `since you left` line opens its record, file or place, a spend or `next up` row opens spend or standing, a fold line opens the place it names |
| `pgup` / `pgdown` | jump a screenful |
| `tab` | **the next place** on the bar |
diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md
index abb42edfc9..d012653dab 100644
--- a/internal/manual/chat/places.md
+++ b/internal/manual/chat/places.md
@@ -416,7 +416,7 @@ project, as **seven panels** in one, two or three columns: `needs you`, `where y
empty panel keeps its heading and one dim line naming what arrives there.
Its own keys are in the **Home** page: `↑↓` walk a panel, `←→` cross columns, a digit
-answers the top question in `needs you`, and `enter` opens a row. `tab` is the way to the
+answers the one row of `needs you` drawing its answers, and `enter` opens a row. `tab` is the way to the
next place, and the errand pane is taken into with `→` rather than `tab`. Home has no
`alt+` keys — `alt+g` and `alt+q` are unbound there.
diff --git a/internal/tui3/homeband_answer.go b/internal/tui3/homeband_answer.go
index 906b56bb7f..6f94155343 100644
--- a/internal/tui3/homeband_answer.go
+++ b/internal/tui3/homeband_answer.go
@@ -117,13 +117,26 @@ func answerable(row session.SessionRow, now time.Time) (session.PresenceQuestion
// answerSent is what this window already sent for one row's question, when it
// sent one and the question is still the same question.
func (a *app) answerSent(row session.SessionRow, question session.PresenceQuestion) (homeAnswered, bool) {
- sent, ok := a.answered[strings.TrimSpace(row.Dir)]
+ sent, ok := a.answered[answerSlot(row.Dir, question)]
if !ok || sent.kind != question.Kind || sent.id != question.ID {
return homeAnswered{}, false
}
return sent, true
}
+// answerSlot is the key this window remembers one answer under: the session
+// folder AND the question inside it.
+//
+// ONE SLOT PER QUESTION, NOT PER CONVERSATION. A conversation used to have at
+// most one answerable row on this screen, so its folder was identity enough.
+// `to check` gives one conversation a row per landing it is holding
+// (homepanel_needs.go), and a single slot meant answering the second landing
+// forgot the first — whose chips came back, and whose key could be pressed
+// again, sending a second answer for a question already answered from here.
+func answerSlot(dir string, question session.PresenceQuestion) string {
+ return strings.TrimSpace(dir) + "\x00" + string(question.Kind) + ":" + itoa64(question.ID)
+}
+
// drawAnswerBand is the chips, or the line that says they have been pressed.
//
// A WINDOW THAT CANNOT ANSWER DRAWS NOTHING (the absence law): with no seam
@@ -347,7 +360,7 @@ func (a *app) rememberAnswered(dir string, question session.PresenceQuestion, la
delete(a.answered, at)
}
}
- a.answered[dir] = homeAnswered{kind: question.Kind, id: question.ID, label: label, at: now}
+ a.answered[answerSlot(dir, question)] = homeAnswered{kind: question.Kind, id: question.ID, label: label, at: now}
}
// answeringHere reports that the row on home IS the conversation this window is
diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go
index 3766d47f63..c863cd1537 100644
--- a/internal/tui3/homecell.go
+++ b/internal/tui3/homecell.go
@@ -112,6 +112,8 @@ func (a *app) homeLineRows(line homeLine, at, width int, pal palette, heading bo
texts, head = []string{homeCellHead(cell, width, pal, heading)}, at
case cellWhisper, cellFold:
texts = []string{homeCellQuiet(cell, width, pal, lit)}
+ case cellGroup:
+ texts = []string{homeCellGroup(cell, width, pal)}
case cellBar:
texts = []string{homeCellBand(homeCellLeadBlank+homeSpendMeter(cell.share, width-homeGridLead, pal), width, pal, lit)}
case cellSpark:
@@ -200,6 +202,18 @@ func homeSparkCells(values []float64) []string {
return out
}
+// homeCellGroup is a group's own line inside a panel: its word and count at the
+// left and its clause at the right, both dim, under the rows' own lead
+// ([homePanelGroup]).
+//
+// IT IS DIMMER THAN A HEADING ON PURPOSE. A panel's heading is the places' one
+// heading ink and marks itself when the cursor is in it ([homeCellHead]); a
+// group is a sorting of rows INSIDE one panel, and a second thing on the column
+// wearing heading ink would read as a second panel.
+func homeCellGroup(cell *homeCell, width int, pal palette) string {
+ return switcherSides(max(1, width), cell.title, cell.right, pal.dim, pal.dim)
+}
+
// homeCellQuiet is a whisper or a fold: dim words under the rows' own lead.
func homeCellQuiet(cell *homeCell, width int, pal palette, lit bool) string {
text := homeCellLeadBlank + pal.dim(fit(cell.title, max(0, width-homeGridLead)))
@@ -216,15 +230,18 @@ func homeCellBand(text string, width int, pal palette, lit bool) string {
}
// homeCellRow paints a row and the line under it.
+// A ROW THAT GROWS DRAWS ITS SECOND LINE ONLY UNDER THE CURSOR, and the band
+// covers both of them: the two lines are one row, and a ground that stopped
+// half way would read as two ([homeCell.grows]).
func (a *app) homeCellRow(line homeLine, at, width int, pal palette, lit bool) []string {
cell := line.cell
body := homeCellBody(a.homeCellDoor(cell, at, width-homeGridLead), width-homeGridLead, pal, lit)
rows := []string{homeCellBand(a.homeCellLead(cell, at, pal)+body, width, pal, lit)}
- if cell.sub != "" {
- under := switcherSides(max(1, width-homeGridLead), cell.sub, a.homeRowAnswers(line), pal.dim, pal.muted)
- rows = append(rows, homeCellLeadBlank+under)
+ if cell.sub == "" || (cell.grows && at != a.home.cursor) {
+ return rows
}
- return rows
+ under := switcherSides(max(1, width-homeGridLead), cell.sub, a.homeRowAnswers(line, at), pal.dim, pal.muted)
+ return append(rows, homeCellBand(homeCellLeadBlank+under, width, pal, lit && cell.grows))
}
// homeCellLead is the row's mark and the air after it, or two blank cells.
diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go
index b484a42197..701d6528bc 100644
--- a/internal/tui3/homegrid.go
+++ b/internal/tui3/homegrid.go
@@ -254,6 +254,13 @@ type homeGridInput struct {
rows []switcherRow
// ledger is the `since you left` lines the switcher reads.
ledger []switcherRow
+ // calls is every landing whose check is the person's, as the `to check`
+ // group draws them, and older how many aged out of it. IT IS READ ONCE,
+ // HERE, because three readers need the same answer: the group's rows, the
+ // `since you left` line that steps aside for a landing already on the
+ // screen, and the pulse's count (homepanel_needs.go's [needsCallOf]).
+ calls []needsItem
+ callsOlder int
// world is every project, the ones home knows only through a watch
// included ([homeView.everyProject]).
world session.World
@@ -283,7 +290,9 @@ func (h *homeView) gridInput() homeGridInput {
world.Projects = h.everyProject()
here := switcherHere{session: h.here, coming: h.claim, hosted: h.far}
reading := readSwitcher(world, h.items, h.fired, here, h.gone, h.seen, h.world.Read, h.ledger)
- return homeGridInput{rows: reading.rows, ledger: reading.ledger, world: world, items: h.items,
+ calls, older := needsFresh(needsCalls(world, h.world.Read), h.world.Read)
+ return homeGridInput{rows: reading.rows, ledger: reading.ledger, calls: calls, callsOlder: older,
+ world: world, items: h.items,
errands: h.switchExchanges(), bucket: h.bucket, launch: h.launch, tilde: h.tilde, last: h.last,
repos: h.repos, spend: h.spend, seen: h.seen, now: h.world.Read}
}
@@ -304,11 +313,42 @@ type homePanelRows struct {
// said is what the heading carries after its word — a count, an age — and
// "" for the word alone.
said string
+ // group is the run of rows at the FOOT of lines that stands under a dim line
+ // of its own and folds before any row above it, and nil for a panel whose
+ // rows are one list ([homePanelGroup]).
+ group *homePanelGroup
// right is a clause the heading carries at its right margin, and money the
// figure inside it drawn in the money ink — the day's spend on `spend`.
right, money string
}
+// homePanelGroup is a SECOND HEADING INSIDE ONE PANEL: a run of the panel's own
+// rows, drawn under a dim line of their own, which folds to nothing before the
+// panel gives up a row above it.
+//
+// IT IS THE PANEL'S SHAPE SAID AGAIN ONE LEVEL DOWN, deliberately: a panel is a
+// heading, its rows and a fold, and a group is a line, its rows and the same
+// fold — so a squeeze needs no second rule. The rows sit at the FOOT of
+// [homePanelRows.lines], so the ordinary bottom-up cut already takes them first,
+// and when none of them is left on the screen the group's line goes and the
+// panel's own fold names the group instead of saying `more`
+// ([homeGridPanel.fold]).
+//
+// `needs you` is the only panel with one today: the landings a person has not
+// checked, under the live questions that stopped a conversation
+// (homepanel_needs.go).
+type homePanelGroup struct {
+ // at is where the group's rows begin in [homePanelRows.lines]; everything
+ // before it is the panel's own.
+ at int
+ // word is the group's name — the left of its line, and the word the panel's
+ // fold uses instead of `more` while the group is shut.
+ word string
+ // said is the count after the word, and right the dim clause the line
+ // carries at its margin.
+ said, right string
+}
+
// homeCellKind is which shape one line of a panel is drawn in.
type homeCellKind uint8
@@ -319,6 +359,10 @@ const (
cellHead
cellWhisper
cellFold
+ // cellGroup is a group's own line inside a panel: its word and count dim at
+ // the left, its clause dim at the right ([homePanelGroup]). It is not a
+ // stop and it is never lit.
+ cellGroup
// cellBar, cellSpark and cellFacts are the spend panel's three lines: the
// day against its allowance, the fortnight, and who it went to and what for
// (homepanel_spend.go).
@@ -365,8 +409,19 @@ type homeCell struct {
hold bool
door string
// sub is the line under the row, and subRight what that line carries at
- // its right — the answers a digit sends.
+ // its right — the answers a key sends.
sub, subRight string
+ // answers is the clause of chips this row WOULD draw at the right of its
+ // second line — each key beside its own word — and "" for a row with no
+ // answer to offer. Whether they are actually drawn is the frame's to say:
+ // exactly one row draws them ([app.homeAnswerAt]).
+ answers string
+ // grows says the row draws that line ONLY WHILE THE CURSOR IS ON IT, which
+ // is how a landing is one line at rest and two under the cursor
+ // (homepanel_needs.go). The line it grows into is reserved by its panel
+ // ([homeGridPanel.height]) so the column does not change shape as the
+ // cursor walks over the rows.
+ grows bool
// share is how full the spend bar is, and spark the fortnight's days.
share float64
spark []float64
@@ -384,8 +439,14 @@ type homeCell struct {
}
// height is how many screen rows one line takes.
+//
+// A ROW THAT GROWS UNDER THE CURSOR IS ONE ROW HERE. The line it grows into is
+// the PANEL's reservation ([homeGridPanel.height]) rather than this row's
+// height, because exactly one row of a frame is under the cursor and a layout
+// that changed shape as the cursor walked over these rows would move every panel
+// under them on every arrow key ([homeCell.grows]).
func (l homeLine) height() int {
- if l.cell != nil && l.cell.sub != "" {
+ if l.cell != nil && l.cell.sub != "" && !l.cell.grows {
return 2
}
return 1
@@ -447,12 +508,44 @@ func (p homeGridPanel) height() int {
for _, line := range p.read.lines[:p.shown] {
n += line.height()
}
+ // A GROUP'S LINE AND THE AIR OVER IT ARE THE PANEL'S ROWS TOO, drawn only
+ // while the group has a row of its own left on the screen ([homeGridPanel.lines]).
+ if p.groupOpen() {
+ n++
+ if p.read.group.at > 0 {
+ n++
+ }
+ }
+ // AND ONE LINE IS KEPT FOR THE ROW THE CURSOR WILL GROW. Which row that is
+ // belongs to the paint; that one of them will grow is known here, and
+ // reserving it is what keeps the column the same height whichever row the
+ // cursor is standing on ([homeCell.grows]).
+ if p.growsARow() {
+ n++
+ }
if p.folds() {
n++
}
return n
}
+// groupOpen reports that the panel is drawing its group's own line, which it
+// does while at least one of the group's rows is still shown.
+func (p homeGridPanel) groupOpen() bool {
+ return p.read.group != nil && p.shown > p.read.group.at
+}
+
+// growsARow reports that one of the rows on the screen will grow a line under
+// the cursor.
+func (p homeGridPanel) growsARow() bool {
+ for _, line := range p.read.lines[:p.shown] {
+ if line.cell != nil && line.cell.grows {
+ return true
+ }
+ }
+ return false
+}
+
// homeColumnHeight is a column's height with one blank row between panels.
func homeColumnHeight(column []*homeGridPanel) int {
n, drawn := 0, 0
@@ -581,23 +674,18 @@ func growColumn(column []*homeGridPanel, room int) {
}
}
-// shrink cuts a panel to its floor: the heading, as many whole rows as fit in
-// what the floor leaves after the fold line, and the fold. A panel already
-// inside its floor is left alone.
+// shrink cuts a panel to its floor: the heading, as many whole rows as fit under
+// it, and the fold. A panel already inside its floor is left alone.
+//
+// IT TAKES ROWS FROM THE BOTTOM, WHICH IS WHY A GROUP NEEDS NO RULE OF ITS OWN.
+// A group's rows sit at the foot of the panel's list ([homePanelGroup]), so the
+// walk down from the natural height spends them first and the group's line goes
+// with the last of them — the `to check` landings fold before a `needs you` row
+// is given up, without this function knowing that either exists.
func (p *homeGridPanel) shrink() {
- if p.height() <= p.slot.least {
- return
+ for p.shown > 0 && p.height() > p.slot.least {
+ p.shown--
}
- budget := p.slot.least - 2
- kept, used := 0, 0
- for _, line := range p.read.lines {
- if used+line.height() > budget {
- break
- }
- used += line.height()
- kept++
- }
- p.shown = kept
}
// homeGridLayout reads every panel and fits each column: the panels in table
@@ -698,7 +786,20 @@ func (p homeGridPanel) lines() []homeLine {
}
return out
}
- out = append(out, p.read.lines[:p.shown]...)
+ if group := p.read.group; group != nil && p.groupOpen() {
+ out = append(out, p.read.lines[:group.at]...)
+ // A GROUP STANDS OFF THE ROWS ABOVE IT with the same blank row that
+ // stands between two panels, and takes none when it is the panel's
+ // first line.
+ if group.at > 0 {
+ out = append(out, homeLine{kind: homeBlank})
+ }
+ out = append(out, homeLine{kind: homeSwitchHead, cell: &homeCell{kind: cellGroup,
+ panel: id, title: group.word + rowSep + group.said, right: group.right}})
+ out = append(out, p.read.lines[group.at:p.shown]...)
+ } else {
+ out = append(out, p.read.lines[:p.shown]...)
+ }
if p.folds() {
out = append(out, p.fold())
}
@@ -712,7 +813,7 @@ func (p homeGridPanel) lines() []homeLine {
func (p homeGridPanel) fold() homeLine {
var counts []string
if n := p.hidden(); n > 0 {
- counts = append(counts, groupedInt(n)+" more")
+ counts = append(counts, groupedInt(n)+" "+p.foldMoreWord())
}
if n := p.read.older; n > 0 {
counts = append(counts, groupedInt(n)+" "+homeFoldOlderWord)
@@ -729,6 +830,22 @@ func (p homeGridPanel) fold() homeLine {
return homeLine{kind: homeSwitchHead, cell: cell}
}
+// homeFoldMoreWord is what the fold calls the rows it is standing for when they
+// are simply more of the panel's own.
+const homeFoldMoreWord = "more"
+
+// foldMoreWord is what the fold calls the rows it is standing for: the GROUP's
+// name where every one of them is the group's and the group's own line is not on
+// the screen — `8 to check · tasks` is the whole of what a squeezed panel says
+// about its landings — and `more` everywhere else.
+func (p homeGridPanel) foldMoreWord() string {
+ group := p.read.group
+ if group == nil || p.groupOpen() || p.shown < group.at {
+ return homeFoldMoreWord
+ }
+ return group.word
+}
+
// homeFoldKey is the identity a fold door carries beside its place word, so the
// cursor on `3 more · tasks` is told apart from a `since you left` line that
// opens the same place ([homeLine.sameRow]). It starts with a NUL, which no
@@ -1035,47 +1152,94 @@ func switcherRowLine(row switcherRow, cell *homeCell) homeLine {
dir: homeBucketOf(row.session.Transcript), cell: cell}
}
-// homeGridAnswer is a digit on the resting grid: THE TOP QUESTION, FROM
-// ANYWHERE ON HOME, WITH NO CURSOR MOVE (law 7). The top question is the first
-// row of `needs you` that draws its answers, so the key a person presses is one
-// they can see on the screen; a digit with no such row falls through to the
-// row under the cursor and then to the box, as it always did.
+// homeGridAnswer is an answer key on the resting grid: THE ONE ANSWERING ROW OF
+// THE FRAME, FROM ANYWHERE ON HOME (law 7). That row is the one drawing the
+// chips, so the key a person presses is one they can see on the screen; a key
+// with no such row falls through to the row under the cursor and then to the
+// box, as it always did.
func (a *app) homeGridAnswer(key string) (tea.Cmd, bool) {
if !a.home.gridOn() {
return nil, false
}
- for _, line := range a.home.lines {
- if line.cell == nil || line.cell.panel != panelNeeds {
- continue
- }
- if words := a.homeRowAnswers(line); words != "" && words != answerWaitingWord && words != needsOpenWord {
- return a.answerRowKey(a.homeTrue(line.row), key)
+ at := a.homeAnswerAt()
+ if at < 0 {
+ return nil, false
+ }
+ line := a.home.lines[at]
+ if line.task != nil {
+ return a.homeAnswerLanding(line, key)
+ }
+ return a.answerRowKey(a.homeTrue(line.row), key)
+}
+
+// homeAnswerAt is THE ONE ROW OF THE FRAME THAT DRAWS THE CHIPS AND TAKES THE
+// KEY: the row under the cursor when it can take an answer, and the top row of
+// `needs you` that can otherwise. It is -1 when nothing on the frame is
+// answerable.
+//
+// THE CURSOR OUTRANKS THE TOP ROW BECAUSE A PERSON WHO WALKED SOMEWHERE MEANT
+// IT. Law 7 put the chips on the top question so that a digit worked without
+// moving the cursor, and that still holds for a frame nobody has walked; once
+// somebody has walked onto a landing, `a` has to mean THAT landing or the key
+// answers a row the person is not looking at. One function, so the chip and the
+// key can never be two different rows.
+func (a *app) homeAnswerAt() int {
+ if a.homeOffersAnswer(a.home.cursor) {
+ return a.home.cursor
+ }
+ for at := range a.home.lines {
+ if a.homeOffersAnswer(at) {
+ return at
}
}
- return nil, false
+ return -1
}
-// homeRowAnswers is what a `needs you` row draws at the right of its question:
-// the answers, under the answer band's own four rules ([drawAnswerBand]) — the
-// question is fresh and offered answers, this window has somewhere to leave
-// one, it has not already sent one (the waiting word stands in for a moment
-// after it has), and no question this window raised about the row is standing
-// over it ([app.answersStepAside]). Every fact is in memory; nothing is read.
+// homeOffersAnswer reports that the line at `at` is a row this window can
+// actually send an answer for.
+func (a *app) homeOffersAnswer(at int) bool {
+ if at < 0 || at >= len(a.home.lines) {
+ return false
+ }
+ words := a.homeRowOffer(a.home.lines[at])
+ return words != "" && words != answerWaitingWord
+}
+
+// homeRowOffer is the answers ONE row could take, under the answer band's own
+// four rules ([drawAnswerBand]) — the question is fresh and offered answers,
+// this window has somewhere to leave one, it has not already sent one (the
+// waiting word stands in for a moment after it has), and no question this
+// window raised about the row is standing over it ([app.answersStepAside]).
+// Every fact is in memory; nothing is read.
//
-// A ROW THAT SAYS `enter` KEEPS SAYING IT. It is an instruction rather than an
-// answer — the row is not the top question, or its question has a paragraph —
-// and the gate is about answers (homepanel_needs.go).
-func (a *app) homeRowAnswers(line homeLine) string {
- if line.cell == nil || line.cell.subRight == "" {
+// A LANDING IS THE SAME FOUR RULES WITH NOTHING ASKING. There is no live
+// question to be fresh, so what is left is the doorstep: a conversation with a
+// folder to leave the answer in can be answered from here, and one without
+// cannot (homepanel_needs.go's [app.homeAnswerLanding]).
+func (a *app) homeRowOffer(line homeLine) string {
+ if line.cell == nil || line.cell.panel != panelNeeds || line.cell.answers == "" {
return ""
}
- if line.cell.subRight == needsOpenWord {
- return needsOpenWord
+ row, now := a.homeTrue(line.row), a.home.world.Read
+ if line.task != nil {
+ question, ok := needsLandingQuestion(line)
+ if !ok {
+ return ""
+ }
+ if sent, ok := a.answerSent(row, question); ok {
+ if now.Sub(sent.at) < answerHoldFor {
+ return answerWaitingWord
+ }
+ return ""
+ }
+ if a.leaveAnswer == nil && !a.answeringHere(row) {
+ return ""
+ }
+ return line.cell.answers
}
if line.kind != homeSession {
return ""
}
- row, now := a.homeTrue(line.row), a.home.world.Read
question, ok := answerable(row, now)
if !ok {
return ""
@@ -1089,6 +1253,26 @@ func (a *app) homeRowAnswers(line homeLine) string {
if (a.leaveAnswer == nil && !a.answeringHere(row)) || a.answersStepAside(row) {
return ""
}
+ return line.cell.answers
+}
+
+// homeRowAnswers is what the line under one row carries at its right: the
+// answers where this is the frame's one answering row, the waiting word where
+// this window has already answered it, and the row's own door word everywhere
+// else.
+//
+// A ROW THAT SAYS `enter` KEEPS SAYING IT. It is an instruction rather than an
+// answer — the row is not the answering row, or its question has a paragraph —
+// and the gate is about answers (homepanel_needs.go).
+func (a *app) homeRowAnswers(line homeLine, at int) string {
+ if line.cell == nil {
+ return ""
+ }
+ if words := a.homeRowOffer(line); words != "" {
+ if words == answerWaitingWord || at == a.homeAnswerAt() {
+ return words
+ }
+ }
return line.cell.subRight
}
diff --git a/internal/tui3/homemachine.go b/internal/tui3/homemachine.go
index 625c058c39..a0e9a2fa9b 100644
--- a/internal/tui3/homemachine.go
+++ b/internal/tui3/homemachine.go
@@ -94,7 +94,7 @@ func (f machineFacts) nearCeiling() bool {
// read with it. It runs on a beat and never on a draw.
func (a *app) readMachine(now time.Time, sessions []session.SessionRow, bands map[string][]StandingItemView) {
a.readMachineMoney(now)
- a.machine.hands, a.machine.wants = machineCounts(sessions, bands, a.exchanges)
+ a.machine.hands, a.machine.wants = machineCounts(now, sessions, bands, a.exchanges)
}
// readMachineMoney is the money half alone: what the day has cost, and the
@@ -127,12 +127,19 @@ func (a *app) readMachineMoney(now time.Time) {
// draws and these figures are about the MACHINE, so a count taken from the rows
// on screen would fall the moment a ninth thing started — which is the opposite
// of what the figure means.
-func machineCounts(sessions []session.SessionRow, bands map[string][]StandingItemView, exchanges []*homeExchange) (hands, wants int) {
+func machineCounts(now time.Time, sessions []session.SessionRow, bands map[string][]StandingItemView, exchanges []*homeExchange) (hands, wants int) {
for _, row := range sessions {
+ // AND A LANDING NOBODY HAS CHECKED IS A WANT TOO. How many rows of
+ // `needs you` a conversation is — its own question, its landings, or
+ // both — is ONE reading and it is the panel's own
+ // (homepanel_needs.go's [needsWants]), so a pulse over a home cannot
+ // claim a number the rows under it do not show. It answers zero for an
+ // archived conversation and for one whose only question IS its landing,
+ // which is the row the panel drops.
+ wants += needsWants(row, now)
switch {
case row.Archived:
case row.NeedsPerson():
- wants++
case row.Tasks.Running > 1:
hands += row.Tasks.Running
case row.Tasks.Running == 1 || row.Live && row.Presence.State == session.PresenceWorking:
diff --git a/internal/tui3/homeneedsgroups_test.go b/internal/tui3/homeneedsgroups_test.go
new file mode 100644
index 0000000000..92acc21e93
--- /dev/null
+++ b/internal/tui3/homeneedsgroups_test.go
@@ -0,0 +1,313 @@
+package tui3
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Agent-Field/aforge-v2/internal/session"
+)
+
+// ── `needs you`, IN TWO GROUPS (#884, the spec of record §1) ────────────────
+//
+// The panel holds the live questions that have stopped something, then — under
+// a dim line of their own — the landings nobody has checked. These tests pin
+// the rules that are easy to break from the other side of the column: what the
+// group line is, which row takes a key, and what `since you left` may say about
+// a landing this panel is already showing.
+
+// openAt is the live lab at a frame of a chosen size, which is what the squeeze
+// needs.
+func (l *liveLab) openAt(width, height int) *app {
+ l.t.Helper()
+ a := l.app(l.mine)
+ a.width, a.height = width, height
+ a.leaveAnswer = func(string, session.QuestionKind, uint64, string) error { return nil }
+ a.openHome()
+ homeText(a)
+ return a
+}
+
+// archive puts one of the lab's conversations away, the way a person does.
+func (l *liveLab) archive(id string) {
+ l.t.Helper()
+ dir := filepath.Join(l.project("-alpha"), id)
+ meta, err := session.LoadMeta(dir)
+ if err != nil {
+ l.t.Fatal(err)
+ }
+ meta.Archived = true
+ if err := session.SaveMeta(dir, meta); err != nil {
+ l.t.Fatal(err)
+ }
+}
+
+// landed writes one landing whose call is the person's.
+func (l *liveLab) landed(id, label string, ago time.Duration) {
+ l.t.Helper()
+ l.task("-alpha", session.TaskIndexEntry{ID: id, SessionID: "aaaa000000000002", Label: label, Title: label,
+ Status: string(session.TaskUnverified), EndedAt: l.now.Add(-ago)})
+}
+
+// THE GROUP LINE IS FURNITURE AND NOT A STOP: the cursor walks from the last
+// question straight onto the first landing, and the blank over the group line
+// is not a stop either.
+func TestTheToCheckGroupLineIsNotACursorStop(t *testing.T) {
+ l := newLiveLab(t)
+ l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting,
+ Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))})
+ l.landed("4", "fix the flaky sieve", 30*time.Minute)
+ a := l.open()
+ at := -1
+ for i, line := range a.home.lines {
+ if line.cell != nil && line.cell.title == "Pricing Site" {
+ at = i
+ }
+ }
+ if at < 0 {
+ t.Fatal("the question is not a line of the column")
+ }
+ a.home.cursor = at
+ a.home.move(1)
+ line := a.home.lines[a.home.cursor]
+ if line.task == nil || line.task.ID != "4" {
+ t.Fatalf("the cursor did not step from the question onto the landing: %+v", line.cell)
+ }
+ for _, line := range a.home.lines {
+ if line.cell != nil && line.cell.kind == cellGroup && line.stop() {
+ t.Fatal("the group line is a cursor stop")
+ }
+ }
+}
+
+// THE CHIPS ARE ON EXACTLY ONE ROW OF THE FRAME (law 7). Two questions and a
+// landing, and only one answer clause is painted anywhere.
+func TestTheAnswerChipsAreOnOneRowOfTheFrame(t *testing.T) {
+ l := newLiveLab(t)
+ l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting,
+ Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))})
+ l.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting,
+ Question: consentQuestionAt(9, "needs your ok to run write", l.now.Add(-5*time.Minute))})
+ l.landed("4", "fix the flaky sieve", 30*time.Minute)
+ a := l.open()
+ frame := homeText(a)
+ if got := strings.Count(frame, "1 allow once"); got != 1 {
+ t.Fatalf("the consent's chips are drawn on %d rows:\n%s", got, frame)
+ }
+ if got := strings.Count(frame, needsYesKey+" accept"); got != 0 {
+ t.Fatalf("a landing nobody is standing on drew its answers:\n%s", frame)
+ }
+ // AND THE CURSOR OUTRANKS THE TOP ROW. Walked onto the landing, the chips
+ // move with it and the question above says `enter` again.
+ for i, line := range a.home.lines {
+ if line.task != nil && line.task.ID == "4" {
+ a.home.cursor = i
+ }
+ }
+ frame = homeText(a)
+ if strings.Count(frame, "1 allow once") != 0 {
+ t.Fatalf("the chips stayed on the top question with the cursor on a landing:\n%s", frame)
+ }
+ if got := strings.Count(frame, needsYesKey+" accept"); got != 1 {
+ t.Fatalf("the landing under the cursor drew its answers %d times:\n%s", got, frame)
+ }
+}
+
+// A DIGIT WITH THE CURSOR ON NOTHING ANSWERABLE STILL REACHES THE TOP QUESTION,
+// which is law 7 and did not change.
+func TestADigitStillReachesTheTopQuestionFromALandingThatCannotAnswer(t *testing.T) {
+ l := newLiveLab(t)
+ l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting,
+ Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))})
+ a := l.open()
+ var left []string
+ a.leaveAnswer = func(dir string, kind session.QuestionKind, id uint64, key string) error {
+ left = append(left, string(kind)+"/"+key)
+ return nil
+ }
+ a.home.point(a.file)
+ homeText(a)
+ if _, took := a.homeGridAnswer("1"); !took {
+ t.Fatal("the digit did not reach the top question")
+ }
+ if len(left) != 1 || left[0] != string(session.QuestionConsent)+"/1" {
+ t.Fatalf("the digit went somewhere else: %v", left)
+ }
+}
+
+// SAID ONCE ACROSS THE COLUMNS: `since you left` does not repeat a landing that
+// `to check` is showing, and shows it again once the landing has aged out of the
+// group.
+func TestSinceYouLeftOmitsALandingToCheckIsShowing(t *testing.T) {
+ l := newLiveLab(t)
+ l.landed("4", "fix the flaky sieve", 30*time.Minute)
+ a := l.open()
+ a.home.seen = l.now.Add(-4 * time.Hour)
+ a.home.build()
+ frame := homeText(a)
+ if got := strings.Count(frame, "fix the flaky sieve"); got != 1 {
+ t.Fatalf("the landing is drawn on %d rows of the column:\n%s", got, frame)
+ }
+ if rows := panelRows(a, panelLeft); len(rows) != 0 {
+ t.Fatalf("since you left repeated the landing: %+v", rows)
+ }
+ // AND ONCE IT IS HISTORY IT IS AN ORDINARY LINE OF `since you left` AGAIN.
+ l2 := newLiveLab(t)
+ l2.landed("4", "fix the flaky sieve", homeNeedsTaskFresh+time.Hour)
+ b := l2.open()
+ b.home.seen = l2.now.Add(-homeNeedsTaskFresh - 2*time.Hour)
+ b.home.build()
+ found := false
+ for _, row := range panelRows(b, panelLeft) {
+ if strings.Contains(row.title, "fix the flaky sieve") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("an aged-out landing never came back to since you left:\n%s", homeText(b))
+ }
+}
+
+// THE GROUP FOLDS BEFORE A `needs you` ROW GOES. At a hundred and twenty by
+// fourteen the panel keeps its question and says the landings on its fold.
+func TestToCheckFoldsBeforeANeedsYouRowGoes(t *testing.T) {
+ l := newLiveLab(t)
+ l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting,
+ Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))})
+ for i, label := range []string{"one", "two", "three"} {
+ l.landed(itoa(i+1), "landed "+label, time.Duration(i+1)*time.Hour)
+ }
+ a := l.openAt(120, 14)
+ frame := homeText(a)
+ if !strings.Contains(frame, "Pricing Site") {
+ t.Fatalf("the question gave way before the landings did:\n%s", frame)
+ }
+ if strings.Contains(frame, needsCheckClause) {
+ t.Fatalf("the group line survived the squeeze:\n%s", frame)
+ }
+ if !strings.Contains(frame, "3 "+needsCheckWord+" · tasks") {
+ t.Fatalf("the fold does not say what the folded group is:\n%s", frame)
+ }
+}
+
+// THE PULSE'S COUNT IS THE SUM OF THE TWO GROUPS, read through the panel's own
+// reading of a conversation's rows rather than through a second one
+// ([needsWants]).
+//
+// THE LANDINGS' OWN CONVERSATION IS LIVE AND WAITING ON THEM HERE, which is the
+// shape that made the first cut of this count disagree with the rows: the
+// conversation contributed a want the panel does not draw a row for.
+func TestThePulseCountsBothGroupsOfNeedsYou(t *testing.T) {
+ l := newLiveLab(t)
+ l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting,
+ Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))})
+ l.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting,
+ Reason: needsYourCallLead + "fix the flaky sieve"})
+ l.landed("4", "fix the flaky sieve", 30*time.Minute)
+ l.landed("5", "port the parser", 5*time.Hour)
+ // AND A LANDING THAT AGED OUT IS NOT COUNTED, because it is not a row.
+ l.landed("6", "old business", homeNeedsTaskFresh+time.Hour)
+ a := l.open()
+ if rows := panelRows(a, panelNeeds); len(rows) != 3 || a.machine.wants != len(rows) {
+ t.Fatalf("the pulse says %d want you over %d rows: %+v", a.machine.wants, len(rows), rows)
+ }
+}
+
+// A CONVERSATION STOPPED AT ITS FUEL GATE KEEPS ITS ROW, landing or no landing.
+// It banks no card at the desk and so writes no question object, and the first
+// cut of the "said once" test read that absence as "this is only waiting on its
+// landing" — which took a paused run off home entirely.
+func TestAPausedRunKeepsItsRowBesideItsOwnLanding(t *testing.T) {
+ l := newLiveLab(t)
+ l.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting,
+ Reason: "out of fuel · 12 of 12 spent"})
+ l.landed("4", "fix the flaky sieve", 30*time.Minute)
+ a := l.open()
+ rows := panelRows(a, panelNeeds)
+ if len(rows) != 2 {
+ t.Fatalf("the paused run lost its row beside its landing: %+v", rows)
+ }
+ frame := homeText(a)
+ if !strings.Contains(frame, "out of fuel") || !strings.Contains(frame, "fix the flaky sieve") {
+ t.Fatalf("the frame does not hold both:\n%s", frame)
+ }
+ if a.machine.wants != len(rows) {
+ t.Fatalf("the pulse says %d want you over %d rows", a.machine.wants, len(rows))
+ }
+}
+
+// WORK IN A CONVERSATION SOMEBODY PUT AWAY IS NOT WAITING ON THEM: no row on
+// `to check`, and nothing in the count.
+func TestAnArchivedConversationsLandingIsNotOnToCheck(t *testing.T) {
+ l := newLiveLab(t)
+ l.landed("4", "fix the flaky sieve", 30*time.Minute)
+ l.archive("aaaa000000000002")
+ a := l.open()
+ if rows := panelRows(a, panelNeeds); len(rows) != 0 {
+ t.Fatalf("an archived conversation's landing is on to check: %+v", rows)
+ }
+ if a.machine.wants != 0 {
+ t.Fatalf("the pulse counts an archived conversation's landing: %d", a.machine.wants)
+ }
+}
+
+// ONE ANSWER PER LANDING, AND A CONVERSATION MAY HOLD SEVERAL. This window
+// remembers what it sent per QUESTION; a single slot per conversation meant
+// answering the second landing forgot the first, whose chips came back and whose
+// key sent a second answer for a question already answered from here.
+func TestAnsweringASecondLandingDoesNotReopenTheFirst(t *testing.T) {
+ l := newLiveLab(t)
+ l.landed("4", "fix the flaky sieve", 30*time.Minute)
+ l.landed("5", "port the parser", 5*time.Hour)
+ a := l.open()
+ var left []string
+ a.leaveAnswer = func(dir string, kind session.QuestionKind, id uint64, key string) error {
+ left = append(left, itoa64(id)+"/"+key)
+ return nil
+ }
+ press := func(id string) {
+ for i, line := range a.home.lines {
+ if line.task != nil && line.task.ID == id {
+ a.home.cursor = i
+ }
+ }
+ homeText(a)
+ a.homeGridAnswer(needsYesKey)
+ }
+ press("4")
+ press("5")
+ press("4")
+ want := []string{"4/" + session.LandingYesKey, "5/" + session.LandingYesKey}
+ if len(left) != len(want) || left[0] != want[0] || left[1] != want[1] {
+ t.Fatalf("a landing was answered twice: %v", left)
+ }
+}
+
+// SAID ONCE INSIDE THE PANEL TOO. A live conversation waiting on nothing but its
+// own landing writes `waiting on you · your call on ` with no question
+// object ([session.Agent.waitingOnPerson] reads a pending decision last), and
+// drawing it as a question put the same piece of work on two rows of one panel.
+// The landing's row is the one that survives — it has the work's own name, its
+// files and its two answers.
+func TestAConversationWaitingOnItsOwnLandingIsOnlyTheLandingsRow(t *testing.T) {
+ l := newLiveLab(t)
+ l.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting,
+ Reason: "your call on fix the flaky sieve"})
+ l.landed("4", "fix the flaky sieve", 30*time.Minute)
+ a := l.open()
+ rows := panelRows(a, panelNeeds)
+ if len(rows) != 1 || rows[0].title != "fix the flaky sieve" {
+ t.Fatalf("the landing is on the panel twice, or not at all: %+v", rows)
+ }
+ // AND A CONVERSATION WITH A QUESTION OF ITS OWN KEEPS ITS ROW, landing or no
+ // landing: the question is a different thing, and it blocks.
+ l2 := newLiveLab(t)
+ l2.live("-alpha", "aaaa000000000002", session.SessionPresence{State: session.PresenceWaiting,
+ Question: consentQuestionAt(7, "needs your ok to run bash", l2.now.Add(-time.Minute))})
+ l2.landed("4", "fix the flaky sieve", 30*time.Minute)
+ b := l2.open()
+ if rows := panelRows(b, panelNeeds); len(rows) != 2 {
+ t.Fatalf("a conversation with a question of its own lost its row: %+v", rows)
+ }
+}
diff --git a/internal/tui3/homepanel_left.go b/internal/tui3/homepanel_left.go
index 3da381cec9..eb288bb863 100644
--- a/internal/tui3/homepanel_left.go
+++ b/internal/tui3/homepanel_left.go
@@ -22,9 +22,19 @@ import (
// own ledger rows ([switcherReading.addLedger]), each carrying its door.
type leftPanel struct{ homePanelBase }
+// SAID ONCE ACROSS THE COLUMNS. A landing whose check is still the person's is
+// drawn by `needs you`'s `to check` group, one column over and higher up the
+// page ([needsChecking]); this panel drew it a second time, as an ordinary thing
+// that happened while nobody was looking. It comes back here the moment it stops
+// being a question — answered, or aged out of the group — because then it IS
+// just something that happened.
func (leftPanel) rows(in *homeGridInput) homePanelRows {
+ checking := needsChecking(in)
lines := make([]homeLine, 0, len(in.ledger))
for _, row := range in.ledger {
+ if row.task != nil && checking[taskLedgerKey(*row.task)] {
+ continue
+ }
lines = append(lines, leftLine(row))
}
out := homePanelCut(panelLeft, lines)
@@ -43,12 +53,22 @@ func leftLine(row switcherRow) homeLine {
view: row.item, item: row.item.Item, cell: cell}
}
+// taskLedgerKey is ONE piece of work's identity across home's columns: the
+// conversation that ran it and the node's number, which is the only pair that
+// is unique ([session.TaskIndexEntry.ID] repeats across sessions). It is spelled
+// here because three readers ask it — this panel's own key, the `to check`
+// group's set of what it is already drawing, and the comparison between them
+// (homepanel_needs.go's [needsChecking]).
+func taskLedgerKey(task session.TaskIndexEntry) string {
+ return task.SessionID + "/" + task.ID
+}
+
// leftKey is a ledger line's identity beside its place word ([homeLine.sameRow]):
// the task, the file, or — for a firing or memory's line — its words.
func leftKey(row switcherRow) string {
switch {
case row.task != nil:
- return row.task.SessionID + "/" + row.task.ID
+ return taskLedgerKey(*row.task)
case row.path != "":
return row.path
}
diff --git a/internal/tui3/homepanel_live_test.go b/internal/tui3/homepanel_live_test.go
index 0ed1fad1bf..525fdbc273 100644
--- a/internal/tui3/homepanel_live_test.go
+++ b/internal/tui3/homepanel_live_test.go
@@ -120,20 +120,28 @@ func TestNeedsYouOrdersTheWaitsAndDrawsAnswersOnTheTopRowOnly(t *testing.T) {
}
}
-// A TASK THE RECORD MARKS AS YOUR CALL IS A ROW OF ITS OWN, and enter aims at
-// the task rather than at the conversation's live edge. Work nobody could check
-// says what the person can do about it, and draws no second `enter` beside that.
+// A TASK THE RECORD MARKS AS YOUR CALL IS A ROW OF THE `to check` GROUP, one
+// line of its own, under a group line that says what the group is. enter aims at
+// the task rather than at the conversation's live edge.
func TestNeedsYouCarriesATaskWaitingOnYourCall(t *testing.T) {
l := newLiveLab(t)
l.task("-alpha", session.TaskIndexEntry{ID: "4", SessionID: "aaaa000000000002", Label: "fix the flaky sieve",
- Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-30 * time.Minute)})
+ Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-30 * time.Minute),
+ FilesChanged: 3})
a := l.open()
rows := panelRows(a, panelNeeds)
- if len(rows) != 1 || rows[0].title != "fix the flaky sieve" || rows[0].sub != needsUncheckedWord || rows[0].subRight != "" {
- t.Fatalf("the task's call is not a row of needs you that says what to do: %+v", rows)
+ if len(rows) != 1 || rows[0].title != "fix the flaky sieve" || rows[0].right != "3 files · 30m" {
+ t.Fatalf("the task's call is not a one-line row of needs you: %+v", rows)
+ }
+ if rows[0].mark != cellMarkNone {
+ t.Fatalf("a landing wears a mark: %+v", rows[0])
+ }
+ frame := homeText(a)
+ if !strings.Contains(frame, needsCheckWord+" · 1") || !strings.Contains(frame, needsCheckClause) {
+ t.Fatalf("the group line does not name the group and say what it is:\n%s", frame)
}
- if frame := homeText(a); !strings.Contains(frame, "landed unchecked · enter to look") || strings.Contains(frame, "nobody could check it") {
- t.Fatalf("the row under the call does not say what to do:\n%s", frame)
+ if strings.Contains(frame, "landed unchecked") {
+ t.Fatalf("the retired sub-line is still drawn:\n%s", frame)
}
for _, line := range panelLines(a, panelNeeds) {
if line.cell.kind == cellRow && (line.task == nil || line.task.ID != "4") {
@@ -142,6 +150,125 @@ func TestNeedsYouCarriesATaskWaitingOnYourCall(t *testing.T) {
}
}
+// THE GROUP LINE IS DRAWN ONLY WHERE THE GROUP HAS ROWS, and it is not a stop:
+// the cursor walks from the last question straight onto the first landing.
+func TestToCheckDrawsNoGroupLineWithoutLandings(t *testing.T) {
+ l := newLiveLab(t)
+ l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting,
+ Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-2*time.Hour))})
+ a := l.open()
+ if frame := homeText(a); strings.Contains(frame, needsCheckClause) {
+ t.Fatalf("a group with no rows drew its line:\n%s", frame)
+ }
+ for _, line := range panelLines(a, panelNeeds) {
+ if line.cell.kind == cellGroup {
+ t.Fatalf("a group line was built with no rows under it")
+ }
+ }
+}
+
+// BLOCKING FIRST, HOWEVER OLD THE LANDING IS: a consent asked a minute ago sits
+// above a landing from a week back, and the landing is under the group line.
+func TestNeedsBlockingRowsSortFirst(t *testing.T) {
+ l := newLiveLab(t)
+ l.live("-beta", "bbbb000000000001", session.SessionPresence{State: session.PresenceWaiting,
+ Question: consentQuestionAt(7, "needs your ok to run bash", l.now.Add(-time.Minute))})
+ l.task("-alpha", session.TaskIndexEntry{ID: "4", SessionID: "aaaa000000000002", Label: "fix the flaky sieve",
+ Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-40 * time.Hour)})
+ a := l.open()
+ rows := panelRows(a, panelNeeds)
+ if len(rows) != 2 || rows[0].title != "Pricing Site" || rows[1].title != "fix the flaky sieve" {
+ t.Fatalf("the week-old landing did not sort under the fresh question: %+v", rows)
+ }
+ if rows[0].mark != cellMarkNeeds || rows[1].mark != cellMarkNone {
+ t.Fatalf("the mark is not on the stopped row alone: %+v", rows)
+ }
+ kinds := []homeCellKind{}
+ for _, line := range panelLines(a, panelNeeds) {
+ kinds = append(kinds, line.cell.kind)
+ }
+ if len(kinds) < 4 || kinds[0] != cellHead || kinds[1] != cellRow || kinds[len(kinds)-2] != cellGroup {
+ t.Fatalf("the group line does not stand between the question and the landing: %v", kinds)
+ }
+}
+
+// THE LANDINGS ARE NEWEST FIRST, which is the opposite of the questions above
+// them and is said in [needsPanel.rows].
+func TestToCheckDrawsTheNewestLandingFirst(t *testing.T) {
+ l := newLiveLab(t)
+ for i, ago := range []time.Duration{30 * time.Minute, 5 * time.Hour} {
+ id := itoa(i + 1)
+ l.task("-alpha", session.TaskIndexEntry{ID: id, SessionID: "aaaa000000000002", Label: "call " + id,
+ Title: "call " + id, Status: string(session.TaskUnverified), EndedAt: l.now.Add(-ago)})
+ }
+ a := l.open()
+ if rows := panelRows(a, panelNeeds); len(rows) != 2 || rows[0].title != "call 1" || rows[1].title != "call 2" {
+ t.Fatalf("to check is not newest first: %+v", rows)
+ }
+}
+
+// ONE LINE AT REST AND TWO UNDER THE CURSOR: the landing grows the report's
+// first sentence and the task's own two answers, and the words are the ask's.
+func TestALandingGrowsItsReportAndAnswersUnderTheCursor(t *testing.T) {
+ l := newLiveLab(t)
+ l.task("-alpha", session.TaskIndexEntry{ID: "4", SessionID: "aaaa000000000002", Label: "fix the flaky sieve",
+ Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-30 * time.Minute),
+ Outcome: "Reseeded the generator and the sieve is stable over a thousand runs."})
+ a := l.open()
+ at := -1
+ for i, line := range a.home.lines {
+ if line.task != nil && line.task.ID == "4" {
+ at = i
+ }
+ }
+ if at < 0 {
+ t.Fatal("the landing is not a line of the column")
+ }
+ if frame := homeText(a); strings.Contains(frame, "Reseeded the generator") {
+ t.Fatalf("the landing grew its line with the cursor elsewhere:\n%s", frame)
+ }
+ a.home.cursor = at
+ frame := homeText(a)
+ under := homeLineAfter(frame, "fix the flaky sieve")
+ if !strings.Contains(under, "Reseeded the generator") {
+ t.Fatalf("the cursor row did not grow the report's first sentence:\n%s", frame)
+ }
+ if !strings.Contains(under, needsYesKey+" accept") || !strings.Contains(under, needsNoKey+" not right") {
+ t.Fatalf("the grown line does not carry the ask's own answers:\n%s", frame)
+ }
+}
+
+// AND THE KEY THE GROWN LINE DRAWS IS THE KEY THAT ANSWERS IT, through the one
+// door a landing is answered by anywhere ([app.homeAnswerLanding]).
+func TestALandingUnderTheCursorTakesItsOwnAnswerKey(t *testing.T) {
+ l := newLiveLab(t)
+ l.task("-alpha", session.TaskIndexEntry{ID: "4", SessionID: "aaaa000000000002", Label: "fix the flaky sieve",
+ Title: "fix the flaky sieve", Status: string(session.TaskUnverified), EndedAt: l.now.Add(-30 * time.Minute)})
+ a := l.open()
+ var left []string
+ a.leaveAnswer = func(dir string, kind session.QuestionKind, id uint64, key string) error {
+ left = append(left, string(kind)+"/"+itoa64(id)+"/"+key)
+ return nil
+ }
+ for i, line := range a.home.lines {
+ if line.task != nil && line.task.ID == "4" {
+ a.home.cursor = i
+ }
+ }
+ homeText(a)
+ // AND A BARE LETTER STILL TYPES, which is why the key on this screen is a
+ // digit: `a` is the landing's key everywhere else and must not be one here.
+ if _, took := a.homeGridAnswer(session.LandingYesKey); took {
+ t.Fatal("a bare letter answered a landing from home")
+ }
+ if _, took := a.homeGridAnswer(needsYesKey); !took {
+ t.Fatal("the landing under the cursor did not take the accept it draws")
+ }
+ if len(left) != 1 || left[0] != string(session.QuestionLanding)+"/4/"+session.LandingYesKey {
+ t.Fatalf("the accept did not reach the conversation's doorstep as a landing answer: %v", left)
+ }
+}
+
// A YOUR-CALL OLDER THAN TWO DAYS IS HISTORY, NOT A QUESTION: two fresh calls
// are the panel's rows and its count, and the three that landed days ago are
// one door into tasks under them.
@@ -153,12 +280,18 @@ func TestNeedsYouAgesOldCallsOntoTheFold(t *testing.T) {
Status: string(session.TaskUnverified), EndedAt: l.now.Add(-ago)})
}
a := l.open()
- if rows := panelRows(a, panelNeeds); len(rows) != 2 || rows[0].title != "call 2" || rows[1].title != "call 1" {
+ if rows := panelRows(a, panelNeeds); len(rows) != 2 || rows[0].title != "call 1" || rows[1].title != "call 2" {
t.Fatalf("needs you is not the two fresh calls: %+v", rows)
}
frame := homeText(a)
- if !strings.Contains(frame, "needs you · 2") || !strings.Contains(frame, "3 older · tasks") {
- t.Fatalf("the heading does not count what is listed, or the fold does not count what aged:\n%s", frame)
+ // AND THE HEADING COUNTS THE QUESTIONS, NOT THE LANDINGS. With nothing
+ // stopped it draws its word alone (the emptiness law); the landings are
+ // counted on the group's own line and the aged ones on the fold.
+ if !strings.Contains(frame, "needs you") || strings.Contains(frame, "needs you · ") {
+ t.Fatalf("the heading counted the landings:\n%s", frame)
+ }
+ if !strings.Contains(frame, needsCheckWord+" · 2") || !strings.Contains(frame, "3 older · tasks") {
+ t.Fatalf("the group does not count what is listed, or the fold what aged:\n%s", frame)
}
}
diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go
index bb79db6705..3585cda8e5 100644
--- a/internal/tui3/homepanel_needs.go
+++ b/internal/tui3/homepanel_needs.go
@@ -6,30 +6,52 @@ import (
"strings"
"time"
+ tea "charm.land/bubbletea/v2"
+
"github.com/Agent-Field/aforge-v2/internal/session"
)
// needsPanel is `needs you` (docs/design/home-mission-control/DESIGN.md §1, §3
-// P1): every question on the machine that is waiting on a person, the longest
-// wait first (homeattention.go's [attentionOlder]).
-//
-// THREE SOURCES AND ONE LIST. A conversation stopped on a question, a task the
-// record marks as the person's call, and a watch that needs somebody are three
-// different engines asking the same thing, and a person answering them does not
-// care which engine it was. Each row is two lines: the title with its age at the
-// right, and under it the question's own sentence.
-//
-// THE ANSWERS ARE DRAWN ON THE TOP ANSWERABLE ROW AND NOWHERE ELSE (law 7). A
-// digit pressed anywhere on home answers that row ([app.homeGridAnswer]), so the
-// chips beside it are the key a person is about to press. Chips on a second row
-// would be a `1` on screen twice with one meaning, which is a guess; every other
-// row says `enter`, and enter opens it.
+// P1): everything on the machine that is waiting on a person, in TWO GROUPS on
+// one panel.
+//
+// BLOCKING FIRST, THEN WHAT HAS LANDED (the spec of record for #884). The top
+// group is the live questions — a conversation stopped on a question or on a
+// consent card, a watch that needs somebody — longest wait first
+// ([attentionOlder]), each wearing the amber mark and carrying its own sentence.
+// Under them the `to check` group is every task whose call is the person's,
+// newest first, one line each. A live question is always above a landing however
+// old the landing is, because a landing costs nothing while it waits and a
+// stopped conversation costs everything.
+//
+// THE DIFFERENCE BETWEEN THE TWO GROUPS IS SAID ONCE, ON THE GROUP'S OWN LINE:
+// `to check · 8` at the left and `finished, nobody has checked it` at the right
+// (placeprose.go's [needsCheckWord] and [needsCheckClause]). It used to be said
+// under every landing row, which was the same nine words nine times and pushed
+// `where you were` off a forty-row frame (owner, 2026-09-11).
+//
+// A LANDING IS ONE LINE AT REST AND TWO UNDER THE CURSOR. The second line is the
+// first sentence of what the work came to and the two answers the task itself
+// offers ([session.TaskAsk]), which is the whole of what "check it" means; the
+// panel reserves the line it grows into ([homeCell.grows]) so the column does
+// not move as the cursor walks.
+//
+// THE ANSWERS ARE DRAWN ON ONE ROW OF THE FRAME AND NOWHERE ELSE (law 7): the
+// row under the cursor when it can take an answer, and the top row that can
+// otherwise ([app.homeAnswerAt]). Drawing and routing ask that one function, so
+// a `1` on the screen and the key a person presses cannot be two different rows.
type needsPanel struct{ homePanelBase }
const (
- // needsOpenWord is what a row says where its answers are not drawn: a
- // question with a paragraph, a task's call, a watch's question, any row
- // below the top one. enter opens it.
+ // needsOpenWord is what a QUESTION row says where its answers are not drawn:
+ // one with a paragraph, or one that is not the frame's answering row. enter
+ // opens it.
+ //
+ // A LANDING NEVER SAYS IT. Its second line only exists under the cursor, and
+ // there the answers are the thing the line is for; a `to check` row that this
+ // window cannot answer draws the report's sentence and nothing at its right,
+ // which is the emptiness law rather than an instruction repeated on every
+ // row. enter still opens the record ([app.homeLandOnTask]).
needsOpenWord = "enter"
// needsAnswersCap is how many of a question's answers fit on its row. A
// question with more draws the first ones and then [needsOpenWord], because
@@ -37,37 +59,42 @@ const (
needsAnswersCap = 3
)
-// needsItem is one row before the panel orders it: when it was asked, the line,
-// and the answers it would draw if it were the top answerable row.
+// needsItem is one row before the panel orders it: when it was asked, and the
+// line.
type needsItem struct {
- asked time.Time
- line homeLine
- answers string
+ asked time.Time
+ line homeLine
}
func (needsPanel) rows(in *homeGridInput) homePanelRows {
- calls, older := needsFresh(needsCalls(in), in.now)
- items := append(needsAsked(in), calls...)
- sort.SliceStable(items, func(i, j int) bool { return attentionOlder(items[i].asked, items[j].asked) })
- lines := make([]homeLine, 0, len(items))
- drawn := false
- for _, item := range items {
- // A line that already names its door draws no second one ([needsUncheckedWord]).
- if item.line.cell.sub != needsUncheckedWord {
- item.line.cell.subRight = needsOpenWord
- }
- if item.answers != "" && !drawn {
- item.line.cell.subRight, drawn = item.answers, true
- }
+ asked := needsAsked(in)
+ sort.SliceStable(asked, func(i, j int) bool { return attentionOlder(asked[i].asked, asked[j].asked) })
+ // AND THE LANDINGS ARE NEWEST FIRST, which is the opposite order and the
+ // right one for them: `needs you` is ranked by how long something has been
+ // stopped, and nothing is stopped here — the freshest landing is the work
+ // still in the person's head, and the oldest ages out of the group entirely
+ // ([needsFresh]).
+ calls := append([]needsItem(nil), in.calls...)
+ sort.SliceStable(calls, func(i, j int) bool { return attentionOlder(calls[j].asked, calls[i].asked) })
+ lines := make([]homeLine, 0, len(asked)+len(calls))
+ for _, item := range append(asked, calls...) {
lines = append(lines, item.line)
}
out := homePanelCut(panelNeeds, lines)
- out.said = countWord(len(lines))
- out.older = older
+ // THE HEADING COUNTS THE QUESTIONS AND THE GROUP LINE COUNTS THE LANDINGS.
+ // A frame holding only landings draws `needs you` with no count at all
+ // rather than `needs you · 0`, which is the emptiness law applied to a
+ // heading ([countWord]).
+ out.said = countWord(len(asked))
+ out.older = in.callsOlder
+ if len(calls) > 0 {
+ out.group = &homePanelGroup{at: len(asked), word: needsCheckWord,
+ said: countWord(len(calls)), right: needsCheckClause}
+ }
return out
}
-// needsFresh is the task calls that are still a question, and how many are
+// needsFresh is the task calls that are still worth checking, and how many are
// history.
//
// A YOUR-CALL OLDER THAN [homeNeedsTaskFresh] IS HISTORY, NOT A QUESTION (owner,
@@ -80,7 +107,7 @@ func (needsPanel) rows(in *homeGridInput) homePanelRows {
// not known to be old, and stays.
func needsFresh(items []needsItem, now time.Time) (fresh []needsItem, older int) {
for _, item := range items {
- if !item.asked.IsZero() && now.Sub(item.asked) > homeNeedsTaskFresh {
+ if needsAged(item.asked, now) {
older++
continue
}
@@ -89,15 +116,31 @@ func needsFresh(items []needsItem, now time.Time) (fresh []needsItem, older int)
return fresh, older
}
+// needsAged is that rule as one predicate, so the panel's rows and the pulse's
+// count age a landing out at the same instant ([machineCounts]).
+func needsAged(asked, now time.Time) bool {
+ return !asked.IsZero() && now.Sub(asked) > homeNeedsTaskFresh
+}
+
// needsAsked is the conversations and watches the switcher already ranks as
// waiting on somebody.
+//
+// A CONVERSATION WAITING ONLY ON ITS OWN LANDING IS NOT ONE OF THEM — THE
+// LANDING IS. [session.Agent.waitingOnPerson] reads a pending decision LAST and
+// writes the sentence `your call on ` into the presence with no question
+// object, so such a session drew as a question ABOVE the `to check` row for the
+// very same piece of work: once under the CONVERSATION's name with a bare
+// `enter`, once under the TASK's name with its files and its two answers. The
+// second row is the better one and this drops the first (the spec of record's
+// "said once", applied inside the panel as well as across the columns).
func needsAsked(in *homeGridInput) []needsItem {
var items []needsItem
for _, row := range in.rows {
- if !row.needs {
+ if !row.needs || (row.kind == switcherConversation &&
+ needsLandingsSpeakFor(row.session, needsCallTitlesOn(in, row.session.ID))) {
continue
}
- cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: row.title}
+ cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: row.title, subRight: needsOpenWord}
homeLiveMargin(cell, row, sinceAt(row.at, in.now))
item := needsItem{asked: row.at}
switch row.kind {
@@ -105,7 +148,7 @@ func needsAsked(in *homeGridInput) []needsItem {
head, whole := needsSentence(row.session)
cell.sub = head
if _, ok := answerable(row.session, in.now); ok && whole {
- item.answers = answersWord(row.session.Presence.Question)
+ cell.answers = answersWord(row.session.Presence.Question)
}
case switcherStanding:
cell.sub = switcherFirstLine(row.item.Item.NeedsPerson)
@@ -116,6 +159,89 @@ func needsAsked(in *homeGridInput) []needsItem {
return items
}
+// needsYourCallLead opens the sentence a session writes into its presence while
+// nothing but a landed task is waiting on somebody — internal/session's
+// `yourCallLine`, which is the tier's own word and a preposition
+// ([tierYourCallWord]; taskpresence.go builds it the same way from the same
+// word). It is rebuilt here rather than imported because the engine keeps it
+// unexported; the seam is noted in the change entry.
+const needsYourCallLead = tierYourCallWord + " on "
+
+// needsLandingsSpeakFor reports that the WHOLE of what a conversation is waiting
+// on is one of its own landings, which the `to check` group is already drawing
+// under the work's own name.
+//
+// IT IS THE ENGINE'S SENTENCE AND NOT AN ABSENCE. The first cut of this test was
+// "the presence carries no question object", and that is wrong in two lanes that
+// bank no card at the desk and say so: a sub-harness offer (`wants to run …`)
+// and an adaptive run at its fuel gate (`out of fuel · …`) both write a reason
+// with no question, and a conversation stopped at either of those would have
+// VANISHED from home the moment it happened to own a landing
+// (internal/session's taskpresence.go says which lanes bank nothing). So the
+// test is the sentence the pending-decision arm writes, opened by
+// [needsYourCallLead] and closed by the title of one of this conversation's own
+// landings — a question of any other kind keeps its row.
+func needsLandingsSpeakFor(row session.SessionRow, titles []string) bool {
+ if len(titles) == 0 || row.Presence.Question.Kind != "" {
+ return false
+ }
+ reason := strings.TrimSpace(row.Reason())
+ if !strings.HasPrefix(reason, needsYourCallLead) {
+ return false
+ }
+ for _, title := range titles {
+ if title != "" && strings.HasSuffix(reason, title) {
+ return true
+ }
+ }
+ return false
+}
+
+// needsCallTitlesOn is the titles the `to check` group is drawing for one
+// conversation, read out of the reading the beat already took.
+func needsCallTitlesOn(in *homeGridInput, id string) []string {
+ var titles []string
+ for _, call := range in.calls {
+ if call.line.task != nil && call.line.task.SessionID == id {
+ titles = append(titles, strings.TrimSpace(call.line.task.Title))
+ }
+ }
+ return titles
+}
+
+// needsWants is how many rows of `needs you` ONE conversation is: one per
+// landing of its own that the `to check` group draws, and its own question where
+// the panel keeps that row.
+//
+// THE PANEL BUILDS ITS ROWS FROM THESE READINGS AND THE PULSE COUNTS THEM FROM
+// THIS ONE, so `N want you` over a home and the rows under it are the same
+// arithmetic ([machineCounts]).
+func needsWants(row session.SessionRow, now time.Time) int {
+ titles := needsCallTitles(row, now)
+ n := len(titles)
+ if row.NeedsPerson() && !needsLandingsSpeakFor(row, titles) {
+ n++
+ }
+ return n
+}
+
+// needsCallTitles is one conversation's landings as the `to check` group would
+// draw them, by title, taken straight off the index row.
+func needsCallTitles(row session.SessionRow, now time.Time) []string {
+ if row.Archived {
+ return nil
+ }
+ var titles []string
+ for i := range row.Tasks.Rows {
+ entry := row.Tasks.Rows[i]
+ if _, ok := needsCallOf(row, entry); !ok || needsAged(needsCallAt(entry), now) {
+ continue
+ }
+ titles = append(titles, strings.TrimSpace(entry.Title))
+ }
+ return titles
+}
+
// needsCalls is every task the project's record marks as the person's call —
// work that finished and nobody could check, a landing that turned the work
// back, a branch that would not fasten. They are read off the world's own index
@@ -123,48 +249,187 @@ func needsAsked(in *homeGridInput) []needsItem {
// the tasks place can never disagree about which work is waiting.
//
// ENTER OPENS THE CONVERSATION ON THE TASK'S RECORD ([app.homeLandOnTask]),
-// which is where the question and its two answers are.
-func needsCalls(in *homeGridInput) []needsItem {
+// which is where the question and the whole of its evidence are.
+func needsCalls(world session.World, now time.Time) []needsItem {
var items []needsItem
- for _, project := range in.world.Projects {
+ for _, project := range world.Projects {
for _, row := range project.Sessions {
for i := range row.Tasks.Rows {
entry := row.Tasks.Rows[i]
- status := taskEntryStatus(entry, row.Runs(entry))
- if !status.Attention || status.On != session.TaskWaitPerson || needsAskedInChat(row, entry) {
+ status, ok := needsCallOf(row, entry)
+ if !ok {
continue
}
- items = append(items, needsCall(project, row, entry, status, in.now))
+ items = append(items, needsCall(project, row, entry, status, now))
}
}
}
return items
}
-// needsCall is one of those rows.
+// needsCallOf is THE ONE READING OF "THIS LANDING IS WAITING ON THE PERSON",
+// and its status.
+//
+// THREE READERS AND ONE ANSWER. The `to check` group draws these rows, `since
+// you left` drops the landings this group is already drawing ([leftPanel.rows]),
+// and the pulse counts them beside the questions ([machineCounts]). A second
+// spelling of this test anywhere would be a `3 want you` over four rows, which
+// is the exact failure the one-reader law exists for.
+//
+// A LANDING THE CONVERSATION IS ITSELF ASKING ABOUT IS NOT ONE OF THESE
+// ([needsAskedInChat]): that is a stopped conversation, it is a `needs you` row,
+// and it is the row that can take a digit.
+func needsCallOf(row session.SessionRow, entry session.TaskIndexEntry) (session.TaskStatus, bool) {
+ // WORK IN A CONVERSATION SOMEBODY PUT AWAY IS NOT WAITING ON THEM. Archiving
+ // is the decision to stop being asked about it, and every other panel already
+ // reads it that way ([machineCounts] skips an archived row outright).
+ if row.Archived {
+ return session.TaskStatus{}, false
+ }
+ status := taskEntryStatus(entry, row.Runs(entry))
+ if !status.Attention || status.On != session.TaskWaitPerson || needsAskedInChat(row, entry) {
+ return session.TaskStatus{}, false
+ }
+ return status, true
+}
+
+// needsCallAt is when a landing asked — when the work ended, or when it started
+// for a row that never recorded an ending.
+func needsCallAt(entry session.TaskIndexEntry) time.Time {
+ if !entry.EndedAt.IsZero() {
+ return entry.EndedAt
+ }
+ return entry.StartedAt
+}
+
+// needsCall is one of those rows: the title, how many files it wrote and how
+// long ago, and — under the cursor — what it came to and its two answers.
func needsCall(project session.Project, row session.SessionRow, entry session.TaskIndexEntry, status session.TaskStatus, now time.Time) needsItem {
title := strings.TrimSpace(entry.Label)
if title == "" {
title = strings.TrimSpace(entry.Title)
}
- sub := switcherFirstLine(status.Reason)
- if sub == "" {
- sub = status.Word
- }
- if status.Ask.Kind == session.TaskAskCheck {
- sub = needsUncheckedWord
- }
- asked := entry.EndedAt
- if asked.IsZero() {
- asked = entry.StartedAt
- }
- cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: title, right: sinceAt(asked, now), sub: sub,
- key: needsCallKey + entry.ID}
+ asked := needsCallAt(entry)
+ // A LANDING WEARS NO MARK (law 8). The amber `?` means a thing has stopped
+ // and will not move until somebody answers it; a landing has already
+ // finished, and a column of question marks over work that is DONE was the
+ // screen saying the opposite of what was true.
+ cell := &homeCell{panel: panelNeeds, title: title, right: needsCallFacts(entry, asked, now),
+ key: needsCallKey + entry.ID,
+ grows: true, sub: needsCallSub(entry, status), answers: needsCallAnswers(status)}
line := homeLine{kind: homeSession, row: row, project: project.Name,
dir: homeBucketOf(row.Transcript), task: &entry, cell: cell}
return needsItem{asked: asked, line: line}
}
+// needsCallSub is the line a landing grows under the cursor: THE FIRST SENTENCE
+// OF WHAT THE WORK CAME TO, which is what a person needs in order to say whether
+// it holds. The engine's own reason for the call stands in where the work left no
+// report, and the tier's word where there is neither.
+func needsCallSub(entry session.TaskIndexEntry, status session.TaskStatus) string {
+ if sub := switcherFirstLine(entry.Outcome); sub != "" {
+ return sub
+ }
+ if sub := switcherFirstLine(status.Reason); sub != "" {
+ return sub
+ }
+ return status.Word
+}
+
+// needsCallFacts is a landing's right margin: how many files it wrote and how
+// long ago, as ONE clause — `3 files · 1d`.
+//
+// IT IS ONE CLAUSE AND NOT TWO FACTS SIDE BY SIDE because they are read as one
+// sentence about the same piece of work, and because a row cut between them
+// would leave `3 files` with no age beside a column of rows that all have one.
+// A landing that wrote no files says NOTHING where the count would be, never
+// `0 files` (the emptiness law).
+func needsCallFacts(entry session.TaskIndexEntry, asked, now time.Time) string {
+ age := sinceAt(asked, now)
+ if entry.FilesChanged <= 0 {
+ return age
+ }
+ files := itoa(entry.FilesChanged) + plural(" file", entry.FilesChanged)
+ if age == "" {
+ return files
+ }
+ return files + rowSep + age
+}
+
+// The two keys a landing is answered with ON HOME, and the landing keys they
+// stand for.
+//
+// ── WHY THEY ARE DIGITS HERE AND LETTERS EVERYWHERE ELSE ────────────────────
+//
+// A landing is `[a] accept · [n] not right` on its card, in its room and on its
+// record, and [session.LandingYesKey] is that letter (answers.go: the landing
+// keys are task-states' own, letter for letter). HOME CANNOT OFFER A LETTER. Its
+// foot promises "type to search or start something new" and the promise has no
+// asterisk — a bare letter on this screen always types, whatever the cursor is
+// resting on (home.go's [app.homeKey] states it), and the ONE printable
+// exception it allows is a digit drawn on the row itself. An `a` that sometimes
+// accepted a landing and sometimes began "a site for my café" is the mode that
+// law exists to forbid.
+//
+// So the KEY is home's and the WORDS are the task's: `1 accept 2 not right`,
+// read off [session.TaskAsk], mapped back to the landing's own keys before the
+// answer leaves this surface ([needsLandingKey]). Nothing about the answer is
+// renumbered — only the key a person presses on THIS screen, which is the same
+// bargain the drawn digit has always been.
+const (
+ needsYesKey = "1"
+ needsNoKey = "2"
+)
+
+// needsCallAnswers is the two answers a landing offers, in the task's own words
+// on home's own two digits.
+//
+// THE WORDS ARE THE ASK'S ([session.TaskAsk.Yes] and .No) and this file spells
+// none of them. A row whose ask has no words offers nothing, which is the
+// absence law: there is no default pair to fall back on.
+//
+// AND EVERY ASK THAT REACHES HERE IS [session.TaskAskCheck]. The status comes
+// from [session.TaskIndexEntry.StatusFacts], which fills the state, the ending
+// and the kind and nothing else — the merge, the ground hold and the phase are
+// the NODE's facts and the index does not carry them — so `taskAskOf` can only
+// take its `check` arm for an index row. A conflict is a different question with
+// different words, it is banked by the engine as `conflict:`
+// ([session.Agent.landingQuestion]), and it reaches a person through the task's
+// own card and not through this panel. So [needsLandingQuestion] names one kind
+// rather than guessing between two.
+func needsCallAnswers(status session.TaskStatus) string {
+ yes, no := strings.TrimSpace(status.Ask.Yes), strings.TrimSpace(status.Ask.No)
+ if yes == "" || no == "" {
+ return ""
+ }
+ return needsYesKey + " " + yes + homeCellGap + needsNoKey + " " + no
+}
+
+// needsLandingKey is home's digit as the landing key the engine answers to.
+func needsLandingKey(key string) (string, bool) {
+ switch key {
+ case needsYesKey:
+ return session.LandingYesKey, true
+ case needsNoKey:
+ return session.LandingNoKey, true
+ }
+ return "", false
+}
+
+// needsChecking is every landing the `to check` group is drawing, by the
+// identity a `since you left` line carries for the same task
+// (homepanel_left.go's [leftKey]) — so one piece of work is one row of home's
+// column and never two.
+func needsChecking(in *homeGridInput) map[string]bool {
+ said := make(map[string]bool, len(in.calls))
+ for _, item := range in.calls {
+ if item.line.task != nil {
+ said[taskLedgerKey(*item.line.task)] = true
+ }
+ }
+ return said
+}
+
// needsCallKey prefixes a task row's [homeCell.key], so a conversation's own
// question and one of its tasks' calls are two rows and never one.
const needsCallKey = "call:"
@@ -222,3 +487,102 @@ func answersWord(question session.PresenceQuestion) string {
}
return strings.Join(parts, homeCellGap)
}
+
+// ── answering a landing from home ───────────────────────────────────────────
+
+// needsLandingQuestion is the landing question a `to check` row stands for, as
+// the answer doors want it: the kind, the node's id, and the two options in the
+// task's own words. It is false for a row that is not a landing or whose id the
+// index never recorded.
+//
+// IT IS BUILT FROM THE ROW AND NEVER FROM A LIVE SESSION'S PRESENCE. That is the
+// whole difference between this group and the one above it: nothing is asking,
+// so there is no question object to read — the task index says the call is the
+// person's and [session.TaskAsk] says what the two answers are called.
+func needsLandingQuestion(line homeLine) (session.PresenceQuestion, bool) {
+ if line.task == nil || line.cell == nil || line.cell.answers == "" {
+ return session.PresenceQuestion{}, false
+ }
+ id, err := strconv.ParseUint(strings.TrimSpace(line.task.ID), 10, 64)
+ if err != nil || id == 0 {
+ return session.PresenceQuestion{}, false
+ }
+ yes, no, ok := needsAnswerWords(line.cell.answers)
+ if !ok {
+ return session.PresenceQuestion{}, false
+ }
+ return session.PresenceQuestion{Kind: session.QuestionLanding, ID: id, Options: []session.AnswerOption{
+ {Key: needsYesKey, Label: yes},
+ {Key: needsNoKey, Label: no, Safe: true},
+ }}, true
+}
+
+// needsAnswerWords reads the two words back out of the clause the row draws, so
+// the chip a person sees and the label the receipt says are the SAME STRING and
+// not two readings of one status.
+func needsAnswerWords(clause string) (yes, no string, ok bool) {
+ parts := strings.SplitN(clause, homeCellGap, 2)
+ if len(parts) != 2 {
+ return "", "", false
+ }
+ yes = strings.TrimPrefix(parts[0], needsYesKey+" ")
+ no = strings.TrimPrefix(parts[1], needsNoKey+" ")
+ return yes, no, yes != "" && no != ""
+}
+
+// homeAnswerLanding takes one of a landed task's own answers from home, and it
+// is THE ONE DOOR on whichever side of the window the conversation is.
+//
+// In this window the answer goes straight to [session.Agent.ResolveQuestion],
+// which is where the record card's `a` and `n` end up too ([app.taskCardKey] →
+// [app.questionOptionKey] → the block). In any other window it is left on that
+// conversation's doorstep ([app.leaveAnswer] → [session.WriteAnswer]), and that
+// session's own heartbeat hands it to the SAME ResolveQuestion
+// ([session.Agent.drainAnswers] → [session.Agent.applyLanding] →
+// [session.Agent.ResolveUnverified]). There is no third path, and nothing here
+// settles a task itself.
+//
+// A CONVERSATION THAT IS NOT RUNNING APPLIES IT WHEN IT NEXT RUNS, which is what
+// this surface has always promised for another window's question and is why the
+// row says `answered · waiting for it to pick that up` until it is picked up.
+func (a *app) homeAnswerLanding(line homeLine, key string) (tea.Cmd, bool) {
+ question, ok := needsLandingQuestion(line)
+ if !ok {
+ return nil, false
+ }
+ label := question.Label(key)
+ answer, ok := needsLandingKey(key)
+ if label == "" || !ok {
+ return nil, false
+ }
+ row := a.homeTrue(line.row)
+ if _, sent := a.answerSent(row, question); sent {
+ // ONE ANSWER PER LANDING, for [app.answerRowKey]'s reason: the first is
+ // on its way and the row is still saying so.
+ return nil, false
+ }
+ if a.answeringHere(row) {
+ doors, ok := a.questionDoors()
+ if !ok {
+ return nil, false
+ }
+ sent := session.Answer{At: time.Now(), Kind: session.QuestionLanding, ID: question.ID,
+ Key: answer, Picked: []string{answer}, DecidedBy: session.DecidedByPerson}
+ if err := doors.ResolveQuestion(sent); err != nil {
+ return nil, false
+ }
+ a.home.say(answerSentWord+label, "")
+ return nil, true
+ }
+ dir := strings.TrimSpace(row.Dir)
+ if a.leaveAnswer == nil || dir == "" {
+ return nil, false
+ }
+ if err := a.leaveAnswer(dir, question.Kind, question.ID, answer); err != nil {
+ a.home.say(answerFailedWord, dir)
+ return nil, true
+ }
+ a.rememberAnswered(dir, question, label)
+ a.home.say(answerSentWord+label, "")
+ return nil, true
+}
diff --git a/internal/tui3/placeprose.go b/internal/tui3/placeprose.go
index cbadb1188d..29d6677133 100644
--- a/internal/tui3/placeprose.go
+++ b/internal/tui3/placeprose.go
@@ -96,15 +96,21 @@ const (
standNotOursWord = "that one does not stand over this conversation"
)
-// needsUncheckedWord is the line under a `needs you` row for work that landed
-// and that nobody could check (homepanel_needs.go's [needsCall]).
+// The two words the `to check` group of `needs you` is drawn with
+// (homepanel_needs.go), spelled once here because the heading, the fold and the
+// manual all quote them.
//
-// IT SAYS WHAT THE PERSON CAN DO, NOT WHAT THE MACHINE COULD NOT. The engine's
-// own reason, `nobody could check it`, is the right sentence on the task's
-// record, where the two answers stand beside it; on home, under a title and an
-// age, it read as a shrug (owner, 2026-09-10). The line names its own door, so
-// the row draws no second `enter` beside it.
-const needsUncheckedWord = "landed unchecked · enter to look"
+// THE CLAUSE IS THE WHOLE EXPLANATION AND IT IS SAID ONCE. The difference
+// between a row that has stopped a conversation and a landing nobody has looked
+// at is not obvious from a title and an age, and it used to be spelled under
+// every landing — `landed unchecked · enter to look`, the same sub-line on nine
+// rows (owner, 2026-09-11). It is seven words on the group's own line now, in
+// the place a heading's clause already lives, and the rows under it are one line
+// each.
+const (
+ needsCheckWord = "to check"
+ needsCheckClause = "finished, nobody has checked it"
+)
// ── THE FIVE-LEVEL SCALE (SCREEN 2a) ────────────────────────────────────────
//