From 06c5c64e5d8771ccee282b66fed5cefe7bd0ea8c Mon Sep 17 00:00:00 2001 From: Marc Lopez Rubio Date: Wed, 21 Oct 2026 10:59:41 +0800 Subject: [PATCH] feat: Show timestamps on assistant replies Show when Pi last replied so users can judge how long a conversation has been idle. Cache retention varies by provider, so we use message time without estimating cache expiry. Signed-off-by: Marc Lopez Rubio --- docs/architecture.md | 2 + docs/extensions/reference/pocket.md | 4 +- docs/features.md | 2 +- docs/map.md | 2 +- test/mobile-ui.test.ts | 141 ++++++++++++++++++++++++++++ web/transcript.js | 18 +++- 6 files changed, 165 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 9fec02ca..111718e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -213,6 +213,8 @@ The server reads each theme folder (`readTheme`): its `theme.json`, `colors.toml What a browser shows is its person's choice of theme (`look`) and colors (`theme`, its name from before themes), else the owner's `config.themeDefault` (`hello.server.themeDefault`, kept as `pocket.themeDefault` so the next load paints it before `hello`): `theme.js` keeps the person's own choices (`pocket.appearance`) apart from the ones in force, and which of the two they made (`pocket.themeChosen`), so a new default reaches only browsers that have not chosen. Files under a theme's current version are served `immutable`; any other version serves the files as they are now, uncached. Script that depends on where a region is laid out reads it (`useSide` in `ui.js`, looked at again on `pocket:theme`, which `theme.js` sends once a theme's stylesheets load) rather than assuming the sidebar is on the left. +**Message times (`transcript.js`).** Completed replies show the stored message timestamp projected as `at`, using the same clock correction and formatting as user messages. The `core:time` reply-slot item puts it before React by default; Layout can hide or move it. It is the message's time, not a provider cache-expiry estimate. + **Live thinking (`transcript.js`).** `theme.js` owns the browser's opt-in; the tab keeps each session's disclosure state. Only an open disclosure selects and draws a thought, memoized separately from the live timer. The active generation, or the latest saved response in the current turn during tools, is authoritative even when it has no thinking. Response and block identity reset follow-scroll for a new thought; only that response's handoff to storage keeps the reader's position. **Launcher openings (`app.js`, `launcher.js`).** `LauncherHost` observes store transitions synchronously, before Preact batches renders, and keys each logical opening separately. A rapid close/reopen therefore gets a fresh query, selection, and theme-commit flag even when the outgoing launcher is still present for its fade-out. The launcher focuses after DOM reconciliation and isolates its background only while open. Large primary windows use `aria-hidden` and a focus guard, rather than the costly native `inert` traversal of thousands of controls; the overlay blocks pointer access. Other background layers use `inert`. Attribute observers keep the owners' current intent through retained-layer transitions, and close restores that intent before destination focus is restored. Its combobox keeps focus while arrows or Tab move the active option; search-type buttons are a separate group outside the listbox. Field highlights do not change the fuzzy scorer's text or group-first ordering. diff --git a/docs/extensions/reference/pocket.md b/docs/extensions/reference/pocket.md index a9f4b05c..8e062bcd 100644 --- a/docs/extensions/reference/pocket.md +++ b/docs/extensions/reference/pocket.md @@ -819,7 +819,9 @@ What shows comes in layers, the last one wins: each item's default place, then t | `status` | `core:connection`, `core:here`, `core:speed`, `core:context`, `core:cache`, `core:spend`, `core:guard` | `core:connection`, `core:guard` | after `core:cache` | | `places` | `core:files`, `core:changes`, `core:browser`, `core:artifacts`, `core:chat`, `core:find`, `core:branch`, `core:pip`, `core:peeks` | none | last: a tile for each panel | | `composer` | `core:places`, `core:attach`, `core:model`, `core:plan` | none (Send, Stop, the mic, and Steer, Queue and Interrupt are not in the slot) | after `core:plan` | -| `reply` | `core:react`, `core:discuss`, `core:pin`, `core:copy`, `core:more` | `core:more`, last | before `core:more` | +| `reply` | `core:time`, `core:react`, `core:discuss`, `core:pin`, `core:copy`, `core:more` | `core:more`, last | before `core:more` | + +The `core:time` item shows a completed reply's stored message time. Appearance → Layout can hide or move it. An item that has nothing to show now shows nothing, wherever it is: Changes with no changes, the cache before the first answer, a quiet button. diff --git a/docs/features.md b/docs/features.md index c247b19f..9b6d5435 100644 --- a/docs/features.md +++ b/docs/features.md @@ -13,7 +13,7 @@ The full tour. For setup, see the [README](../README.md). - **Launcher.** Ctrl/⌘+K (or the **Launcher** button on a phone) opens a rounded search window for sessions, actions, and themes, with fuzzy matching. Search-type pills sit above grouped results with folder-color tiles, action icons, or four-color theme swatches; the selected row has a soft fill. **All**, **Sessions**, **Actions**, and **Themes** select the search type without clearing your words; `>`, `@`, and `#` still work. All starts with six recent sessions; **Show all** searches the whole session list, including archived sessions. Result counts show when a list is capped. Titles and folder or command details highlight matches, and sessions show their current, working, waiting, or archived state in a pill on the right. Arrowing onto colors shows them at once; Enter keeps them, Escape goes back. A theme that changes the look shows once chosen. Arrows, Tab/Shift+Tab, and PageUp/PageDown move the selection; Enter opens it. **Close**, Escape, a tap outside, or Back closes the launcher. Each opening starts with a fresh search and a focused input, even when it reopens during its fade-out. - **The menu.** The top bar's **⋯** opens a 360px popover below it on wide screens, and a bottom sheet on phones. Three columns of rounded place tiles open Files, Changes, Browser, Artifacts, Chat, Find, Branch, PiP, and Peeks when available; badges show counts or news, and open places are tinted. Session actions, Subagents, and App rows have right-aligned hints and separate groups. App includes **Keyboard shortcuts** (`?`). Arrows, Home/End, and Tab move through the controls; Esc, Back, Close, or a tap outside closes the menu. Back from a sheet it opened returns to the menu. - **Keyboard.** Alt+1…9 jumps to a session (hold Alt to see the numbers), Alt+↑/↓ goes to the previous or next one, Alt+N starts a new one (where Ctrl/⌘+1…9 starts it in a favorite folder), Alt+B opens or closes the browser, Alt+E the Files tile, Alt+T turns Show thinking on or off, Alt+V starts or stops dictation, and `?` lists every shortcut. On a computer, a session opens with the caret in its message box, ready to type; on a touch screen it waits for a tap, so the keyboard does not cover the conversation, and on a Mac a session opened with Alt+1…9 or Alt+↑/↓ leaves the box alone, so the next Alt shortcut still works (Option types characters in a box). -- **The conversation.** A column of turns, made for phones. Your messages are cards with the sender's avatar (the accent for yours, the sender's color for someone else's), who sent each and when; Pi's replies are plain prose at Appearance's text size, streamed as they come (lists keep a quiet marker column; code, tables, and rich blocks are rounded cards). What Pi does between its words, its thinking and tool calls, folds into one line that says what it did and for how long ("Thought, searched, read 2 files, ran 1 command · 18s"), and opens, as a card, into a timeline: a line per call with a circle for how it went (✓ done, × failed or denied, ! waiting for an approval, a spinner while it runs, ⑂ for a subagent, ✦ for a thought) and what it came to (lines added and removed, lines read, matches, tests passed, an exit code, "needs approval"), and tapped, its input and output, with the diff viewer's diffs for edits (line numbers, syntax colors, and the words that changed) and bash's live output. The newest turn's groups are open and earlier ones closed, until you open or close one. An approval is a card at the end of its turn (the command, the reason, Deny and Allow; once answered, the call's line says who allowed or denied it), and while Pi works one line, a pulsing dot with the state in the accent (the warning color while it waits for an approval), says what it does (thinking, working, waiting for an approval, retrying) and for how long. With **Show thinking** on in Appearance (or Alt+T, or the launcher), the current thought shows above that line as it streams, and the thoughts in the conversation show their words once written (a thought alone in its group opens, and one on a timeline opens its step). A chevron before the line's dot folds the live thought away, and again brings it back. Alt+T shows a short on/off notice, even while Pi is idle. During tool calls the latest thought stays; a new response clears it until the model shares new thinking. Models that share none say so, rather than showing an old thought as current. A fold holds in that session, also over a reload, until you open it again; the setting is off until you turn it on, in each browser, and while it is off or folded the live thought is neither selected nor drawn. The reading area follows new words until you scroll up in it, and follows again for each new thought. While Pi works, **Steer**, **Queue**, or **Interrupt** beside the model chip says whether a message steers the run, waits for it to end, or stops the run at once (the tool call too, as Stop does, which also withdraws what waits in the queue) and is sent after; Interrupt goes back to Steer after one use, or when Pi stops before it was used, while Steer and Queue stay as chosen; queued messages can be withdrawn, and Stop (or Esc twice) stops the run. A run stopped in the middle of a tool call ends with a "Stopped." line under its card. +- **The conversation.** A column of turns, made for phones. Your messages are cards with the sender's avatar (the accent for yours, the sender's color for someone else's), who sent each and when; Pi's replies are plain prose at Appearance's text size, streamed as they come (lists keep a quiet marker column; code, tables, and rich blocks are rounded cards). The **Message time** item under completed replies shows the time, with the date for earlier days, and can be hidden or moved in Appearance → Layout. What Pi does between its words, its thinking and tool calls, folds into one line that says what it did and for how long ("Thought, searched, read 2 files, ran 1 command · 18s"), and opens, as a card, into a timeline: a line per call with a circle for how it went (✓ done, × failed or denied, ! waiting for an approval, a spinner while it runs, ⑂ for a subagent, ✦ for a thought) and what it came to (lines added and removed, lines read, matches, tests passed, an exit code, "needs approval"), and tapped, its input and output, with the diff viewer's diffs for edits (line numbers, syntax colors, and the words that changed) and bash's live output. The newest turn's groups are open and earlier ones closed, until you open or close one. An approval is a card at the end of its turn (the command, the reason, Deny and Allow; once answered, the call's line says who allowed or denied it), and while Pi works one line, a pulsing dot with the state in the accent (the warning color while it waits for an approval), says what it does (thinking, working, waiting for an approval, retrying) and for how long. With **Show thinking** on in Appearance (or Alt+T, or the launcher), the current thought shows above that line as it streams, and the thoughts in the conversation show their words once written (a thought alone in its group opens, and one on a timeline opens its step). A chevron before the line's dot folds the live thought away, and again brings it back. Alt+T shows a short on/off notice, even while Pi is idle. During tool calls the latest thought stays; a new response clears it until the model shares new thinking. Models that share none say so, rather than showing an old thought as current. A fold holds in that session, also over a reload, until you open it again; the setting is off until you turn it on, in each browser, and while it is off or folded the live thought is neither selected nor drawn. The reading area follows new words until you scroll up in it, and follows again for each new thought. While Pi works, **Steer**, **Queue**, or **Interrupt** beside the model chip says whether a message steers the run, waits for it to end, or stops the run at once (the tool call too, as Stop does, which also withdraws what waits in the queue) and is sent after; Interrupt goes back to Steer after one use, or when Pi stops before it was used, while Steer and Queue stay as chosen; queued messages can be withdrawn, and Stop (or Esc twice) stops the run. A run stopped in the middle of a tool call ends with a "Stopped." line under its card. - **Getting around on a phone, with one hand.** Back (the swipe, Android's back button, the browser's) goes back to where you were: it closes what opened last (a sheet, the menu's next sheet back to the menu, a file back to the Files tree, back to Changes when the file was opened from there, the Files tile or the Browser panel, the drawer) before it leaves the session, and leaving goes to the session before or to the list, scrolled and searched as you left it. A session opened straight away (a link, a notification) has the list under it, so back does not leave the app. What you use most is a thumb's reach from the bottom edge: the button beside the message box (or a swipe up from it) opens the places, a short sheet of big tiles (Files, Changes, Browser, Artifacts, Chat, Find, Branch, Peeks) with the session menu below them. On the conversation, swipe left for Files and right to go back; in Files, swipe right to go back. Each swipe shows a mark that lights when letting go will act. The top bar leads back to the list (counting other sessions that wait for an approval) and keeps only the buttons with something to say. Every control is a thumb's size on a touch screen (small ones, such as a code block's Copy or a reply's Pin, keep their look and take a wider tap); a tap anywhere on the message box, off its one line of text, still goes to the text; a quick second tap does not land on whatever just slid in under it; tables in replies scroll sideways instead of crushing their columns; a diff's header keeps the file's name on a line of its own on a phone; and the command a peek tile asks about opens its session when tapped. The Files tile and the browser slide in as screens of their own and slide away on back, a file slides in over the tree, and the browser's tools sit on their own row under the address. Drag a sheet down, or the drawer left, to close it. In an iPhone home screen app, which has no back gesture of its own, swipe in from the left edge to go back. - **Forks.** Tap Pi's reply → More → **Fork from here**: a new session with everything up to that reply, which then goes its own way. Tap one of your messages to **edit it and send it again**, or send any message again, to the same model or another one (a retry). Each makes a new session that forks just before the message; the original stays as it is. A fork gets its own chat (with the pins of what it inherited), a link back to where it came from, and optionally a git worktree of its own. - **A new context.** Menu → **New context** (or `/reset [note]`): Pi starts fresh and no longer sees the messages so far, while everyone still does. An optional handoff note carries over what matters. diff --git a/docs/map.md b/docs/map.md index 897cff82..8ef15bf5 100644 --- a/docs/map.md +++ b/docs/map.md @@ -150,7 +150,7 @@ Plain ES modules, loaded as they are. `store.js` holds the shared state, and not | `back.js` | The back stack: routes and the layers over them (sheets, the drawer, panels that cover the screen, a file over the tree) as history entries, so back closes what opened last; `useBack`, `pushRoute`, `goHome`, the list put under a conversation opened straight away, taps let go that land on what just slid in | | `gestures.js` | Touch gestures, each following its own finger by its identifier, passive where they can be: sheets dragged down and the drawer dragged left to close (`useDragToClose`); on phones, swipes on the conversation (right back, left Files), in Files (right back), and up from the message box (the places); a swipe in from the left edge goes back in an iPhone's home screen app | | `app.js` | Layout (tiled windows, and the subagents board in the conversation's place), top bar (with what changed, from `diff.js`), notices, keyboard shortcuts, routing | -| `transcript.js` | The conversation as turns: messages, Pi's replies, its thinking and tool calls folded into groups that open into a timeline, extensions' command cards, approvals, the live line (and Pi's thinking above it), breadcrumbs (and `linked-sessions.js`, the links between linked sessions); a message from a linked session shows without its routing words | +| `transcript.js` | The conversation as turns: messages, Pi's replies with message times arranged in the reply slot, its thinking and tool calls folded into groups that open into a timeline, extensions' command cards, approvals, the live line (and Pi's thinking above it), breadcrumbs (and `linked-sessions.js`, the links between linked sessions); a message from a linked session shows without its routing words | | `turns.js` | How the transcript reads a conversation, without drawing it: answers split into runs of words and groups of calls (`itemsOf`), a group in a line (`summarize`, with extensions' own tools as `extension-ui.js` gives them: `setCallSummary`), and what a call came to from what its tool printed. No imports, so Node tests it | | `calls.js` | A tool call in one line, for the transcript's timeline and peek tiles; extensions' own tools as `extension-ui.js` gives them (`setCallDescriber`) | | `slots.js` | Slots: the top bar's buttons, the status line's items, the places, the message box's left buttons, and the actions under answers as named items, the app's own (`defineItem`) and extensions' (`fromExtensions`), in one order: defaults, `before`/`after`, extensions' `pocket.arrange`, then the person's own choices (`prefs().layout`, Appearance → Layout) | diff --git a/test/mobile-ui.test.ts b/test/mobile-ui.test.ts index 2582811a..cdba9820 100644 --- a/test/mobile-ui.test.ts +++ b/test/mobile-ui.test.ts @@ -237,6 +237,147 @@ test("local Markdown file links use the viewer", real, async () => { ); }); +test("completed replies show stored message times across providers", real, async () => { + await fresh(); + await goTo(first); + await until( + () => + inPage(` + return JSON.stringify((await import("/store.js")).store.state.view.conversation?.id === ${first}); + `), + "the session view", + ); + const expected = await inPage<{ text: string; dateTime: string; title: string }[]>(` + const { store } = await import("/store.js"); + const { fromServer } = await import("/seen.js"); + const today = new Date().setHours(10, 30, 0, 0); + const earlier = new Date(2020, 0, 2, 10, 30).getTime(); + const entries = [ + { id: 900101, kind: "assistant", provider: "anthropic", stopReason: "stop", at: today, + blocks: [{ type: "text", text: "Today's answer." }] }, + { id: 900102, kind: "assistant", provider: "openai", stopReason: "stop", at: earlier, + blocks: [{ type: "text", text: "An older answer." }] }, + { id: 900103, kind: "assistant", provider: "google", stopReason: "stop", + blocks: [{ type: "text", text: "An answer without a timestamp." }] }, + { id: 900104, kind: "assistant", provider: "anthropic", stopReason: "toolUse", at: today, + blocks: [{ type: "text", text: "An intermediate step." }] }, + { id: 900105, kind: "assistant", provider: "custom", stopReason: "stop", at: today, + blocks: [ + { type: "text", text: "Before thinking." }, + { type: "thinking", text: "Thinking." }, + { type: "text", text: "After thinking." }, + ] }, + ]; + + store.set({ view: { ...store.state.view, + entries: new Map(entries.map(entry => [entry.id, entry])), + order: entries.map(entry => entry.id), + } }); + + return JSON.stringify([today, earlier, today].map((at, index) => { + const date = new Date(fromServer(at)); + const time = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); + + return { + text: index === 1 + ? date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " + time + : time, + dateTime: date.toISOString(), + title: date.toLocaleString(), + }; + })); + `); + + for (const viewport of [VIEWPORTS.mobile, VIEWPORTS.desktop]) { + await page.setViewport(viewport); + await until( + () => + inPage(` + return JSON.stringify(document.querySelectorAll(".reply").length === 6); + `), + "completed and intermediate replies rendered", + ); + const shown = await inPage(` + return JSON.stringify([...document.querySelectorAll(".reply time")].map(time => ({ + text: time.textContent, + dateTime: time.dateTime, + title: time.title, + }))); + `); + + assert.deepEqual(shown, expected, "one timestamp per completed answer with a stored time"); + assert.equal( + await inPage(` + return JSON.stringify([...document.querySelectorAll(".reply time")].every(time => { + const bounds = time.getBoundingClientRect(); + + return bounds.width > 0 && bounds.right <= innerWidth && getComputedStyle(time).opacity === "1"; + })); + `), + true, + "timestamps remain visible without hovering and fit the viewport", + ); + } + + await page.evaluate(`(await import("/store.js")).openSheet({ type: "appearance" })`); + await until( + () => + inPage(` + return JSON.stringify(document.querySelector('.slot-layout[data-slot="reply"]') !== null); + `), + "reply layout controls", + ); + + try { + assert.equal( + await inPage(` + return JSON.stringify(document.querySelector('[aria-label="Show Message time"]') !== null); + `), + true, + "message time is a configurable reply-slot item", + ); + await tap('[aria-label="Show Message time"]'); + await until( + () => + inPage(` + return JSON.stringify(document.querySelectorAll(".reply time").length === 0); + `), + "message times hidden without hiding the reply actions", + ); + assert.equal( + await inPage(` + return JSON.stringify(document.querySelector(".reply .reaction-host") !== null); + `), + true, + ); + await tap('[aria-label="Show Message time"]'); + await until( + () => + inPage(` + return JSON.stringify(document.querySelectorAll(".reply time").length === 3); + `), + "message times restored", + ); + await tap('[aria-label="Move Message time later"]'); + await until( + () => + inPage(` + const actions = document.querySelector(".answer-actions"); + + return JSON.stringify(actions.firstElementChild.matches(".reaction-host") && + actions.children[1].tagName === "TIME" && + actions.lastElementChild.textContent.trim() === "More"); + `), + "message time moved after React, with More still last", + ); + } finally { + await page.evaluate(` + (await import("/slots.js")).resetSlot("reply"); + (await import("/store.js")).closeSheet(); + `); + } +}); + test( "on a phone, an invite lasts as long as chosen, and the one it replaces ends", real, diff --git a/web/transcript.js b/web/transcript.js index f1f24f32..b77e9c03 100644 --- a/web/transcript.js +++ b/web/transcript.js @@ -1397,6 +1397,22 @@ function ApprovalCard({ approval }) { // The actions under Pi's answers, after its reactions: the app's own items of the `reply` slot (slots.js), which a // person can hide and reorder in Appearance, with extensions' before More, which stays last. Each gets the answer // (`entry`, its `text`), whether it is pinned, and the reaction picker's state. +defineItem("reply", { + id: "core:time", + label: "Message time", + order: 0, + render: ({ entry }) => { + if (!entry.at) { + return null; + } + + const at = new Date(fromServer(entry.at)); + + return html``; + }, +}); defineItem("reply", { id: "core:react", label: "React", @@ -1476,7 +1492,7 @@ defineItem("reply", { `, }); -/** Under Pi's answers: reactions, and ways to talk about the answer with the people here. */ +/** Under Pi's answers: the message time, reactions, and ways to talk about it with the people here. */ function AnswerActions({ entry }) { const { view, me, users, server } = store.state; const [picking, setPicking] = useState(false);