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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/changes/unreleased/895-tasks-row-says-its-state-once.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
kind: fixed
title: a row of the tasks place says its state once, and a root names its folder only where that is news
pr: 895
surface: [chat, engine]
invalidates:
- "A row of the tasks place used to carry the record's own outcome sentence — `2 files · Annual is the default and the monthly price stays visible beside it.` It does not any more. What a row carries is what the work touched, then its state and the reason behind it (`your call · nobody could check it`, `incomplete · lost the connection`, `stopped`), then its cost. The landing's report sentence is on the record card, one `enter` away."
- "`tasksMiddle` (internal/tui3/tasksplace.go) used to compose the state word and its reason itself, three different ways, and `tasksNote` spelled a fourth state word for a row nobody is behind. There is one join now and it is the engine's: `session.TaskStatus.RowWord()`. `tasksNote` says only which window the work is in."
- "`checkerStalled` (internal/session/task_audit.go) used to print the abandoned call's bound with `time.Duration.String`, so a landing read `one call ran 29.24078975s without answering and was abandoned`. It is `taskSpanWord` now — `29s`. `checkerRanOut` still spells its window with `String()`, because that figure is a configured ceiling rather than a measured elapsed."
- "The state word moved to THIRD in the tasks row's ranked tail, ahead of the age and ahead of the conversation the work came out of. A narrow frame now gives up the age and the chat's name before it gives up `incomplete` or `your call`; before this it gave up the word first, so at eighty-five cells a row nothing was running named its conversation and hid the fact that nobody was behind it."
- "A finished row on the tasks place now shows its cost at every width down to about seventy cells. It used not to: the spend is drawn only when every fact ahead of it fits whole (`tasksSpendEarnsCells`), and the fact ahead of it used to be a seventy-seven-cell outcome sentence that fitted nowhere. With the sentence gone the figure earns its cells, which is the rule working rather than a new one."
- "A conversation root on the tasks place used to name any folder whose word was not the chat's own title, so a chat opened in a home directory wore a lone `~` at the right of its name and every row of the folder you were already in wore the same project word. Both were retired by docs/design/home-mission-control/DESIGN.md §1 and are now drawn as nothing. The rule lives in internal/tui3/projecttag.go as `chatProjectWord`, and home's `chatProjectTag` delegates to it."
---

Three defects on one screenshot, and every one of them was a single fact with
two writers. `internal/session` grades a node somebody stopped with the outcome
`stopped`, and the page hung that outcome off its own state word, so the row read
`stopped · stopped` — one reading said twice because each writer believed it was
the one saying it. The join already existed: `session.TaskStatus.RowWord()` puts
the word and its reason together and drops a reason that merely restates the
word, and it is what the column beside a conversation has always read. The page
asks it now instead of composing the pair itself.
19 changes: 16 additions & 3 deletions internal/manual/chat/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -4908,9 +4908,22 @@ Pressing `x` on a run that has already finished does nothing but say so.
The **tasks** place lists main chats and their nested work across projects, grouped by what you do
next: `your call`, `running`, `waiting`, `finished today`, then `earlier` — where
`waiting` is admitted work nothing is doing, drawn with no age on it, and `finished today`
is everything that ended today however it ended. The main chat names its project once. Child rows can include
activity, age from the landing time their own records carry, and last their
measured cost. The row's kind is not drawn. Zero or unknown cost is left
is everything that ended today however it ended.

**A main chat's row names its folder only where that folder is news.** A conversation in the
folder this window is already sitting in wears no tag — the tag would be the same word on
every row — and neither does one whose workspace is your home directory or a scratch folder
at the top of `/tmp`. Another project's conversation keeps its name. The **projects** panel on
home still lists all three, as the paths they are.

**A row of work says its state once.** What a child row can carry is what it touched (`2
files`), then its state and the reason behind it — `your call · nobody could check it`,
`incomplete · lost the connection`, `stopped`, `done`, `working` — and last its measured
cost. A row whose state needs no reason says the bare word; a row that stopped says
`stopped` and not `stopped · stopped`, which is what it read while the page composed that
pair itself instead of taking the engine's own spelling of it. The landing's own report
sentence is **not** on the row: it is on the task's record, one keypress away through
`enter`. The row's kind is not drawn. Zero or unknown cost is left
blank, and so is an age whose older record never carried that landing time. A section with
nothing in it is absent.

