Commit Graph
1036 Commits
Author SHA1 Message Date
AnanovoandGitHub c5f1928eb0 fix(web): consolidate Codex import in new session (#1240)
* fix(web): consolidate Codex import in new session

* fix(web): clear imported Codex history selection
2026-07-31 18:12:30 +08:00
AnanovoandGitHub 567a6c9499 fix(web): improve session date picker visibility (#1254)
* fix(web): improve session date picker visibility

* fix(web): pair selected date theme colors
2026-07-31 18:11:49 +08:00
743c0a06ec test(cursor): isolate cursor-models shared-cache tests per HAPI_HOME (#1268)
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.

Fixes heavygee/hapi#101

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 10:43:33 +01:00
TEEKandGitHub 212e58cba2 fix(web): allow reasoning scroll chaining (#1264) 2026-07-31 16:08:49 +08:00
AnanovoandGitHub 0db7d68b37 feat(web): add stepwise session preview controls (#1266)
* feat(web): add stepwise session preview controls

* fix(web): avoid no-op session preview collapse

* fix(web): expand previews from rendered count
2026-07-31 16:08:27 +08:00
weishu b3a1f5fafd fix(web): hide background message sync indicator
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.
2026-07-31 15:59:42 +08:00
weishu 584d1647d0 fix(web): harden older history loading 2026-07-31 15:59:42 +08:00
Junmo KimandGitHub 61740164fb fix(hub): preserve invocation activity timestamps (#1249) 2026-07-30 23:29:00 +08:00
AnanovoandGitHub 578646812e fix(web): preserve file search when returning from preview (#1251) 2026-07-30 23:24:23 +08:00
Haoqing WangandGitHub 3a931e3c81 fix(web): count unseen messages by rendered block, not raw message (#1255)
* 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.
2026-07-30 23:24:01 +08:00
Haoqing WangandGitHub eaf5ac49bd fix: stop context/cache stats from jumping (subagent usage + stripped context_window) (#1256)
* 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.
2026-07-30 23:22:41 +08:00
a742fdf1a8 feat(hub+web): include scratchlist in session export (#1235) (#1237)
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>
2026-07-30 23:21:08 +08:00
AnanovoandGitHub 99814e9668 fix(web): make share image previews interactive (#1229)
* fix(web): make share preview controls interactive

* fix(web): preserve lightbox modal focus
2026-07-30 23:20:23 +08:00
f8934d81ee feat(web): show machine + last-active in session detail header (#1244)
* 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>
2026-07-30 23:18:15 +08:00
AnanovoandGitHub 36eedc8701 fix(web): align session list status description (#1243) 2026-07-30 09:36:32 +08:00
Junmo KimandGitHub 659913c0c9 fix(shared): hide tool_progress heartbeat events from chat delivery (#1094)
서브에이전트(sidechain)가 오래 걸리는 도구를 실행할 때 SDK가 주기적으로
내보내는 tool_progress heartbeat 이벤트가 isClaudeChatVisibleMessage()의
기본 통과 분기를 거쳐 raw JSON 그대로 채팅에 노출되던 문제를 고친다.
rate_limit_event 필터링(#423)과 동일한 패턴으로 타입 전체를 deny한다.
2026-07-29 20:18:09 +08:00
SSU-WEI HUANGandGitHub 46ab828daa feat(web): show Hub SQLite storage usage in Settings (#1225) 2026-07-29 20:13:04 +08:00
Haoqing WangandGitHub f5673e89bd perf(web): skip session cache writes that only move activeAt (#1232) 2026-07-29 20:12:33 +08:00
Haoqing WangandGitHub e3ca31da08 perf(web): mount session row dialogs only while they are open (#1233) 2026-07-29 20:12:12 +08:00
AnanovoandGitHub c404008072 fix(web): keep sticky workspace headers opaque and aligned (#1234) 2026-07-29 19:55:56 +08:00
weishu 633e990d7f Fix codex action 2026-07-29 19:53:51 +08:00
5eae68d237 fix(web): share picker titles match sidebar (name before summary) (#1219)
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. Closes tiann/hapi#1218 once upstream PR lands after dogfood.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 10:06:05 +08:00
d8aa8736cd fix(cli): surface real Cursor ACP session/load errors (#1198)
* 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>
2026-07-29 10:05:41 +08:00
4c203f17cb feat(web,hub): scratchlist v2.2 hub attachment storage (#921) (#1205)
* 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>
2026-07-29 10:05:24 +08:00
Haoqing WangandGitHub 87e88743e5 fix(web): dedupe react and pre-bundle workbox deps in dev (#1211)
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.
2026-07-29 10:05:06 +08:00
Haoqing WangandGitHub 8d1f84e20b feat: name your machines from web settings (#1214)
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
2026-07-29 10:04:33 +08:00
e32fe146c3 feat(web): @ autocomplete to cite other sessions by title (#1217)
* 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.

Closes tiann/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>
2026-07-29 10:03:52 +08:00
SSU-WEI HUANGandGitHub b281b931f5 fix(web): prevent duplicate session creation (#1222) 2026-07-29 10:02:46 +08:00
SSU-WEI HUANGandGitHub 91022539d9 feat(web): show explicit subagent model (#1223) 2026-07-29 10:01:22 +08:00
Haoqing WangandGitHub 31a7f67e66 fix(cli): mark synthetic SDK user messages as meta (#1209)
Claude Code injects its own user-role turns for skill bodies and compact
continuation summaries. The on-disk transcript flags them `isMeta`, which
claudeLocalLauncher drops before they ever reach the web UI. Over
stream-json the same event is flagged `isSynthetic` instead, and
sdkToLogConverter copied only `message`, dropping the flag entirely.

With no `isMeta` on the converted line, every downstream guard let it
through: OutgoingMessageQueue forwarded it, isExternalUserMessage
classified it as genuine human input (its XML-prefix allowlist does not
match a bare-markdown skill body), and the web UI rendered the full skill
document as a user bubble.

Normalize `isSynthetic` to `isMeta` in the converter so the SDK path
carries the same signal as the transcript path and the existing filters
fire. Fixes skill injections appearing as user messages in remote mode.
2026-07-28 20:45:28 +08:00
SSU-WEI HUANGandGitHub 83fc9cde32 fix(web): keep sticky workspace headers opaque (#1207) 2026-07-28 19:40:33 +08:00
AnanovoandGitHub 5f367dcb6a fix(web): polish shared turn image UX (#1208)
* feat(web): include session title in share image filename

* fix(web): polish shared turn image UX
2026-07-28 19:40:02 +08:00
weishu a469d66bc4 fix(web): patch assistant-ui tap scheduler for bulk history prepends
Loading an older page prepends hundreds of messages in one flush. tap's
scheduler aborts after 50 dirty resources and drops the overflow, so the
thread never applied the merged page: the scroll-restore gate never
passed and the top sentinel kept re-triggering (loads everything at
once). Raise MAX_FLUSH_LIMIT 50->2000 via bun patchedDependencies.

Adds a Playwright regression spec driving the real message-window store
and HappyThread against a fake paginated API: one page per top
approach, scroll restored, no idle reloads.
2026-07-28 19:39:32 +08:00
weishu fcb56989d9 feat(web): load older history by scrolling to top
Remove the redundant "Load older" button; the existing top sentinel
already auto-loads older pages when approaching the top. Loading state
moves to a floating pill overlay so prepends no longer shift layout.
2026-07-28 19:39:32 +08:00
AnanovoandGitHub 8a77d2f84d fix(web): align tool detail layout (#1204)
* fix(web): align inline tool detail spacing

* fix(web): polish tool detail alignment
2026-07-28 17:29:43 +08:00
nannant666andGitHub 25dfa638b3 fix(cli): restore Pi session resume (#1206)
* fix(cli): restore Pi session resume

Use Pi's supported --session flag and keep the session initialized by the CLI instead of replacing it with a racing new_session RPC.

* test(cli): cover fresh Pi startup
2026-07-28 17:28:53 +08:00
AnanovoandGitHub ec12c97aec fix(web): align outline close button styling (#1150) 2026-07-28 16:57:31 +08:00
weishu f0e7e6ad20 Release version 0.25.1 2026-07-28 15:45:13 +08:00
weishu b00c5938a1 fix(web): stabilize history scrolling
Load one older page per top intersection and restore the scroll anchor only after assistant-ui applies the matching history version.
2026-07-28 15:03:10 +08:00
weishu 02c23222b4 Release version 0.25.0 2026-07-28 12:29:30 +08:00
weishu b93b789238 fix(hub): reconcile divergent sqlite schema migrations 2026-07-28 12:27:33 +08:00
weishu 92958890de fix(codex): preserve transcript sync across mode switches 2026-07-28 12:20:54 +08:00
weishu f0c7d0b7e7 fix(codex): preserve reasoning effort across mode switches 2026-07-28 12:20:54 +08:00
weishu faf70c64dd refactor(sync): replace message reloads with incremental tail sync 2026-07-28 12:20:53 +08:00
2235b924a7 feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback (#896)
* feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback (#893)

Promotes scratchlist persistence from per-device localStorage to a hub-
backed typed table so entries follow the operator across devices. v1
panel UI / FUE / shortcut / styling are deliberately unchanged - this is
a backend + sync-layer feature.

Hub side
- New `session_scratchlist` typed table (sessionId, entryId, text,
  createdAt, updatedAt) with composite PK and FK ON DELETE CASCADE from
  sessions. Schema bumped V9 -> V10; idempotent migration added to the
  legacy + step ladders.
- REST CRUD under `/api/sessions/:id/scratchlist[/:entryId]`, all routed
  through the existing `requireSessionFromParam` guard so namespace /
  ownership enforcement is identical to other session-scoped routes.
- Per-session 200-entry cap enforced on POST. Duplicate entryId reported
  idempotently (200) so the migration retry path is safe.
- `SessionPatchSchema` extended with `scratchlistUpdatedAt?: number`;
  every successful mutation emits a `session-updated` SSE patch with the
  token. (Following operator's piggyback decision; aligns with the
  parallel #884 patch-shape extension.)

Web side
- Hub becomes source of truth via TanStack Query
  (`queryKeys.scratchlist(sessionId)`); localStorage demoted to offline
  cache. Add / delete / update mutations are optimistic with rollback
  on error.
- Silent first-load migration: existing localStorage entries are pushed
  to the hub preserving id + createdAt, and a one-time banner (mirroring
  `CursorMigrationBanner`) tells the operator their notes are now in
  the hub. Banner dismissal is per-session and persistent.
- SSE handler queues a `scratchlist` invalidation when the patch carries
  `scratchlistUpdatedAt`, so cross-device + cross-tab updates land
  within an SSE round-trip.
- Delete-session confirm copy now includes a count of scratchlist
  entries that will be cascade-deleted.

Out of scope (separate tracking issue #894): "delete with summarize-and-
migrate" UX flow.

Tests
- Hub: V9->V10 migration (fresh + multi-hop legacy + idempotent reopen
  + cascade-delete), `ScratchlistStore` CRUD + ordering, REST routes
  (happy path + 400/403/404/409), SyncEngine SSE emission.
- Web: hook covers initial fetch, optimistic add/delete/update with
  rollback, localStorage migration + banner, cap enforcement,
  local-only reorder. Banner component renders only on `'completed'`.
- Existing Playwright e2e (10 tests, panel UI regression) all pass
  unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): address HAPI Bot Major findings on PR #896

Two real data-correctness paths the bot caught on the initial review.

1. Migration partial-failure data loss
   The migration loop swallowed each failed POST and still wrote the
   `migrated` flag, while the offline-cache effect mirrored the
   (partial) hub state back into `hapi.scratchlist.v1.<sessionId>` -
   so a transient error or cap rejection could leave entries neither
   on the hub nor in localStorage. Fix:
   - Track failed entries during migration and persist them back to
     localStorage; do NOT advance the flag if any entry failed, so a
     future mount retries.
   - Gate the offline-cache effect on the migration flag. Pre-
     migration, localStorage holds the v1 entries the migration
     reads; mirroring an empty hub fetch over them was the wipe.
   - Drop the "skip migration when hub is non-empty" gate. Combined
     with the duplicate-idempotent POST short-circuit (below), a
     retry against a session that another device already populated
     is a safe union.

2. Duplicate POST returned 409 at cap
   The route checked `count >= SCRATCHLIST_MAX_ENTRIES` BEFORE asking
   the store whether the supplied `entryId` already existed, so an
   idempotent migration retry against a 200-row session returned 409
   instead of 200. Fix: check duplicate first via a new
   `SyncEngine.getScratchlistEntry`, return the existing row with 200,
   and only run the cap check for genuinely new ids.

Tests added:
- hub/routes: at-cap + duplicate entryId returns 200 (not 409); at-cap
  + new entryId still 409.
- web/hook: partial-failure persists the failed entries back to
  localStorage and leaves the flag unset; offline-cache effect does
  not wipe pre-migration localStorage.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web/scratchlist): per-entry age indicator (clock icon + tooltip)

Surfaces the smart-relative time the entry was last saved on every
scratchlist row, mirroring the bucketing used in the session list:
just-now -> Nm -> Nh -> Nd -> absolute date.

Implementation:
- Extract the existing `formatRelativeTime` helper out of SessionList
  into `web/src/lib/relative-time.ts` so the panel can reuse the
  same buckets and i18n keys (no copy-paste drift between surfaces).
  Also add `formatAbsoluteDateTime` for the precise-stamp tooltip
  line.
- Add `updatedAt?: number` to the local `ScratchlistEntry` shape.
  v1-only callers stay valid (the field is optional and `isEntry`
  now accepts rows that omit it). The hub hook forwards the hub's
  `updatedAt` so the indicator reflects edits, not just creation.
- New `EntryAgeIndicator` component: clock SVG in the same style as
  the existing action icons, rendered inside both panel surfaces
  (the older `ScratchlistList` and the drawer variant). Falls back
  to `createdAt` when `updatedAt` is missing (legacy v1 rows during
  the migration window) and renders nothing if neither timestamp is
  usable.
- Tooltip carries the relative bucket plus the absolute timestamp
  on a second line; aria-label carries the relative bucket only so
  screen readers stay terse.
- Mirror `updatedAt` into the localStorage offline cache so an
  offline reload still has accurate ages.

Tests:
- `relative-time.test.ts`: bucket math, seconds-vs-ms detection,
  non-finite guard.
- `ScratchlistPanel.test.tsx`: indicator renders with the right
  smart-relative bucket, falls back to `createdAt` when `updatedAt`
  is absent, and renders nothing when both timestamps are zero.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): bound client-supplied entryId length (HAPI Bot, PR #896)

The POST /api/sessions/:id/scratchlist body validator left `entryId`
unbounded (`z.string().min(1)`), but that string is persisted as part
of the SQLite primary key. An authenticated/direct client could grow
the table and its index well beyond the intended scratchlist limits
by submitting oversized keys.

Adds `SCRATCHLIST_MAX_ENTRY_ID_LENGTH = 128` (comfortably fits a
UUID's 36 chars plus any prefix scheme we might layer on later) and
applies `.max(...)` to the optional `entryId` in
`ScratchlistEntryCreateRequestSchema`. Anything longer is rejected
with 400 before the row hits SQLite.

Test pins the new behavior: a 129-char id returns 400 and never reaches
the engine.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): banner state machine - 'completed' is sticky until dismissed (HAPI Bot, PR #896)

The previous state machine swallowed the migration banner if the
operator reloaded the page before clicking dismiss: the migration flag
was set on success, and on remount the init logic mapped a
flag-set/dismiss-not-set session to 'pre-migrated', a state the banner
explicitly refuses to render. Net effect: a migrated session never
prompted for affirmative dismissal.

Fixes:

- Drop the 'pre-migrated' state. The dismissal flag is now the only
  signal that suppresses the banner; the migration flag alone means
  'banner shows until dismissed' (now or after a reload).
- Sessions that had nothing to migrate (no v1 entries in localStorage)
  pre-emptively write BOTH flags - migrated AND dismissed - so the bot's
  banner-stickiness fix doesn't surface a banner that has nothing to
  announce on freshly-created v2 sessions.

Tests:

- New `reload-before-dismiss leaves the banner visible` test pins the
  fix end-to-end: mount #1 migrates -> 'completed', unmount, mount #2
  on the same session reads the localStorage flags and stays
  'completed'.
- New `opts fresh sessions out of the banner pre-emptively` test pins
  the no-v1-entries shortcut.
- Existing `does not re-migrate on a mount where the migrated flag is
  already set` updated to assert 'completed' (not the dropped
  'pre-migrated').
- Existing `skips migration when localStorage is empty` updated to
  assert the new 'dismissed' status + the banner-dismissed flag.
- Banner test for the 'pre-migrated -> nothing' case removed (the state
  no longer exists).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): transfer rows during session merge so cascade-delete does not strand them (closes #920 for v2.0)

`mergeSessionData` in `sessionCache.ts` ends every merge codepath with
`deleteSession(oldSessionId)`, which fires `ON DELETE CASCADE` on every
FK-tied table. `session_scratchlist.session_id` is FK'd with cascade,
so without an explicit transfer step every dedup (#448 agent-id
collision) and every resume-of-inactive (`syncEngine.resumeSession` ->
mergeSessions) silently destroys the operator's per-session notes.

This is the gap upstream-discovery agent flagged on #920 against PR
#896. With the 2026-06-15 hub-restart cascade incident as evidence
(23 sessions auto-archived in a single bounce, 4 confirmed HAPI-id
rotations across 2 bounces), unmitigated this would violate v2.0's
"survives reloads / second laptop / clear-site-data" promise the
first time the operator hits a hub bounce.

Fix:

- New `transferScratchlistEntries(db, fromSessionId, toSessionId)`
  in `hub/src/store/scratchlist.ts`. Atomic via BEGIN/COMMIT.
  Uses `UPDATE OR IGNORE` so rows that would collide on
  PRIMARY KEY (session_id, entry_id) simply do not move - the
  dedup target's copy wins, matching the operator's mental
  model that the consolidated session is authoritative. Cleans
  up any collision-loser rows so the no-delete codepath
  (`mergeSessionHistory`) is symmetric with the delete path.
- Wired into `mergeSessionData` BEFORE the `deleteSession()`
  call, alongside the existing message-merge step. Both
  `mergeSessions` (deleteOld=true) and `mergeSessionHistory`
  (deleteOld=false) get coverage because both can rotate the
  visible session id.
- Emits `session-updated{scratchlistUpdatedAt}` on the new
  session so any web client looking at the consolidated id
  invalidates and refetches; for the keep-old codepath the
  emit also fires on the old id since it stays alive but is
  now empty of scratchlist.

Tests (`sessionCache-merge-scratchlist.test.ts`, 7 cases):

- mergeSessions (deleteOld=true): rows move, old is gone, no
  stranded rows.
- mergeSessions PK collision: dedup target wins, unique-to-old
  rows still come across.
- mergeSessions SSE: exactly one scratchlist patch on the new id.
- mergeSessions no-op: zero rows -> zero emits.
- mergeSessionHistory (deleteOld=false): rows move, old session
  stays alive but empty of scratchlist.
- mergeSessionHistory SSE: emits on BOTH old and new ids.
- Cascade-delete safety smoke: post-merge, an explicit operator
  delete of the new session DOES cascade-delete its scratchlist
  (i.e. the FK cascade we want is intact; the bug was triggering
  it on the wrong id).

Web layer note:
v1 localStorage is keyed by HAPI session id; on rotation the old
key is orphaned but no longer represents data loss because the
hub now holds the canonical state and the offline-cache mirror
re-populates `hapi.scratchlist.v1.<newId>` on first read of the
consolidated session. Documented as a known limitation; not a
blocker for v2.0 because the hub is the source of truth.

#894 (v2.1 migrate-on-delete) inherits a related concern about
operator-Delete vs merge-Delete consent flow - flagged in the
upstream-discovery handoff, separate scope.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): rebase onto upstream/main - scratchlist migration is V10→V11

Upstream landed V9→V10 as sessions.service_tier (#898/#904). Scratchlist
v2 moves to V10→V11 so both migrations coexist without clobbering each
other.

- mergeSessionData conflict resolved: keep upstream migrateFromV9ToV10
  (service_tier) and add migrateFromV10ToV11 (session_scratchlist)
- SCHEMA_VERSION bumped 10 → 11
- Rename migration-v10.test.ts → migration-v11.test.ts with updated
  multi-hop coverage (V9→V10→V11)
- Add serviceTier: null to scratchlist route test session fixture
  (required by upstream Session type after #898)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): emit SSE on all-collision merge when old session stays alive (HAPI Bot, PR #896)

When mergeSessionHistory deletes every old scratchlist row via PK
collision (moved=0, collided>0) the still-alive old session kept
showing stale cached entries until an unrelated refetch.

Emit scratchlistUpdatedAt on the old id whenever collided>0 on the
keep-old codepath, not only when moved>0. New-session emit stays
gated on moved>0 since the target row is unchanged on full collision.

Test pins the all-collision mergeSessionHistory case.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): stabilize migration queryKey to stop POST retry loop (HAPI Bot, PR #896)

useMemo on queryKeys.scratchlist(sessionId) so the migration effect does not
re-fire every render after a failed POST clears migrationAttemptedRef.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): dedupe optimistic add when SSE refetch wins race (HAPI Bot, PR #896)

onSuccess now drops both the temporary optimistic id and any existing row
with the canonical entryId so a fast SSE invalidation cannot leave twins.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): treat 404 on update/delete as stale cache, not rollback (HAPI Bot, PR #896)

When another client already removed an entry, keep it gone locally and
invalidate instead of restoring previousData from optimistic rollback.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scratchlist): drop optimistic add ghost when previousData missing (HAPI Bot, PR #896)

onError now filters by optimisticEntryId if the initial fetch never
populated cache, so a rejected POST cannot leave an unsaved note.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
2026-07-28 12:16:59 +08:00
07db10f86d fix(web): expose Codex Fast and Plan on Create Session (#1017)
* fix(web): expose Codex Fast and Plan on Create Session

Wire serviceTier and collaborationMode through spawn so Create can set
the same Codex options chat Settings already supports (#1015).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): forward collaborationMode through machine spawn RPC

Create Session Plan was accepted by the hub but dropped in apiMachine
before buildCliArgs; also preserve collaborationMode on resume spawn.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): correct stopSession mock type in spawn RPC test

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): keep Fast mode across Create draft restore while models load

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): preserve pending Fast selection

* fix: apply Fast and Plan to imported Codex sessions

* test: narrow imported Codex session id

* fix: forward explicit Standard service tier

* fix: integrate create-session controls with current main

* test: close Codex RPC suite

* fix: preserve existing session spawn field

* fix(web): integrate Codex controls with current New Session form

* fix(web): reconcile draft types and submit state

* fix(hub): integrate spawn arguments with current resume flow

* test(cli): isolate spawn RPC suite

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:13:29 +08:00
SSU-WEI HUANGandGitHub 5396d5f097 fix(web): only show Codex Fast badge for effective fast tier (#1007) 2026-07-28 11:48:14 +08:00
AnanovoandGitHub adb9e2094e fix(web): stabilize session list alignment and scrolling (#1196) 2026-07-28 11:18:05 +08:00
SSU-WEI HUANGandGitHub 0e3b4394ff fix(web): label terminal cards with concise command names (#1201)
* fix(web): label terminal cards with command names

* fix(web): honor concise titles across shell cards
2026-07-28 11:03:09 +08:00
Junpo YuandGitHub e957d2fc2b fix(cli): ignore CLI-originated backfill prompts (#1192) 2026-07-28 10:48:32 +08:00