Skip to content

emrg: the GUI rows carry the moment the daemon recorded, on live and replay alike - #2341

Merged
how2how2how2-arch merged 1 commit into
masterfrom
fix/gui-message-time
Oct 17, 2026
Merged

how2how2how2-arch merged 1 commit into
masterfrom
fix/gui-message-time

Conversation

@argszero

Copy link
Copy Markdown
Owner

Closes #2340

The GUI showed no time on any message row, though the daemon has been sending one since #1978. This closes the GUI half of rant 2026-10-17T10:03:08 (the TUI half landed with #1978 and is untouched here).

The moment now travels from the daemon, end to end

  • lib/utils.ts — formatMessageTime / messageTimeTitle, mirroring the TUI's format_message_time: HH:MM read from the record's own clock, "" for absent or unreadable input. No timezone conversion, deliberately: the TUI reads the string's own wall clock, and converting would make the client's zone an input to a value only the daemon may produce.
  • lib/transcript.ts — DoneData gains timestamp; handleDone stamps the last assistant entry of that rid (a reply split by tools is one message, not one clock per segment, and a frame that carries no moment — cancel / max rounds / error — leaves the row without one). UserEntry gains timestamp and rid; addUserMessage takes {requestId, timestamp} and a new stampUserMessage(rid, …) claims the row the frame names. An unknown rid does nothing and notifies nobody: a stamp that did not happen must not render.
  • lib/historyReplay.ts — the record's timestamp, typed but previously dropped at the mapping, now reaches the same handlers, so a reopened session and the live row show the same value (asserted directly, not by inspection).
  • emrg/gui/daemon_client.js — forwards user_message, the frame that carries the moment for a message the client only drew optimistically. It was listed as "TUI-only" in the not-forwarded table; the renderer now has a case "user_message" for it.
  • lib/daemonBridge.ts — user_message and steer_committed stamp their row by request_id.
  • components/Composer.tsx — mints the requestId before inserting the row and hands it over, which is what lets the frame find that row. The client still never stamps its own clock.
  • components/TranscriptView.tsx + css/components.css — renders HH:MM in .msg-time with the full YYYY-MM-DD HH:MM on title; no element at all when there is no moment (never empty brackets, Invalid Date or NaN).

Verification

  • Mutation arms, each run and read back (all reverted): deleting the done stamp → 3 red (transcript, bridge, rendered view); dropping the user_message forwarding → the main-process guard red; dropping the bridge's stamp → bridge red; dropping the replay's timestamps → both replay tests red; making the formatter answer a constant → 3 red including the rendered-value assertion. So the tests read the rendered value, not the type declaration.
  • cd emrg/gui/renderer && npm run typecheck && npm test → 684 passed (666 + 18 new: 4 utils, 4 transcript, 2 historyReplay, 3 TranscriptView, 5 daemonBridge).
  • cd emrg/gui && npm test → 157 tests, 149 pass, 8 skipped, 0 fail.
  • uv run --no-sync pytest tests/ -q → 5421 passed, 18 skipped (unchanged; this change is GUI-only).
  • scripts/check-node-test-count.py → both documented counts agree; tests/test_doc_counts.py and tests/test_agent_md_prompt_cap.py green.

The Agent.md cap

The renderer's documented count moved 666 → 684 and its per-file breakdown with it. That growth would have left Agent.md at 7995 of its 8000-char prompt budget, so one phrase was removed to reopen headroom: the architecture line's config.py bullet no longer repeats (~/.emrg/config.toml), which the Configuration section states in full. Agent.md is now 7971 chars. No rule changed.

Not touched

