Skip to content

fix(transcript): automatically continue restored reading ranges - #1850

Merged
vastsa merged 2 commits into
vastsa:mainfrom
new-Beginner:fix/restored-reading-continuation
Oct 24, 2026
Merged

vastsa merged 2 commits into
vastsa:mainfrom
new-Beginner:fix/restored-reading-continuation

Conversation

@new-Beginner

Copy link
Copy Markdown
Contributor

Summary

Follow up on my reading-position restoration change in #1665 without reverting bookmark restoration.

Restoring an evicted session loads a bounded range around its bookmark. That range was treated as a permanently frozen historical snapshot, while automatic paging only handled older messages. This left ordinary restored reading behind a Load later messages button and prevented it from naturally rejoining live output.

  • Automatically request bounded newer pages near the visible lower boundary, including short viewports, without moving the reading position.
  • Keep restoration isolated until the unread gap closes, then resume canonical updates and new live rows without prepending older cached rows.
  • Preserve explicit search's manual paging, canonical model input, and existing cancellation/coalescing ownership.
  • Gate retries by the physical paging cursor to prevent failed or non-advancing pages from looping; clean up listeners and observers for hidden/unmounted panes.
  • Add regression and isolated Electron user-path coverage, and synchronize the UX/E2E specifications.

Verification

Task candidate: a3306d456e36d6f0bd8d2d0eb48e1488bbbd6f2c
Base main: cdad68e61e504adbfd1af10492a338daec09bdfa
Environment: Windows; isolated Electron fixture, no running user instance or real model provider.

Passed on the final candidate:

  • pnpm --filter @pi-desktop/desktop build (chunk-size warnings only)
  • pnpm --filter @pi-desktop/desktop typecheck
  • pnpm lint
  • node --test apps/desktop/test/transcript-*.test.mjs apps/desktop/test/session-search*.test.mjs — 163 passed, 0 failed
  • node scripts/e2e-transcript-reading-position.mjs — scrolling, real SessionPane restoration/continuation, and continuation lifecycle probes all passed
  • git diff --check and pnpm check:pr-base

Full-suite limitation

A broader Windows desktop test run during implementation was not green: 4,281 passed, 64 failed, 8 skipped. Failures included path/permission, SSH/askpass/host fixtures, and longer user-path tests. Two updater path failures were independently reproduced against unchanged main; the remaining failures have not all been baseline-verified and are not claimed to be pre-existing. The focused transcript/search suite is green, but upstream integration CI is still required before merge.

new-Beginner and others added 2 commits October 24, 2026 00:05
Restored reading ranges should preserve their bookmark without leaving
readers behind a manual history paging control. Continue bounded pages
near the viewport boundary and rejoin live output only after the gap closes.

Keep explicit search paging isolated, preserve the viewport and model
input, and cover continuation, retries, and hidden-pane cleanup.
@vastsa
vastsa merged commit 67ce4fa into vastsa:main Oct 24, 2026
6 checks passed
vastsa added a commit that referenced this pull request Oct 25, 2026
…#1796)

* fix(asktool): keep card drafts across session switches

The AskTool card kept its question index and draft answers in component
state, so switching sessions unmounted the card and returning to the same
pending ask reset every answer. Store the draft by requestId instead and
let the card read and write that entry, dropping it when the ask is
resolved, dismissed, or removed.

Amends the asktool card spec's request-transition rule and adds
E2E-ASKTOOL-draft-survives-session-switch.

* fix(persistence): drop empty assistant rows regardless of status

The transcript guard only dropped empty messages whose status was error or
aborted. An empty complete assistant message therefore reached the outbox
on every provider retry, growing the request body and feeding the EMPTY
loop. Drop empty assistant rows whatever their status, while keeping
messages that carry an error payload, a tool row, or a steering
reservation.

Adds empty-assistant-persistence tests for each of those cases.

* fix(chat): open worktree references from the branch copy

An isolated session's files live in its worktree, but chat references can
still carry a path from the parent checkout: project instructions, MCP tools,
or a reference captured before isolation. The resolver rejected that path as
outside its roots, and the renderer then handed the file view a relative path
that it resolved against the parent checkout, so the click either failed or
opened the main-branch copy.

Rebase an absolute parent-checkout reference onto the same relative path in
the worktree when that file exists, and keep the worktree's absolute path in
the renderer so the file view opens the branch copy. Paths that exist only in
the parent still report outside-allowed-roots.

fixes #1747

* fix(transcript): let an unpinned bookmark own the pane reveal

Follow mode can still read pinned when a send or retry hid the pane. On the
next reveal the scroll hook then bottomed the transcript before the
reading-position hook could restore the recorded bookmark, so a reader who
had scrolled up returned to the newest turn.

Route the reveal through one shared ownership rule: an unpinned bookmark or a
pending restore keeps the edge, a genuinely pinned pane bottoms, and an
unpinned pane without a bookmark reapplies its retained offset. Both hooks
now agree on which one acts.

fixes #1771

* fix(agent-runtime): stop waiting for tools host-core cannot cancel

The abort listener returned immediately unless the tool was Bash or
GenerateImages, so Stop was a no-op for every other tool, and
tools.execute was awaited with no deadline. One report measured a Grep
running 21m39s after six Stop clicks.

The listener now applies to every tool. Bash and GenerateImages keep
sending tools.abort and get a bounded 10s grace window for the partial
output the host collected before the kill; every other tool abandons the
wait the moment the signal fires and returns TOOL_ABORTED without a
tools.abort RPC (the host cannot act on it, and the 60s tombstone it
would leave could be inherited by a later Bash reusing the call id).

Both paths race the execute call against the abort, so losing the race
abandons the wait rather than the host call: the orphaned call settles on
its own and its result is discarded.

Fixes #1767

