Skip to content

feat(ui-web): add a turn nav that marks each turn beside the transcript - #1114

Merged
0xKT merged 5 commits into
mainfrom
feat/ui_web_turn_nav
Oct 26, 2026
Merged

0xKT merged 5 commits into
mainfrom
feat/ui_web_turn_nav

Conversation

@LivXue

@LivXue LivXue commented Oct 26, 2026

Copy link
Copy Markdown
Member

Summary

Adds a turn nav to the web UI: a strip of short dashes at the chat column's right edge, one per turn of the conversation on screen. The dash of the turn being read is longer and darker. Pointing at or focusing a dash raises a small card with the turn's number, how long ago it was asked and the question itself; a click scrolls that question to the top of the transcript and stops following the tail.

Where it sits: in the gutter between the transcript and the anchored desk palette, using the strip the chat already gives up for it (--desk-reserve), so it lands 8px left of the palette. With no desk up it sits at the chat's own edge, 14px in, clear of the overlay scrollbar's thumb. The transcript column's 36px side padding is wider than the strip, so no text runs under it.

How it is built:

  • The transcript domain owns it. TranscriptApp is now that domain's manifest root, mounted into #turnNav, an empty box ChatTop renders after #scroll; a CSS sibling rule parks it with the scroller in the trajectory view.
  • It reads only the lane whose host is on #stage, through a new mount.shownLane() that never starts a lane, and follows the host swaps the session layer makes through a MutationObserver. A lane held off the stage while its turn streams is therefore never drawn.
  • A message merged into a running turn (midTurn) opens no turn and gets no dash. A conversation with fewer than two turns draws nothing. A turn nobody typed (a schedule, a heartbeat) is named by its origin's label.
  • A strip longer than its band scrolls, centred on the dash being read, fades at both ends, and shows no scrollbar: chrome/behaviour/scrollbars.ts gains a generic opt-out, data-thumb="none", beside its existing one-line-field exception.
  • AskView carries data-ask so the nav finds a question by its segment id.
  • ago() moves from features/extAgents/Rows.tsx to lib/ago.ts, so both callers share one spelling of a relative time.
  • Two message catalogue keys, gui.transcript.nav and gui.transcript.nav_turn, with the TUI copy regenerated.
  • The transcript leaves NO_ROOT in the domain-shape and island-lang gates now that it has a root. That also makes the "four domains with no root of their own" in ui-web/CONTRIBUTING.md and ui-web/CONTEXT.md true again; island-lang listed five.
  • ui-web/CONTEXT.md gains a Turn Nav entry. The region and boot goldens and the transcript snapshot change by one line each (div#turnNav, data-ask=1).

Known limits, by choice:

  • When the desk palette is up but the chat is too narrow for the reserve, or the palette has been dragged loose, the reserve is zero and the strip stays at the chat's edge, where the palette can cover it. That is the same overlap the transcript itself has in that state.
  • The relative time is computed when the card opens; a card left open does not tick.
  • Turns are counted from the first question the lane holds, which today is the whole conversation.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

Run at this head on origin/main 9efb879.

  • npx vitest run --maxWorkers=4 in ui-web/: 257 files, 4395 tests passed, no unhandled errors. This covers the gates under ui-web/scripts/gates/.

  • npm run type-check: exit 0. npm run lint: 0 errors; the 4 warnings are the existing ones in ConnectionsPage.tsx, CronPage.tsx and SubagentsPage.tsx.

  • npm run build && python3 build.py: both boot snapshots match their goldens (367 and 366 nodes). node scripts/check-page.mjs: OK.

  • npm run lint:i18n in ui-tui/: the generated message catalogue is up to date.

  • scripts/check_source_language.py origin/main...HEAD and scripts/check_large_files.py origin/main...HEAD: exit 0.

  • scripts/check_commit_messages.py origin/main..HEAD, and commitlint over each commit message: pass.

  • Every new test was watched failing before its code existed. Ten single-construct mutants were each killed by the suite: the midTurn filter, the tail-follow release, the tail rule in readingTurn, the stage observer, the scroll lead, the two-turn threshold, the time in the card, the scroll listener, the data-thumb opt-out, and TranscriptApp's language subscription.

  • In Chromium (Playwright) against the vite dev server on the offline fixtures, 1568x778, light and dark themes: six turns drew six dashes; with the desk up (--desk-reserve 362px) the strip's right edge measured 8px from the palette, and 14px from the chat edge with it down; the card showed the turn number, the relative time and the question, and a click put the question 16px below the scroller's top. With 60 turns the strip showed 572 of 624px, a wheel over it raised no scrollbar thumb, and the dash being read sat clear of the 12px fades.

  • The owner also exercised it by hand on a running raven web.

  • No Python changed; the two commits main gained since the branch was cut touch no file in it.

  • Relevant tests pass locally

  • Relevant lint / type checks pass locally

  • User-facing docs or screenshots are updated when needed

No user-facing doc describes the chat column, and screenshots stay out of the repository (AGENTS.md section 7); ui-web/CONTEXT.md documents the new region.

Risk

User-visible: conversations with two or more turns now show the strip at the chat's right edge. The overlay scrollbar behaves as before for every scroller that does not declare data-thumb="none". No RPC, config or stored data changes. Rolling back is reverting this one commit.

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

The card renders the question as React text, never as markup, and the one selector built from data uses the numeric segment id.

Related Issues

N/A

A strip of short dashes at the chat column's right edge, one per turn
of the conversation on the stage, in the gutter left of the anchored
desk (--desk-reserve) or at the column's edge when no desk is up. The
dash of the turn being read is longer and darker; pointing at or
focusing one raises a card with the turn's number, how long ago it was
asked and the question; a click scrolls that question to the top of
the scroller and stops tail-follow.

The transcript domain owns it: features/transcript/TurnNav.tsx is the
manifest root mounted into #turnNav, an empty box ChatTop renders after
the scroller, so the stylesheet parks it with the scroller in the
trajectory view. It reads only the lane whose host is on #stage
(mount.shownLane, which never starts a lane) and follows the host swaps
the session layer makes through a MutationObserver, so a lane held off
the stage mid-turn is never drawn. A message merged into a running turn
opens no turn and gets no mark; fewer than two turns draw nothing.

A strip longer than its band scrolls centred on the mark being read,
fades at both ends, and opts out of the overlay scrollbar through a new
data-thumb="none" declaration in chrome/behaviour/scrollbars.ts.

ago() moves from features/extAgents/Rows.tsx to lib/ago.ts so both
callers share one spelling. AskView carries data-ask so the nav can
find a question by its segment id. Two catalogue keys are added under
gui.transcript and the TUI copy is regenerated.
ui-web/CONTRIBUTING.md 2.1 names a domain's manifest root <Domain>App.
The turn nav was declared as the transcript manifest's root directly,
which domain-shape and island-lang cannot see (they read *App exports)
while both still listed the transcript in NO_ROOT as a domain with no
root of its own.

TranscriptApp is that root now: it holds the language subscription and
renders TurnNav. The transcript leaves NO_ROOT in both gates, which
also makes the "four domains with no root of their own" in
ui-web/CONTRIBUTING.md and ui-web/CONTEXT.md true again; island-lang
listed five. The suite mounts TranscriptApp as src/main.tsx does and
flips the language to show the nav's words follow it.
The suite handed setSources its two sources under `as never`, which
switched off the field check on the one fixture the nav reads through;
setSources takes Partial<Sources>, so the literal now type-checks as it
is. The wipe case asserted only that the nav is gone afterwards, which
a nav that never drew would pass too; it now asserts the two marks
first.
The file header says ChatTop renders the first seven of div.chat's
eight children; the comment on ChatTop() still said five, which was
already one short of the six it rendered before #turnNav. It now says
seven and names the two boxes page.html never carried.
The branch's new comments used five words ui-web/CONTEXT.md retires in
the sense they were used in: "page" for the whole document (one root
per page), "catalogue" alone, "panel" for the desk palette, "writer"
for a caller that draws, and "task" for a new conversation. "pin" for
tail-follow is reworded too, since the root glossary keeps that word
for a configured provider.
@LivXue
LivXue requested review from 0xKT and gloryfromca October 26, 2026 08:57

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

Reviewed the full github/main...HEAD diff plus the surrounding transcript lane/store, chrome ownership, manifest, scrollbar, localization, and session-residency code and the branch history. I also checked AGENTS.md/CLAUDE.md and the Web UI context vocabulary, backward compatibility of the shared ago extraction, and that tests/goldens were extended rather than weakened.

Verification: npm test --prefix ui-web passed all 257 files / 4,395 tests; npm run type-check --prefix ui-web and npm run gen:check --prefix ui-web passed; npm run lint --prefix ui-web passed with four existing warnings on untouched files. git diff --check github/main...HEAD passed.

@0xKT

0xKT commented Oct 26, 2026

Copy link
Copy Markdown
Member

Not a blocker (one non-blocking point and three nits from my acceptance pass on head 8f6ada2 merged onto main 9efb879. This pass was read-only, so the test record is this PR's CI.)

Severity of this one finding, not a verdict on the pull request. Any blocking findings from this pass are review threads on the changed files; GitHub renders those collapsed, as a file name with no text.

Non-blocking:

  1. The nav's "Turn N" and the desk's "Turn N" count different things. turnsOf (TurnNav.tsx:41-43) counts ask segments without midTurn, and the label is n = index + 1 (:148). The workspace turn that the desk prints as gui.ws.dlv_turn "Turn {n}" (features/desk/DeskPalette.tsx:146-148) counts differently:

    • A delegated result opens a turn: replay does turnNo += 1 at features/transcript/store.ts:1684, and live advanceTurn() runs at state/session/stages.ts:134. That result is drawn as an sdlv segment, not an ask, so it gets no mark.
    • An origin-only ask (cron, sentinel, heartbeat) is drawn by askAuto and does get a mark, while turnNo "stays put" (store.ts:1707-1710).

    So with Q1, then a sub-agent result, then Q2 that delivers a file, the desk lists the file under "Turn 3" while the nav labels Q2 "Turn 2". With a cron turn in between, the gap goes the other way.

    Two related points:

    • ui-web/CONTEXT.md says "one per turn of the conversation", but the store's own comment (store.ts:1664-1667) says a delegated result "opens a turn like a question does".
    • askAuto is only reached on replay (store.ts:1710), so a conversation with a cron turn shows one mark fewer live than after a reload, and later numbers shift by one on reload.

    Either count the same turns the workspace counts, or define the nav's number in CONTEXT.md as "the Nth question" so the two "Turn N" do not read as the same thing.

Nits:

  • Tail-follow can stay off after a click that does not scroll. goTo calls tail.setStuck(false) before scrollTo (TurnNav.tsx:73-79). When the reader is already at the tail and the clicked question sits more than LEAD below the scroller's top, the target clamps to the current position. No scroll event fires, so the listener the comment at :70-72 relies on never turns tail-follow back on, and tail.down() returns early (tail.ts:12). A reply that is streaming then grows below the fold. Skipping setStuck(false) when the clamped target equals scrollTop would close it.
  • One Tab stop per turn. Every mark is its own Tab stop with no arrow-key movement, and #turnNav sits before the dock in DOM order, so a 300-turn conversation adds 300 stops between the transcript and the composer. The repo already uses a roving tabindex for strips (trajectory/Tabs.tsx:122, ScopeTabs.tsx:112).
  • Stale comments about the NO_ROOT list. The header of scripts/gates/island-lang.test.mjs:11-14 still describes a NO_ROOT domain "mounted per lane", and transcript was the only one. ui-web/CONTEXT.md (Island) still says "Sixteen of them".
  • Measurement cost while streaming. measure computes every question's top before checking atEnd (TurnNav.tsx:107-113). While a reply streams with tail-follow, that is one selector query plus a rect per turn on every frame. Checking atEnd first would skip the work at the tail.

@0xKT
0xKT merged commit 4718421 into main Oct 26, 2026
24 checks passed
@0xKT
0xKT deleted the feat/ui_web_turn_nav branch October 26, 2026 13:19
0xKT pushed a commit that referenced this pull request Oct 27, 2026
… the docs (#1064)

## Summary

Items 1 to 6 of #1008, the follow-ups the review of #1001 left out. Item
7, file references on Windows, is handled separately.

1. **A decision node's route is no longer the first thing cut on a
tasks-board card.** The agent's name kept its full width (`min-width:
auto`) and the tool-count chip never shrinks, so the selection took all
of the line's overflow: with "Raven-Research" and "3 tools" a 196px card
showed only the arrow. The selection now renders as the arrow with its
first route, which keeps its width, then the routes after it. Those, and
the instance handle, start from nothing and grow into the room the line
has left (`flex: 1 1 0; max-width: max-content`), and the agent's name
gives way after them, with an ellipsis. The later routes keep room for a
letter and an ellipsis: narrower, a line's first character is clipped
rather than replaced by one. The 6px spacing separates fields only: the
two selection spans have no additional gap, and the comma-space
separator stays with the first route, outside the text that can be cut.
A card with no selection lays out as before, a long minted handle
included.
2. **Route conditions are filled and validated for params.** The
executor filled `${params.X}` only in `promptTemplate`, and
`validate_graph_nodes` checked only `promptTemplate` and `inputs`, so
"the deal is above ${params.threshold}" reached the judge as written and
`${params.nobody}` passed validation. Conditions now go through the same
`_fill_text` as the prompt, a secret withheld with the same log line,
naming the route, and a reference to no declared param is an error. The
filling and the check are on `main` now, carried by #1058; this branch
holds the executor's docstring and the tests that pin both.
3. **Hand-written route names `yes`, `no`, `on`, `off`, `true`, `false`
and bare numbers load.** The store parsed the spec block with
`yaml.safe_load`; YAML 1.1 read those keys as booleans or integers,
validation refused them, and the runtime dropped the playbook from the
library. Every mapping is now keyed by a name, and the value keeps its
YAML type. The loader is the one #1057 drafted, so the store carries one
key loader rather than two: a node that is not a mapping is left to
PyYAML to answer, which keeps the error the validate paths catch, and a
key node is retagged through a copy, so an anchor that shares one scalar
between a key and a value leaves the value's type alone. #1057 is closed
as superseded by this branch.
4. **The generator's dag-mode instructions describe branches.** That
section called `dependsOn` the whole graph language and never named
`routes`, `select` or `when`; it now describes a decision node and the
rule that a node depending on one names it in its `when`, and says a
condition may use a param. A new test reads that section on its own.
5. **An ordinary node's judge request is held to a literal golden.**
`test_judge_without_routes_sends_the_old_request_verbatim` compared the
request with the module's own constants, so changing the instruction or
a tool schema's description passed. It now compares the whole request --
messages, tools, model, tool choice -- with the fence nonce fixed,
against `tests/data/dag_verdict_plain_request.json`. The golden is the
request from before #1001: the tree before it and this one send
byte-identical requests over 32 input combinations.
6. **Domain terms around branches.** `CONTEXT.md` gains three `_Avoid_`
notes: "decision" alone for a decision node or for what it chose, since
the word already names the main agent's answer to an exception report in
a DAG run and a choice the host puts to the user; "skipped" for a node a
branch left out; and "branch" for any other part of a graph. The uses
those notes retire are rewritten in the files this pull request touches:
the DAG orchestration skill (the `abandon` paragraphs and the
conditional-branch section), the orchestration and playbook pages in
both languages, a runner docstring, and test docstrings, one test helper
and one test name; two ui-tui test comments that called a path through a
drawn graph a branch now say path and edge. The playbook page's "not a
runtime branching language" had read, since #1001, as saying a
prompt-mode playbook cannot branch; it now says the guidance decides
nothing while the graph runs, and a branch is chosen by a decision node
in the graph it composes.

Left as they are, outside the files this pull request touches:
"decision" alone still names a decision node or its selection in text a
model reads -- the `routes` description in the `run_subagent_dag` schema
and on `DagNodeSpec`, which the playbook generator's tool carries; the
guard-size error in `raven/agent/subagent/dag_guard.py`; the note
`raven/agent/subagent/dag_render.py` writes into a sub-agent's prompt;
the workflow compiler's prompt -- and in the RPC model's field
description and both generated clients, the "Decision missing" reason in
`i18n/messages.json`, the DAG Panel entry of `ui-tui/CONTEXT.md` (which
`test_the_tui_glossary_says_what_the_turned_in_line_carries` quotes),
and docstrings, comments and test names across the DAG modules and both
UIs. Retiring those reaches the RPC schema, the generated clients, a
translated label and pinned tests, so it is left to #1065. Python test
function names, which predate the note, were left alone in these files
too.

Two things the review of this pull request left open are closed here:

- **The card's layout test now lives where a real-environment test
belongs.** `tests/test_web_task_card_route_real_browser.py` measured a
card in a real Chromium, so it moved to
`tests/integration/test_web_task_card_real_browser.py`, and a new
`web-browser` CI job runs it on a runner that ships a system Chrome. It
is the only place the tasks board card's agent line is measured:
happy-dom lays nothing out, so the stylesheet gate beside it can only
read the declarations. Without a browser it skips with the reason,
except under `CI`, where a skip would leave the line unmeasured on every
push and so fails instead.
- **The store's loader answers a node that is not a mapping the way
PyYAML does.** `raven playbook validate` now answers a YAML syntax error
in a spec block as a finding, the way the RPC validator already did,
instead of a traceback.

The branch is rebased onto `main` at `471842132` (#1114). The
`CHANGELOG.md` conflict that rebasing raised is resolved by keeping both
sides' entries, and the branch merges clean.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

Native Windows 11, CPython 3.12.13:

- `uv run --frozen --no-sync pytest tests/test_playbook_store.py
tests/test_playbook_executor.py tests/test_playbook_validate.py
tests/test_subagent_dag_verdict.py tests/test_subagent_dag_core.py
tests/test_cli_playbook_commands.py
tests/integration/test_web_task_card_real_browser.py -m "integration or
not integration" -q -p no:randomly --tb=short`: 770 passed, 11 failed,
15 skipped. The same 11 fail at the merge base on this host, with the
same IDs:
`test_playbook_executor.py::test_a_playbook_run_s_node_attempts_nest_under_the_call_that_ran_it`,
`test_playbook_validate.py::test_path_confinement_waits_for_dispatch_roots`,
`test_subagent_dag_core.py::test_replanning_refuses_a_redeclared_id`,
`test_subagent_dag_core.py::test_a_refused_submission_still_records_the_link_as_unstarted`,
the five
`test_subagent_dag_core.py::test_a_reference_landing_in_either_root_is_accepted`
parameters, and
`test_cli_playbook_commands.py::test_run_judges_its_nodes_so_a_branching_playbook_takes_its_branch`
and `::test_run_judges_on_the_users_own_subagent_dag_settings`. The
first group is the file-reference rules #1066 fixes, and the last two
shell out to `cat`. The branch adds the passing cases.
- The baseline comparison above, run over the same six files at the
merge base: 746 passed, 11 failed, 15 skipped -- the same failing IDs.
- `uv run --frozen --no-sync pytest
tests/integration/test_web_task_card_real_browser.py -m integration -q
-n 0 -p no:randomly --tb=short`: 10 passed, the board's real 196px card
measured in a real Chromium. Playwright could not create its artifacts
directory under this host's default temp path, so the run needed a
writable `TEMP`; with the default one the nine browser cases skip and
the path-lookup case still passes.
- After the rebase onto `main` at `471842132`, with `-n 0`: the six
playbook/DAG files (`tests/test_playbook_store.py`,
`tests/test_playbook_executor.py`, `tests/test_playbook_validate.py`,
`tests/test_subagent_dag_verdict.py`, `tests/test_subagent_dag_core.py`,
`tests/test_cli_playbook_commands.py`): 790 passed;
`tests/integration/test_web_task_card_real_browser.py -m integration`:
10 passed; `scripts/check_commit_messages.py`,
`scripts/check_source_language.py` and `scripts/check_large_files.py`
over `origin/main..HEAD`, and `git diff --check`: passed.
- `uv run --frozen --no-sync ruff check raven/playbook/store.py
raven/cli/playbook_commands.py tests/test_playbook_store.py
tests/test_cli_playbook_commands.py
tests/integration/test_web_task_card_real_browser.py` and `ruff format
--check` over the same files: passed.
- `uv run --frozen --no-sync ty check raven/playbook/store.py
raven/cli/playbook_commands.py`: passed.
- `uv run --frozen --no-sync lint-imports`: 10 kept, 0 broken.
- `uv run --frozen --no-sync --extra dev python
scripts/check_commit_messages.py origin/main..HEAD`,
`scripts/check_source_language.py origin/main..HEAD` and
`scripts/check_large_files.py origin/main..HEAD`: passed.
- `git merge-tree` against `origin/main`: no conflict, through
`--write-tree` on the Windows host and through the three-argument form
on the Linux workstation, whose git predates `--write-tree`. Before the
rebase the same check reported one conflict, in `CHANGELOG.md`.

ui-web, run in `ui-web/`:

- `node node_modules/vitest/vitest.mjs run
src/features/tasks/BoardCard.test.tsx
scripts/gates/task-card-selection-css.test.mjs --no-file-parallelism`: 2
files, 36 tests passed.
- `node node_modules/vitest/vitest.mjs run src/features/tasks
scripts/gates --no-file-parallelism --testTimeout=60000`: 62 files, 560
tests passed. The same command over `main` at the time of the run
reported 61 files and 552 tests passed; the branch adds the stylesheet
gate and the cases beside it. Without the longer timeout two of the
gates time out on a loaded host; they pass on their own and the files
they read are byte-identical to `main`.

At head `211d226d`, the branch's CI run: 23 checks passed, 2 skipped
(`claude`, `docs deploy`), 0 failed. The rebased head re-runs the same
workflow, including the `web layout in a real browser` job, which runs
the browser cases on a runner that ships a system Chrome.

Not run here: macOS, a browser other than Chromium, and the full
repository suite.

## Risk

A stored playbook whose route condition names an undeclared param now
fails validation, and so leaves the library, as one doing the same in
`promptTemplate` already did. On the board, a crowded agent line now
shortens the agent's name where it used to cut the selection. The
store's loader now reads the frontmatter through the same path as the
spec block; a frontmatter key that YAML 1.1 would have resolved to a
boolean or a number is now read as the text it was written as, which the
frontmatter's two string fields never were.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

#1008 (items 1 to 6)
0xKT pushed a commit that referenced this pull request Oct 28, 2026
… silence (#1092)

## Summary

Supersedes #1052, which was closed unmerged. That PR ended a turn
without a reply whenever the model returned nothing. After a survey of
how other agents handle the same replies, empty and abnormal replies now
follow Hermes Agent, and Raven keeps its reasoning-effort step-down only
for a reply that stopped while still thinking. This also ends the Codex
HTTP 400 "Unsupported value: 'minimal'": the old empty-reply recovery
lowered the effort on every resend, down to `minimal`, which Codex
refuses. Neither recovery nor the in-turn compaction summary asks for
`minimal` any more; a session whose own effort is `minimal` still sends
it, and the compacting history strategy's checkpoint summary goes out at
the provider's configured effort.

Silence is now something the model says, not something it omits:

- A reply that is a silence marker ends the turn without a reply. A
  turn a person started honours only the bracketed `[SILENT]` the
  runtime teaches, as the whole reply (whitespace aside): such a turn is
  never taught a marker, so `Silent.` or `No reply.` there is the
  person's answer. That is stricter than Hermes, whose strict rule takes
  all four spellings. A proactive turn (cron, heartbeat, Sentinel, a
  sub-agent report) uses Hermes' rules over all four (`[SILENT]`,
  `SILENT`, `NO_REPLY`, `NO REPLY`): the whole reply, at most 64
  characters, or the marker on the first or last line. An A2A turn,
  whose peer waits on the text, gets its reply back as written.
- The marker rules live in `raven/agent/context/silence.py`, outside
  the kernel. The kernel carries only the outcome: `TurnOutcome.silent`
  and the `silent_turn` notice.
- The cron reminder and the host's note on an unprompted sub-agent
  report teach the marker, in Hermes' words.
- Streaming holds back text that may still turn out to be a marker, so
  a silenced reply never flashes on screen. On a person's turn only a
  reply that opens with whitespace or `[` is held, and an inline think
  block streams as it arrives while what follows it is held; a
  proactive turn holds the whole call. The hold lasts across a stream
  cut and its continuation.
- The marker row is stored with `silent: true` and the turn ends with a
  `silent_turn` notice; the TUI and the web UI draw one muted line
  ("Raven chose not to reply."). ACP replay, the session list, the
  session export and the cron run list do not show it as a reply. A
  reply an `after_send` hook composes in place of the marker is
  delivered and stored as that reply.
- A channel stops its typing indicator (`end_turn`) when a turn a user
  started, or one a user's message was merged into, ends without a
  reply. A silent cron run is a success: it wakes nobody and does not
  count toward the silent-fire limit.

An empty reply is recovered by kind, decided by two pure functions
(`reply_kind`, `decide`):

- only thinking, no text (cut at the output limit or not): two
  prefills when there is thinking to re-feed, then the same step asked
  again at `high`, then `low` (only the rungs below the turn's effort,
  each once per turn), then the fallback models, then a failure;
- plain empty: resent after Hermes' jittered backoff, up to three
  times, or once when the call reported a cost at or above
  `emptyRetryCostThresholdUsd` (default 0.25 USD); two zero-output
  replies from one model stop the resends early. On a streamed turn
  the first resend goes out without streaming (see below);
- empty after a tool call: Hermes' nudge;
- cut at the output limit with neither text nor thinking: a
  continuation with max_tokens doubled each time (x2, x4, x8), held to
  the model's own output ceiling and to the room its prompt leaves;
- a refusal or a content filter (read off the finish, or off the
  `refusal` field, which litellm moves into `provider_specific_fields`):
  never resent; one fallback model, whose answer opens a step of its
  own, then a failure that quotes the model's explanation;
- fallback models are tried once each, and a switch holds for the rest
  of the turn.

Failures are worded after Hermes, per kind, and `no_output` (on
`TurnFailed` and `AnswerlessTurnError`) marks a turn whose last reply
was empty. A transient `llm_retry` notice says when a resend is
waiting.

A stream that dies after showing text is continued (up to three times,
stitched into the same reply). If it stays cut, or the turn runs out of
iterations or time while a continuation is owed, the partial is
delivered with a note that it is partial, status `interrupted`, instead
of failing the turn. A cut is read before any marker, so a fragment that
happens to spell one is continued, and a stream that had already
received a refusal finish keeps it; the reply the turn ends on, the
joined parts with their closed think blocks taken out, is then read for
a marker like any other, and parts that join into nothing but a thought
take the thinking-only path an uncut thought takes. A continuation that
calls a tool keeps the words the reader saw on that call's row, as they
came. Providers now report the finish they actually got: `unknown` for a
missing one, `refusal` / `content_filter`, and an error for a 200
response that carries one. ACP reports a turn its own budget ended as
`max_turn_requests` (#1121); a cut stream delivered as a partial spent
no budget and stays `end_turn`.

Decisions beyond the design, so a reviewer does not rediscover them:

- Only a real mid-stream death is continued. The stream hand-back marks
  it `finish_reason="stream_cut"`; a reply that merely lacks a finish is
  delivered as is. Continuing on a missing finish would draw three
  continuations on every reply of a backend that never sends one.
- The output-limit continuation and the post-tool nudge keep Hermes'
  frame, but say that the cut left nothing and that the model may
  answer or make the call the task needs: Raven sends them only for a
  cut that produced nothing, which Hermes' wording does not fit.
- Once the message tool has replied in a turn
  (`MessageTool.sent_in_turn`: its send to the turn's own chat went
  out), an empty last reply that a nudge or a prefill does not mend ends
  the turn as answered, without the paid resends, step-downs and
  fallbacks; a refusal there ends it the same way. A marker after such a
  reply does not make the turn silent, so no `silent_turn` notice is
  sent.
- On a streamed turn, the first resend of a plain empty reply asks the
  same model without streaming, with no provider-side fallback, and its
  answer is shown through the same sinks and gates a stream's deltas
  pass; later resends stream again. A stream can come back empty where
  the plain call does not: the locked litellm 1.85 drops a streamed
  chunk that carries only a refusal, so a refusal an OpenAI-compatible
  model streams arrives as an empty stop, and the plain call reads it
  and hands it to the refusal path. A gateway whose streams fail is
  rescued the same way.
- Sub-agent and DAG lanes keep their own recovery; besides the note
  above, only the providers' finish reasons reach them (see Risk).
- `after_send` hooks still run on a silent turn, with
  `outbound_content=None`; `FACTORY_LOOP_SURFACE_VERSION` is now 4.

How a stored turn is read: the live turn files `replied_via_tool` on the
last assistant row a turn the message tool answered stores, and the
session list and the ACP replay read that flag from one place,
`raven/session/turn_shape.py`, instead of guessing from the calls they
can see. A send that failed or went to another chat answered nobody, so
both surfaces agree with the live view about which turn answered.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

At the PR head, on origin/main 06509f8 (Linux, 32 cores).

- The full Python suite: 36878 passed, 9 failed, 255 skipped, 2
  xfailed.

  ```
nice -n 19 uv run --frozen --all-extras pytest -n 4 -q -p
no:cacheprovider
  ```

  The same 9 fail on a clean archive of origin/main 06509f8 on this
  machine: five proxy tests in tests/test_config_update_providers.py,
  test_install_script, test_knowledge_parser_excel,
  test_subagent_node_runtime, and test_cli_agent_commands'
  empty-message-file test, whose error box is cut to the terminal
  width. They come from the environment, not from this branch.
- ui-web `npx vitest run`: 260 files, 4461 passed.
- ui-tui `npx vitest run --maxWorkers=2`: 145 files, 2275 passed, 13
  skipped.
- `make lint-python lint-imports lint-deps lint-types lint-ui lint-tui
  check-commits check-large-files check-source-language`: all exit 0.
  ui-tui `npm run lint:i18n` and `npm run lint:rpc`, and ui-web
  `npm run gen:check`: exit 0.
- The review round's fixes were each written test first, and each was
  checked by removing or inverting it and watching a test go red. An
  independent re-review repeated those mutants, probed the stream and
  marker cases through the real loop against the previous head, and
  fuzzed the stream gate over about 35,000 random two-part cut replies
  without finding a silent turn that showed its marker.
- The rebase onto 06509f8 replayed every commit unchanged; its one
  meeting point with main, #1121's budget-stop marker, is described in
  the summary. Earlier rebases met #1031 (compacting history), #1078
  (refused cancel), #1119 (the context ring a resume restores), #1047
  and #1114; what each needed is pinned by tests on this branch.
- Prompt text, by a paper exercise with fresh model readers, at an
  earlier head (no prompt text has changed since): a routine sub-agent
  report was answered with `[SILENT]` and a finished one with a real
  report; a plain one-shot reminder was still delivered; an hourly
  watch with nothing new stayed silent. With the text before this
  change, the two routine cases produced a message.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

What a user will notice: a reply that is only a silence marker is not
delivered; an empty reply is asked again, stepped down or switched to a
fallback before the turn fails, and fails in Hermes' wording; a cut
stream is continued and, if it stays cut, delivered as partial instead
of failing the turn.

Known gaps, each left as it is here:

- A continuation request that raises (a non-retryable error, a cancel,
  or a retryable error that outlasts the stream's own ladder, about
  105 s) fails the turn although the reader saw the partial.
- An error the provider raises mid-stream (LiteLLM raises its HTTP
  errors) is not switched to a fallback model: it is waited out and
  fails the turn, as before. A stall before any output, and an error
  that comes back as a response, do fall back.
- On a plain (non-streamed) call, a fallback the provider switched to
  on its own is not known to the loop, so a later empty-reply fallback
  can ask that model again.
- A refusal a fallback model then answers streams live in a step of
  its own, and only the fallback's answer is stored, so a reload shows
  the answer alone. Storing the refused words would need a row kind
  shown to the reader but never sent to the model, and none exists.
- When the message tool replied and the model then ended on a marker,
  a reload draws the muted line after the reply; on the web the reply
  then sits in the turn's folded steps.
- A silent turn the runtime opened is left out of an ACP session/load
  replay with its tool calls, as the session list leaves it out,
  unless the message tool replied in it.
- The cron rule applies to every proactive origin, including sub-agent
  result turns that are never taught the marker: such a reply whose
  first or last line is a bare "No reply" or "Silent" is not delivered.
- Silent cron runs never reach the silent-fire limit, so a recurring
  job that always stays silent is never paused, and each run adds about
  3.5 KB to its session file and curator traces until consolidation
  trims them. A job whose model always returns nothing keeps firing,
  visible only as lastStatus=error.
- A refusal an OpenAI-compatible model streams costs one extra call
  before it is handled as a refusal, and only the turn's first plain
  empty resend can read it: a fallback model is never resent to, so a
  streamed refusal from a fallback still reads as an empty reply. The
  unstreamed resend's answer is shown whole rather than as it is
  written, has none of a stream's idle watchdogs, and shows only the
  retry notice while a long-reasoning model works; a proxy that caps
  plain responses turns it into an error, which takes the usual error
  path. A test pins what litellm 1.85 passes on, so an upgrade that
  starts passing the words is noticed.
- The cost guard reads only a reported `usage.cost_usd`, so it does
  nothing on direct OpenAI, Azure, Codex or the Anthropic API unless a
  gateway adds the cost.
- The failure for a thinking-only reply quotes up to 500 characters of
  the model's thinking in the TUI and web detail; channels get the
  bounded 200-character text.
- `stream_cut` shows as the call's finish in the trace viewer.
- Sub-agent runs keep their own recovery, and with #1047 merged they
  read the providers' honest finish reasons this way: a refusal with
  text is the run's answer; a refusal, content filter or missing finish
  with no text is a blank reply, which gets the one wrap-up call and
  then fails as having no answer; a 200 response carrying an error fails
  the run as a failed model call, with no wrap-up. An empty stream whose
  prompt was never read is asked once more without streaming in a
  sub-agent's direct chat before it fails.
- Near the context window, an output-limit continuation asks for no more
  than the room its prompt leaves, as the first call did; a reply cut
  there with nothing in it is therefore usually cut again and fails
  after its continuations.
- A cut keeps its two scaffolding rows (the words so far and the ask
  to go on) in the window for the rest of the turn, so a continuation
  that calls a tool shows the model the partial twice, and a cut that
  joins into a bare thought is prefilled, stepped down or sent to a
  fallback after them: the re-ask reads as "continue the cut thought",
  and a model that goes on mid-thought has the thought's tail delivered.
  The stored history has neither row. Taking the rows out lost a hook's
  note and left the question autofill reading an old list.
- Three rare cut shapes still show or deliver part of a marker: a cut
  whose continuation is refused and then answered by a fallback shows
  the held marker in the refused step; a cut marker prefix whose
  continuation starts the marker over (`[SILEN`, then `[SILENT]`)
  delivers the fragment, as before; a hook rollback of the
  continuation's tool call drops the partial from that call's row, as
  before.
- A cron turn that took in a person's message and then fails with no
  output leaves that chat's typing indicator running until the next
  send.
- When a refused stop holds the lane, the stopped turn's late end is
  dropped, and with it any silent mark that turn set, so the next turn
  is not drawn as silent.
- Unchanged from main: the think-block pattern and the stream's opening
  hold are quadratic on very long runs of tags or whitespace.

Compatibility and rollback:

- #1031 (merged) also changes `raven/session/manager.py`: it adds a
  caller of `_scan_file` and changes neither its return shape nor the
  silent-turn rule.
- `after_send` hooks now also run on a silent turn with
  `outbound_content=None`; an out-of-tree hook that assumes a string
  must accept None.
- The new fields (`silent` and `replied_via_tool` on stored rows and on
  `TranscriptMessage`, `no_output` on failures, the `silent_turn` notice
  kind) are optional; an older Raven reading a session written by this
  one shows a marker row's text as a reply.
- New config key `agents.defaults.emptyRetryCostThresholdUsd`
  (default 0.25). Rollback is a revert of the squash commit; there is
  no data migration.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A. Supersedes #1052 (closed unmerged).
0xKT pushed a commit that referenced this pull request Nov 4, 2026
…ll (#1173)

## Summary

Two defects in the composer's drag handle, both found by the owner while
using it:

1. Releasing a drag snapped the field back. #1150 treated a dragged
height as a cap on the content: on release `fitField()` settled the
field to `min(cap, scrollHeight)`, so an empty or short draft went
straight back to the resting 52px. A dragged height is now the field's
height. A short draft, an emptied field and a sent message leave it
where the reader put it, and a longer draft scrolls inside it. The
half-viewport ceiling still clamps it (a smaller window, a soft
keyboard) and gives it back afterwards. A field that was never dragged
still fits its content, and the height is still not persisted across a
reload.

2. The turn nav moved with the drag. `.transcript-nav` ended its band at
`--lift`, which `resizeField()` republishes on every pointer move, so
the centred marks slid up by half of each drag and a long strip was
squeezed. `dockLift()` now also publishes `--lift-rest`, the same
distance with the field at its resting height: `--lift` minus the
field's growth over its `min-height`, floored at 0 for a field that is
not drawn (the trajectory view hides the card). The nav stands on that.
It sits in the gutter beside the card, never over it (the dock's 36px
side padding, plus the desk reserve), so the field's height is nothing
it has to clear. The transcript reserve, the tail fade, the back pill
and the trajectory view's bottom padding still follow `--lift`.

Decisions a reviewer may want to weigh:

- The nav ignores the field's growth from any cause, typing included,
not only the drag. Excluding only the dragged part would make the nav
jump when the first drag starts. Docked sheets, queued rows, the
attachment tray and the running-task chip still lift it, as before.
- There is no "drag back to the floor to return to the content fit"
rule: shrinking a tall draft to the floor would then bounce it back up
on release, which is the first defect again.
- A press on the handle now has to travel 4px before it counts as a
drag, the slop the desk, the DAG board and the trajectory details
already use. With a held height, a click that jittered by a pixel would
otherwise pin the field and switch off the content fit until a reload.
Past the slop the field follows the whole travel, including a move back
inside it.

Four existing tests pinned the cap behaviour and were flipped rather
than kept: "caps the content fit without fixing the height" and "keeps
the cap while the content moves under it" became "holds the dragged
height whatever the content"; "previews the drag while the pointer moves
and settles back to the content on release" became "keeps the dragged
height on release when the content is shorter"; "leaves the field free
to shrink after a key sets a cap" became "holds the height a key set
when the content shrinks".

Checked and deliberately left alone:

- The handle is a focusable `role="separator"` with no `aria-valuenow`,
`aria-valuemin` or `aria-valuemax`. That predates this change (#1150)
and belongs to a separate one.
- The write of `--lift-rest` (a unit test) and its read by the nav (a
stylesheet gate) are pinned by name in two separate tests, the same
shape the existing `--lift` contract has; nothing ties the two names to
each other beyond that.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Run at the head of this branch, on `origin/main` f401dfc, which had
not moved when the branch was pushed.

- `npx vitest run --maxWorkers=4` in `ui-web/`: 287 files, 5026 tests
passed, exit 0, no unhandled errors. This includes the gates under
`ui-web/scripts/gates/`.
- `npm run type-check`: exit 0, with `tsc --listFiles` confirming it
read this checkout's `store.ts`. `npm run lint`: 0 errors; the 4
warnings are the existing ones in `ConnectionsPage.tsx`, `CronPage.tsx`
and `SubagentsPage.tsx`.
- The first commit's 7 new or flipped tests (the release snap-back, the
held height under shorter content and under a key press, `--lift-rest`
published and held through a drag, the nav's `bottom` declaration) and
the slop commit's 2 (a click with 1px of travel, the whole travel once
dragging) were watched failing before their fixes. The test for an
undrawn field and the third commit's test were written after their code
and are proven by the mutants below.
- Mutation testing at this head, each mutant applied alone with an
unmutated control passing in the same harness: the cap semantics
restored in `fitField()`, the ceiling ignored for a held height,
`--lift-rest` not published for an undrawn dock, the field's growth not
subtracted, the `Math.max(0, ...)` floor removed, the nav back on
`--lift`, the nav's `96px` fallback dropped, the slop guard removed, the
drag latch removed, and the drag lagging by the slop. All ten are killed
by the intended tests. An eleventh, writing the two variables on every
call instead of only on a change, survived the whole composer suite
before the third commit, whose test now kills it.
- Real Chromium, on a two-turn conversation of the offline fixtures
(`?stub=1`), dragging up 250px. Before (`origin/main` under `vite dev`):
the field went from 52px to 302px during the drag and back to 52px on
release, while the nav's band shrank from 746px to 496px and its marks
moved from 389px to 264px. After (this head, the built page served by
`raven web`): the field holds 302px through the release and through
typing, and the nav's band and marks do not move.
- Also in Chromium at this head: a strip of 92 marks keeps its box
through a 300px drag; a drag below the floor stops at 52px; ArrowUp and
Shift+ArrowUp step 10px and 40px and hold; a 500px-tall window clamps
the field to 250px and a 900px one gives 302px back; at 700px wide the
nav and the card touch without overlapping; with no drag at all, typing
grows the field, clearing it shrinks it back, and the nav does not move;
a click on the handle with 2px of wobble leaves the field fitting its
content, and nine typed lines then grow it to 234px.
- Over `origin/main..HEAD`: commitlint (run in place, verbose: 3 inputs,
0 problems), `scripts/check_commit_messages.py`,
`scripts/check_large_files.py` and `scripts/check_source_language.py`
all exit 0. Each was also run on a control commit that breaks its rule,
and each failed there.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

No user-facing doc describes the drag handle, so there was nothing to
update.

## Risk

User-visible changes: after a drag the composer keeps its height until
the next drag or a reload, including after a message is sent, and a long
draft scrolls inside it instead of growing it; the turn nav no longer
moves while the composer grows from typing either; a press on the handle
needs 4px of travel before it resizes anything.

Rollback is a revert of the squash commit. The change is confined to
`ui-web/src/features/composer/store.ts` and one declaration in
`ui-web/src/features/transcript/styles.css`, plus tests: no stored
state, no RPC, no Python.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A. No issue was filed for these defects; they follow up #1150 (the
drag handle) and #1114 (the turn nav).
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.

3 participants