Expand Down
14 changes: 13 additions & 1 deletion internal/session/task_audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -735,8 +735,20 @@ func checkerRanOut(window time.Duration) string {
// the retry that follows it answers, and the verdict it reaches is what lands.
// What keeps it here is the run where the retry does not answer either, where
// this is the only account there is of where five minutes went.
// AND THE BOUND IS SPELLED THE WAY EVERY OTHER MEASURED ELAPSED TIME IN THIS
// PACKAGE IS ([taskSpanWord]). [time.Duration.String] is the engine talking to
// itself: it landed `one call ran 29.24078975s without answering` on a person's
// screen, eight decimal places of a figure nobody can act on, and the
// nanoseconds were never a fact about the run — they are the clock's resolution.
//
// [checkerRanOut] BESIDE IT STILL SAYS `5m0s` AND THAT IS NOT AN OVERSIGHT. Its
// figure is the window somebody CONFIGURED — a round number a person chose, in
// the units they chose it in — and `5m 0s` would be an elapsed-time spelling put
// on a ceiling. The two sentences can land on one card and say a duration two
// ways because they are saying two different kinds of thing; what they may never
// do is either of them in nanoseconds.
func checkerStalled(bound time.Duration) string {
return "one call ran " + bound.String() + " without answering and was abandoned"
return "one call ran " + taskSpanWord(bound) + " without answering and was abandoned"
}

// checkerWindowClosed is what a person reads BESIDE A CALL'S OWN ACCOUNT when
Expand Down
32 changes: 32 additions & 0 deletions internal/session/task_audit_words_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package session

import (
"regexp"
"strings"
"testing"
"time"
)

// A DURATION A PERSON READS IS SPELLED IN THE UNIT THEY ARE IN.
//
// [time.Duration.String] is the engine talking to itself. It put `one call ran
// 29.24078975s without answering and was abandoned` on a landing a person read
// — eight decimal places of a figure nobody can act on, and the nanoseconds were
// never a fact about the run, only the resolution of the clock that measured it.
// [taskSpanWord] is this package's one spelling of how long something took.
func TestAnAbandonedCallIsSaidInWholeSeconds(t *testing.T) {
raw := regexp.MustCompile(`\d+\.\d+s`)
for _, bound := range []time.Duration{
29240789750 * time.Nanosecond,
2*time.Minute + 500*time.Millisecond,
90 * time.Minute,
} {
said := checkerStalled(bound)
if raw.MatchString(said) {
t.Fatalf("the landing reads\n %s\nand a person cannot act on the decimals", said)
}
if want := taskSpanWord(bound); !strings.Contains(said, want) {
t.Fatalf("the landing reads\n %s\nwant the one spelling of how long it took, %q", said, want)
}
}
}
9 changes: 5 additions & 4 deletions internal/tui3/homepanel_recent.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,12 @@ func recentCell(row switcherRow, in *homeGridInput) *homeCell {
// and a scratch folder at the top of the temporary directory, whose word is a
// name somebody made up for a minute (DESIGN §1, "what is retired"). The
// projects panel still lists both, as the paths they are.
// The rule itself is [chatProjectWord] (projecttag.go), because the tasks place
// asks the same question about the same conversations. This panel has already
// settled the own-folder half of it — a row of this window's own bucket never
// reaches here ([recentCell]) — so it hands no folder to compare.
func chatProjectTag(row switcherRow, tilde string) string {
if row.project == "~" || homeScratchFolder(row.session.Workspace, tilde) {
return ""
}
return row.project
return chatProjectWord(chatFolder{project: row.project, workspace: row.session.Workspace}, "", tilde)
}

// homeScratchRoots are the directories a throwaway folder is made at the top
Expand Down
2 changes: 1 addition & 1 deletion internal/tui3/place_tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ const taskSheetDays = 14
// that could ask it at paint time.
func (a *app) taskSheetMine() tasksMine {
rows := a.taskSheetOwnRows()
mine := tasksMine{row: a.taskSheetSelfRow(), rows: make([]tasksMineRow, 0, len(rows))}
mine := tasksMine{row: a.taskSheetSelfRow(), tilde: a.tilde, rows: make([]tasksMineRow, 0, len(rows))}
for _, entry := range rows {
row := tasksMineRow{entry: entry, runs: a.recordRuns(&entry)}
if node := a.taskSheetNodeFor(&entry); node != nil {
Expand Down
71 changes: 71 additions & 0 deletions internal/tui3/projecttag.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package tui3

import (
"path/filepath"
"strings"
)

// projecttag.go holds THE ONE RULE about whether a row naming a conversation
// also names the folder that conversation works in.
//
// The mission-control ruling retired two of the three answers this surface used
// to give (docs/design/home-mission-control/DESIGN.md §1, "What is retired"):
// a project tag on a row in THIS WINDOW'S OWN FOLDER, where the tag is the same
// word on every row and says nothing that tells one row from another; and `~`
// as a project name, because the home directory is where a person stands rather
// than something they are working on. A scratch folder at the top of the
// temporary directory goes with them — its word is a name somebody made up for
// a minute. The projects panel still lists all three, as the paths they are.
//
// IT IS HERE AND NOT ON ONE OF THE TWO PAGES because both of them draw the same
// object. Home's list of conversations asks it ([chatProjectTag]) and the tasks
// place asks it of every conversation root ([tasksChatRow]); the tasks place
// used to ask nobody at all, so a chat in the home directory arrived on it
// wearing a lone `~` at the right of its name.

// chatFolder is the three facts the rule needs about one conversation: what its
// folder is CALLED, the tools root recorded for the conversation, and the
// project directory it belongs to.
//
// IT IS A STRUCT AND NOT THREE STRINGS IN A ROW. Every one of them is a path or
// a path's name, so a positional signature is one a caller can transpose and
// still compile — and the failure that buys is silent, a tag drawn for the wrong
// reason on a row nobody looks at twice.
type chatFolder struct {
project string
workspace string
dir string
}

// chatProjectWord is that rule: the folder's word, and NOTHING where the folder
// is not a project. `here` is the folder the window drawing the row is itself
// standing in, and "" where the caller has no such folder to compare against —
// which is the honest answer for a surface that draws several projects at once,
// and the answer a window whose own conversation the world scan has not met yet
// falls back to ([readTasks] says so where it reads the folder).
func chatProjectWord(chat chatFolder, here, tilde string) string {
project := strings.TrimSpace(chat.project)
switch {
case project == "" || project == "~":
return ""
case homeScratchFolder(chat.workspace, tilde):
return ""
// BOTH SIDES ARE CLEANED, and not because either is known to be ragged: the
// check above this one cleans its paths, [session.Project]'s own path is a
// recorded workspace or a launch directory with no cleaning applied to
// either, and a rule that is byte-exact on one line and forgiving on the next
// is a rule nobody can predict.
case here != "" && sameFolder(chat.dir, here):
return ""
}
return project
}

// sameFolder is that comparison, spelled once.
func sameFolder(a, b string) bool {
a, b = strings.TrimSpace(a), strings.TrimSpace(b)
if a == "" || b == "" {
return false
}
return filepath.Clean(a) == filepath.Clean(b)
}
8 changes: 0 additions & 8 deletions internal/tui3/taskending.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,3 @@ func halted(ending session.TaskEnding) bool {
// docs/design/task-states/DESIGN.md). The same cell in both glyph tiers,
// because ! is already a character a screen with no Unicode has.
const glyphHalted = "!"

// refused says the task reached a check and the check did not accept its claim.
// The engine still settles that node as failed so dependencies and delivery do
// not advance; the surface calls the person's state incomplete because the
// report names work still to do rather than a runtime fault.
func refused(ending session.TaskEnding) bool {
return ending == session.TaskEndingRefused
}
11 changes: 9 additions & 2 deletions internal/tui3/taskphone_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,11 @@ func TestThePhoneRosterRowsAreTwoLineCards(t *testing.T) {
if strings.Contains(line, "Sweep the call sites") {
name = i
}
if strings.Contains(line, "it came home clean") {
// The tail is what the work came to and how long ago: the row's own
// state word and its age ([tasksCardTail]). It used to be the record's
// raw outcome sentence, which on every graded row was the state word
// again in different clothes ([tasksMiddle] says why that went).
if strings.Contains(line, "done · 3h") {
tail = i
}
}
Expand Down Expand Up @@ -179,7 +183,10 @@ func TestThePhoneRosterScrollsToACardPastTheFold(t *testing.T) {
// AND IT IS DRAWN WHOLE. A card is two lines, so a window that counted rows
// rather than lines would leave the tail of the one a thumb scrolled to
// clipped at the fold.
if head+1 >= len(lines) || !strings.Contains(lines[head+1], "it came home clean") {
// IT ASSERTS THE WHOLE TAIL AND NOT A WORD OF IT. A clipped tail still
// carries its first segment, so a test that looked for one would pass on
// exactly the frame this test exists to fail.
if tail := tasksCardTail(last, a.now()); head+1 >= len(lines) || tail == "" || !strings.Contains(lines[head+1], tail) {
t.Fatalf("the cursor's card is clipped at the fold:\n%s", joined)
}
for i, line := range lines {
Expand Down
Loading
Loading