* fix(agent-runtime): release in-flight tools from the stop itself

A stop can reach the runtime without reaching the tool it was meant to
cancel. The executor then keeps awaiting `tools.execute`, the turn never
settles, and `abort()` — which awaits that settlement — never returns. The
renderer awaits the abort RPC before it clears "processing", so the session
stayed pinned even though the ledger had already recorded the turn as
aborted: three turns in one field report were `aborted` while the tool's
process was still alive minutes later.

`abort()` now drives the in-flight tools directly instead of relying only on
the signal each one was handed: it walks the active abort handlers and runs
the same path a signal abort would. Cancellable tools still send
`tools.abort` and get the grace window; every other tool abandons the wait
at once.

Refs #1767

* fix(composer): search past a truncated file index for @ mentions

The file index keeps only the shallowest 8000 entries, so in a large
workspace — a Unity project, where every asset has a .meta sibling — a
script several folders down matches no query at all, even though the
truncation note invites the reader to refine it (#1781).

While the snapshot is truncated, a non-empty query is now also searched
over the workspace's full listing through the read-only fs/search channel,
after a short typing pause, and the best rows merge with the index's own
matches. The listing is the git list the index already builds, or a deeper
ignore-set walk when the index came from the walk. A complete index never
searches, and a remote session keeps its snapshot because the remote file
protocol has no search operation.

fixes #1781

* fix(work-panel): keep the active tab's close button inside the strip

The close button is a sibling of the tab button inside the tab wrapper, but
scroll-into-view targeted only the button. With `inline: "nearest"` a button
that is already visible scrolls zero pixels, so the sibling close button
stayed clipped outside the strip's overflow edge — the tab looked like it had
no close control at all (issue #1762).

Scroll the wrapper instead (falling back to the button), and reserve the
close button's width at the inline end via `scroll-padding-inline` so
sub-pixel rounding cannot reintroduce the clip.

* fix(mcp): fail loudly when a server has no DCR endpoint

#1782: when discovery returns no registration endpoint, the login flow fell
back to a literal client id:

    } else {
      clientId = "pi-desktop";
    }

No authorization server has ever registered that id, so the app opened
`<authorize>?client_id=pi-desktop` and the user landed on a bare 404 — with
no explanation and no way forward. It is not a GitHub-specific failure: any
OAuth MCP server without Dynamic Client Registration is affected.

Throw before opening anything, naming what is missing and the two real ways
out. A settings field for a client id does not exist yet, so the message does
not promise one.

The test drives the real startLogin through a mocked discovery that omits
registration_endpoint, and asserts both the message and that openExternal was
never called. Verified against the old code: the test does not merely fail,
it hangs until the 300s timeout, because the previous flow waited for a
callback that could never arrive — the same experience the report describes.

Verification: mcp-oauth.test.mjs 20/20, mcp-oauth-discovery.test.mjs pass,
tsc --noEmit clean.

* fix(settings): keep the last successful market list when a query fails

A failed catalog query used to drop the list back to built-in picks,
discarding what the user was just looking at (issue #1692 follow-up).
Both markets now keep the last successful result for the same query on
screen — under the failure with a timestamped note, and during the
retry that follows — until a retry succeeds or the query changes.

The snapshot never answers a different query, an empty successful
answer is not a failure to cover, and Load more refreshes it.

* fix(e2e): escalate to SIGKILL when the certificate probe hangs

#1751's Electron E2E job ran 30m18s and was cancelled by the job budget with
no failing step to read. The cause is this runner's only timeout:

    const timer = setTimeout(() => child.kill(), 60_000);

SIGTERM is a request. An Electron child still initialising its renderer can
ignore it, and the 'close' event this script awaits then never arrives, so the
step holds the job until GitHub cancels it. The runner's own 60s budget is
never enforced.

Escalate: SIGTERM at 60s, SIGKILL at 65s, and report which one fired. A probe
that cannot start now fails this step in about a minute with its output
attached, instead of burning the job budget.

Verified: normal path still passes ("provider client certificate E2E
passed"); with a child that ignores SIGTERM the escalation force-kills it at
~800ms in an isolated harness, so the timeout path terminates rather than
hanging.

* perf(sessions): cache file-derived session summaries by file identity

native.session.list() re-read, re-hashed and re-parsed every session file
on each call: nativePiSnapshot() does readFileSync + SHA-256 + per-line
JSON.parse, and list() then rebuilt SessionManager + branch +
buildSessionContext per file. Cost grew linearly with session count and
file size, all of it synchronous on the host main thread.

Cache only the facts that come from one file's bytes (header identity,
cwd, title, message count, timestamps, model, thinking level, structural
flags), keyed by (dev, ino, size, mtimeMs). Any append, rotation or
replacement changes the key and re-parses; projections for files a scan
no longer sees are dropped.

Nothing live is cached. Ownership, project trust, provider availability
and cwd existence are recomputed on every call, and every write path
still hashes the full bytes through nativePiSnapshot(), so external
rewrite detection and lease validation are unchanged.

The list path also stops needing a full snapshot to prove ownership: a
missing .pi-desktop.lock already means the lease is acquirable, so the
snapshot is only materialized when a lock file exists.

Measured on a real ~/.pi/agent/sessions tree (28 sessions, one file at
21 MB): list() 826/807/933 ms before, 802 ms cold and 21/16 ms warm
after. The cold round still parses every file, as before.

* fix(runtime): size the output cap from the folded request transcript

Long sessions accumulate one system-transcript snapshot per instruction
change. Unless a model declares mid-conversation system support, every
adapter folds them into a single leading message, but the output-cap
estimate charged each superseded snapshot again. The clamp collapsed to
1 and the provider floor (16) went on the wire while the request was far
from the window, so reasoning models were cut off and the turn ended as
EMPTY_MODEL_RESPONSE without compaction ever triggering.

The parent and subagent stream paths now fold the transcript the way
the adapter will before clamping, and hand that same transcript to
pi-ai so its own streamSimple clamp agrees. Context calibration and the
compaction budget keep their existing basis.

* feat(transcript): add display modes and thinking shimmer

Add a persisted 	ranscriptDisplayMode setting with flat, command-first,
and fully-folded layouts. Fully-folded keeps the v0.20.0 command grouping,
command-first keeps run rows standalone, and flat removes nested activity
groups while preserving one outer process.

Make Thinking rows default expanded in flat and command-first, collapse to
a whole-run TextShimmer indicator, and use the same shimmer for the hidden
reasoning live indicator. Keep fully-folded's existing Thinking defaults.
Deprecate keepCommandsExpanded; it remains readable for compatibility.

Cover mode segmentation, settings normalization and sync, and renderer
wiring; update transcript and settings specs and ADRs.

* test(transcript): align turn-process probe with Thinking defaults

Open the completed process and Thinking disclosure before asserting the
retained reasoning text now that a collapsed Thinking row shows only its
label. Also assert flat mode keeps one outer process without nested
activity groups or standalone run wrappers.

* fix(agent-runtime): skip expired OS trust anchors when merging the CA set

The sidecar merges the operating system's trust store into Node's default CA
set at startup. Those stores are a cache and keep entries that already
expired: Windows still lists Let's Encrypt's `ISRG Root X2` and `R3` with a
2025-09-15 expiry long after the fact.

Merging one of those makes OpenSSL accept an expired certificate as the anchor
for any chain that names it as issuer. A chain like

    leaf -> YE2 -> Root YE -> ISRG Root X2 -> ISRG Root X1

stopped at the expired `ISRG Root X2` instead of walking up to the valid
`ISRG Root X1`, so the handshake failed with `CERT_HAS_EXPIRED` even though
the provider's own chain was valid. The failure is reported as a TLS category
error and is correctly not retried, so the provider is unusable until the
expired entry leaves the store.

Every provider behind such a chain failed, which is why the report looked
provider-specific (issue #1393). Reproduced on Windows 11 with Electron 43.6.0:
`tls.getCACertificates("system")` returned 146 certificates, 17 of them
expired, and the same host handshakes fine with the bundled roots alone.

An anchor that has already expired can verify nothing, so it is never merged.
The bundled and `NODE_EXTRA_CA_CERTS` sets are untouched, and chain,
expiration and hostname validation stay enabled - this narrows the merge, it
never disables verification.

- `system-ca.ts`: filter the system store by `validTo` before merging.
- `system-ca.test.ts`: assert no expired system anchor reaches the defaults.
- `e2e-provider-certificates.mjs`: the trust probe now measures the usable
  system roots, because the previous "every system certificate is present"
  expectation cannot hold once expired entries are excluded.

* test(transcript): mock TextShimmer in session reference probe

The shared transcript module now imports the Thinking shimmer. Keep the
session-reference renderer probe's dependency table complete so it can
continue exercising message references without loading the app store.

* fix(e2e): test certificate probe kill escalation

Keep the normal Electron path unchanged while asserting that a child
ignoring SIGTERM receives SIGKILL after the grace period.

* fix(mcp): localize missing DCR recovery

Fail before opening OAuth with an unregistered client, and send a
stable error code so settings can show translated recovery guidance.

* fix(transcript): fall back to the bookmarked offset when its row is unmounted

Stacked on #1783, which fixes who owns the reveal edge but not this path.

A reveal mounts only a trailing slice of history (TRANSCRIPT_INITIAL_MOUNT 15,
then TRANSCRIPT_WINDOW_MIN 60, lib/transcript-window.ts:23-28), so an older
bookmark routinely names a row that is not in the DOM. restoreTranscriptReading
Position reported that as false, and the alignment loop returns on false without
arming the deadline that would clear pendingRef (use-transcript-reading-
position.ts:99). While a restore is pending, useTranscriptScroll discards every
scroll event (:384-387), so the reader cannot travel up to mount the row either:
no restore, no scrolling, permanently.

The bookmark already recorded the absolute offset it was taken at
(TranscriptReadingPosition.scrollTop, captured at :59/:68), but the restore path
never read it. Use it as the fallback: an approximate place is strictly better
than a pane that can neither scroll nor restore. A bookmark with no usable
offset (NaN, negative, infinite) still reports failure, and a pane with no
layout box is still refused outright.

Tests: transcript-reading-position-fallback.test.mjs (5) covers the mounted-row
path, the unmounted-row fallback, a zero-height row, unusable offsets, and the
no-layout refusal. Related suites: 42/42. typecheck clean after a workspace
build.

* fix(host-runtime): expect the platform scratch separator in the launch-resolver test

join(dataDir, ...) yields native separators and the assertion hardcoded the POSIX spelling, so the suite failed on every Windows checkout while Linux CI stayed green. Mirror the production expression instead.

* fix(agent-runtime): tolerate the Windows symlink privilege in the sessionDir tests

The #1732 tests create real file symlinks, which EPERMs on an unprivileged Windows checkout and crashes the fixtures (storage-directory-migration already degrades the same way). Catch EPERM and keep the remaining assertions, which hold without the alias; privileged runs and CI keep the full coverage.

* feat(composer): toggle background task output from the whole row

The background-tasks bar only opened a task's log through its "Output"
button, so the row itself — the state dot, the command and the meta text —
looked clickable but did nothing, and the click target was a few pixels
wide.

The row lead is now the toggle: mouse click, Enter and Space all flip the
log, with aria-expanded on the interactive region. Stop and dismiss keep
their own buttons outside that region, and a source test pins the split so
a later refactor cannot regress the click target.

* docs(spec): restore the missing rationale cell on D669

The D669 decision row landed with three cells in a four-column table in
both languages, so `pnpm --filter @pi-desktop/docs test` fails on
`origin/main` with a page-structure error.

Fill in the rationale the decision actually records: the shallowest-8000
entry index matched nothing for a script several folders down, which is
what issue #1781 reported. The table parses again.

* fix(e2e): bound the client certificate baseline fetch

Electron's direct fetch rejects before reaching the TLS fixture on macOS,
but can stall at the same client-certificate negotiation on Linux. Bound
the baseline probe and accept its abort as the platform-specific outcome,
while still requiring that no HTTP request reached the fixture.

This keeps the certificate discovery, proxy, redirect, and cancellation
assertions running instead of letting the child process time out.

* fix(e2e): bound the client certificate baseline probe

Electron's direct fetch can remain pending during client-certificate
negotiation on Linux, and its AbortSignal does not reliably interrupt
that platform request. Race the baseline probe against a short deadline;
keep the process alive for the remaining discovery checks, then exit
Electron after the fixture has been verified.

A successful fetch still fails the test, and the fixture must receive no
HTTP request before the certificate error or platform timeout.

* fix(chat): parse positioned and ambiguous file references safely

Fixes #1722

Tested: pnpm lint && node --test apps/desktop/test/chat-links.test.mjs apps/desktop/test/chat-ref-resolve.test.mjs && pnpm --filter @pi-desktop/agent-runtime exec vitest run src/runtime.test.ts && node scripts/e2e-file-ref-line-scroll.mjs && pnpm docs:check
passed (exit code 0)

* fix(transcript): keep activity details visible after turns settle

Fixes #1806

Tested: pnpm test:e2e:transcript && node --test apps/desktop/test/thinking-ui.test.mjs apps/desktop/test/transcript-display-modes-ui.test.mjs && pnpm typecheck
passed (exit code 0)

* test(transcript): align search test with group defaults

* fix(window): restrict recovery pinning to macOS

Bounds recovery could leave Windows and Linux windows always-on-top
because only the macOS watchdog releases the temporary recovery pin.
Keep pin acquisition on the platform that owns its cleanup.

Regression coverage protects normal focus and hidden or minimized state
while preserving the existing macOS recovery behavior.

* fix(transcript): keep running marker inline

The shimmer styles were inserted after the first half of the running-label selector, so the active label inherited inline-grid and the pulse marker became an auto-placed grid item. Separate the marker and shimmer selectors again so the marker stays in the label's inline flow and does not disturb truncation.

* fix(transcript): center running marker

The live dot was generated content inside the running label. A selector collision with the shimmer rule turned that label into a grid and pushed the dot onto a second row; even with the selectors separated, the inline pseudo-element sat below the row centre and could be clipped by the label's ellipsis box.

Render the marker as a real flex sibling so the header's existing align-items centres it, and keep the label's text truncation untouched.

* chore(models.dev): refresh catalog before beta.5

Refresh the bundled provider catalog so the 0.21.0-beta.5 test build packages the current models.dev snapshot.

* test(provider): align DeepSeek window with catalog

The refreshed models.dev snapshot publishes a 1,048,576-token window for deepseek-v4-flash; update the endpoint metadata assertion so the release verification pins the shipped catalog.

* chore(release): v0.21.0-beta.6

* feat(transcript): flatten edit rows and restore group hierarchy

A review-backed Edit or Write now renders as a single tool-level review row: the file header is the only disclosure header, with its diff body directly beneath it. This removes the duplicate generic tool row while keeping rollback and adding a copy action for the hunks.

The command-first and fully-folded group bodies restore the released v0.20.1 12px child indent and left hover/focus collapse rail, so non-command tools keep their two-level hierarchy without adding a third disclosure.

* fix(settings): size segmented controls to their content

Keep translated display-mode labels on one line instead of squeezing them
into the fixed field column. Allow settings controls and whole options
to wrap when the available pane width is limited.

* feat(chat): rework the top-level turn fold header

The turn header now reads as a hairline row: status, recorded duration and tool count stay in one left-aligned run, with the disclosure caret trailing the metadata instead of sitting at the far edge of the transcript. Hovering only strengthens the text colour, and the issue count is no longer repeated at this level because the status already names a failure.

Nested activity-group headers and their hover fill stay unchanged so the transcript hierarchy below the turn fold is not flattened.

* fix(chat): keep the hairline header on the transcript edge

The top-level row no longer bleeds into the gutter, so its fill starts at the prose edge while nested group and run fills keep their 4px hover bleed. The shared 5px gap keeps the turn title on the same text line as the nested titles, and the transcript E2E probes the new content edge and full-width hairline.

* fix(e2e): measure the turn hairline against its process band

The probe compared the header with the padded fixture root, so a correct full-width row could never match. Measure the header against the turn process that owns it instead.

* feat(chat): show per-turn token usage capsule

Completed assistant turns now show the model, average generation speed, and total tokens on one quiet row. Hovering or focusing the capsule reveals cache-read, input, and output counts without opening the composer inspector, while the composer remains the last-request occupancy authority.

The capsule aggregates every parent assistant message in the visual turn, so its total answers what the round consumed rather than what the newest request occupied. Subagent spend stays out of the per-message usage contract.

* fix(agent-runtime): preserve executions when providers reuse tool IDs

Repeated provider IDs were mistaken for replayed history, dropping real
results and freezing the outgoing request. Assign execution IDs before
Pi journals each response and retain completed pairs at the request
boundary. Keep identity reservations across compaction and model fallback.

Fixes #1815

* fix(live-voice): repair Gemini work setup and report protocol failures

Gemini rejects the additionalProperties keyword in its setup Schema.
Use its supported schema subset while keeping OpenAI and Host validation
strict. Reject failed setup immediately and retain the first socket
failure's diagnostics in the provider log without exposing them over IPC.

Fixes #1812

* fix(desktop): keep toasts clear of the work panel

Anchor the global toast stack to the live main-content bounds so docked and resized work panels remain usable while notifications are visible. Preserve viewport fallback placement outside the chat shell and cover resize and horizontal-shift geometry in the question-notification journey.

Refs #1820

* test(e2e): control toast focus transitions

Drive the focus and blur lifecycle through explicit DOM events so the countdown coverage does not depend on operating-system window activation. Keep native keyboard input coverage in Electron.

* fix(transcript): wait for history before applying fallback

A bookmarked anchor can be outside the mounted window while its bounded
history lookup is pending. Start viewport alignment only after the lookup
succeeds so the fallback deadline cannot cancel it.

Cover delayed lookup and document the approximate offset behavior.

* fix(persistence): retain one trusted empty completion row

Drop ordinary empty assistant rows to stop provider retries growing the transcript, while preserving the one-shot silent reply allowed by a Host-verified completion notice. Spend or revoke that exception on the first completed response, accepted steering, and turn settlement.

fixes #1766

* fix(mcp-market): retain source badges in stale results

The retained list is rendered after the current remote entries are cleared, so source badges must follow the displayed snapshot. Keep the existing provenance visible until a retry replaces it.

* test(work-panel): cover tab close reachability after resize

Make the geometry fixture shrink the actual scrollport and reset it between stages so resize and hover are independently exercised. Update the bilingual E2E scenario to document both paths.

* fix(agent-runtime): cancel pending tools safely

Stop immediately only when the active work is read-only or has a host cancellation path. Cancel pending approvals and remote reads, while waiting for started mutations and extension calls to settle before reporting the turn stopped.

* fix(test): stub the turn usage capsule in the session reference fixture

The session reference UI test transpiles the real shared transcript module and asserts every dependency is stubbed. Importing the new capsule broke that fixture graph, so the fixture now provides a capsule stub and the breakdown helper Mock.

* feat(chat): show project breadcrumb and inline title editing

The conversation top bar only named the task, so the project a task
belongs to was hidden in a tooltip. Put the session's project beside the
title as a quiet crumb and let the title be renamed in place, matching
the rest of the app's direct-manipulation surfaces.

The crumb resolves from the active session rather than the globally
selected workspace, so a task opened from another project stays labeled
correctly. Remote and scheduled transcripts keep a plain title because
their titles are host-owned, and the inline editor keeps the existing
title limit, IME behavior, and error path.

Update the component spec and its source-contract test to match.

* docs(spec): sync the Chinese topbar description

The English component spec now describes the project breadcrumb, the
inline title editor, and the expanded state table. Mirror those changes
in the Chinese page so the locale pair keeps the same table and fence
structure and the docs checks stay green.

* feat(chat): keep the workspace crumb on an empty chat

An empty chat has no session yet, so the crumb previously disappeared
even though the app still has an active project. Fall back to the
workspace name only in that state; temporary sessions keep the plain
title because they belong to no project.

Extend the topbar rendering test and both spec pages.

* fix(ci): realign stale store and adapter contracts

Recent main changes added AskTool drafts and persistence bookkeeping without updating every fixture, moved the empty-message guard into session-transcript, and changed the AppShell section attributes and host-core cancellation shape. The stale assertions failed all PR checks, including unrelated branches.

The Gemini Live close handler also assumed a reason buffer always exists; a close event without one now keeps the adapter's teardown path instead of throwing.

* chore(models.dev): refresh catalog before beta.7

Refresh the packaged provider catalog to the current models.dev snapshot so the prerelease ships the same model metadata the release workflow packages.

* chore(release): v0.21.0-beta.7

* feat(shared): add inline visualization contracts

The desktop shell, host-core, and agent runtime need one definition of
where visualization fragments live and how a reply refers to them, so
the reference format and URL scheme cannot drift between processes.

This adds the `pi-genui:` URL helpers, the session directory and file
name rules, the `::visualize{...}` reference parser (outside code
blocks, tolerant of Windows paths), the guest message types, and the
VISUALIZATION_HTML_ONLY error code.

refs #1825

* feat(host-core): add a session visualization root

Visualizations need a host-owned place the model can write without a
permission prompt that is still clearly separated from the project and
from disposable scratch files.

Local sessions get `<data_dir>/visualizations/<sessionId>/` as a second
contained root next to scratch (amends D114). Write/Edit there accept
only direct `.html` fragments, an Edit move out of the contained roots
still asks, and the root wins over a workspace that contains the data
directory. The directory is removed with its session, only orphans are
swept at startup, and forks copy the fragments they reference so cards
survive deleting the parent. Remote and contract sessions get no root.

refs #1825

* feat(desktop): serve visualizations through a sandboxed pi-genui scheme

The renderer must be able to frame a model-written fragment without
granting it network, host, or same-origin access, and without the
fragment reaching outside the session's visualization directory.

Main registers `pi-genui:` (standard and secure only, no fetch or
CORS), resolves `pi-genui://session/<id>/<file>` against the real
session directory with O_NOFOLLOW and a 2 MB cap, rejects full HTML
documents, and reports failures as fixed error codes. Responses wrap
the fragment in a shell with a strict CSP and Permissions-Policy, a
small host runtime for size, theme, view, wheel, and Escape messages,
and a styling kit adapted from ZCode's Apache-2.0 visualize plugin.
Bundled D3 and Floating UI are served from `pi-genui://assets`, pinned
CDN URLs are rewritten to them, and every other remote asset is
blocked. The main window CSP gains only `frame-src pi-genui:` and
permission requests from the scheme are denied.

refs #1825

* feat(agent-runtime): teach agents to write inline visualizations

A model only produces visualizations if it knows where to write them,
what the sandbox allows, and how to reference the result.

Local sessions advertise the visualization directory in the system
prompt next to scratch, and a built-in `pi-desktop/visualize` skill
(condensed from ZCode's Apache-2.0 skill) describes the fragment
format, theme tokens, kit classes, and the reference line. Remote
workspaces get neither the directory nor the skill, including in the
`/` menu and send-time command expansion, and disabling the skill
also withholds the directory.

refs #1825

* feat(chat): render visualization references as inline cards

Completed assistant messages should show a referenced visualization as
an interactive card instead of a raw `::visualize{...}` line.

The transcript splits reference lines out before Markdown rendering
and hides an unfinished one while streaming. Each card frames the
fragment from its owning session in a `sandbox="allow-scripts"`
iframe, follows the app theme through postMessage, sizes itself from
the guest, forwards wheel scrolling to the transcript, and can expand
into a focus-trapped dialog. At most eight frames stay live; cards
release their slot off-screen and reload on return. Missing, invalid,
remote, or oversized fragments show a localized unavailable card.

refs #1825

* docs: record inline generative UI visualizations

Captures the decision and its trade-offs so later changes to the
sandbox, storage root, or fork behavior can be reviewed against them.

Adds ADR gen-ui-inline-visualization and D671, the session
visualization directory in the tools-and-permissions and data-storage
specs, the VISUALIZATION_HTML_ONLY error code, the
E2E-GENUI-inline-visualization-card scenario, an unreleased note, and
the matching zh-CN mirrors.

closes #1825

* fix(agent-runtime): emit extension context before every request

Trusted extensions never saw the first provider request of a run: the
context event was emitted from prepareNextTurn, which pi-agent-core
only calls after a completed turn. A reply without tool calls was
never shown to context handlers at all, unlike pi-coding-agent, which
fires the event before every LLM call.

Emit context from transformContext instead, so each request is shaped
once and the replacement stays a per-request view instead of becoming
the run's working context. A runtime test covers the first request,
the follow-up after a tool result, and the persisted transcript.

fixes #1838

* fix(gen-ui): harden contained roots and native fragment serving

Visualization paths must not grant implicit access through redirected
roots or let startup cleanup remove unrelated data. Validate owned roots
at authorization and execution, preserve Windows canonical spelling, and
keep fork references independent of a relocated data prefix.

Serve fragments through pinned native directory capabilities instead of
raceable path snapshots. Enforce response-level sandboxing and one frame
quota, preserving inline state while expanded. Add regression and real
Host/Electron coverage without changing renderer or plugin permissions.

refs #1826

* fix(test): allow standalone host cold-start handshake

The native Host can spend about thirty seconds initializing without an
Electron bridge, so the smoke client's thirty-second handshake races its
own cold start. Match the shared harness's forty-five-second setup budget
while retaining thirty-second deadlines for ordinary requests.

Cover both deadlines with a controlled clock and real NDJSON streams so
candidate smoke validation does not fail before exercising task code.

* fix(composer): drop stale failure restores from superseded submits

restoreDraftForKey guards only an empty slot, so a late accepted=false from an older submit repainted that message, attachments included, once a newer submit's clear had emptied the composer again (#1809). clearDraftForKey already refuses to clear a draft it does not own; give the restore the same staleness guard with a submit epoch, and let only the newest rejection restore.

* fix(composer): track the submit epoch without a renderer-owned ref

remote-unified-chat drives the submit hook outside a renderer, where useRef throws, so the #1809 guard moves into a module-scope Map keyed by draft slot. Per-slot keys also keep two composers (home/docked, plugin panes) from invalidating each other's restores, and the epoch survives re-renders the way a fresh closure could not.

* fix(composer): release stale submit restore ownership

Only the newest unresolved submission may restore a rejected draft. Unique
submission tokens prevent older requests from regaining ownership after
settlement, and completed entries are released so draft slots do not accumulate
in a module-level map.

A controlled async regression test protects the user-visible draft behavior.

* fix(chat): open turn usage popover upward

The capsule sits at the end of a completed turn, where the next answer or the composer follows immediately. Opening the breakdown upward keeps that content visible, so the capsule now uses the placement helper's above-first default and the unused below-preference option is removed.

* fix(gen-ui): establish Windows directory share pins

Attribute-only directory handles do not participate in Windows sharing
accounting, leaving ancestors replaceable during fragment reads. Request
list/read access so the existing no-write/no-delete sharing policy pins
the entire canonical chain for the duration of each read.

Exercise the actual native reparse operation and preexisting mutation
handles, with an unpinned victim control. Correct cmd junction fixture
quoting and DOS spelling so these tests reach the filesystem boundary.

* fix(browser): keep session pages available in the background

Decouple browser execution from side-panel presentation so switching
conversations, collapsing the panel, or reloading the renderer no longer
interrupts a session's page. Preserve trusted session routing and sandbox
boundaries while retaining a hidden native backing host for Chromium.

Cover background input, capture, renderer recovery, and resource cleanup
with regression and native Electron tests.

* fix(gen-ui): bind Windows fragment reads to native directories

Windows permits attribute-only handles to change reparse points despite
read sharing pins, as the real FSCTL control demonstrated. Open canonical
ancestors and every owned child relative to retained native directory
handles so no absolute child path can be redirected during serving.

Keep the successful attribute-junction attack as a regression control at
session, base, and data boundaries. Preserve bounded same-handle reads,
reparse validation, normal ACLs, and the existing private RPC contract.

* fix: preserve explicit model reasoning effort overrides

Let a saved model binding opt into reasoning_effort for compatible gateways
without removing the automatic DeepSeek protection. Preserve the choice
through Host persistence, runtime projection, copying and configuration sync.

Add the tri-state settings control, request-body and persistence regressions,
and real renderer save/reopen coverage across locales and window sizes.

Fixes #1821

* feat(chat): make inline visualizations an opt-in setting

Inline visualization cards were on for every local session, so a model could
write fragments and the transcript would frame them without the user ever
asking for the feature. The new `AppSettings.inlineVisualizations` switch is
absent and false by default, which keeps the transcript unchanged for anyone
who does not opt in.

One helper decides what the process treats as disabled, so the session launch,
the `/` menu, and the on-demand `Skill` tool cannot disagree. While the switch
is off the prompt advertises no visualization directory, the `visualize` skill
is withheld from the catalog, and the renderer still strips reference lines —
a historical reply must not show its absolute path as text — but produces no
card, so nothing is read from disk or framed.

* feat(models): infer custom endpoint auth from optional API keys

Let custom endpoints work without a key instead of requiring a separate
no-auth switch. Preserve stored keys on blank edits and stage explicit
key removal until Save, including anonymous model discovery.

Cover credential transitions and localized setup paths while retaining
the existing host storage and runtime no-auth contracts.

* feat(chat): scope background tasks to their session and share one dock with todo (#1844)

* feat(chat): scope background tasks to their session and share one dock with todo

Background commands belonged to the host, not to the session that started them:
the list was global, the tab was global, and deleting a session left its servers
running. Todo and the task bar were also two stacked surfaces, so a running task
competed with the plan and ask rows for the same vertical space.

Tasks now carry a per-session concurrency limit of 10, refusals name that limit,
a process-wide ceiling stays only as a host safety net, and a spawn is rejected
when its session is already gone. Deleting a session signals its running tasks
through the existing per-session cleanup path; archiving does not, because
archiving is reversible. Both are covered by host tests.

The renderer keeps the wire contract session-agnostic and filters locally, so
switching sessions changes only what is displayed. Todo and tasks share one
container whose header carries the progress summary, the live count, the tab
list and the disclosure: collapsing hides only the panels, so tabs stay
reachable, and a newly started task raises a badge on its tab instead of
stealing the tab. Removal is a view action gated on a terminal state in the
state layer, so a running or still-settling task can never be dropped from the
list while its process lives.

* docs(chat): record the task-dock plan behind the shared container

The layout was settled by building it and clicking through it, so the reasoning
belongs with the change rather than in a chat log. The interactive prototypes
live outside the repository and are linked from the pull request.

The plan records the decisions that shaped the result and the ones rejected
along the way: why the per-session limit is 10 while a process-wide ceiling stays
behind it, why the wire contract keeps returning every task and the renderer
filters, why deletion signals stop while archiving does not, and why the header
became a row of siblings instead of one button.

* docs(chat): point the plan at the uploaded prototype

The prototypes are an uploaded artifact, not repository content, so the plan
must not describe a docs/demos tree that does not exist. It now links the single
chosen page and records which alternatives it beat and why: an inline tab list in
the header won because it is the only form that keeps the tabs reachable while
collapsed without introducing a second header state.

* fix(chat): expand the dock when a collapsed tab is clicked

The container advertised a behaviour it did not implement: clicking a tab
while collapsed only swapped the remembered tab, so the panel stayed
hidden and the click looked like it did nothing (D15). A tab click now
goes through selectTab, which writes the tab and the expansion together,
making the user's intent one operation instead of two that could disagree.

The badge rule also decided visibility with its own copy of the tab logic
and passed a hard 	rue for the todo side, so it could disagree with the
panel actually on screen. Both paths now call the same rule, and the hook
takes the todo side from the container that already reads it.

dock-state.ts had no direct tests, which is how the two defects survived.
It now has a suite covering the collapse-tab rule, the fallback for an
emptied side, and the badge/visibility agreement.

---------

Co-authored-by: v_wyxgwang <v_wyxgwang@tencent.com>

* chore(release): v0.21.0-beta.8 (#1854)

* docs(changelog): note session-scoped tasks and the shared dock

The 0.21.0 catalog is the in-app whats-new source for the beta that ships
these two user-visible changes, so testers can read them without a network
fetch. English stays the source of truth and the other eight catalogs
carry the same two entries in the same position.

* chore(release): v0.21.0-beta.8

* fix(models): place saved-key clear icon beside the input

Keep credential controls on one row instead of spending another row on
key removal. Retain the localized tooltip, accessible name, and existing
staged credential-removal behavior.

Exercise the rendered icon geometry, target size, focus restoration,
and localized setup journeys.

* fix(transcript): continue restored reading ranges automatically (#1850)

Restored reading ranges should preserve their bookmark without leaving
readers behind a manual history paging control. Continue bounded pages
near the viewport boundary and rejoin live output only after the gap closes.

Keep explicit search paging isolated, preserve the viewport and model
input, and cover continuation, retries, and hidden-pane cleanup.

Co-authored-by: Lan <48862574+vastsa@users.noreply.github.com>

* fix(provider): send reasoning_effort to official DeepSeek endpoints (#1849)

* fix(provider): send reasoning_effort to official DeepSeek endpoints

deepseekRequestCompat() forced supportsReasoningEffort off for every
DeepSeek-family Completions row so relays would stop rejecting the field
(#1701 / #1664). Official api.deepseek.com rows document reasoning_effort
next to thinking, so they only ever sent the thinking toggle and low,
high, and max all produced the same request — the selected level was
silently ignored (#1821).

Keep the suppression for non-official endpoints, where the field was
reported rejected, and leave official rows to pi-ai's URL detection. The
per-model supportsReasoningEffort selection added for #1846 still wins in
both directions, so a user can omit the field on an official row too.

Probed api.deepseek.com live with deepseek-flash and deepseek-v4-pro:
every level the runtime can send (low, medium, high, xhigh, max) returns
200 and the request body carries the selected value; off sends only
thinking: {type: "disabled"}. Recorded as D672 amending D389.

refs #1821

* chore(ci): rerun checks after a runner-side Electron E2E hang

The previous run failed only in the trusted-extensions Electron E2E at
scripts/e2e-provider-client-certificate.mjs: the mTLS fixture probe hung
and was killed by SIGKILL after 65s. The same job on main prints the same
"Electron 43 net.fetch failed before HTTP" baseline line and passes, so
the failure is runner-side rather than caused by this branch.

Rerun is not available to this account (workflow_dispatch and run rerun
both require repository admin rights), so this empty commit retriggers
the workflow without changing any content.

* fix(provider): match official DeepSeek hosts precisely

Substring matching can classify a relay as an official DeepSeek endpoint and disable the relay compatibility guard. Match parsed hostnames so only deepseek.com and its subdomains use the official endpoint behavior.

---------

Co-authored-by: Lan <48862574+vastsa@users.noreply.github.com>
Co-authored-by: vastsa <hhxk666@gmail.com>

* fix(chat): 切回缓存会话时保留阅读页,修复误显示【加载后续消息】(#1851) (#1852)

* fix(chat): keep the returned reading page across session revalidation

Switching back to a retained session committed its cached page and then
replaced it with a bounded history read around the bookmark, because the
"bookmark already loaded" short-circuit was gated on selectingSessionId
not matching the target session. During a warm return that condition is
always false, so the 60-row window replaced the 100-row page and painted
the load-later affordance even though no newer row was missing, while
live turns arriving afterwards were not merged.

Publish the retained page as the reading range when it already holds the
bookmark, which keeps the anchor and the reader's offset across the
revalidation swap. Fall back to the bounded read only when the anchor is
outside the loaded page, as ADR 0137 intends.

fixes #1851

* fix(chat): preserve active transcript navigation

Automatic reading position restoration must not replace an explicit search or navigation that already owns the transcript view. Check focused or loading state before accepting a bookmark already present in the current page.

---------

Co-authored-by: vastsa <hhxk666@gmail.com>

* fix(transcript): make automatic folding deterministic

A pointer press or a nested disclosure toggle recorded every enclosing
level's current state as a permanent choice, so automatic folding stopped
applying for the rest of the pane's life and a reveal could pin a level
closed. Activity groups also folded as soon as they stopped owning the
live segment, hiding work mid-run and folding away reasoning the reader
had just asked to see.

Disclosure ownership is split in two: an explicit header or rail choice
stays local to its level, while focus or a selection holds a level and
its ancestors open only until the reader leaves. Group defaults now
follow the turn, and visible reasoning keeps its group open. The
activity issue counter becomes one inline chip instead of an unstyled
span that wrapped its icon and count in narrow bands.

* fix(composer): keep the task dock collapse across session switches

The Todo/background-task container stored its collapse inside the
per-session entry, so every session the user switched to started from
its own default and reopened a container they had just closed.

Move the collapse out of the per-session state: it is one container-wide
condition that survives session switches, while the tab choice and the
badge stay per session. The container now also starts collapsed, so the
composer keeps its room until the user opens it, and the state is no
longer pruned together with dead sessions.

* chore(release): v0.21.0

Promote the 0.21 preview line to a stable desktop release. Align all
workspace version surfaces, refresh the bundled model catalog, and
complete the shipped release notes in every supported locale.

* fix(agent-runtime): do not treat connection abort wording as user Stop (#1865)

A bare abort match classified socket and stream failures such as
"software caused connection abort" and "stream aborted" as
TURN_ABORTED. Those turns skipped provider retry and rendered as a
user Stop.

Message text is a cancellation only for the fixed runtime and
AbortError phrases. Connection-abort wording stays a retriable
NETWORK_ERROR, and stream-abort wording stays a retriable
STREAM_FAILED.

Co-authored-by: xpeng5278-web <330743750+xpeng5278-web@users.noreply.github.com>

* fix(file-manager): theme editor search controls (#1861)

CodeMirror kept light control defaults while the dark file view inherited white text, making find and replace content invisible. Rebuild the vendored view with explicit theme surfaces, ship a bounded hotfix version, and cover the light/dark user path in Electron.

Co-authored-by: vastsa <hhxk666@gmail.com>

---------

Co-authored-by: liubai <liubai0106@hotmail.com>
Co-authored-by: zszz3 <91608029+zszz3@users.noreply.github.com>
Co-authored-by: blackEvil217 <1278844978@qq.com>
Co-authored-by: muzimu217 <muzimu217@users.noreply.github.com>
Co-authored-by: CJLY-0915 <CJLY-0915@users.noreply.github.com>
Co-authored-by: spacker-343 <67576022+spacker-343@users.noreply.github.com>
Co-authored-by: v_wyxgwang <v_wyxgwang@tencent.com>
Co-authored-by: luoshang <luoshang@local>
Co-authored-by: Lan <48862574+vastsa@users.noreply.github.com>
Co-authored-by: vastsa <hhxk666@gmail.com>
Co-authored-by: liuzixiang <zixiang326@163.com>
Co-authored-by: xieqiying <xiexiexienila@outlook.com>
Co-authored-by: Xuese <3275996223@qq.com>
Co-authored-by: TioitWang <104554654+Tioit-Wang@users.noreply.github.com>
Co-authored-by: Seraph-x <zzs012310@163.com>
Co-authored-by: Tony <13052086+AuhNijGnahz@users.noreply.github.com>
Co-authored-by: new-Beginner <100430809+new-Beginner@users.noreply.github.com>
Co-authored-by: xpeng5278-web <xpeng5278@gmail.com>
Co-authored-by: xpeng5278-web <330743750+xpeng5278-web@users.noreply.github.com>
Co-authored-by: Tony <zhangjinhua@fastmail.com>
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.

2 participants