From 4caf9115106dca32d0b802b18e86afb8157f3af5 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Fri, 25 Sep 2026 10:47:49 -0400 Subject: [PATCH 1/3] session: a reminder's clock is joined with a middle dot. The yes button was spelling the landed time with an em dash. Product words use a middle dot. --- docs/design/conversations-and-teams/DESIGN.md | 2 +- internal/manual/chat/keeping-an-eye.md | 4 ++-- internal/session/answers.go | 12 +++++++++--- internal/session/answers_test.go | 6 ++++++ internal/session/standing_test.go | 16 ++++++++-------- internal/session/tools_standing.go | 8 ++++---- internal/tui3/homeexchange_test.go | 2 +- internal/tui3/standing_test.go | 6 +++--- 8 files changed, 34 insertions(+), 22 deletions(-) diff --git a/docs/design/conversations-and-teams/DESIGN.md b/docs/design/conversations-and-teams/DESIGN.md index 5247b5df6..63d76de6b 100644 --- a/docs/design/conversations-and-teams/DESIGN.md +++ b/docs/design/conversations-and-teams/DESIGN.md @@ -1502,7 +1502,7 @@ depth block is not shown and the store's own `SetParent` is the only check. A proposal card names its kind on the first line, then what it does, then when and what one time costs. The words live in `session.StandingOptions` so the conversation, home, `--host` and the recorded `labels` say the same thing. -- A reminder (`when.at`): `wants to remind you`. `Remind me `, `Change…`, `Don't remind me`. No once. +- A reminder (`when.at`): `wants to remind you`. `Remind me `, `Change…`, `Don't remind me`. No once. A distance from now is said back with the clock it landed on, joined by a middle dot: `Remind me in 1 minute · 07:35`. - A repeating check (`when.every`): `wants to set up a repeating check`. `Set it up · `, `Change…`, `Only now, don't repeat`, `Don't set it up`. - A watch (file, idle, probe): `wants to watch for something`. `Watch for it`, `Change…`, `Check once now`, `Don't watch`. - A rule (`when.hold`): `wants to keep a rule`. `Keep this rule`, `Change where…`, `Don't keep it`. No once. diff --git a/internal/manual/chat/keeping-an-eye.md b/internal/manual/chat/keeping-an-eye.md index faf713c29..9f4a47829 100644 --- a/internal/manual/chat/keeping-an-eye.md +++ b/internal/manual/chat/keeping-an-eye.md @@ -119,7 +119,7 @@ that instant, then says back the moment it landed on. That is what the card shows: ``` -in 2 minutes — 06:54 +in 2 minutes · 06:54 ``` so you can check the time it settled on without reading a timestamp. @@ -214,7 +214,7 @@ been marked `expired`, and the only trace would be a line in its own log: `its time ran out — no longer watching`. The mistake it exists to catch is arithmetic, not carelessness: a reminder said -as "in 1 minute — 23:11" lands at 23:11:11, and an end taken from the same words +as "in 1 minute · 23:11" lands at 23:11:11, and an end taken from the same words lands at 23:11:00 — eleven seconds too early. That is why the refusal spells both stamps out to the second. diff --git a/internal/session/answers.go b/internal/session/answers.go index 73f80e537..aca1c236e 100644 --- a/internal/session/answers.go +++ b/internal/session/answers.go @@ -466,14 +466,20 @@ const standingCadenceMark = " · " // StandingPlainLabel is a label with its cadence removed. A label that carries // none is returned as it is. +// +// A REMINDER'S WHEN IS THE WHOLE TAIL, including a clock joined on with the +// same mark a repeating check uses between its verb and its cadence. "Remind +// me in 1 minute · 07:35" drops to "Remind me", not to "Remind me in 1 minute": +// the mark inside the when is part of the when, and a narrow row still loses +// the when before it cuts a character. func StandingPlainLabel(label string) string { - if at := strings.Index(label, standingCadenceMark); at > 0 { - return label[:at] - } const stem = "Remind me " if strings.HasPrefix(label, stem) && len(label) > len(stem) { return "Remind me" } + if at := strings.Index(label, standingCadenceMark); at > 0 { + return label[:at] + } return label } diff --git a/internal/session/answers_test.go b/internal/session/answers_test.go index f09575266..17f2d85f0 100644 --- a/internal/session/answers_test.go +++ b/internal/session/answers_test.go @@ -356,6 +356,12 @@ func TestStandingCardWordsFollowTheKind(t *testing.T) { if c.item.When.Kind == standing.WhenAt && StandingPlainLabel(c.yes) != "Remind me" { t.Errorf("reminder plain label %q", StandingPlainLabel(c.yes)) } + if c.item.When.Kind == standing.WhenAt { + clocked := "Remind me in 1 minute · 07:35" + if got := StandingPlainLabel(clocked); got != "Remind me" { + t.Errorf("a reminder with a clock kept %q", got) + } + } } long := standing.When{Words: "every Monday morning after the deploy window closes and the board has signed off"} if got := long.ShortWords(); len(got) > 40 || !strings.HasSuffix(got, "...") { diff --git a/internal/session/standing_test.go b/internal/session/standing_test.go index 127d53bdf..f3cb10758 100644 --- a/internal/session/standing_test.go +++ b/internal/session/standing_test.go @@ -1100,7 +1100,7 @@ func TestStandInResolvesTheDurationAndSaysTheMomentBack(t *testing.T) { item.When.At.Format(time.RFC3339), before.Format(time.RFC3339), after.Format(time.RFC3339)) } // AND IT IS SAID BACK, in both places a person and the model read. - want := "in 2 minutes — " + item.When.At.Format("15:04") + want := "in 2 minutes · " + item.When.At.Format("15:04") if item.When.Words != want { t.Fatalf("the cadence in words = %q, want %q", item.When.Words, want) } @@ -1149,11 +1149,11 @@ func TestStandingAtMomentReadsAStampOrADistanceAndNeverBoth(t *testing.T) { problem string }{ {name: "a stamp", at: "2026-08-21T18:00:00Z", moment: time.Date(2026, 8, 21, 18, 0, 0, 0, time.UTC)}, - {name: "two minutes", in: "2m", moment: now.Add(2 * time.Minute), echo: "in 2 minutes — 06:54"}, - {name: "one minute", in: "60s", moment: now.Add(time.Minute), echo: "in 1 minute — 06:53"}, - {name: "ninety seconds", in: "90s", moment: now.Add(90 * time.Second), echo: "in 2 minutes — 06:53"}, - {name: "under a minute", in: "30s", moment: now.Add(30 * time.Second), echo: "in 30 seconds — 06:52"}, - {name: "an hour and a half", in: "1h30m", moment: now.Add(90 * time.Minute), echo: "in 1 hour 30 minutes — 08:22"}, + {name: "two minutes", in: "2m", moment: now.Add(2 * time.Minute), echo: "in 2 minutes · 06:54"}, + {name: "one minute", in: "60s", moment: now.Add(time.Minute), echo: "in 1 minute · 06:53"}, + {name: "ninety seconds", in: "90s", moment: now.Add(90 * time.Second), echo: "in 2 minutes · 06:53"}, + {name: "under a minute", in: "30s", moment: now.Add(30 * time.Second), echo: "in 30 seconds · 06:52"}, + {name: "an hour and a half", in: "1h30m", moment: now.Add(90 * time.Minute), echo: "in 1 hour 30 minutes · 08:22"}, {name: "both", at: "2026-08-21T18:00:00Z", in: "2m", problem: "two answers to one question"}, {name: "backwards", in: "-2m", problem: "a distance into the future"}, {name: "not a duration", in: "two minutes", problem: "when.in is a duration"}, @@ -1498,7 +1498,7 @@ func TestADistanceFromNowIsUntouchedByTheRefusal(t *testing.T) { if !moment.Equal(now.Add(time.Minute)) { t.Fatalf("moment = %s, want one minute on", moment.Format(time.RFC3339)) } - if echo != "in 1 minute — 07:35" { + if echo != "in 1 minute · 07:35" { t.Fatalf("echo = %q", echo) } } @@ -1536,7 +1536,7 @@ func TestAnExpiryAlreadyPassedIsRefused(t *testing.T) { // it was attached to stood up an item the pass could only ever retire. // // The receipt this pins is the one from that issue, to the second. The model -// wrote "in 1 minute — 23:11" for the words and took 23:11 for the end out of +// wrote "in 1 minute · 23:11" for the words and took 23:11 for the end out of // the same words, while the engine resolved the moment to 23:11:11 — so the // item was born eleven seconds past its own end, ran zero times, and was retired // as `expired` by rail one of the pass (internal/standing/tick.go). diff --git a/internal/session/tools_standing.go b/internal/session/tools_standing.go index fc91dfe96..b140dae1e 100644 --- a/internal/session/tools_standing.go +++ b/internal/session/tools_standing.go @@ -372,7 +372,7 @@ func standingPassed(field string, moment, now time.Time, tail string) string { // The minute is the right grain everywhere else, because a person names minutes // and a model writes them back. It is the wrong grain for [standingRetires], // where the whole mistake can live inside one minute: a model that wrote -// `in 1 minute — 23:11` for the words and `23:11` for the end, against a moment +// `in 1 minute · 23:11` for the words and `23:11` for the end, against a moment // the engine resolved to 23:11:11, would otherwise be told that 23:11 is not // after 23:11 and have nothing to work with. func standingClockExact(moment time.Time) string { return moment.Format("15:04:05 -07:00") } @@ -642,7 +642,7 @@ func standingWhen(parsed standArguments, now time.Time) (standing.When, string) // THE ECHO IS A FALLBACK AND NEVER AN OVERRIDE. [Agent.standingItem] // puts the model's own when_words over the top of this when it sent // any; what is left here is the case it sent none, where a card reading - // "in 2 minutes — 06:54" is the difference between a person checking a + // "in 2 minutes · 06:54" is the difference between a person checking a // stamp and a person reading a sentence. when.Words = echo case standing.WhenEvery: @@ -775,7 +775,7 @@ func standingRails(parsed standArguments, when standing.When, now time.Time) (st // for the same reason. // // The defect this pins is exact arithmetic and not a slip: the model - // wrote `in 1 minute — 23:11` for the words and took `23:11` for the end + // wrote `in 1 minute · 23:11` for the words and took `23:11` for the end // from the same words, while the engine resolved the moment to // 23:11:11 — eleven seconds later. So the refusal is spelled to the // SECOND, or it would read as a moment that is not after itself. @@ -889,7 +889,7 @@ func standingAtMoment(rawAt, rawIn string, now time.Time) (moment time.Time, ech return time.Time{}, "", "Invalid arguments: when.in has to be a distance into the future" } landed := now.Add(span) - return landed, "in " + standingSpanWords(span) + " — " + landed.Format("15:04"), "" + return landed, "in " + standingSpanWords(span) + " · " + landed.Format("15:04"), "" } parsed, err := standingMoment(rawAt) if err != nil { diff --git a/internal/tui3/homeexchange_test.go b/internal/tui3/homeexchange_test.go index 1668a3d71..afe66f9dc 100644 --- a/internal/tui3/homeexchange_test.go +++ b/internal/tui3/homeexchange_test.go @@ -452,7 +452,7 @@ func TestTheErrandHintNamesOnlyTheAnswersTheCardDrew(t *testing.T) { // A one-off reminder. "Do it once, now" says the wrong thing at the // wrong moment for a line that was meant for six o'clock, so the card // draws two numbered chips and the hint may name two digits. - {"a one-off reminder", standReminder(), "1 Remind me in 1 minute — 07:35 · 0 Don't remind me · o Change…", 2}, + {"a one-off reminder", standReminder(), "1 Remind me in 1 minute · 07:35 · 0 Don't remind me · o Change…", 2}, // A repeating check is a thing a person may reasonably want done once, now. // The third answer is drawn, so the third digit is named. {"a repeating check", standItem(), "1 Set it up · Mondays at 9am · 3 Only now, don't repeat · 0 Don't set it up · o Change…", 3}, diff --git a/internal/tui3/standing_test.go b/internal/tui3/standing_test.go index cfd388daf..aef352a93 100644 --- a/internal/tui3/standing_test.go +++ b/internal/tui3/standing_test.go @@ -495,7 +495,7 @@ func standReminder() standing.Item { ID: "abc", Words: "remind me to sleep in 1 min", Workspace: "/tmp/lab", - When: standing.When{Kind: standing.WhenAt, Words: "in 1 minute — 07:35", At: time.Now().Add(time.Minute)}, + When: standing.When{Kind: standing.WhenAt, Words: "in 1 minute · 07:35", At: time.Now().Add(time.Minute)}, Does: standing.Action{Kind: standing.ActionSay, Say: "time to sleep"}, Rails: standing.Rails{PerRunUSD: 0.05, MaxPerDay: 1}, Status: standing.StatusActive, @@ -513,7 +513,7 @@ func TestAOneOffReminderCardDrawsTwoChips(t *testing.T) { a, agent, tick := standApp(t) standAsk(t, a, tick, session.StandingNotice{ Item: standReminder(), - WhenWords: "in 1 minute — 07:35", + WhenWords: "in 1 minute · 07:35", CostWords: "about a cent, once", Options: session.StandingOptions(standReminder()), }) @@ -540,7 +540,7 @@ func TestAOneOffReminderCardDrawsTwoChips(t *testing.T) { // The derivation names the keys the card drew and not one more. (The slot // itself is quiet while the block draws them — hints pick A — and this is // the reading behind it, which is where the defect would be.) - const twoHint = "1 Remind me in 1 minute — 07:35 · 0 Don't remind me · esc later" + const twoHint = "1 Remind me in 1 minute · 07:35 · 0 Don't remind me · esc later" if got := a.questionHintFor(); got != twoHint { t.Fatalf("the hint is %q, want %q", got, twoHint) } From 2d468cff3894f09d0883b6bad385a555b60e8a7e Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Fri, 25 Sep 2026 10:48:25 -0400 Subject: [PATCH 2/3] tui3: a Traffic row stays a compact age past a month. A task row and a home session print a date after thirty days. A Traffic row is a few cells, so it keeps counting: 30d, then weeks, then years. --- docs/design/conversations-and-teams/DESIGN.md | 6 ++- internal/manual/chat/team-manager.md | 10 ++-- internal/manual/chat_test.go | 1 + internal/tui3/teamrail.go | 50 +++++++++++++++++-- internal/tui3/teamrail_test.go | 34 +++++++++++++ 5 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 internal/tui3/teamrail_test.go diff --git a/docs/design/conversations-and-teams/DESIGN.md b/docs/design/conversations-and-teams/DESIGN.md index 63d76de6b..fd53393da 100644 --- a/docs/design/conversations-and-teams/DESIGN.md +++ b/docs/design/conversations-and-teams/DESIGN.md @@ -378,8 +378,10 @@ them. reads `from → to words`. The manager is `◆`, several recipients are `@scrape +2`, and the words follow, and how long ago at the right (`◆ → @scrape +2 Please provide a st… ▸ 2m`). The age is `now`, `2m`, `3h`, `1d`, the same ladder a task row and a home session use - (`sinceAt`), dim, on every kind of row: a thread, a `↳` reply, a band question, General, - and a member's own lines. It moves when the minute in the row cache moves, and nowhere + (`sinceAt`) through a day, dim, on every kind of row: a thread, a `↳` reply, a band question, General, + and a member's own lines. Past thirty days a task row and a home session print a date + (`sinceAt`). A Traffic row stays compact (`trafficAgeAt`): `30d`, weeks from six weeks + (`6w`, `12w` at ninety days), then years (`1y` at four hundred days). It moves when the minute in the row cache moves, and nowhere else. The state its answers leave it in and its message count give way to the words when the column is narrow; the hint says them. The arrow and the names keep their cells, then the age, and a narrow column cuts only the words, at a word, with `…`. A band row is diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index 26a7bbba4..d8cfb1635 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -105,7 +105,8 @@ General 2 msgs ▸ 1d for the ones that do not fit, then what it said. The state its answers leave it in (`running`, `asking`, `done`, `failed`) and how many messages it holds sit at the right when they fit, and the hint line always says them. How long ago sits at the right of - every row, dim: `now`, `2m`, `3h`, `1d`, the same words a task row uses. The band's + every row, dim: `now`, `2m`, `3h`, `1d`, and past a month `30d`, `12w`, `1y`. A task row + switches to a date after thirty days. A Traffic row does not. The band's questions carry it too. In a narrow column the arrow and the names stay, then the age, and only the words are cut, at a word, with `…`. - `▸` lays the thread's replies open under it, one `↳` line per member, each `from → to` @@ -176,7 +177,8 @@ are the first handle and `+2` for the rest. In a member's chat that member is `y question to it reads `◆ → you` and its answer reads `you → ◆`. A reply under a thread is the same shape after `↳` (`↳ @model → ◆ working… now`). The band's question is the same shape in amber (`? @model → ◆ keep the old schema? 3m`). How long ago is on the row, at the right, -dim, at every width: `now`, `2m`, `3h`, `1d`. A narrow column keeps the arrow and the names, +dim, at every width: `now`, `2m`, `3h`, `1d`, and past a month `30d`, `12w`, `1y`. A narrow +column keeps the arrow and the names, then the age, and cuts only the words, at a word, with `…`. Press a handle to open that member at the message. Press anywhere else on the row to open the chat that message belongs to, at that message. The hint says `Open @model's message · 3m ago · click`. @@ -184,7 +186,9 @@ to, at that message. The hint says `Open @model's message · 3m ago · click`. ## How old a Traffic row is, and what pressing it opens Every Traffic row ends with how long ago it was, dim and at the right: `now` (under a minute), -`2m`, `3h`, `1d`. The same words a task row and a home session use. It is on a thread +`2m`, `3h`, `1d`. Past a month it stays a compact age: `30d`, then weeks from six weeks +(`6w`, and `12w` at about three months), then years (`1y`). A task row and a home session switch +to a date after thirty days (`26 Aug`). A Traffic row does not. It is on a thread (`◆ → @scrape +2 Please provide… ▸ 2m`), on a reply (`↳ @model → ◆ ✓ done 4m`), on General, on the band's question, and on a member's own lines (`you → ◆ now`). A narrow column keeps the arrow, the names and the age, and cuts only the words, at a word, with `…`. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index a604a098a..7dd35590d 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -89,6 +89,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"what does Traffic 3 new mean in the column header", "team-manager"}, {"who is a traffic row from and who is it to", "team-manager"}, {"how old is a traffic row", "team-manager"}, + {"how old is a traffic row after a month", "team-manager"}, {"what does pressing a traffic row open", "team-manager"}, {"what does chats on the tab bar do", "places"}, {"how do I get back to my conversation from a place", "places"}, diff --git a/internal/tui3/teamrail.go b/internal/tui3/teamrail.go index bff0b797f..74ba528e5 100644 --- a/internal/tui3/teamrail.go +++ b/internal/tui3/teamrail.go @@ -2,6 +2,7 @@ package tui3 import ( "strings" + "time" "github.com/charmbracelet/x/ansi" @@ -87,11 +88,52 @@ func trafficAsking(e teamstore.Entry) bool { return strings.HasPrefix(text, "ask") || strings.HasPrefix(text, "needs you") } -// trafficAge is how long ago an entry was written, in the same few cells a -// task row and a home session use ([sinceAt]): `now`, `2m`, `3h`, `1d`. One -// ladder, so a row and the home never disagree about what two minutes is. +// trafficAge is how long ago an entry was written. Through a day it is the +// same few cells a task row and a home session use ([sinceAt]): `now`, `2m`, +// `3h`, `1d`. Past that a Traffic row keeps a compact age ([trafficAgeAt]), +// because [sinceAt] prints a calendar date and a date in this margin is a +// second clock. func (a *app) trafficAge(e teamstore.Entry) string { - return sinceAt(e.At, a.now()) + return trafficAgeAt(e.At, a.now()) +} + +// trafficAgeAt is [sinceAt] for the spans where both say a count, and a +// compact count after that. +// +// A TASK ROW AND A HOME SESSION PRINT A DATE PAST THIRTY DAYS, and that is +// the right answer on a list of sessions a person is picking by the month. +// A Traffic row is a few cells beside a message. `2 Jan` there does not say +// how stale the line is, so the row stays on the count: days through the +// forty-first (`41d`, which includes the thirtieth day [sinceAt] already +// gives up), weeks from six weeks (`6w`, `12w`) until a year, then years +// (`1y`). Six weeks is the first week reading, so the number does not step +// backwards from `41d` to `4w`. The count truncates, so a row never claims +// to be older than it is. +func trafficAgeAt(at, now time.Time) string { + if at.IsZero() { + return "" + } + // A missing latch is the wall clock, the same fallback [sinceAt] takes, + // and the reading is still this ladder rather than a date. + if now.IsZero() { + now = time.Now() + } + d := now.Sub(at) + const day = 24 * time.Hour + switch { + case d < time.Minute: + return "now" + case d < time.Hour: + return itoa(int(d/time.Minute)) + "m" + case d < day: + return itoa(int(d/time.Hour)) + "h" + case d < 42*day: + return itoa(int(d/day)) + "d" + case d < 365*day: + return itoa(int(d/(7*day))) + "w" + default: + return itoa(int(d/(365*day))) + "y" + } } // trafficBelongsTo is the conversation a Traffic entry was written in. A diff --git a/internal/tui3/teamrail_test.go b/internal/tui3/teamrail_test.go new file mode 100644 index 000000000..3c6b26d22 --- /dev/null +++ b/internal/tui3/teamrail_test.go @@ -0,0 +1,34 @@ +package tui3 + +import ( + "strings" + "testing" + "time" +) + +// A Traffic row stays on a compact age at every span. A task row and a home +// session print a date past thirty days, and that date is the wrong clock in +// a margin of a few cells. +func TestTrafficAgeStaysCompactPastAMonth(t *testing.T) { + now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) + day := 24 * time.Hour + for _, c := range []struct { + name string + span time.Duration + want string + }{ + {"29d", 29 * day, "29d"}, + {"30d", 30 * day, "30d"}, + {"90d", 90 * day, "12w"}, + {"400d", 400 * day, "1y"}, + } { + if got := trafficAgeAt(now.Add(-c.span), now); got != c.want { + t.Errorf("%s reads %q, want %q", c.name, got, c.want) + } + } + // The shared ladder still prints a date at the month. This row does not + // borrow it. + if got := sinceAt(now.Add(-30*day), now); !strings.Contains(got, " ") { + t.Errorf("sinceAt at 30d reads %q, want a date", got) + } +} From c0b1e6be200f540aece4306cce141d1b18e6f1dc Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Fri, 25 Sep 2026 11:20:27 -0400 Subject: [PATCH 3/3] changes: note for #1503 --- docs/changes/unreleased/1503-traffic-nits.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 docs/changes/unreleased/1503-traffic-nits.md diff --git a/docs/changes/unreleased/1503-traffic-nits.md b/docs/changes/unreleased/1503-traffic-nits.md new file mode 100644 index 000000000..aa4662656 --- /dev/null +++ b/docs/changes/unreleased/1503-traffic-nits.md @@ -0,0 +1,6 @@ +--- +kind: fixed +title: a reminder's time reads with a middle dot, and Traffic ages stay short past a month +pr: 1503 +surface: [chat] +---