Commit Graph
496 Commits
Author SHA1 Message Date
226b2d066a feat(hub): native companion (FCM) push channel + device registry + pairing QR (#803)
* feat(hub): native companion (FCM) push channel + device registry

Adds opt-in FCM HTTP v1 notification delivery so a companion mobile/wearable
app can receive permission, ready, and task notifications end-to-end. The
channel is gated entirely on FCM_SERVICE_ACCOUNT_PATH + FCM_PROJECT_ID being
set; operators not running a companion see zero behavior change.

What lands:

- POST/DELETE /api/devices/register — JWT-authed FCM token registry,
  upsert on (namespace, deviceId, platform), platforms `phone` | `wear`.
- Sqlite v9 → v10 migration adds `fcm_devices` (idx on namespace + token).
- FcmService — minimal HTTP v1 client, RS256 service-account JWT via
  jose (dep already in tree), 5-minute access-token cache, 401 retry.
- FcmNotificationChannel — implements NotificationChannel, sends data-only
  FCM (so companion can route to phone+watch surfaces). Body composition
  parses an optional trailing `AGENT_NOTIFY_SUMMARY {json}` line for richer
  ready summaries; truncates plain assistant text to 280 chars otherwise.
  Tags each payload with `severity` (info/warning/success/error) so clients
  can color/categorise the notification.
- PushNotificationChannel gains a NativeFallbackProbe — when a namespace
  has at least one registered FCM device, web-push and SSE in-page toast
  are skipped so the operator does not double-notify on phone+browser.
  Probe is no-op when no FCM device is registered; PWA-only setups
  unchanged. Branch trace gated on HAPI_NOTIFY_DEBUG=1.
- shared/src/messages.ts — `extractAssistantPlainText` (codex + Claude SDK
  shapes) and `extractNotifySummary` (strict end-anchored line parser).
- hub/src/notifications/toolArgs.ts — tool-arg formatters lifted out of
  telegram/sessionView (kept duplicated there in this PR; refactor of
  Telegram is a follow-up).
- docs/api/native-companion-contract.md — payload + endpoints + env vars,
  versioned at contract v1.

Test coverage:

- 260 hub tests pass (incl. 23 new across FCM channel, push dedup,
  v10 migration, devices route).
- 60 shared tests pass (messages parsers).

Notes for reviewers:

- Reference companion implementation lives in a separate Android repo
  (Kotlin, phone APK + Wear OS APK) — this PR is hub-side only.
- No new runtime deps (`jose` and `zod` already declared in hub).

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

* docs(contract): clarify scope - companion is remote-hub client, not hub-on-phone

Adds a Scope section to the native-companion contract so anyone
implementing it knows the audience: operators running the hub on a
server who want phone/watch as a notification surface, not users
expecting a Termux-bundled hub. Mirrors the framing now in
heavygee/hapi-companion README.

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

* docs(contract): correct Scope section - hub topology is unchanged

Removes the prior framing that referenced a non-existent 'Termux
hub-on-phone' alternative. This contract describes a native client to
the same hub the PWA talks to; it does not change where the hub runs.

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

* feat(web): companion app pairing QR in Settings

Companion section in Settings renders a QR code encoding the deeplink
hapicompanion://bind?hub=<base>&code=<token>. Scanning it from the HAPI
companion app (Android phone or Wear OS) auto-fills the bind form and
authenticates against this hub - no manual URL/token paste.

QR is gated behind a Show button so the access token doesn't sit visible
on screen by default; a Copy link affordance and the textual deeplink
are also exposed for manual onboarding.

Adds qrcode + @types/qrcode to web/ (already a hub dep, no new resolved
package - just a workspace declaration).

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

* feat(hub): terminal QR for companion app pairing alongside PWA QR

After the existing PWA access QR is rendered on tunnel start, also print
the hapicompanion://bind?hub=...&code=... deeplink and a matching QR.

Same tunnel + token, different scheme: phones with the companion app
installed pick up the deeplink via the manifest intent filter; phones
without it ignore it and fall back to the PWA QR above.

QR rendering failure is non-fatal in both cases - the textual deeplink
above the QR is sufficient for manual paste.

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

* fix(fcm): address HAPI Bot review on PR #803

Two bugs surfaced by the upstream review bot:

1) Web Push silently dropped when FCM is not actually configured.
   The native-fallback probe only checked the device registry; it did
   not check whether resolveFcmConfig() actually succeeded. So an
   operator who previously enabled FCM, registered a phone, then later
   started the hub WITHOUT FCM_SERVICE_ACCOUNT_PATH would see the probe
   return true (devices still in DB) -> Web Push suppressed -> no FCM
   channel registered -> notifications go to /dev/null.

   Fix: extracted the probe construction into buildNativeFallbackProbe()
   which short-circuits to () => false when fcmConfig is missing. Probe
   never even consults the device store in the no-config branch, so
   stale rows can never matter.