emrg/client/** (the TUI half already does this correctly), and the daemon, which already sends every value used here.

@pm25coder pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261017-102307

Verified independently at head 878c507c1c92248d863c83917187cdbb1e2c8127, whose base bbbd75e94623ac5c865c0a607abcb45e39a46ae6 is master's tip — so the tree this lands is the head tree, 83a278d373a9f54871d619aad6c9f4143425f3a7.

What I checked, and how:

  1. The daemon really sends the moment. emrg/server/daemon.py carries timestamp and request_id on both frames this change depends on — user_message and steer_committed. So the GUI reads a recorded value instead of inventing one from its own clock.

  2. The middle hop is not cut. Two green ends with a broken bridge between them is the failure that survives a review of both suites. emrg/gui/main.js:1184 forwards frames through conn.onEvent generically, with no per-name whitelist, and preload.js passes the object through unchanged — so no field is dropped between the renderer's test doubles and the daemon's real frames.

  3. A late frame stamps its own row, and only its own. stampUserMessage in emrg/gui/renderer/src/lib/transcript.ts searches backwards for the entry whose rid matches and writes the timestamp there; when no row carries that rid it does nothing at all, so a message another client sent is never stamped by falling back to "the last row". Both frames route through it — user_message and the injection branch of steer_committed — which are the only two producers of a persisted user row.

  4. The formatter agrees with the TUI. emrg/gui/renderer/src/lib/utils.ts renders HH:MM from the recorded wall clock with no timezone conversion, and yields an empty string when the value is unreadable — the same contract as the TUI's format_message_time. A GUI and a TUI disagreeing about one timestamp is the defect this PR exists to prevent.

  5. Both suites, run at this head by me: tsc --noEmit rc 0; renderer 684 passed (48 files) in 56.02s; main process 149 pass / 8 skip / 0 fail. Both match the PR's own claim.

  6. Two mutation arms, both killed. Deleting the user_message forward from emrg/gui/daemon_client.js fails not ok 36 - 守卫:daemon _broadcast 的每一种帧类型,中间层要么转发、要么在豁免表里具名 (daemon_client.test.js:913) — precisely the guard that exists for this cut. Making the formatter return a constant fails 8 tests / 31 passed, including all three rendered-output assertions. Both files were restored byte-exact afterwards (sha256 1A7477ED3BC4DADA… for the first).

  7. The tree passes the full suite: scripts/check-merge-plan-suite.py 2341 → 83a278d373a9f54871d619aad6c9f4143425f3a7, 4996 passed, 478 skipped, 902.59s. check-merge-freshness.py 2341 → FRESH.

@pm25coder pm25coder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261017-132058

At head 878c507c1c92248d863c83917187cdbb1e2c8127. Master has moved since this head was cut (it now contains #2339), so I vote on the landing tree, which I measured this cycle: 0e7ed8acf0085ba8fbe781526d561c35163cfcc4 — check-merge-plan-suite.py 2341 reports 4960 passed, 478 skipped, 884.81s on that tree, and check-merge-landing-diff.py 2341 reads the same tree and shows it changing exactly this PR's own 15 paths.

That second reading matters here and is worth stating: ten paths in diff(master, head) are the base's own newer commits shown as reversals this PR does not make — #2339's files, which this PR never touched. A review of that diff would be a review of the wrong change.

This cycle's own readings at this head:

  1. Renderer suite: 684 passed, 48 files, 59.26s.
  2. Main-process suite: 157 tests, 149 pass, 8 skip, 0 fail. Both match the PR's claim exactly.
  3. The daemon really sends the moment. emrg/server/daemon.py carries timestamp and request_id on both frames the change depends on — user_message and steer_committed — so the GUI displays a recorded value rather than inventing one.
  4. The middle hop is not cut. emrg/gui/main.js:1184 forwards frames through conn.onEvent generically, with no per-name whitelist, and preload.js passes the object through unchanged. Two green ends over a broken bridge is the failure a pair of green suites does not catch; this one is intact.
  5. A late frame stamps its own row and only its own. stampUserMessage in renderer/src/lib/transcript.ts searches backwards for the entry whose rid matches and writes the timestamp there; when no row carries that rid it does nothing, so a message another client sent is never stamped by falling back to "the last row". Both user_message and the injection branch of steer_committed route through it — the only two producers of a persisted user row.
  6. The formatter agrees with the TUI (renderer/src/lib/utils.ts: HH:MM from the recorded wall clock, no conversion, empty when unreadable — the format_message_time contract).

At this same head, before master moved, I also ran two mutation arms: deleting the user_message forward from daemon_client.js fails the named frame-type guard (not ok 36), and making the formatter return a constant fails 8 tests including all three rendered-output assertions. Both files were restored byte-exact. Those readings are the head's, and the head has not moved.

One note, not a blocker: npm ci in this worktree exits 1 with npm's own Exit handler never called!, and it does the same on master's tree at the same moment — that is this host's npm, not this PR. No lockfile error appears, and both suites still ran to the counts above.

@how2how2how2-arch how2how2how2-arch left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261017-141925

The third vote, cast on the landing tree, not on a stale CI run: this head diverged from master by one commit, so check-merge-plan-suite.py 2341 measured the tree the merge actually produces (0e7ed8acf008). Its only failures are the four tests/test_tui_theme.py pty rows, and the instrument confirms all four fail on the base tree 010461305f25 the same way — host-specific, owned by no PR in the plan.

check-merge-landing-diff.py 2341 was read before voting: the landing change is GUI-only (the 15 paths in the PR's own diff), and the 10 paths it flags as reading backwards are the base's own later commit (#2339) that this head does not contain, correctly identified as reversals this PR does not make.

Read the code, and the parts that carry the risk are the parts done right:

  • formatMessageTime / messageTimeTitle parse the record's own clock with a range check (+mo < 1 || +mo > 12 || +d < 1 || +d > 31 || +h > 23 || +mi > 59) and answer "" otherwise — so 2026-13-45T99:99 is unreadable rather than rendered. No timezone conversion, which is what keeps the client's zone out of a value only the daemon may produce.
  • stampUserMessage does nothing when the rid matches no row, and says so in its own docstring ("绝不退化成「给最后一行盖章」"). That is the fail-closed direction: a stamp that did not happen must not render.
  • The moment goes on the last assistant segment only (i === last), so a reply split by tools is one message with one clock rather than one clock per segment.
  • MessageTime returns null for an absent or unreadable stamp — no empty brackets, no Invalid Date, no NaN.

Verified locally on the head 878c507c: npm run typecheck clean, and npm test in emrg/gui/renderer → 684 passed (48 files), matching the PR body's count. CI is green on both legs (test, test-windows), and test.yml does run that renderer suite (typecheck + npm test), so the vitest half is covered by CI rather than only by this run.

@how2how2how2-arch
how2how2how2-arch merged commit ccd950b into master Oct 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

emrg: the GUI shows no time on any message, though the daemon sends the moment

3 participants