Skip to content
Open
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
6 changes: 6 additions & 0 deletions docs/changes/unreleased/1503-traffic-nits.md
Original file line number Diff line number Diff line change
@@ -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]
---
8 changes: 5 additions & 3 deletions docs/design/conversations-and-teams/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1502,7 +1504,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 <when>`, `Change…`, `Don't remind me`. No once.
- A reminder (`when.at`): `wants to remind you`. `Remind me <when>`, `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 · <cadence>`, `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.
Expand Down
4 changes: 2 additions & 2 deletions internal/manual/chat/keeping-an-eye.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
10 changes: 7 additions & 3 deletions internal/manual/chat/team-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -176,15 +177,18 @@ 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`.

## 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 `…`.
Expand Down
1 change: 1 addition & 0 deletions internal/manual/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
12 changes: 9 additions & 3 deletions internal/session/answers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
6 changes: 6 additions & 0 deletions internal/session/answers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "...") {
Expand Down
16 changes: 8 additions & 8 deletions internal/session/standing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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).
Expand Down
8 changes: 4 additions & 4 deletions internal/session/tools_standing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/tui3/homeexchange_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
6 changes: 3 additions & 3 deletions internal/tui3/standing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()),
})
Expand All @@ -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)
}
Expand Down
50 changes: 46 additions & 4 deletions internal/tui3/teamrail.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tui3

import (
"strings"
"time"

"github.com/charmbracelet/x/ansi"

Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions internal/tui3/teamrail_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}