2) Transient FCM failures permanently unregistered devices.
   sendToToken() returned a single boolean and sendToNamespace() removed
   any device whose send returned false. A 429 (rate limit), 503
   (server error), 401 (auth glitch), or even an ECONNREFUSED would
   delete the device row, after which the user would need to re-pair to
   get notifications again. The bot caught it; the fix is the obvious
   one.

   Fix: sendToToken() now returns 'sent' | 'invalid' | 'failed'.
   - 'invalid' is reserved for the responses that genuinely indicate a
     dead token: HTTP 404 with UNREGISTERED/NOT_FOUND, and HTTP 400
     with INVALID_ARGUMENT explicitly referencing the token field.
   - Everything else (429, 5xx, 401, 403, network errors) is 'failed'
     and counts toward the failed tally without removing the device.

   sendToNamespace() only calls removeDeviceByToken() on 'invalid'.

Tests: 11 new tests across two new files. fcmService.test.ts covers
all six branches (200, 404 unregistered, 429, 503, 401, network error)
plus a mixed-batch case that proves invalid tokens get removed in the
same call where transient-failure tokens survive. nativeFallbackProbe
.test.ts covers both no-config and configured branches plus the
explicit "no-config never touches the store" guarantee.

Hub test count: 273 -> 284 (all passing).

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

* docs(contract): correct FCM visibility rule and remove unsupported event type

HAPI Bot review on PR #803 caught two contract-doc accuracy gaps:

1) Visibility rule was wrong. Doc said "FCM fires when Web Push would
   fire AND client not visible via SSE", but FcmNotificationChannel
   ALWAYS fires regardless of PWA visibility (deliberately - native
   companion is the canonical wrist-first surface, and there is a
   passing test asserting this). Companion app implementers reading
   the contract would have built foreground-suppression logic and
   then dropped notifications when the PWA tab was open.

2) Documented `session-completed` event doesn't exist. NotificationHub
   never calls into a 'session-completed' channel method on
   FcmNotificationChannel; the type would never reach a native client.
   Removed from the documented enum, leaving only the three actual
   events: ready, permission-request, task-notification.

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

* docs(contract): drop trailing whitespace, use blank line for paragraph break

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

* fix(web): persist CLI access token after Telegram bind so pairing QR works

The Settings -> Companion pairing QR reads the original CLI access token
from localStorage (hapi_access_token::<baseUrl>) so it can be encoded into
the hapicompanion://bind deeplink. For browser/CLI logins useAuthSource
already persists the token via setAccessToken, but the Telegram Mini App
bind path went through useAuth.bind() which exchanged the typed CLI token
for a JWT and never persisted it. Telegram users therefore always saw the
"signed in via Telegram..." fallback and got no usable QR.

After a successful client.bind() we now mirror useAuthSource's behavior
and write the same accessToken to the same localStorage key, restoring
parity between the two auth paths. No change for browser/CLI users.

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

* fix(fcm): gate native-fallback probe on rolling FCM health

The native-fallback probe previously returned true whenever FCM was
configured AND devices were registered, which suppressed web-push for
the namespace. The HAPI Bot correctly pointed out the gap: if the FCM
pipeline silently breaks (expired service-account key, sustained 5xx,
OAuth token-fetch failure, network blackhole) the operator gets nothing
on either channel until they manually intervene.

