Repository navigation
feat(ui-web): add a turn nav that marks each turn beside the transcript - #1114
Conversation
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.
gloryfromca
left a comment
There was a problem hiding this comment.
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.
|
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:
Nits:
|
… 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)
… 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).
…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).
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:
TranscriptAppis now that domain's manifest root, mounted into#turnNav, an empty boxChatToprenders after#scroll; a CSS sibling rule parks it with the scroller in the trajectory view.#stage, through a newmount.shownLane()that never starts a lane, and follows the host swaps the session layer makes through aMutationObserver. A lane held off the stage while its turn streams is therefore never drawn.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.chrome/behaviour/scrollbars.tsgains a generic opt-out,data-thumb="none", beside its existing one-line-field exception.AskViewcarriesdata-askso the nav finds a question by its segment id.ago()moves fromfeatures/extAgents/Rows.tsxtolib/ago.ts, so both callers share one spelling of a relative time.gui.transcript.navandgui.transcript.nav_turn, with the TUI copy regenerated.NO_ROOTin thedomain-shapeandisland-langgates now that it has a root. That also makes the "four domains with no root of their own" inui-web/CONTRIBUTING.mdandui-web/CONTEXT.mdtrue again;island-langlisted five.ui-web/CONTEXT.mdgains 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:
Type
Verification
Run at this head on
origin/main9efb879.npx vitest run --maxWorkers=4inui-web/: 257 files, 4395 tests passed, no unhandled errors. This covers the gates underui-web/scripts/gates/.npm run type-check: exit 0.npm run lint: 0 errors; the 4 warnings are the existing ones inConnectionsPage.tsx,CronPage.tsxandSubagentsPage.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:i18ninui-tui/: the generated message catalogue is up to date.scripts/check_source_language.py origin/main...HEADandscripts/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
midTurnfilter, the tail-follow release, the tail rule inreadingTurn, the stage observer, the scroll lead, the two-turn threshold, the time in the card, the scroll listener, thedata-thumbopt-out, andTranscriptApp'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-reserve362px) 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
maingained 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.mddocuments 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.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