* perf(hub): gzip the SSE stream without delaying delivery
SSE payloads are plain JSON that repeat the same field names on every
event, so they compress well - measured 72-77% on real captured traffic
from a hub with 15 active sessions.
Compression could not simply be turned on, though. Hono's compress()
middleware bails out whenever Transfer-Encoding is set, which streamSSE
always sets, so mounting it is a no-op. Wrapping the body in a
CompressionStream does compress, but it buffers until the stream ends -
measured on a 10-event stream, every event arrived at once when the
stream closed. On a connection that stays open for hours that means
events never arrive at all.
So drive zlib directly and issue a Z_SYNC_FLUSH after each chunk. That
costs about one percentage point of ratio and keeps delivery immediate:
verified in a real Chromium EventSource, first event at 13ms and each
subsequent event at its own 500ms tick, with no error events.
Clients that do not send Accept-Encoding: gzip keep the uncompressed
stream. No event payload or timing changes.
* fix(hub): cancel through the reader, gate reads on demand, honour q=0
Three defects in the first version of the SSE gzip wrapper:
Cancelling the source directly threw. The wrapper holds a reader for the
whole life of the connection, and cancelling a locked stream is invalid -
in Bun it throws TypeError: Invalid state: ReadableStream is locked
synchronously out of the cancel callback. Since SSE clients disconnect
mid-stream as a matter of course, this fired on essentially every
disconnect, and the upstream cancel never ran. Cancel through the reader
instead, which is allowed to.
Reads were not gated on downstream demand. Only zlib's own buffer was
consulted, and SSE compresses well enough that a slow client can be
megabytes behind while the compressed queue still looks nearly empty: a
test with a non-reading consumer pulled 1752 chunks before stalling.
Reading now waits for desiredSize to go positive, resumed from pull().
Accept-Encoding was matched with a substring test, so "gzip;q=0" - which
means the client refuses gzip - was read as acceptance. Parse the q-value.
Re-verified that none of this costs the property the change exists for:
in a real Chromium EventSource the first event still arrives at 13ms and
each one after it on its own 500ms tick, with no error events.
* feat(cli): allocate a loopback port for the OpenCode ACP subprocess
opencode does not announce which port it bound when launched with
--port 0 (verified: not present in stdout/stderr even at DEBUG level),
so pick a free loopback port ourselves and pass it explicitly via
--port/--hostname. This makes the ACP subprocess's internal HTTP API
reachable at a known baseUrl for follow-up work (native /compact
bridging).
* feat(cli): add REST bridge for OpenCode native session compaction
opencode's ACP method table has no session/compact RPC, but the
opencode acp subprocess also runs an internal HTTP API. That API's
POST /api/session/:id/compact route is an unimplemented v2 stub
(always 503); the route that actually performs native AI compaction
is the legacy POST /session/:id/summarize, which requires providerID
and modelID in its payload. This adds a small client for that route
plus a helper to split ACP's combined "provider/model" wire id.
Not wired into the slash-command flow yet.
* feat(cli): trigger native OpenCode compaction from /compact
Wires the REST bridge into the OpenCode slash-command flow so /compact
performs real context compaction instead of returning a "not yet
supported" message.
- slashCommands.ts: /compact now resolves to its own `kind: 'compact'`
(the synchronous 'handled' shape can't carry an async REST round
trip). /clear is unchanged.
- opencodeRemoteLauncher.ts: registers a compact trigger callback once
the ACP backend + internal HTTP baseUrl are ready, reading the
session's current provider/model on every call so it reflects
inline model switches. A `runExclusive` promise-chain mutex
serializes the compact trigger against `backend.prompt()` so a
still-in-flight prompt and a `/compact` sent moments later can't
race against the same OpenCode session concurrently, in either
arrival order.
- runOpencode.ts: on /compact, emits "Compaction started" as a session
event (the same `sendSessionEvent({ type: 'message', ... })` status-
line channel Claude/Codex already use for compaction and other
transient state, rather than a chat message), awaits the bridge with
no artificial timeout (compaction on a reasoning model can take
90s+), then emits "Compaction completed" or "Compaction failed:
<reason>" the same way. Falls back to the previous not-yet-supported
chat message when no trigger is registered (local mode has no ACP
backend, so this is unreachable there today). If the user cancels
the message while compaction is queued or in flight, the eventual
result is suppressed instead of surfacing a stray status message
for an action the user already considered cancelled (aborting the
in-flight HTTP call itself is left as follow-up scope).
- help text now notes /compact is remote-sessions only, since local
mode has no ACP backend to bridge through.
Also fixes a runOpencode.test.ts fixture gap: the mocked
OpencodeSession was missing onThinkingChange, so any exception in that
call path was silently swallowed by the handler's outer catch instead
of failing the test.
* feat(cli): show compaction summary as a reasoning block
After a native OpenCode compaction succeeds, OpenCode's session
history gains a `role: "user"` marker message (a `{type:"compaction"}`
part with no text) followed by a normal assistant message holding the
actual generated summary in a `text` part. Left alone, neither is
visible in HAPI — the bridge only checked success/failure and never
looked at the resulting messages.
- opencodeCompactBridge.ts: `fetchCompactionSummary()` does one more
GET against the session's message list after a successful compact,
finds the most recent `type:"compaction"` marker, and extracts the
following assistant message's text — preferring a match via the
marker's `parentID` (order-safe) and falling back to simple array
adjacency, both gated on `role === 'assistant'` so an unrelated
same-shaped sibling can't be silently misattributed as the summary.
Concatenates every `text` part in case a summary spans more than
one. Never throws: any unexpected shape or request failure just
yields "not found" so the caller can skip showing anything.
- opencodeRemoteLauncher.ts / runOpencode.ts: on a successful compact,
forward the extracted summary as a `{ type: 'reasoning', text, id }`
AgentMessage through the same `handleAgentMessage()` /
`convertAgentMessage()` path OpenCode's own live thought-chunk
streaming already uses, so it renders in the existing collapsible
"Reasoning" block — no new UI component or schema field. The
`role:"user"` marker is never constructed or forwarded at all, so
there's nothing to filter (unlike Claude's compact summary, which
is hidden after the fact via an `isCompactSummary` flag).
* fix(cli): disable Bun's hardcoded fetch timeout for OpenCode compaction
Isolated E2E against a real OpenCode session showed compaction always
failing with "Compaction failed: The operation timed out." after
~250s, even though session/prompt-style unlimited waits were intended.
Root cause: Bun's global fetch() hardcodes an internal ~5 minute
timeout that fires independently of any AbortSignal (or its absence) —
see oven-sh/bun#16682. The only documented workaround is passing the
non-standard `timeout: false` fetch option that Bun itself recognizes.
Cast through a local `BunFetchInit = RequestInit & { timeout?: false }`
type alias rather than `Record<string, unknown>`, so the option stays
structurally checked against the rest of the fetch call's shape.
* fix(cli): serialize /compact through the message queue instead of a mutex
HAPI Bot flagged two Major correctness issues on the PR:
- `/compact` bypassed `MessageQueue2` and entered a `runExclusive` mutex
directly from the user-message handler. If prompt A was in flight and
prompt B already queued behind it, `/compact` sent afterward could
still reach the mutex before B did, running compaction ahead of an
earlier-queued user prompt.
- `compactTriggerRef` (a closure capturing the remote backend) was
never cleared on a remote→local handoff, so `/compact` typed after
switching to local mode could call a stale closure targeting an
already-disposed backend instead of the intended local-mode fallback.
Removes the mutex entirely and routes `/compact` through the same
`messageQueue` regular prompts use (a new `operation?: 'compact'` field
on `OpencodeMode`, dequeued by the launcher's single sequential
consumer loop, which branches to a new `runCompactOperation()` instead
of `backend.prompt()`). Ordering is now enforced by construction (one
queue item in flight at a time) rather than a second bolted-on lock.
Replaces the closure-based `onCompactTriggerReady` callback with a
boolean-flag `onCompactAvailabilityChange`, reset to `false` on every
entry into local mode (cold-start and handoff alike) before the remote
backend is even disposed, so there is no window where the flag says
available but the backend underneath it is gone.
* fix(cli): let /compact's cancel ack fire at dequeue time, not on queue
A follow-up HAPI Bot review caught a Major issue this PR's queue-based
/compact serialization (previous commit) left open: runOpencode.ts
still called session.emitMessagesConsumed manually and synchronously
the instant /compact was pushed onto the queue -- a leftover from
before /compact was routed through the queue at all. That beat
MessageQueue2's automatic dequeue-time ack (onBatchConsumed, wired in
sessionBase.ts, already used by every regular prompt) to the hub, so a
/compact sitting behind other queued prompts was marked "invoked"
immediately and could never actually be cancelled from the UI.
Removes the manual ack; the queue's own dequeue-time ack now covers
/compact exactly like any other queued operation. Adds a deterministic
test for the reported scenario: prompt A generating, /compact queued
behind it, cancelled before A finishes -- the compact REST bridge must
never be called.
* fix(cli): suppress live ACP updates while a compact REST call runs
Found via live use: OpenCode keeps streaming session/update
notifications (thought chunks, etc.) over the ACP transport while
/compact's REST call runs outside prompt(), and
AcpSdkBackend.handleSessionUpdate forwarded them unconditionally to
whatever messageHandler was still installed from the last real prompt
turn -- rendering the compaction summary a second time as a plain
assistant message, alongside the explicit Reasoning block this PR
already sends for the same content.
Adds a narrow, opt-in AcpSdkBackend.suppressUpdatesDuring() (shared
with Gemini, but a pure addition with no behavior change for existing
prompt() callers) that temporarily swaps out the message handler for
the duration of an async callback, restoring it afterward. Wraps the
compact bridge's REST call with it so the live stream produces no
output during that window -- the Reasoning block built from the
explicit GET response becomes the only place the summary appears.
* fix(cli): let Stop/switch-to-local interrupt an in-flight /compact REST call
triggerOpencodeCompact's HTTP request is intentionally unbounded (a
real compaction can take minutes), but the launcher awaited it with no
way to interrupt it once dequeued and running. handleAbort() only
cancels the ACP prompt() turn and resets the queue -- neither touches
this raw HTTP call -- so Stop (and switch-to-local, which routes
through the same handler) stayed blocked until the request settled on
its own: a stale "Compaction completed/failed" could still surface
afterward, queued prompts behind it were delayed, and remote->local
handoff couldn't proceed.
Add a per-call AbortController (compactAbortController) that
handleAbort() aborts before cancelling the prompt, and thread it
through triggerOpencodeCompact's new optional `signal` (kept separate
from the existing timeout:false Bun workaround, which stays
unconditional). An aborted call now folds into the same isCancelled()
check that already suppresses a stale result for the existing
queue-cancel race, so either kind of interruption behaves the same
way.
* fix(cli): structurally close remaining /compact abort/lifecycle races
Two more narrow races surfaced in review, both symptoms of ad hoc
per-site fixes rather than a shared mechanism:
1. The signal threaded through triggerOpencodeCompact (the POST) did
not also reach fetchCompactionSummary (the GET runCompactOperation
makes right after it), so Stop/switch-to-local could still block on
that call alone. Introduce OpencodeCompactCallOpts with `signal`
required (not optional) so every HTTP step opencodeCompactBridge.ts
makes on behalf of one /compact operation is forced by the compiler
to accept it, not left to remembering to wire it in per call site.
2. /compact availability (runOpencode.ts's compactSupported flag) was
only reset to false on the *next* local-mode entry (loop.ts's
runLocal callback), leaving a window between "switch/exit was
requested" and "local mode actually started" where a /compact
arriving mid-transition could still queue, and -- since local mode
hands straight back to remote when it finds a non-empty queue --
run anyway despite the user having already left remote mode. Add an
onLeavingRemote() hook to RemoteLauncherBase (no-op default, so the
other six flavors built on it are unaffected) called synchronously
as the very first action of requestExit() -- closing the race at
its source -- and again unconditionally in start()'s finally block
as a backstop for exit paths that never call requestExit() at all
(an exception thrown from runMainLoop, for instance).
OpencodeRemoteLauncher overrides it to flip availability false;
loop.ts's local-entry reset is removed as redundant now that
leaving remote is what's authoritative.
* fix(cli): create compactAbortController before the inline model/effort switch
A hostile-review whole-feature sweep of the /compact abort/lifecycle
surface (round 5) found that the dequeue loop applied the per-batch
inline model/effort switch (real async ACP round-trips that yield to
the event loop) *before* branching into runCompactOperation(), which
is where compactAbortController used to get created. An abort firing
during that switch hit a still-null controller (a no-op), and by the
time the switch resolved and runCompactOperation() created a fresh
one, the abort was forgotten -- the compact's unbounded REST call
would then run to completion despite the user having already pressed
Stop/switch/exit.
Move controller creation to the top of the loop iteration, as soon as
the batch is known to be a compact operation, and pass it into
runCompactOperation() rather than having that function create its
own. Also: soften an overstated doc comment about what the required
`signal` field actually guarantees, and add a regression test locking
in that two sequential compact operations each get an independent
controller (no leak or cross-clearing).
* fix(cli): drain quietly before restoring the handler in suppressUpdatesDuring
A 5th PR-review round found that suppressUpdatesDuring restored the
suppressed messageHandler the instant its callback settled, but
aborting the client-side HTTP call (e.g. OpenCode's compact bridge
aborting via compactAbortController) does not necessarily stop the
agent from continuing that operation server-side -- session/update is
a separate notification channel from the HTTP request's lifecycle.
A late straggler notification from a still-running server-side
operation could leak straight into the restored handler (or into a
new one prompt() installs right after).
Reuse the same quiet-drain prompt() already runs before installing a
new handler for the next turn (waitForSessionUpdateQuiet with the
PRE_PROMPT_* constants) -- same class of race, same validated
mechanism, just on the way back in instead of the way out. No new
state or Stop-vs-switch branching: the wait is internal to
suppressUpdatesDuring and the handler stays suppressed throughout it.
* fix(cli): queue /compact during remote-mode initialization instead of rejecting it
compactSupported alone conflated two different situations: a
genuinely local-mode session (compact fundamentally can't run) versus
a session that's already in remote mode but hasn't finished ACP
initialize + session load/new yet (onCompactAvailabilityChange(true)
hasn't fired yet, but will shortly). A regular prompt sent in that
same startup window queues normally and just waits its turn; /compact
sent in the identical window instead got an immediate
not-yet-supported reply.
Check sessionWrapperRef.current?.mode (the actual OpencodeSession
instance's mode, synced synchronously by onModeChange before either
launcher starts) alongside compactSupported: only genuinely local mode
now gets the not-yet-supported reply. A session already in remote mode
but still initializing queues /compact exactly like a prompt and lets
it settle into its real FIFO position once the launcher is ready.
* fix(cli): don't let the remote-init /compact queuing fix reopen the teardown race
A hostile-review whole-feature sweep found that gating /compact
queuing on sessionWrapperRef.current?.mode alone (26344118) fixed the
startup window but silently reopened the exact race
OpencodeRemoteLauncher's onLeavingRemote() exists to close: mode stays
'remote' for the entire teardown window too (it only flips back to
'local' once runMainLoop() fully unwinds), so a /compact arriving
after switch/exit was requested -- while compactSupported has already
gone false -- queued anyway instead of getting rejected, and could
still run once local mode bounced back to remote to drain a non-empty
queue.
Add compactTeardownInProgress, true from the moment
onCompactAvailabilityChange(false) fires (which -- since the old
reset-on-local-entry was removed -- only ever means "leaving remote",
never "not ready yet") until the session next re-enters remote mode.
Wrap the onModeChange callback opencodeLoop already receives to reset
it back to false on that re-entry. The existing "stops queuing /compact
once availability is reset to false" test didn't catch this because
its mock never set mode to 'remote' during the simulated teardown,
unlike real production timing -- fixed to match.
* fix(cli): keep the dequeue loop blocked on a compact until it really finishes on plain Stop
A 6th PR-review round rejected the quiet-drain mitigation from the
previous round (bounded at ~1.2s) and asked for the originally
proposed fix instead: quiet-drain cannot guarantee a multi-minute
server-side compaction has actually finished, since session/update is
a separate notification channel from the aborted HTTP request's
lifecycle. Unconditionally aborting compactAbortController on any
abort (the previous fix) let the dequeue loop move on to the next
queued prompt while the agent could still be compacting the same
OpenCode session server-side -- breaking the core invariant this
feature's whole queue-based redesign depends on: compact and a prompt
must never touch the same session concurrently.
handleAbort() now takes a leavingRemote parameter. Plain Stop
(leavingRemote=false, the default) only sets compactResultSuppressed
-- the eventual result gets hidden, but compactAbortController is left
alone, so runCompactOperation()'s own awaits keep blocking the dequeue
loop until the real HTTP response arrives, i.e. until the server
actually finishes. Switch-to-local/exit (leavingRemote=true) still
abort the controller for real, since cleanup() disconnects the whole
ACP subprocess right after regardless -- there's no shared session left
to protect there, and the responsiveness fixed in an earlier round
still matters for that path.
The quiet-drain from the previous round (AcpSdkBackend.suppressUpdatesDuring)
is left in place -- it still helps for a compaction that finishes
quickly and for trailing session/update stragglers right after a real
completion, it's just no longer the thing plain Stop relies on for
correctness.
* fix(cli): close 3 gaps a hostile-review pass expected the next bot round to flag
Pre-emptively addresses findings a hostile-review final pass on
65729bb7 judged likely for the next external bot round, since a
communication round-trip costs more than fixing them now:
1. No dedicated test existed for the Stop/switch-to-local RPC-overlap
message-ordering fix (handleAbort() re-reading compactAbortController's
abort state instead of a stale snapshot). The test harness has no way
to observe MessageBuffer/Ink content, so the decision logic that
picks the status message and whether to clear `thinking` is extracted
into a pure, exported selectAbortStatusMessage() function and unit
tested directly against all four (hasCompactInFlight, leavingRemote,
compactAborted) combinations, including the exact overlap case.
2. No test verified compactResultSuppressed doesn't leak between two
sequential compact operations. Added a regression test: a Stop-suppressed
compact #1 finishing for real must not silence a normally-completed
compact #2 queued after it.
3. The "Stop requested — waiting..." status message didn't tell the user
how to actually leave the session (switch-to-local/exit) while a
compaction is deliberately left running. Appended that guidance.
* fix(cli): skip a compact cancelled before its request ever went out
A plain Stop landing during the inline model/effort switch that
precedes a compact batch only set compactResultSuppressed; it never
stopped the dequeue loop from calling runCompactOperation()
unconditionally once that switch resolved, so a cancelled-before-start
compact would still fire a brand new REST request and block the loop
for however long that call takes.
Skip starting the operation when compactResultSuppressed is set and
the controller was never actually aborted (plain Stop leaves the
signal alone by design). Deliberately excludes the
compactAbortController.signal.aborted case (switch/exit) and
isLocalIdCancelled: both must keep falling through to
runCompactOperation() as before, per Round 5's and the
isLocalIdCancelled suite's existing coverage.
* fix(cli): also skip a compact cancelled-before-start via isLocalIdCancelled
Round 7's pre-start skip check only covered compactResultSuppressed
(plain Stop), deliberately leaving isLocalIdCancelled out so as not
to disturb the "Compaction started is never suppressed" tests that
predated that check. But isLocalIdCancelled's backing Set can only
ever be populated during the brief ack-vs-hub-DB-write race before a
queued item's REST call is sent, never while it's actually running
(see runOpencode.ts's cancelledBeforeEnqueue doc comment) — so a true
result here unconditionally means the same "cancelled before it ever
went out" situation Round 7 already handles for plain Stop, and
deserves the same treatment: skip starting the operation instead of
sending "Compaction started" for a request already known to be
discarded.
Updates the two tests that had encoded the old "started is never
suppressed" behavior for this specific signal to their corrected
expectation, and removes a no-longer-consumed mockImplementationOnce
that would otherwise have leaked its failure response into the next
test that actually calls the REST bridge.
* fix(cli): don't resurrect /compact availability after a startup-time switch/exit
onCompactAvailabilityChange(true) fired unconditionally right after
newSession/loadSession resolved, with no way to know a terminal
switch-to-local/exit had already run during that pending ACP round
trip (RemoteLauncherBase.requestExit() sets shouldExit synchronously
before awaiting its handler, so the flag is already accurate at that
point). runOpencode.ts's compactSupported/compactTeardownInProgress
gate treats compactSupported flipping true as reason enough to ignore
compactTeardownInProgress entirely, so this belated true could let a
/compact arriving right after slip into the queue mid-teardown.
Guard the call with the same shouldExit flag requestExit() already
set. The race can only be reached via the terminal UI's onExit/
onSwitchToLocal callbacks (wired up before runMainLoop starts, well
before the RPC 'abort'/'switch' handlers exist), so the regression
test forces isTTY and captures ink's render() props to invoke them
directly.
* fix(cli): clear the hub's queued-thinking grace when a compact is skipped pre-start
The cancelledBeforeStart skip path never calls session.onThinkingChange(true)
(the whole point of skipping), but also never told the hub the queued
item was done. The hub's 15s queued-thinking grace (markMessageQueued,
sessionCache.ts) keeps thinking pinned true regardless of keepalives
until a messages-consumed ack with clearQueuedThinkingGrace arrives,
so the web UI spinner could sit stuck for the full grace window.
Applies the same pattern already used by runOpencode.ts's synchronous
slash.kind === 'handled' path (e.g. /model): an emitMessagesConsumed
ack with clearQueuedThinkingGrace, then an immediate thinking=false
keepalive. This is additive to, not a replacement for, the queue's own
unflagged onBatchConsumed ack — a second ack for an already-invoked
localId is a no-op on the hub's first-write-wins protocol, and
clearQueuedThinkingGrace is keyed by session, not localId, so both are
idempotent.
Replace the always-visible wrapping chip row below the md breakpoint with a
filter icon button in the session list header (right side, next to the new
session button). The button opens a radio menu with per-machine counts and
an inline health summary, shows an active-filter dot, clamps to the
remaining viewport/safe-area space, and supports Escape/Arrow-key
navigation with focus restore. Desktop keeps the one-tap chip bar.
Remove the refresh icon button from the session list toolbar and make
the list itself the refresh affordance via a touch pull gesture.
Gesture (SessionList):
- Touch listeners on the scrollable list container; pull engages only
at scrollTop 0, with 16px feedback / 64px trigger thresholds, and
fires on release past the trigger. Mirrors the established
pull-to-load-older pattern in HappyThread.
- Touch-only by design: desktop has no overscroll bounce, so a wheel
pull feels broken; desktop keeps relying on SSE live updates and
query focus refetch.
- onRefresh widened to () => Promise<unknown> | void so the indicator
tracks the in-flight refetch and ignores re-entrant pulls.
Feedback:
- Status pill over the list (role=status, aria-live) shows
pull/release/refreshing states with a spinner while refreshing; it
also covers the initial useSessions load (isLoading), which lost its
only busy indication when the toolbar button was removed.
- The success toast is dropped (the pill is the feedback); the failure
toast is kept. handleRefresh now returns its promise.
Empty states (review P2s):
- SessionsEmptyState and the no-results message move from the shrink-0
header container into the scroll container, so the gesture works on
the visible empty state (retry path after a failed initial fetch)
and short viewports scroll instead of crushing the gesture area.
- SessionsEmptyState is gated on !isLoading so a slow initial request
no longer flashes the final empty state with active actions.
i18n: add sessions.refresh.pull/release/refreshing (en + zh-CN),
remove now-unused button.refresh and sessions.refresh.success.*.
Desktop wheel pull was implemented and then reverted after review.
* refactor(web): pin abort button to end of composer left cluster
Abort's position shifted with conditional siblings (terminal/switch/
schedule), making the destructive action's location unpredictable.
Move it to the last child of the left button group so it's always
immediately left of Send — pure JSX reorder, no markup/props/handler
changes.
* fix(web): keep abort last in default toolbar
Keep the product default predictable without changing saved custom order or the registry used to append missing items.
* fix(web): show Auto instead of Default for Cursor model picker
Cursor CLI uses `auto` for automatic model selection; labeling it
"Default" in HAPI was confusing and inconsistent with `agent --list-models`.
Fixes#1247
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): align Cursor unavailable copy with Auto label
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: re-trigger Codex PR review
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(web): update Cursor catalog JSDoc for Auto label
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): remap stale Cursor grok wires on ACP resume (#1270)
When hub sessions still store legacy grok-4.5[fast=…] wires, remap to live
cursor-grok-4.5-* catalog ids before spawn and retry once on model_not_found.
Keeps #1198 honest errors when remap cannot find a candidate.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): address HAPI bot review on grok wire remap (#1271)
Stop Available-models parsing at newline/Tip; remap legacy wires even when
stale id remains in mixed availableModels+cliModelSkus cache.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(shared): rank catalog SKUs by fast hint before effort score
When medium-fast is absent, grok-4.5[fast=true] must not lose to slow
medium just because default effort scoring double-counts medium.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): stderr remap fallback + queued model sync (#1271)
Retry model_not_found remaps on the original legacy wire when cache
pre-resolution picked a stale SKU; enqueue user turns from session model.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(shared): reject unavailable SKU variants without ACP wires
matchCliSkuToAcpWireId no longer nearest-matches same-base CLI SKUs
when no wire exists; legacy grok remap stays on remapStaleCursorModelId.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): suppress transient model rejection on remap retry
Defer surfacing Cannot use this model stderr until initialize/load
retry fails; success path no longer shows a false error in chat.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Move the session-list search out of its dedicated full-width row and
into the sidebar toolbar, so the sidebar gets one compact header row
instead of two stacked rows.
Behavior:
- Collapsed by default: a search icon sits at the left of the toolbar
row, on the same line as the existing tool icons (codex import,
refresh, browse, settings, new session).
- Clicking the icon expands the input to fill the entire row width
(tool icons hide) and focuses the input; when focus leaves the
search container the input collapses back to the icon.
- Query and date range stay applied while collapsed; the icon shows
an indicator dot whenever any filter is active.
- The date-range picker stays inside the expanded input.
Implementation:
- SessionList gains a headerActions prop and renders a single header
row: [collapsible search] [spacer] [optional renderHeader plus]
[headerActions].
- SessionsPage drops its own toolbar wrapper and passes its five
buttons through headerActions instead.
- The safe-area top inset moves to the router container wrapping both
the error banner and the list, so iOS PWA error text no longer sits
beneath the status bar/notch.
Focus handling (review follow-ups):
- Controls that unmount themselves on click (clear-query X, picker
backdrop, range-end selection, picker footer Clear) silently moved
focus to <body>, leaving the search expanded but no longer
collapsible via blur. Each now returns focus to the input; the
picker footer Clear is wired through a dedicated onClear prop.
- Header actions can no longer be suppressed by a stale expanded
state: they render whenever the search control itself is absent
(e.g. the session list empties via SSE), and the expansion resets
when the list becomes empty.
i18n: add sessions.search.open (en / zh-CN).
Tests: adapt existing search/date-filter tests to expand the search
before interacting; add regressions for collapse-on-blur query
persistence, focus restore after clearing the query and after
clearing the date range, and header actions surviving an emptied
session list.
* feat(web): feature-flagged rich composer for inline session @ mentions
Custom segmented contenteditable (not TipTap) inserts caret-local session
atoms from the existing @ picker and serializes to markdown links on send.
Textarea path remains default until flag parity dogfood.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): rich composer mention boundary + #1215 refs
Treat U+FFFC mirror atoms as word boundaries so @ after a session
token still opens autocomplete. Point comments at Fixes#1215.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(web): peer-stack e2e for rich composer session @ mentions (#1215)
Smoke: flag on, @ picker inserts inline session atom chip (not prose dump).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): preserve newlines in rich composer Enter-newline mode
Chromium splits contenteditable on Enter into block divs; serialize those
as \\n and insert <br> when parent leaves Enter unhandled (Shift+Enter /
enter-inserts-newline).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): show @ badge when rich composer mentions flag is on
Dogfood was invisible: flag-off looks like a normal textarea, and flag-on
had no chrome. Surface a small @ badge when enabled.
* fix(web): rich session composer on by default (not a user setting)
The plan dual-path was an engineering kill-switch, not an opt-in. Default
to the segmented composer; only richMentions=0 disables. Drop the flag
badge and record a peer-stack motion proof covering chips + baseline UX.
* fix(web): make rich composer Shift+Enter create a visible newline
Trailing <br>+empty text node was a silent no-op at EOL. Use
insertLineBreak (ZWSP pad fallback), assert real \\n in peer e2e.
* feat(web): hover tooltips on rich composer session chips
Show full title, status, short id, and path on chip hover via a portal
bubble fed by live useSessions lookup (drafts fall back to title + id).
* fix(web): dismiss rich composer chip tooltips on mouse leave
contenteditable pointerout/relatedTarget was flaky so tips stuck after
leaving the chip. Hit-test on pointermove, clear on prose/input/leave.
* fix(web): address cold-review Blocker/Majors on rich composer
Exclude peer e2e from default Playwright; force plain-text paste; restore
newline hard-stop in findActiveWord; fix root-anchored selection mapping
and nested-block serialize; cover with unit tests.
* chore: drop accidental .cursor files from rich-composer tip
* fix(web): close remaining cold-review gaps on rich composer
Drop absolute peer e2e tooling imports, prove chip→markdown send, and
harden paste/EOL/focus/tooltip/Enter edges before Meta rematerialize.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: absorb soup playwright.config union for clean remat
Keep fork peer-stack timeouts/annotated-video wiring and add testIgnore
for e2e/peer so the next driver rematerialize does not conflict.
Co-authored-by: Cursor <cursoragent@cursor.com>
* revert: drop fork playwright tooling from upstreamable tip
Peer-stack annotated-video + HAPI_PEER wiring stay on fork main / soup.
Product tip only needs testIgnore for e2e/peer (see docs/tooling/peer-stack.md).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): fix rich composer Shift+Enter double newline and paste space
Prefer manual newline+pad over execCommand insertLineBreak, and stop
applying autocomplete trailing-space on paste/drop paths.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): pad EOL Shift+Enter after Range.insertNode split
insertNode always leaves an empty text sibling, so !nextSibling never
saw EOL; detect meaningful trailing content and cover with jsdom tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): drop custom onDrop from rich composer
Intercepting drop without caretRangeFromPoint landed text at EOF or
no-oped in-editor moves. Native CE drop is enough for #1215; paste
still forces plain text.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): sidebar-parity tooltips on rich composer session chips
Reuse SessionRowSummary (flavor, thinking/attention, schedule, todos,
relative ago, path) for chip hover so the tip matches the session list.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: keep peer-stack e2e off the upstreamable tip
Peer specs and playwright.peer.config stay on fork main per
docs/tooling/peer-stack.md; default config still testIgnore's e2e/peer.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: cite sessions with UUID wire + inspect_peer for agent/overseer
Rich composer chips already serialize to [title](/sessions/<id>); flush
before send so the agent prompt never gets title-only chip text. Add
inspect_peer (MCP + hapi inspect-peer) as the read twin of ping_peer so
that same id is immediately usable for overseer/agent peer lookup, with
system-prompt glue from citations to inspect/ping.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): gate inspect_peer behind permission approval
Cross-session history reads need the same prompt path as ping_peer:
keep inspect_peer off Claude --allowedTools and treat it as sensitive
in ACP/OpenCode read-only mode so prompt injection cannot silently
enumerate peer transcripts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: clarify playwright peer testIgnore is upstream-safe
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): keep session UUIDs on rich composer copy/cut/paste
Copy/cut write wire markdown so chips do not collapse to @title-only
clipboard text; paste reparses session links back into atoms.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Follow-up to #1268. The per-file `mkdtempSync`/`join(tmpdir(), ...)` HAPI
homes in the cursor-models test cluster were never removed, so every run
leaked a directory into the system temp dir (afterEach only cleared the
cache file inside them). Save/restore HAPI_HOME and recursively remove
each per-file temp root in teardown across all three cursor-models test
files (cursorModels, cursorModelsSharedCache, and the stale-lock test that
had the same pattern). No source/runtime change.
Co-authored-by: Cursor <cursoragent@cursor.com>
The four cursorModels* CLI test files share one on-disk cache path
($HAPI_HOME/cache/cursor-models.json, defaulting to /tmp/hapi when
HAPI_HOME is unset). Two files already isolate it (cursorModelsStaleLock
via a PID-namespaced home; handlers/cursorModels via a unique temp home),
but cursorModels.test.ts and cursorModelsSharedCache.test.ts do not.
Under vitest's parallel file execution, cursorModelsSharedCache's
afterEach(_resetSharedCursorModelsCacheForTests) rmSyncs that shared file
between the other file's write and read, so the read returns null and
"inherits cliModelSkus from shared cache" fails with
`expected undefined to deeply equal [...]`. Passes in isolation; fails at
random in the full parallel suite.
Give both un-isolated files their own mkdtemp HAPI_HOME at module load so
each test file's cache path is unique regardless of worker-process reuse.
Fixesheavygee/hapi#101
Co-authored-by: Cursor <cursoragent@cursor.com>
Remove the top-right Loading messages pill shown while tail reconciliation runs with cached conversation content.
Keep background tail synchronization silent so it does not compete with older-history loading feedback or imply that the visible conversation is blocked. Cold-start skeletons, pull-to-load guidance, older-page loading state, unseen-message navigation, and global reconnect feedback remain unchanged.
* fix(web): count unseen messages by rendered block, not raw message
The "N new messages" pill counted raw DecryptedMessages while the
timeline renders folded blocks, so the two never agreed. A subagent run
is dozens of sidechain messages but a single Task card; a tool_use and
its tool_result are two messages and one card; consecutive tools collapse
into one group. The pill could read "47 new messages" when scrolling down
revealed two new rows.
collectNewUnseenIds never inspected isSidechain, and it could not: the
reducer's grouping is stateful (it needs the Task tool_use before it can
map parentToolUseId), so a per-message predicate in the store cannot
reproduce it. Adding an isSidechain check there would also invert the
error for orphan sidechain messages, which tracer.ts falls back to
emitting at the top level.
Instead, drop the store's unseen bookkeeping entirely and count what the
renderer actually produced. Watermark the visible blocks when the user
scrolls away from the tail, then count the blocks past the last one they
had seen.
The count is anchor-based rather than timestamp-based because the blocks
array is not monotonic in createdAt: messages sort by invokedAt ??
createdAt, so a queued message carries an old createdAt while sitting at
the end. Anchoring also makes prepended history free, since older blocks
land before the anchor.
Known limit, documented at the call site: once the history window fills
up, mergeIntoWindow trims incoming messages off the tail, so the pill
reports 0 instead of a count. Under-reporting is preferable here, and
returning to the tail force-refetches the latest page anyway.
* fix(web): keep unseen watermark stable across optimistic id replacement
The watermark snapshotted only block.id, but that id is not stable for
the user's own messages: mergeMessages replaces an optimistic row with a
stored row that keeps localId under a new server id, and the user block
renders with the message id. Scrolling into history while an own message
was still optimistic meant its echo anchored one block earlier and bumped
the pill by one, with no new rendered row.
Track localId alongside id in the watermark and match on either.
Reported by HAPI Bot on #1255.
* fix(web): count joined assistant cards, not pre-join blocks
visibleBlocks is still not one-to-one with rendered rows: assistant-ui
joins a run of adjacent assistant-role blocks into a single card, so a
response made of reasoning + text + a tool call was reported as three new
messages instead of one, and appending another block to an in-flight
response bumped the pill without adding a row.
Walk the blocks after the anchor and only start a new row where the
assistant run breaks.
Role assignment is the part that would drift, so rather than restating it,
visibleBlockRole moves from assistant-runtime.ts to toolGroups.ts (next to
the VisibleChatBlock definition it describes) and both the runtime and the
counter import the one copy.
Reported by HAPI Bot on #1255.
* fix(web): exclude subagent usage from the parent context indicator
The status bar's `ctx N/M` and `cache N` come from latestUsage, which
scans the normalized messages backwards for the most recent usage. That
scan includes sidechain messages, so while a Task subagent runs its
usage — describing the subagent's own, much smaller context — becomes
the parent's numerator, then snaps back when the parent resumes.
The existing `scope_role !== 'child'` guard never fired on any path.
Claude never stamps scope_role (sdkToLogConverter.ts says so outright),
and Codex drops child token_count events in the CLI before they can
reach the web layer, so no producer ever emits 'child'. isSidechain is
the signal that actually survives.
sdkToLogConverter.ts:308-313 already documents this exact reducer
behaviour, but works around only the denominator by forcing the main
session's context_window onto sidechain messages. The numerator was
left unguarded.
* fix(cli): stop stripping context_window from local-session usage
UsageSchema is a plain z.object, so Zod's default strip mode drops every
undeclared key. sessionScanner forwards parsed.data rather than the raw
line, so on the local-JSONL path usage is truncated to the five declared
fields and context_window — injected on the SDK path by
sdkToLogConverter — never survives.
The web status bar then falls back to getContextBudgetTokens, which
subtracts a 10k headroom, so the same model reports a 1.0M denominator
on a remote session and 990k on a local one.
RawMessageSchema right below already carries .passthrough() with a
comment about losing message.model and messageId the same way; the
nested usage object just never got the same treatment.
Bump export schema to v2 with scratchlist text and attachment metadata
so operators keep notes when they export-then-delete. Markdown gets a
Scratchlist section; attachment bytes stay out of the JSON.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): show machine and last-active in session header
Multi-machine estates lose the machine signal after leaving list filter
chips; surface machine label + relative age in SessionHeader meta row.
Fixes#1241.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): keep session-header age fresh under keep-alive
Treat detail-cache activeAt keep-alives as render-relevant now that the
header reads them, and tick relative age every minute so labels advance
without a session prop change.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): skip sub-minute activeAt keep-alives in detail cache
Relative age only changes at 60s boundaries; accepting every ~10s
heartbeat replaced the Session object and re-rendered the chat tree
for no visible header change.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: retrigger Codex PR review after stream disconnect
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
서브에이전트(sidechain)가 오래 걸리는 도구를 실행할 때 SDK가 주기적으로
내보내는 tool_progress heartbeat 이벤트가 isClaudeChatVisibleMessage()의
기본 통과 분기를 거쳐 raw JSON 그대로 채팅에 노출되던 문제를 고친다.
rate_limit_event 필터링(#423)과 동일한 패턴으로 타입 전체를 deny한다.
The /share route used a local getSessionTitle that preferred summary.text
over metadata.name, so Android share-target rows disagreed with the
session sidebar. Reuse @/lib/sessionTitle and lock the precedence with a
unit test. Closestiann/hapi#1218 once upstream PR lands after dogfood.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): surface real Cursor ACP session/load errors
Stop mislabeling every ACP session/load failure as a legacy
stream-json protocol problem. Prefer Cursor's Cannot use this model
stderr (including Available models when present), attach drained
stderr on process close, and keep structured formatAcpLoadError logs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): accumulate ACP stderr across split chunks
Address Codex Major on #1198: child_process stderr data events are not
message boundaries. Concatenate raw chunks in a rolling window, extract
Cannot use this model from the window on close, and prefer that over a
partial onStderrError hint when classifying resume failures.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): pin ACP model-rejection stderr when catalog overflows
Once Cannot use this model appears, keep the buffer from that match
head so a long Available models list cannot roll the rejection out of
the rolling window (Codex follow-up on #1198).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): wait for model id before ACP model-rejection emit
Only emit Cannot use this model via onStderrError once a non-space
token follows the colon, so a split before the id cannot suppress the
completed rolling-window message (Codex Minor on #1198).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): block ACP writes between process exit and close
Keep the post-exit stdin write guard while deferring markClosed until
stdio close so stderr can still enrich the failure (Codex Minor on #1198).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(hub,shared): scratchlist v2.2 hub attachment storage foundation (#921)
Hub stores scratchlist attachment bytes on filesystem; SQLite holds
AttachmentMetadata[] JSON via session_scratchlist.attachments (v11→v12).
Upstream ladder: v10→v11 text-only scratchlist table (#896), v11→v12
attachments column. Configurable limits via HAPI_SCRATCHLIST_* env vars.
Upload, serve, and limits REST routes; delete entry cleans hub files.
Web promote/rehydrate still TODO. Soup renumber branch follows.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): scratchlist v2.2 attachment UX (#921)
Route scratchlist-mode composer submits with attachments to hub storage,
show image thumbnails in the drawer, and rehydrate attachments on promote
to composer or queue (hub fetch → CLI upload for send).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): scratchlist attach submit, float thumbs, copy tooltip (#921)
Hub upload adapter now sets path on ready attachments so the composer send
button unlocks in scratchlist mode; routing label matches attachments too.
Entry thumbnails float left with text wrap; copy tooltip clarifies text-only.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): adapt scratchlist update tests to patch API (#921)
update() now takes { text?, attachments? }; v12 CRUD tests still passed a string.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,web): harden scratchlist attachment ownership and orphan cleanup
Resolve claimed hub paths against the current session before persist,
count on-disk session bytes for upload caps, delete blobs dropped on
entry update, and DELETE pending uploads when composer remove runs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: drop accidental .cursor files from attachment PR
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): exit scratchlist mode before rehydrate; delete raced uploads
Promote-to-composer flushes mode exit so attachments use the chat adapter.
Cancel-during-upload deletes the hub blob once upload returns.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,web): exact UUID delete match; stage hub paths on chat send
Reject partial attachment ids on disk delete, and restage scratchlist hub
attachments through uploadFile when sending after leaving scratchlist mode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): skip text-only PUT resolve; cleanup session attachment dirs
Text-only edits keep existing attachment metadata after session-id transfer.
Require full UUID on resolve. Delete scratchlist attachment files when a
session is deleted.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,web): scratchlist attach route, PUT bytes, orphan deletes
Park only hub-resident attachments; subtract removed blobs from the PUT
session cap; delete attachment files only when no other entry still
references them.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): canonicalize scratchlist attachment filenames
Resolve stores the on-disk sanitized name (not claimed.filename) and
hardens Content-Disposition against CR/LF/quote injection.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(hub): cover toxic filename canonicalize on resolve
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,web): serialize scratchlist uploads; drop hub blobs after chat stage
Per-session upload lock keeps disk byte caps honest under concurrency.
After a successful toggle-off chat send, delete the staged hub copies so
they no longer count against the session attachment budget.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(shared,web): allow clearing scratchlist attachments; cleanup staged uploads
PUT may send attachments:[] without a text change. Staging to chat rolls
back partial normal-upload copies on failure.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): re-key scratchlist attachment files on session merge
Move hub blobs when scratchlist rows transfer between session ids so
quota and path ownership stay correct. Reject PUT that would leave an
empty textless entry after clearing attachments.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): reuse restored scratchlist hub attachments without re-upload
Composer draft remount was re-uploading blobs that already had a
hapi-hub:scratchlist path, orphaning the originals against session quota.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Radix Popover crashed with "Invalid hook call" because
@radix-ui/react-popover is not linked into web/node_modules and
resolves react from the repo root — a different instance than the
one app code imports. Two React copies make every hook-using third
party component throw on render and unmount the whole tree.
The VitePWA dev service worker also pulls its workbox imports only
after registration, so Vite re-optimizes deps and force-reloads the
page mid-run, which nondeterministically kills whichever e2e test is
in flight.
Machines are labelled by hostname with no way to give them a friendlier
name. `MachineMetadataSchema` has declared `displayName` all along and the
whole read path already honours it (`displayName → host → id`), but nothing
could ever write it: the CLI never sends the field, the hub exposed no route
that sets it, and the web UI had no editor.
Add the missing write path:
- `PATCH /api/machines/:id` with `{ displayName }`, guarded by the existing
`requireMachine`. An empty value removes the key so the label falls back to
the hostname; the empty string is never stored.
- `machineCache.renameMachine` merges that one key into the stored metadata
and lets `refreshMachine` publish `machine-updated`, which `useSSE` already
invalidates on — so every connected client relabels without new plumbing.
- A `/settings/machines` page listing online machines with inline rename,
placed between Voice and About so the existing preference pages keep their
order. Each row keeps the hostname visible, so a renamed machine is still
identifiable.
The merge reads the raw stored metadata rather than the cached `Machine`
view. That view is narrowed by `MachineMetadataSchema`, which strips unknown
keys and yields `null` for a row that fails validation — reachable, since the
CLI's `machine-update-metadata` handler accepts `z.unknown()`. Merging
against it would have written those fields out of existence.
The row's save is guarded by a ref rather than `isPending`: disabling the
focused input forces a blur, so Enter otherwise reaches `save` twice and
fires two PATCHes, the second of which can lose the version race and report
a failure for a rename that succeeded.
`mergeMachineMetadata` already preserves hub-side fields on CLI
re-registration, so a reconnect does not clobber the name.
Closes#1210
* feat(web): @ autocomplete to cite other sessions by title
Composer @ ranks fleet session titles (and id prefixes), inserting the
same Copy-reference citation grammar. Codex file @ mentions remain and
follow session hits. Bare /sessions/<id> paths autolink for in-app nav.
Closestiann/hapi#1213.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): show session @ picks as composer chips
Selecting a session from @ autocomplete adds an attachment-style chip
(flavor + title + active dot) instead of expanding prose in the textarea.
On send, chips serialize to markdown session links that render as chips
in the transcript.
Part of tiann/hapi#1213 dogfood.
Co-authored-by: Cursor <cursoragent@cursor.com>
* revert(web): v1 session @ mentions use plain-text expansion
Attachment-style composer chips cannot express positional
"this → session A / that → session B" intent. Keep @ autocomplete
and Copy-reference text insert for v1; rich segmented composer is v2.
Part of tiann/hapi#1213.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): do not autolink source paths under sessions/
Reject dotted session-id tails and stop treating `.ext` as a soft path
end so citations like `routes/sessions/chat.tsx` stay file-path links.
Part of tiann/hapi#1213.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): keep Vite BASE_URL on session citation anchors
SessionPathAnchor href now uses buildSessionReferencePath so copy /
open-in-new-tab stay in the PWA subpath scope.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): @ session mention search uses sessionMatchesQuery
Reuse the share/sidebar matcher (name + summary + path + id + machine)
while still labeling and inserting getSessionTitle (name before summary).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>