Approach (deliberate, not the bot's exact suggested fix):

- FcmService now keeps a small rolling window (last 8 outcomes) of send
  attempts and exposes `isHealthy()`. The threshold is 5+/8 failures =
  unhealthy; the buffer starts empty so a freshly-booted hub is
  optimistic ("innocent until proven guilty") and does not double-fire
  on event #1.
- Token-fetch failure (`getFcmAccessToken` throws) now records exactly
  one health-failure (not one per device), short-circuits the send
  loop, and returns a result so `sendToNamespace` no longer leaks the
  exception.
- `invalid` token responses are explicitly excluded from the health
  buffer because they are per-device facts (rotated/uninstalled token),
  not pipeline failures - FCM was reachable, it just rejected one
  stale token.
- `buildNativeFallbackProbe` now optionally accepts the FcmService and
  short-circuits to "let web-push fire" when health is bad, before it
  even queries the device registry. The single-arg call shape is still
  supported for back-compat.

Why not the bot's exact suggestion ("invert: call FCM first, fall back
on result.sent === 0"):
- Couples PushNotificationChannel to FcmService and FcmSendPayload,
  reversing the clean parallel-channel architecture established earlier
  in this PR.
- Treats every transient single-event failure as fallback-worthy, which
  re-opens the duplicate-notification race that the suppression logic
  was added to close (FCM HTTP timeout that delivers later + the web
  push we sent in the meantime = two pings).
- A rolling health window only flips on sustained breakage, which is
  the actual operational scenario the bot is worried about.

The wrist-first design intent ("FCM fires unconditionally, web-push is
suppressed for the same namespace") documented in
docs/api/native-companion-contract.md is preserved on the happy path.
The probe only re-enables web-push when there is concrete evidence the
native pipeline is not delivering.

Tests:
- New FcmService.isHealthy suite covers empty-buffer, threshold flip,
  recovery as failures age out of the window, invalid-token exclusion,
  and network-error path.
- nativeFallbackProbe gains coverage for the unhealthy-but-registered,
  healthy-and-registered, and absent-fcmService (back-compat) cases.
- All 292 hub tests still pass; typecheck clean.

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

* refactor(telegram): drop duplicate tool-args formatter, use shared module

The Telegram session view had its own copy of formatToolArgumentsDetailed
identical to the one in hub/src/notifications/toolArgs.ts (already used by
the FCM channel). Replace the local copy with an import.

Removes ~70 lines of duplication, plus the now-unused MAX_TOOL_ARGS_LENGTH
constant and `truncate` import. The shared signature accepts an optional
opts arg whose default maxArgLength is 150 - matching the prior constant -
so the call site is unchanged.

Two benign upgrades come along for the ride from the shared module:
?? instead of || on field fallbacks (no real-world difference; permission
arguments never carry empty-string fields), and String(...) wrapping plus
a typeof object guard that makes non-string values render gracefully
instead of throwing into the catch block.

Hub tests: 311 pass / 0 fail. Telegram subset: 5 pass / 0 fail. typecheck
green.

Cold-reviewed by an out-of-context Claude Opus peer before push.

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

* fix(fcm): require positive evidence in health window before suppressing web-push

Addresses HAPI Bot Major review on PR #803.

The previous health gate treated an empty outcome buffer as healthy
("innocent until proven guilty"). That created a silent-blackhole window
on cold start with broken FCM credentials: the push channel suppressed
SSE/Web Push for the first ~5 events while the FCM channel attempted
each delivery and recorded failures, until enough stacked to flip the
threshold. Every notification in that gap was silently lost.

New invariant: isHealthy() requires at least one successful FCM send in
the recent window (HEALTH_WINDOW=8) AND failures below threshold
(HEALTH_FAILURE_THRESHOLD=5). Both conditions are necessary; either
alone is insufficient evidence to safely suppress web-push fallback.

Trade-off: one duplicated notification per hub restart per namespace.
On the first event after restart, web-push fires alongside FCM (because
the gate has no positive evidence yet). Once FCM records that first
success, the gate engages and subsequent events are FCM-only. Worth it
for guaranteed delivery during cold-start outages.

Tests reworked to match new semantics:
- "starts UNHEALTHY with empty buffer" (was: healthy)
- "flips to healthy after first successful send" (new)
- "stays unhealthy across failures-only run" (new, exercises the exact
  blackhole scenario the bot flagged)
- "flips back to unhealthy after threshold breach with prior successes"
  (renamed, establishes successes first)
- "invalid tokens don't count against health" (reworked: send a mixed
  batch first to establish health, then verify invalids don't flip it)
- "network errors count as failures" (reworked: establish health first)

Hub tests: 313 pass / 0 fail. typecheck green.

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

* fix(hub): bump FCM migration to V10→V11 after upstream service_tier V9→V10

Upstream/main landed sessions.service_tier at schema v10. The companion
FCM device registry now migrates at v11 so both changes compose cleanly
after the courtesy rebase onto current upstream/main.

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

* fix(hub): per-dispatch native gate instead of stale FCM probe

FCM runs before web-push; PushNotificationChannel skips web/SSE only
when the same notify() dispatch already delivered via FCM. Removes the
isHealthy()+device-row probe that could suppress web-push after warm
FCM outages.

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

* fix(hub,web): cap notifySummary for FCM limits; fix PWA test cast

Rebase follow-up: truncate AGENT_NOTIFY_SUMMARY summary/action before
FCM data payload (bot Major). Fix usePwaUpdate.test.ts setTimeout mock
cast so bun typecheck passes on current main.

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

* fix(hub): cap all FCM notifySummary fields and task bodies

Whitelist and truncate AGENT_NOTIFY_SUMMARY auxiliary fields before
JSON serialization; cap task-notification summaries to glance limit.

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

* fix(hub): FCM fetch timeouts and cap Grep/Glob permission args

10s AbortSignal.timeout on OAuth + FCM send so sequential web-push
fallback is not blocked on hung Google endpoints; truncate Grep/Glob
pattern in permission detail formatter.

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

* fix(hub): bind FCM token to one namespace on re-pair

Delete stale fcm_devices rows sharing the same token when a native
install registers under a different namespace.

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

* fix(web): localize Companion settings and pairing copy

Add en/zh-CN keys for the Companion section title and CompanionPairing
strings; matches locale-driven Settings pattern (bot Minor on #803).

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

* fix(hub): tighten FCM token-invalid detection and truncation edge cases

Parse FCM error JSON: only UNREGISTERED or token-field INVALID_ARGUMENT
unregister devices; generic NOT_FOUND stays transient. Guard limit<=3
in truncateReadyText so tiny action budgets cannot blow the glance cap.

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

* fix(hub): parse FcmError details.errorCode for UNREGISTERED tokens

FCM v1 often returns HTTP 404 with root NOT_FOUND plus
details[].errorCode UNREGISTERED; prune those tokens while keeping
generic project/resource NOT_FOUND transient.

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

* fix(web): mock AppContext for About Companion pairing in settings tests

Settings About now mounts CompanionPairing via useAppContext after the
#1027 hub redesign rebase; wrap the About route test with AppContext and
Companion mocks so the suite stays green.

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

* docs(contract): point companion auth at POST /api/auth, not /api/bind

Pairing QR carries the CLI access token as `code`. /api/bind requires
Telegram initData; native companions must use /api/auth with accessToken.

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

* fix(web): mount Companion pairing under Settings General

About is version/links only after the settings hub redesign; pairing is
setup, so keep Companion with language prefs and update the route tests.

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-27 19:52:54 +08:00
AnanovoandGitHub b28088bfe3 feat(web): show timestamps in conversation outline (#1111) 2026-07-27 19:52:33 +08:00
AnanovoandGitHub 5e8515c1a5 feat(web): add image preview navigation (#1100) 2026-07-27 19:27:45 +08:00
AnanovoandGitHub 24a2656a3b fix(web): center active scratchlist icon (#1105) 2026-07-27 19:27:20 +08:00
AnanovoandGitHub 4a63418103 feat(web): share conversation turns as images (#1047) 2026-07-27 19:26:38 +08:00
Junmo KimandGitHub e52443f4cf feat(web): global word-wrap toggle for code and diff views (#985)
* refactor(web): add per-line shiki line-splitting helper

* feat(web): add global word-wrap toggle for code, markdown, and diff views

* feat(web): add a shaded gutter background behind line numbers

* fix(web): make DiffView compact rows follow the global wrap setting
2026-07-27 19:09:59 +08:00
Aqi77suandGitHub d53e25700d fix(web): prefer live Hub connectivity over navigator status (#1051)
* fix(web): trust live hub connectivity over navigator status

* fix(web): align offline and reconnecting banner precedence

* test(web): provide i18n context to banner tests
2026-07-27 17:02:37 +08:00
weishu f4be735cb5 fix(hub,web): stop machines from showing raw id prefixes as names
Hub: getOrCreateMachine now merges incoming machine-owned metadata over
the stored row (first-write-wins previously kept rows registered without
a host name nameless forever; hub-only fields like displayName survive).

Web: session-list machine labels are cached in localStorage so machines
whose row is gone or whose query has not loaded yet keep their last
known name instead of flickering to the 8-char id prefix.
2026-07-27 13:10:45 +08:00
Junmo KimandGitHub bb5275a333 fix: recover Codex resume ID from stored messages (#1180)
* refactor(hub): generalize recovered ID helpers

* fix(hub): recover Codex resume ID from messages

* fix(web): allow Codex message recovery
2026-07-27 12:59:57 +08:00
d115f8d960 fix(web): stop touch taps from double-firing session navigation (#1185)
useLongPress binds both touch and mouse handlers. After a tap, touch
browsers emit compatibility mouse events (~300ms later) that the page did
not preventDefault, so onClick fired twice: once from touchend, once from
the synthesized mouseup. On the wide tablet sidebar layout the list stays
under the finger, so the second onClick lands on whatever row slid into
that position and navigates to the wrong session.

preventDefault() on touchend for every handled tap, and additionally
swallow mouse events that arrive within 700ms of a touch so browsers that
still dispatch the compatibility sequence cannot re-trigger onClick.

Based on the fix by RiriAgent in the fork (commits 1bbfcc20, 5e3d135a).

Co-authored-by: RiriAgent <39219425+RiriAgent@users.noreply.github.com>
2026-07-27 12:59:40 +08:00
weishuandGitHub 54bddd9db1 fix(hub,web): query Codex models via machine RPC instead of session cwd (#1186)
SessionChat fetched Codex models through the session-scoped endpoint,
so the CLI listed models in the session process cwd, where a missing
directory or project-level Codex config could skew or break the result.
Use the machine-scoped endpoint (already used by NewSession) and drop
the now-unused session route and RPC plumbing.

Fixes #1072
2026-07-27 12:58:22 +08:00
weishuandGitHub da6f4cc5b0 fix(web): keep session preview fold while searching (#1183)
Searching the session list forced every directory group to expand all
sessions (expanded: isFiltering) and hid the Show more button, so the
user's per-group preview fold was ignored during filtering. Stop
overriding the preview state while filtering and keep the Show more /
Show less control available.

Fixes #1068
2026-07-27 12:57:51 +08:00
311e0cef55 fix(web): exit scratchlist mode after successful promote-to-queue send (#960)
* fix(web): exit scratchlist mode after successful promote-to-queue (#959)

After Send to queue accepts, call onExitScratchlistMode so the operator
can continue normal chat. Rejected sends keep mode on. Unit + Playwright
smoke coverage.

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

* fix(web): add execStartedAt/execCompletedAt to ToolCard test mock

Upstream ChatToolCall gained exec timestamps; ToolCard.test.ts mock
was missing them and broke CI typecheck after rebase onto main.

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-27 12:56:02 +08:00
weishu 2e54d9fdff feat(web): replace machine tree level with filter chips in session list
Single-machine users no longer expand a redundant machine layer; with
multiple machines a chip filter bar (persisted, with hover health popup)
replaces the collapsible machine headers. Directory groups now render
top-level with machine-name suffixes when unfiltered. Also removes the
redundant session/project count header text.
2026-07-27 11:55:43 +08:00
AnanovoandGitHub 84323496c6 fix(web): clarify settings heading hierarchy (#1177) 2026-07-27 07:38:50 +08:00
SSU-WEI HUANGandGitHub 500407c6b1 fix(codex): show catalog-default Fast tier (#1179)
* fix(codex): show catalog-default Fast tier

* fix(web): show inherited Fast tier in header
2026-07-27 07:38:24 +08:00
Junmo KimandGitHub deb5d63695 fix(cli,web): group orphaned subagent trace by parentToolUseId (#1175) 2026-07-26 23:01:46 +08:00
AnanovoandGitHub d90bde0b88 feat(web): show tool execution timing (#1140) 2026-07-26 15:08:40 +08:00
SSU-WEI HUANGandGitHub 44390af35c fix(web): stabilize message timestamps (#1152) 2026-07-26 15:08:15 +08:00
AnanovoandGitHub 351ebafe54 fix(web): connect circular action icon arrowheads (#1154) 2026-07-26 15:07:52 +08:00
AnanovoandGitHub 75e803f359 fix(web): align session agent icons with text (#1159) 2026-07-26 15:05:31 +08:00
Haoqing WangandGitHub 51c2b4b3f5 fix(web): stop voice backend detection from raising the error banner (#1164) 2026-07-26 15:04:59 +08:00
TEEKandGitHub 31ad7a6616 fix(web): reasoning overflow and settings label spacing (#1166) 2026-07-26 15:04:39 +08:00
SSU-WEI HUANGandGitHub c305c5ce31 fix(web): keep session sidebar stable after selection (#1173) 2026-07-26 15:02:52 +08:00
NightWatcher314andGitHub 5300e1ee39 feat(codex): support file mentions (#774)
* feat(codex): support file mentions

* fix(codex): quote file mention paths with spaces

* fix(codex): keep punctuation outside file mentions

* fix(codex): avoid parsing literal at-mentions

* fix(codex): scope file mention autocomplete
2026-07-24 11:00:48 +08:00
AnanovoandGitHub 72476b9cea fix(web): align session sidebar content widths (#1098) 2026-07-24 10:59:06 +08:00
AnanovoandGitHub 224ae07438 feat(web): customize composer toolbar layout (#1101)
* feat(web): customize composer toolbar layout

* fix(web): reorder toolbar across split boundary

* fix(web): match toolbar focus and visual order

* fix(web): support accessible toolbar reordering
2026-07-24 10:58:46 +08:00
AnanovoandGitHub 33015b67db feat(web): add conversation outline search (#1102)
* feat(web): add conversation outline search

* fix(web): keep outline close action accessible
2026-07-24 10:58:23 +08:00
AnanovoandGitHub df36cec01e feat(web): sort file search results (#1109) 2026-07-24 10:58:07 +08:00
AnanovoandGitHub ee5b0239cb fix(web): prevent narrow session tooltips (#1114) 2026-07-24 10:57:32 +08:00
AnanovoandGitHub 8c1d5057d7 feat(web): remember new-session model and effort (#1116)
* feat(web): remember new-session model and effort

* fix(web): wait for launch preference validation

* fix(web): wait for cwd preference validation
2026-07-24 10:57:12 +08:00
AnanovoandGitHub 0834dc098c feat(web): indicate session activity in date picker (#1103)
* feat(web): indicate session activity in date picker

* fix(web): expose session activity to assistive tech
2026-07-24 10:56:20 +08:00
AnanovoandGitHub b2ec09bd0c fix(web): preserve composer attachments across session switches (#1110)
* fix(web): preserve composer attachments across session switches

Persist composer files per session and restore completed uploads without
uploading them again. Clear attachment drafts alongside text after send and
cover restoration, isolation, and adapter reuse with regression tests.

Fixes #465

* fix(web): keep cleared attachment drafts tombstoned

Retain an empty in-memory cache entry until the queued IndexedDB delete completes so a fast remount cannot restore stale files. Add regression coverage for the clear/remount race.

* fix(web): defer attachment restore for inactive sessions

Only restore or clear attachment drafts while the session attachment adapter is available. Preserve saved files when an inactive session mounts without attachment support and add regression coverage.
2026-07-24 10:55:42 +08:00
AnanovoandGitHub 3021c9cba0 fix(web): zero-pad message and session timestamps (#1112) 2026-07-24 10:55:20 +08:00
TEEKandGitHub 81934cf354 fix(web): use dedicated split breakpoint for compact tablets (#1141)
* fix(web): use dedicated split breakpoint for compact tablets

Some compact Android tablets (e.g. OPPO Pad mini) report a landscape
CSS viewport below Tailwind's `lg` (1024px) despite having enough
physical screen space, so the sessions layout fell back to a single
column. Add a dedicated `split` breakpoint at 920px and use it for the
sessions split layout and the sidebar width/resize CSS, leaving the
global `lg` breakpoint (and all other pages) untouched.

* fix(web): cap sidebar width against viewport on compact split

A persisted sidebar width (up to 600px from resizing on desktop) could
shrink the detail pane to 316px at the new 920px split breakpoint, below
the previous 1024px worst case of 420px. Cap the sidebar width at
min(var(--sidebar-w), calc(100vw - 424px)) so the detail pane keeps at
least 420px down to 920px, with no effect on desktop.

* fix(web): seed sidebar drag from rendered width

When the compact-split viewport cap renders the sidebar narrower than the
persisted width, dragging the handle to shrink it had a dead zone until
the stored width fell below the rendered width. Seed the drag from the
sidebar's rendered width so it responds immediately; unchanged on desktop
where rendered and stored widths match.
2026-07-24 10:54:21 +08:00
3b025a69cc feat(web): surface Mermaid parse/render failure reason in fallback (#1145)
The mermaid fallback previously showed only the raw source with no reason,
making a supposedly-valid diagram that fails in HAPI (but passes the Mermaid
CLI) impossible to diagnose from the running UI. Every diagnostic was
swallowed: setParseErrorHandler no-op, suppressErrorRendering, parse with
suppressErrors returning false, and an empty catch.

renderMermaidSvg now returns a { svg, error } outcome. On a failed
suppressErrors parse it re-parses once (no side effects: parse-error handler
is a no-op, suppressErrorRendering stays on) purely to capture the thrown
reason; render() failures capture their message too. The fallback surfaces
that reason plus a data-mermaid-error attribute for automation, while keeping
the raw source verbatim. The failure notice is gated on an actual error so the
async load window does not flash a false 'could not render' banner.

Preserves the #785/#813 hardening (no error-SVG injection, no crash, no new
XSS surface). Addresses the weak-feedback half of #1117.

Refs #1117

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 10:53:45 +08:00
f46e7301e8 Peer #1120: file-path autolink ergonomics (#1142)
* fix(web): autolink markdown links, inline code, and .mmd file paths in chat

Chat autolinking previously only worked for bare file paths in plain text.
Fancier markdown forms silently produced dead links:

- COMMON_FILE_EXTENSIONS omitted common agent-cited types (mmd, puml, rst,
  csv, ini, etc.), so bare diagram.mmd never linked.
- inlineCode nodes were never processed, so `path/to/file.md` never linked.
- explicit [label](relative/file.md) links kept a raw relative URL that the
  SPA router treated as a dead route under /sessions/.

Changes:
- Expand COMMON_FILE_EXTENSIONS with justified doc/diagram/config/lang exts;
  deliberately exclude TLD-lookalikes (org/com/io) to avoid domain false
  positives.
- Autolink inlineCode nodes whose ENTIRE value is a single path pattern match
  (whitespace-free, allowlisted ext), wrapping an inlineCode child to keep
  monospace. Real code snippets are left untouched.
- Rewrite explicit markdown links whose target is a repo-relative allowlisted
  file path into hapi-file: hrefs (aligns with #1113). Preserves label.

Security invariants preserved: shouldLinkPath still rejects abs / ~/ / ../ /
Windows-drive / scheme:// paths; scheme-bearing link urls are left for the
deny-scheme layer; deny-scheme handling untouched.

Refs tiann/hapi#1120

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

* fix(web): don't rewrite explicit links in standalone markdown preview

Codex review (#1142): rewriteFileLinkNode ran on the standalone file-preview
surface too, but that surface has no HappyChatContext so the shared `A` anchor
collapses hapi-file: links to plain text (returns props.children when !chat).
That turned an explicit [label](file.md) link in a README preview from an
anchor into plain text.

Gate explicit-link rewriting behind a rewriteExplicitLinks option (default on
for chat) and disable it for the standalone renderer via new
MARKDOWN_PLUGINS_STANDALONE(_WITH_BREAKS) arrays. Bare-path and inlineCode
autolinks are kept — they were already inert on the standalone surface, so no
behavior change there.

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

---------

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 10:53:35 +08:00
b7f1f4390f feat(web): copy session reference from context menu (#1144)
* feat(web): add Copy reference to session context menu

Refs tiann/hapi#950

Adds a More actions item that copies a cross-session citation
(see session "title" (/sessions/id) for context) instead of a
bare share URL.

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

* fix(web): sanitize session titles in copy-reference text

JSON-escape titles and collapse whitespace so arbitrary session
names cannot inject prompt text into cross-session citations.

Addresses Codex review on tiann/hapi#951.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 10:53:07 +08:00
SSU-WEI HUANGandGitHub aa5beb3af2 feat(codex): preserve native exploration actions (#1139) 2026-07-24 10:52:24 +08:00
weishu 74ad25ec57 feat(codex): refine tool activity display 2026-07-24 09:39:22 +08:00
weishu dd42263aaf fix(web): use distinct icon for Codex session import
The Codex import button used the same circular-arrow SVG as the
session-list refresh button, making the two adjacent actions look
identical. Switch the import affordance to a download-into-tray icon
to match its 'import sessions' semantics.

Closes #1135
2026-07-24 09:37:28 +08:00
SSU-WEI HUANGandGitHub 6bedd0d924 feat(tooling): preserve native tool titles (#1133) 2026-07-23 08:41:33 +08:00
SSU-WEI HUANGandGitHub e69ca0782f feat(web): make grouped tool summaries specific (#1134) 2026-07-23 08:41:02 +08:00
7688756827 fix(web): honor explicit older-history loads (#1125)
* fix codex session import merge (#1123)

修复 Codex 会话导入合并后列表为空的问题。

Fix Codex session import merge so a session is not detected as a duplicate of itself and deleted during merge.

Co-authored-by: LIUZHIRU <ryuu@fine-net.co.jp>

* fix(web): honor explicit older-history loads

Clear the initial scroll-settling window before Load older, outline
load-more, and outline target fetches so a user click is not swallowed
during the first-scroll-to-bottom settle period.

Automatic top-sentinel loads still respect settling.

Fixes #1067

---------

Co-authored-by: Himehane <36065996+Himehane@users.noreply.github.com>
Co-authored-by: LIUZHIRU <ryuu@fine-net.co.jp>
2026-07-22 23:32:49 +08:00
cd25660658 fix(web): keep overlays below PWA status bar (#1124)
* fix codex session import merge (#1123)

修复 Codex 会话导入合并后列表为空的问题。

Fix Codex session import merge so a session is not detected as a duplicate of itself and deleted during merge.

Co-authored-by: LIUZHIRU <ryuu@fine-net.co.jp>

* fix(web): keep overlays below PWA status bar

Account for env(safe-area-inset-top) on fixed top banners, image
preview chrome, and session action menus so installed PWA mode no
longer draws controls under the OS status bar.

Fixes #1066

---------

Co-authored-by: Himehane <36065996+Himehane@users.noreply.github.com>
Co-authored-by: LIUZHIRU <ryuu@fine-net.co.jp>
2026-07-22 23:32:10 +08:00
HimehaneandGitHub a965b0ab21 fix codex session import merge (#1123) (#1127)
修复 Codex 会话导入合并后列表为空的问题。

Fix Codex session import merge so a session is not detected as a duplicate of itself and deleted during merge.
2026-07-22 23:31:46 +08:00
AnanovoandGitHub 2623a51b0b feat(web): filter sessions by last activity (#1083)
* feat(web): filter sessions by last activity

* test(web): make session date filter tests deterministic
2026-07-19 14:15:24 +08:00
64834467e3 feat(codex): import and resume sessions from runners (#1088)
* fix codex import resume flow

* fix hub restart session active state

* fix codex transcript workspace scoping

* Address Codex import review findings

* Fix Codex import machine selection

* Update Codex sessions error test

* Address Codex import review findings

* Preserve forked Codex session id on sync

* Make Codex duplicate cleanup source-aware

* Handle Codex archive failures

* Limit existing session flag to Codex

* Preserve Codex import machine binding

* fix: rebase runner Codex import onto current main

* fix: preserve runner-scoped Codex import behavior

---------

Co-authored-by: syy <815728149@qq.com>
2026-07-19 14:14:42 +08:00
weishu 651c83a1d9 feat(web): replace agent flavor letter badges with brand logos
Use @lobehub/icons (already a dependency) for per-agent SVG logos in the
session list, session header, and new-session agent selector.

- Color variants for claude/codex/gemini; Mono (currentColor) for
  cursor/grok/opencode so glyphs track the theme text color
- kimi uses Mono as well: KimiColor's main glyph is hard-coded #fff and
  vanishes on the light theme
- pi and unknown flavors keep the letter-badge fallback (no logo shipped)
- Deep component imports avoid the package root's ./features re-export,
  which pulls uninstalled peer deps (antd, @lobehub/ui)
2026-07-19 13:05:25 +08:00
Junmo KimandGitHub 289c9f2218 feat(cli,web): show Claude Code's away recap in local-mode chat (#1089)
* feat(shared,cli): whitelist away_summary so auto recap reaches the hub

Claude Code's local TUI writes an automatic away-summary recap to the
session transcript on window blur/focus (5min+ idle), but
VISIBLE_CLAUDE_SYSTEM_SUBTYPES dropped it before it ever reached the
hub. Add it to the whitelist so the local launcher forwards it like
the other system subtypes, and cover the forwarding + Zod passthrough
of the recap `content` field with tests.

* feat(web): render Claude Code's automatic away recap in the chat

Once away_summary reaches the hub (previous commit), the web chat
still dropped it silently: normalizeAgent had no branch for the
subtype, so it fell through to `return null`. Add a `recap` AgentEvent,
a normalizeAgent branch mirroring the existing turn_duration/compact
subtype branches, and a presentation entry that prefixes the text with
`recap:` so it reads distinctly from the manual /recap assistant
bubble (which already renders as a normal message). No new render
component needed: it flows through the existing generic system-event
row (SystemMessage.tsx + getEventPresentation) that every other system
subtype already uses.

* fix(web): drop inaccurate manual-/recap comparison from recap comments
2026-07-19 12:24:32 +08:00