Commit Graph
283 Commits
Author SHA1 Message Date
weishu 97be34e21c Add Codex model selection 2026-04-25 10:48:12 +08:00
Junmo KimandGitHub b712ee67a5 fix(web): fall back to getRandomValues when crypto.randomUUID is unavailable (#523)
crypto.randomUUID is only exposed in secure contexts (HTTPS or
localhost). When the web app is served over HTTP on a LAN IP the
attachment adapter, toast provider, message localId helper, file
attachment metadata and terminal id creation all call
crypto.randomUUID() synchronously and throw TypeError, so the UI
silently does nothing (e.g. the file picker opens and closes with no
chip).

Add a small web/src/lib/randomId helper that tries crypto.randomUUID
first, then falls back to crypto.getRandomValues-derived UUID v4,
and finally to a Date.now/Math.random string for very old
environments. Route all five call sites through it. Output format is
identical for secure contexts and UUID v4 for the getRandomValues
path, so existing DB/SSE/RPC consumers see the same shape.
2026-04-24 13:59:19 +08:00
weishu 06adfa32c5 Add gpt-5.5 2026-04-24 11:18:42 +08:00
f097f10716 Preserve history when deduplicating agent sessions (#471)
* fix(hub): merge histories for duplicate agent sessions

* fix(hub,web): refresh active duplicate history merges

* fix(hub): avoid active-active history merges

* fix(web): reset message window on history invalidation

---------

Co-authored-by: Liu-KM <Liu-KM@users.noreply.github.com>
2026-04-21 13:56:50 +08:00
1cb353b7b3 fix(web): use global pointer listeners for sidebar resize handle (#497)
* fix(web): use global pointer listeners for sidebar resize handle

The current implementation attaches pointermove/pointerup to the
resize handle element via setPointerCapture. When the cursor moves
fast enough to leave the narrow 4px handle, the browser may not
deliver subsequent pointer events to the element, causing:

- Cursor stuck as col-resize even after releasing the mouse
- Sidebar stops tracking the pointer, requiring a page reload

Switch to document-level pointermove/pointerup/pointercancel listeners
that are added on drag start and cleaned up on drag end. This
guarantees events are captured regardless of cursor position.

Also removes onPointerMove and onPointerUp from the hook's return
value (and the JSX props in router.tsx) since they are no longer
needed — the hook manages everything internally via useEffect.

* ci: retrigger CI (flaky AcpSdkBackend test)

* fix: scope drag listeners to the initiating pointer ID

Address review feedback: filter pointermove/pointerup/pointercancel
by the pointer that started the drag, so a second finger or stylus
cannot interfere with the resize.

---------

Co-authored-by: huchenxi <huchenxi@lattebank.com>
2026-04-21 13:55:49 +08:00
Junmo KimandGitHub 32755f9056 feat(web): show queued status for messages pending inference (#492) 2026-04-20 19:49:26 +08:00
hu chenxiandGitHub d69f6dfc1e feat(web): make content width responsive on wide screens (#496) 2026-04-20 19:48:09 +08:00
Haoqing WangandGitHub bec7ac3c84 feat(web): widen content area from 720px to 960px (#493) (#494) 2026-04-19 19:05:08 +08:00
f408608db0 fix(web): exclude brackets from CJK autolink punctuation stripping (#486)
* fix(web): exclude brackets from CJK autolink punctuation stripping

Fullwidth brackets and parentheses (()【】「」etc.) can appear in
valid URL paths, so they should not be stripped. Narrow the regex to
only sentence-ending punctuation: comma, period, semicolon, colon,
exclamation, question mark.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): always show 'Agent launched' for internal metadata results

The tool state is set to 'completed' immediately when the result
arrives, so the state-based label was always showing 'Done' for
internal launch metadata. Remove the state check and always show
'Agent launched'.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): handle sentence-ending punctuation followed by closing brackets

The regex was missing cases like 。) where a sentence-ender is followed
by a closing bracket. Use a pattern that matches sentence-ending
punctuation optionally followed by trailing closing brackets/parens.
A bare closing bracket without a preceding sentence-ender is still
preserved as a valid URL character.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-17 11:37:06 +08:00
9248b6825f fix(web): hide raw internals in Agent tool card (#481)
* fix(web): hide raw internals in Agent tool card (#480)

The Agent tool card was exposing raw JSON input (including full prompts)
and internal system messages (agentId, output_file paths, system
instructions) in the details dialog. Register dedicated views:

- knownTools: show description as title, subagent_type as subtitle
- AgentFullView: show description, type, background status (not prompt)
- AgentResultView: detect internal launch messages and show "Agent
  launched" instead; render actual results as markdown for completed
  agents

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix: widen Agent result redaction to catch more internal metadata variants

Use || instead of && so any single internal marker (agentId:,
output_file:, internal ID) triggers redaction, not just the combination
of all markers.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix: use structural checks for Agent metadata redaction

Replace loose substring matching (which could false-positive on
legitimate agent output) with:
1. Structural check: result object has agentId/output_file keys
2. Strict text pattern: starts with the exact launch message prefix

Also make the label state-aware: "Done" for completed, "Agent launched"
for running.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-17 11:14:30 +08:00
5d1e616585 fix(web): strip CJK punctuation from auto-linked URLs (#479)
* fix(web): strip CJK punctuation from auto-linked URLs (#478)

remark-gfm auto-links bare URLs but only handles ASCII trailing
punctuation. When a URL is followed by CJK punctuation like ,or 。
without whitespace, the punctuation gets included in the link. Add a
remark plugin that walks the MDAST after GFM and moves any trailing
CJK/fullwidth punctuation out of the link node into a sibling text node.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix: add non-null assertion for link.children in test

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix: only strip CJK punctuation from auto-linked URLs, not explicit links

Only process links where the text content matches the URL (auto-links).
Explicit markdown links like [text](url) are left untouched, preventing
unintended mutation of deliberately authored URLs.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix: add non-null assertion for textChild.value

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix: remove duplicate unicode escapes from CJK punctuation regex

7 characters were listed twice (once as literals, once as \uXXXX
escapes). Keep only the literals and the 2 unique escapes (\u3000
ideographic space, \uFF0E fullwidth full stop).

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-17 11:02:30 +08:00
09c2c57eaf fix(web): hide assistant message copy button on mobile (#456) (#458)
The copy button on assistant messages was always visible on mobile
(opacity-60), positioned as a detached row below the message content.
Hide it entirely on small screens (hidden sm:flex) and keep hover-only
behavior on desktop. Mobile users can still copy via native long-press
text selection.

via [HAPI](https://hapi.run)

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-14 17:40:29 +08:00
fa8cf53c56 fix(web): prevent iOS PWA keyboard from pushing header behind status bar (#454) (#457)
On iOS PWA (black-translucent + viewport-fit=cover), opening the virtual
keyboard causes iOS to scroll the page upward, pushing the session header
behind the system status bar. Fix by resetting window.scrollTo(0, 0) when
the keyboard is detected open, and listening to visualViewport scroll
events in addition to resize to catch any deferred scrolling.

via [HAPI](https://hapi.run)

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-14 17:40:25 +08:00
40e51cb8d4 fix(web): hide filesystem path and raw prompt in Skill tool card (#455)
* fix(web): hide filesystem path and raw prompt in Skill tool card (#453)

The Skill tool card was exposing the full absolute filesystem path
(including username, plugin cache structure, and version numbers) and
the raw SKILL.md prompt content. Register a dedicated Skill presentation
in knownTools with a friendly title showing only the skill name, and add
a SkillResultView that displays "Skill loaded" instead of the raw output.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): register Skill full view to hide raw input in details dialog

Add SkillFullView to toolFullViewRegistry so the details dialog shows
only the skill name instead of falling back to renderToolInput which
would expose raw JSON input.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): handle non-text error payloads in SkillResultView

When the error payload is not text-extractable, fall back to a generic
"Failed to load skill" message instead of falling through to the success
path showing "Skill loaded".

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-14 17:40:21 +08:00
Haoqing WangandGitHub 7c6a7fa8ef fix(hub,web): deduplicate sessions by agent session ID (#448)
* fix(hub,web): deduplicate sessions by agent session ID

When multiple CLI wrappers independently resume the same Codex thread,
each generates a random tag, causing the hub to create duplicate session
records for a single underlying thread. This leads to duplicate
conversations in the web UI and messages routing to the wrong session.

Add two-layer deduplication:
- Hub: when a metadata update sets an agent session ID (codexSessionId,
  claudeSessionId, etc.) that already exists on another session in the
  same namespace, automatically merge the duplicate into the current
  session using the existing mergeSessions logic.
- Web: deduplicate the session list display by agentSessionId as a
  safety net, keeping the active/most-recent session visible.

Closes #446

* chore: add review-driven comments for dedup clarity

- Explain single-threaded assumption in before/after metadata comparison
- Document merge direction rationale (duplicate → active session)
- Document deduplicateInProgress guard as known limitation
- Add catch comment explaining web safety net fallback

* fix: address review feedback from bot, Opus, and Codex

- Skip active duplicates during hub-side dedup to avoid deleting
  sessions with live CLI sockets and pending agent state
- Pass selectedSessionId into web dedup sort to prevent hiding
  the session the user is currently viewing
- Add test for active-duplicate-not-merged case

* fix: retry dedup on session-end and preserve agentState in merge

- Trigger dedup when a session ends (handleSessionEnd), so active
  duplicates skipped during earlier dedup get merged once they disconnect
- Preserve agentState from old session during mergeSessions when the
  new session has no agentState (mirrors existing model/effort/todos
  preservation pattern)
- Extract triggerDedupIfNeeded helper for reuse across trigger points

* fix(web): prefer active session over selected in dedup sort

Active session always wins the dedup tie-break so the live connection
is never hidden in favor of a selected inactive duplicate. Among
inactive duplicates the selected one is still preferred.

* fix: dedup on inactivity timeout and deep-merge agentState

- expireInactive now returns expired session IDs so SyncEngine can
  trigger dedup for sessions that timed out (crash/network drop)
  instead of only on explicit session-end
- mergeSessions now deep-merges agentState requests/completedRequests
  from both sessions instead of only copying when new is null

* fix: exclude completed requests from merged pending set

Filter out request IDs that already appear in completedRequests when
merging agentState, preventing completed permission prompts from
resurrecting as pending after session dedup.

* fix: guard resume merge against prior auto-dedup

The automatic dedup (triggered when the spawned CLI sets its agent
session ID) can delete the old session before resumeSession reaches
its own explicit mergeSessions call. Skip the merge if the old session
no longer exists instead of failing the resume with a false error.

* test: add coverage for dedup retry paths and web dedup sort

Hub tests:
- session-end triggers dedup retry for previously-active duplicates
- inactivity timeout expiry triggers dedup retry
- agentState deep merge filters completed requests from pending set

Web tests:
- basic dedup by agentSessionId
- active session wins over inactive duplicate
- selected session preferred among inactive duplicates
- active always wins over selected inactive
- sessions without agentSessionId pass through
- independent dedup across different agentSessionIds

* fix: read latest agentState before merge write to avoid overwriting live updates

Re-read the target session's agentState right before writing the merged
result, with a version-mismatch retry loop, so concurrent update-state
events from the active CLI are not lost during dedup merge.

* fix: sort expired sessions by recency before dedup

When multiple duplicates for the same agent thread expire in a single
sweep, process the most recent one first so it becomes the merge target
and survives, rather than keeping the oldest by arbitrary iteration order.

* fix: select most recent session as merge target in dedup

deduplicateByAgentSessionId now collects all inactive candidates
(including the caller) and picks the one with the highest activeAt
(then updatedAt) as the merge target. This ensures the newest session
survives regardless of which trigger point or ordering calls the dedup.
2026-04-13 19:56:22 +08:00
Junmo KimandGitHub c32378b3ba feat(web): persist composer draft across session switches (#438)
* feat(web): persist composer draft across session switches

Switching between sessions now preserves the text typed in the
composer. Drafts are stored per-session in sessionStorage and
restored when the user navigates back.

- Add composer-drafts utility (sessionStorage, in-memory cache)
- Restore draft on HappyComposer mount, save on unmount
- Clear draft on message send
- Evict oldest drafts when exceeding 50 entries
- Add unit tests for composer-drafts

Fixes #231

* fix(web): add key prop to HappyComposer for explicit remount on session switch

* fix(web): move clearDraft to SessionChat after send validation

Prevents draft loss when Codex rejects an unsupported slash command.

* fix(web): remove explicit clearDraft, rely on unmount save

Successful sends clear the composer text, so the unmount save
naturally persists an empty string which deletes the draft entry.
This avoids clearing the draft when the send is blocked or fails.

* fix(web): clear draft on successful send via onSuccess callback

Move draft clearing to the send-success path so drafts are only
removed after the message is actually accepted by the server.

* fix(web): pass session ID to onSuccess to clear correct draft

The previous version used the current route's sessionId, which could
clear the wrong draft if the user switched sessions before the send
completed.

* test(web): add useSendMessage onSuccess callback tests

Verify that onSuccess receives the correct session ID (including
resolved IDs), and is not called on send failure or block.

* refactor(web): extract useComposerDraft hook with unit tests

Extract the draft save/restore logic from HappyComposer into a
dedicated useComposerDraft hook. Adds 6 unit tests covering:
- mount: restores saved draft via rAF
- mount: skips restore if composer already has text
- mount: skips restore if no saved draft
- unmount: saves current text after rAF has fired
- unmount: skips save before rAF (draftReady guard)
- no-op when sessionId is undefined

* fix(web): clear both route and resolved session drafts after send

When resolveSessionId swaps the session (e.g. inactive → resumed),
the sent ID differs from the route's session ID. Extract
clearDraftsAfterSend so both are cleared and unit-testable.

* fix(web): refresh eviction order when updating an existing draft

Delete the key before re-inserting so Object.keys() reflects the
most recent write, preventing a recently edited draft from being
evicted first.
2026-04-12 11:04:52 +08:00
Haoqing WangandGitHub 3b92268a40 fix(web): restore conditional TTL guard on visibility refresh (#443)
The forced refresh added in #442 triggers a full /api/auth round-trip
on every tab focus/visibility event. Since the JWT lifetime is now
4 hours, the original minTtlMs guard (refresh only when <60s remains)
is sufficient and avoids unnecessary auth traffic.
2026-04-11 22:12:20 +08:00
Haoqing WangandGitHub 813ac7fdda feat(web): add LaTeX math formula rendering with KaTeX (#436)
* feat(web): add LaTeX math formula rendering with KaTeX

Add remark-math + rehype-katex to the markdown rendering pipeline
so inline ($...$) and display ($$...$$) math formulas are rendered
as proper KaTeX output in chat messages and tool results.

Closes #237

* fix(web): disable single-dollar math parsing and add KaTeX to reasoning

- Set singleDollarTextMath: false to prevent $HOME, $PATH etc from
  being misinterpreted as math formulas. Only $$...$$ (display) is
  parsed; inline math requires explicit \(...\) or $$...$$.
- Add rehypePlugins to the reasoning renderer so math formulas
  render consistently across chat, tool results, and reasoning blocks.

* refactor(web): use satisfies for type-safe plugin exports

Replace any[] with satisfies NonNullable<MarkdownTextPrimitiveProps[...]>
to preserve type safety on the shared plugin lists without needing
eslint suppressions.

* fix(web): enable single-dollar inline math syntax

Re-enable $...$ parsing (remark-math default) so inline formulas
like $E=mc^2$ render correctly. Shell variables like $HOME typically
appear inside code spans/blocks which remark-math does not parse,
so false positives are minimal in practice.
2026-04-11 22:09:37 +08:00
Haoqing WangandGitHub 9a48d5af3a fix(hub,web): extend JWT expiration and harden visibility refresh (#442)
- Extend JWT expiration from 15 minutes to 4 hours in both auth and
  bind endpoints. 15 minutes was too short — browser timer throttling
  in background tabs prevented the scheduled refresh from firing
  before expiration, causing unexpected logouts.

- Change the visibility/focus refresh from conditional (minTtlMs) to
  forced, so returning to a backgrounded tab always re-authenticates
  regardless of remaining token TTL. This eliminates the race between
  timer throttling and token expiration.

HAPI is a self-hosted tool, so the longer token lifetime is an
acceptable security tradeoff. The auth source (Telegram initData or
CLI access token) is still validated on every refresh.

Closes #412
2026-04-11 22:03:50 +08:00
weishu 73e3d6e774 fix typecheck 2026-04-11 21:51:32 +08:00
Haoqing WangandGitHub f04a6fa226 fix(web): use explicit Manager+socket for terminal namespace connection (#433)
The convenience `io()` function misparses the `/terminal` path
component as part of the Engine.IO endpoint in some browser
environments, producing requests to `/terminal/socket.io/` instead
of `/socket.io/`. Using `new Manager(baseUrl)` + `manager.socket('/terminal')`
separates the transport URL from the namespace unambiguously.

Closes #251
2026-04-11 16:50:39 +08:00
Haoqing WangandGitHub 30f8b125a6 fix(web): allow multiline input with modifier+Enter in composer (#431)
* fix(web): allow multiline input with modifier+Enter in composer

Previously only Shift+Enter was recognized for newline insertion while
Ctrl+Enter, Alt+Enter, and Cmd+Enter all triggered message send.
This broadens the modifier check so any modifier+Enter inserts a newline.

Also sets submitOnEnter={false} to let the custom handleKeyDown manage
all Enter logic, eliminating dual-handler ambiguity with the library's
built-in submit behavior.

Closes #429

* fix(web): prevent modifier+Enter from accidentally sending messages

Only plain Enter should send; Ctrl/Alt/Cmd+Enter were incorrectly
falling through to the send path because the guard only checked
e.shiftKey. Now all non-Shift modifier combos are blocked from
sending (preventDefault + no-op).

Also keeps submitOnEnter={false} so the custom handleKeyDown is the
sole owner of Enter-key logic, avoiding dual-handler ambiguity.

Closes #429

* fix(web): restore Enter to accept autocomplete suggestions

The previous refactor made the Enter handler unconditionally return
before reaching the suggestion-selection path. Move suggestion
handling above the send/no-op block so Enter still accepts visible
autocomplete entries.
2026-04-11 16:50:36 +08:00
FemoonandGitHub ef87e30727 feat(web): redesign sidebar with resizable width and 3-level hierarchy (#427) 2026-04-11 09:52:56 +08:00
weishu 79a13d26c6 Fix Codex reasoning effort resume and updates 2026-04-10 11:50:03 +08:00
MimoandGitHub 0e1b653d43 feat: display background task count in status bar (#421) 2026-04-09 20:17:12 +08:00
MimoandGitHub f1daed80d6 fix: composer keyboard behavior and allow sending while agent is running (#422)
- Enter no longer inserts newline when sending is blocked (no-op instead)
- Shift+Enter now inserts newline (standard convention)
- Allow appending messages while agent is running (remove threadIsRunning
  gate from canSend) — matches Claude Code CLI behavior where users can
  queue messages during execution
2026-04-09 10:58:12 +08:00
e7ba48c761 fix(web): normalize non-sidechain text-only array user output as user message (#409)
The CLI wraps array-content user messages as agent output because
isExternalUserMessage rejects non-string content. On the web side,
detect text-only arrays in non-sidechain user output and emit them as
role:'user' so they display in the user lane.

Also handle sidechain user messages with mixed array content (e.g.
tool_result + text) by extracting text parts into a sidechain block.

Closes #407's original scope on top of the #402 base.

via [HAPI](https://hapi.run)

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-06 20:49:11 +08:00
73fa846df3 fix(web): drop "No response requested." assistant messages (#402)
* fix(web): drop "No response requested." assistant messages

When Claude Code injects system messages (task notifications, system
reminders) as user turns, Claude responds with "No response requested."
In the HAPI web UI this appears as a reply to the user's message,
making it look like Claude is ignoring their input.

Filter these out in isSkippableAgentContent() (catches the fallback
path in normalize.ts) and in normalizeAssistantOutput() (catches the
primary path). Both checks verify the assistant message contains only
the text "No response requested." with no tool calls.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): filter text block instead of dropping entire message

Address bot review: dropping the whole normalized record breaks
sidechain UUID threading (parentUUID chain orphans).

Instead of returning null, suppress only the "No response requested."
text block during content extraction. The message record (uuid,
parentUUID, usage) is preserved so the tracer's sidechain grouping
continues to work.

Also remove the isSkippableAgentContent check since we no longer
need to drop the message at that layer.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): move "No response requested." filter to reducer layer

Address bot review: filtering in normalizeAssistantOutput() produced
empty content arrays, breaking traceMessages() which reads uuid and
parentUUID from content[0]. Sidechain child messages whose parentUUID
pointed to the filtered message became orphaned.

Fix: revert the normalizer to always emit the text block (preserving
the UUID chain for the tracer), and filter the sentinel text in
reducerTimeline.ts where text blocks become visible AgentTextBlocks.
At this point tracing is already complete.

Also adds reducer-level tests for the filter and updates the
normalize test to verify the text block is preserved.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): scope sentinel filter to single-block assistant messages only

Address bot review: the previous filter suppressed any text block
matching "No response requested.", which could hide legitimate replies.

Now the filter only triggers when the message has exactly one content
block (msg.content.length === 1) — i.e., the assistant response is
purely the sentinel text with no tool calls or reasoning blocks.
This prevents false positives while still catching the system-injection
auto-reply case.

Add test for the multi-block case (text + tool call) to verify the
sentinel text is preserved when other content exists.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): add parentUUID structural check to sentinel filter

Address bot review: raw text match alone could theoretically suppress
a legitimate reply.  Add c.parentUUID !== null as a structural guard:

- Sentinel auto-replies always follow a prior assistant turn, so their
  parentUUID is set (pointing to the previous message in the chain).
- A first message in a conversation has parentUUID: null and will
  never be filtered.

Combined conditions: msg.content.length === 1 (sole block, no tool
calls) AND c.parentUUID !== null (not the first reply) AND exact text
match.

Add tests for the parentUUID=null escape hatch.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): use injected-turn UUID tracking for sentinel filter

Address review: use structural markers instead of broad text matching.

1. Pre-scan collects UUIDs from sidechain content blocks (system-
   injected user turns). The sentinel filter now only triggers when
   parentUUID points to one of these known injected turns.

2. Move task-notification event extraction from normalizer to reducer.
   Previously, task-notifications with summary were normalized as
   role:'event', losing their uuid. Now they stay as sidechain (uuid
   preserved for pre-scan), and the reducer extracts the summary as
   an agent-event block.

3. Remove redundant 'uuid' in c guard (always present on sidechain type).

False positive analysis: a legitimate reply is only suppressed when ALL
of: (a) sole content block, (b) parentUUID matches a sidechain-injected
turn, (c) exact sentinel text. This combination cannot occur for real
user-facing content.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): add parentUUID to sidechain content type for tracer linkage

The sidechain content block was missing parentUUID, so traceMessages()
could not chain system-injected user turns (task notifications, system
reminders) inside a Task sidechain back to their parent. This caused
later sidechain messages pointing to the injected turn's UUID to become
orphaned and disappear from the Task card.

Add parentUUID to the sidechain type definition and propagate it from
normalizeUserOutput() in both the isSidechain and non-sidechain paths.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): handle array-content sidechain user messages to prevent prompt leak

Sidechain user messages can arrive with either string content or array
content ([{type:'text', text:'...'}]) depending on how Claude Code
serialises them. The previous fix only handled the string case, causing
intermittent prompt leaks when array format was used.

Now normalizeUserOutput extracts text from array-content sidechain
messages and emits them as sidechain blocks, so the tracer can match
them to their parent Task tool call.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* test(web): verify parentUUID propagation from assistant output data

Add integration tests confirming normalizeAssistantOutput correctly
maps data.parentUuid to text block parentUUID (used by the reducer's
sentinel detection). Tests cover both present and absent parentUuid.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-06 20:42:40 +08:00
7ba15d1e75 fix(web): move copy button to inline layout to prevent tool card overlap (#404)
The copy button was absolutely positioned (right-0 top-0) over the
entire message content area. When assistant messages contain both text
and tool calls (e.g. TodoWrite), the button overlapped the tool card UI.

Move the button from absolute positioning inside the content wrapper to
an inline flex layout after the content. This places it at the bottom-
right of the message, below all content (text + tool cards), so it never
overlaps anything. The hover-to-reveal behavior is preserved.

Also restores getAssistantCopyText to its original logic so mixed
text+tool messages remain copyable (the previous fix of suppressing the
button entirely for mixed messages was too aggressive).

via [HAPI](https://hapi.run)

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-06 20:41:59 +08:00
Haoqing WangandGitHub 1c8cb5c90d fix(web): prevent mobile keyboard from covering chat input (#403) 2026-04-06 05:39:40 +08:00
Junmo KimandGitHub ea09663cdc refactor: organize model definitions and flavor capabilities into dedicated modules (#400) 2026-04-05 22:49:29 +08:00
4ffcb4cfdb fix(hub): raise maxRequestBodySize so file uploads work (#397)
* fix(hub): raise maxRequestBodySize so file uploads work

The Bun server inherited maxRequestBodySize from Socket.IO's default
maxHttpBufferSize (1 MB).  The upload endpoint sends files as base64
in JSON, so any image > ~750 KB was silently rejected before reaching
the route handler.  The frontend allows 50 MB uploads.

Raise the limit to at least 100 MB to accommodate 50 MB files with
base64 encoding overhead (~33%).

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(hub,web): fix file uploads — raise body limit, lower max size, show errors

Three changes:

1. hub/server.ts: Bun's maxRequestBodySize inherited Socket.IO's 1 MB
   default, silently rejecting any upload. Raise to 10 MB.

2. hub/routes + web/attachmentAdapter: lower MAX_UPLOAD_BYTES from
   50 MB to 5 MB (realistic for images; 5 MB base64 ≈ 6.7 MB body,
   fits within the 10 MB server limit).

3. web/AttachmentItem: show "Upload failed" text and strike-through
   filename on error, instead of just a tiny icon.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(hub): keep 50MB upload limit, size maxRequestBodySize to match

Bot review correctly flagged that lowering MAX_UPLOAD_BYTES to 5 MB
regresses the documented 50 MB limit. Revert to 50 MB and calculate
maxRequestBodySize properly: 50 MB × 4/3 (base64) + 1 MB (JSON
overhead) ≈ 68 MB.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-05 13:21:31 +08:00
450b4f8aa7 fix(web): reconnect SSE immediately when tab becomes visible (#398)
* fix(web): reconnect SSE immediately when tab becomes visible

The SSE watchdog skips heartbeat checks while the tab is hidden. If the
connection dies in the background, the user sees stale messages after
switching back and has to wait up to 10 s for the next watchdog tick.

Add a visibilitychange listener that checks heartbeat staleness
immediately when the tab becomes visible and reconnects if stale.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): map visibility-recovery reason in reconnecting banner

The new 'visibility-recovery' reconnect reason was not mapped in
getReasonLabel(), so the raw string would appear in the UI banner.
Add localized labels for both en and zh-CN.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-05 13:21:19 +08:00
29b87d92fc fix(web): disable indented code blocks in markdown rendering (#395)
* fix(web): disable indented code blocks in markdown rendering

In CommonMark, text indented by 4+ spaces is treated as a code block.
LLM responses frequently have indented content inside numbered lists or
quoted text, causing large chunks to render as a single code block
instead of formatted markdown.

Add a remark plugin that disables the codeIndented tokenizer. Fenced
code blocks (``` … ```) continue to work normally.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): fix typecheck for remark plugin this binding

Use `as any` cast for the unified processor `this` context instead of
an explicit type annotation that conflicts with the Processor type.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): use this:unknown annotation to satisfy noImplicitThis

The previous `as any` cast on `this` still triggers noImplicitThis in
strict mode. Annotate the parameter as `this: unknown` and cast to
the required shape inside the function body. Also use the key-based
`data(key, value)` API instead of mutating the returned object.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-04-05 12:37:38 +08:00
DengQiandGitHub 139a21c66f feat(web): add copy button to assistant messages (#392) 2026-04-05 12:32:36 +08:00
Junmo KimandGitHub 00ba610ab0 feat: display rate limit warnings instead of raw JSON (#388)
* refactor(web): extract normalizeTimestamp helper in presentation

Extract the shared seconds-vs-milliseconds normalization logic into
a private `normalizeTimestamp()` helper. No behavior change —
`formatUnixTimestamp()` produces identical output.

* refactor(web): return AgentEvent from parseClaudeUsageLimit

Change return type from `number | null` to `AgentEvent | null` so
the caller doesn't need to construct the event object. No behavior
change — the same `limit-reached` event is produced.

* feat(cli): convert rate_limit_event to standardized text format

Parse undocumented Claude `rate_limit_event` JSON in the CLI adapter
layer (AcpMessageHandler) before it reaches the web.

Converted text format (pipe-delimited):
  - "Claude AI usage limit warning|{ts}|{pct}|{rateLimitType}"
  - "Claude AI usage limit reached|{ts}|{rateLimitType}"

Status handling:
  - `allowed_warning` → warning text with utilization and limit type
  - `rejected` → reached text with limit type
  - `allowed` → silently suppressed (noise)
  - unknown statuses → passed through as-is (forward-compatible)

* feat(web): display rate limit warnings with limit type

Parse standardized pipe-delimited text from the CLI adapter into
`limit-warning` and `limit-reached` events, displaying the rate
limit type (5-hour, 7-day) when available.

- `limit-warning`: "⚠️ Usage limit 90% (5-hour) · resets 2:00 PM"
- `limit-reached`: " Usage limit reached (5-hour) until 4/2/2026"
- Backward compatible: `limit-reached` without limitType still works

The `reached` regex uses `(?:\|([^|]*))?$` to optionally match the
limitType field, maintaining compatibility with the existing format.

* refactor(cli): move rate limit parsing out of flushText

Remove rate limit detection from flushText() back to plain buffer
flush. The next commit will re-add parsing at the chunk level
(handleUpdate) where it can intercept before buffer merging.

Includes failing tests that demonstrate the mixed-chunk bug:
when a rate_limit_event chunk arrives in the same turn as normal
text, the JSON leaks into the merged buffer.

* fix(cli): intercept rate_limit_event at chunk level, not flush

Move rate limit detection from flushText() to the agentMessageChunk
handler so it fires before the chunk enters the shared text buffer.

Previously, a rate_limit_event chunk arriving in the same turn as
normal text would merge into bufferedText and leak as raw JSON.
Now the chunk is intercepted individually, the existing buffer is
flushed first (preserving prior text), and the converted message
is emitted separately.

* fix(cli): skip flush when suppressing allowed rate_limit_event

Only flush the text buffer when the parsed event will actually be
displayed. Suppressed events (e.g. status: 'allowed') now return
immediately without flushing, preventing a text → allowed → text
sequence from splitting one answer into two agent-text blocks.

* fix(web): include limitType in limit-reached reconcile key

Without this, reprocessing a message from the old format (no
limitType) to the new typed format reuses the stale block and
the (5-hour)/(7-day) suffix never appears.
2026-04-03 08:25:22 +08:00
Haoqing WangandGitHub 36022a0a1a fix(web): filter system-injected XML tags from rendering as raw text (#387)
* fix(web): filter system-injected XML tags from rendering as raw text

Claude Code injects internal messages (<task-notification>, <system-reminder>,
<command-name>, <local-command-caveat>) as user-role messages. The web UI was
rendering these as raw XML text visible to users.

- Parse <task-notification> and display as agent-event with summary text
- Silently drop <system-reminder>, <command-name>, <local-command-caveat>
- Add tests covering all injection prefixes and edge cases

* fix(web): scope system injection filtering to Claude sessions only

Address review feedback: the XML tag filtering was applied at the
generic timeline layer, which could incorrectly hide legitimate user
messages in Codex/Gemini sessions.

- Add isClaudeSession flag threaded from Session.metadata.claudeSessionId
- Only filter system-injected tags when isClaudeSession is true
- Add tests verifying non-Claude sessions pass through all messages

* fix(web): treat all string user output as sidechain to prevent prompt leaks

Restores the fix from 3cf96ab that was accidentally reverted in 2205e04.

In normalizeUserOutput(), string-content user messages arriving through
the agent output path are never real user input (real user text goes
through normalizeUserRecord). Previously, non-sidechain string messages
were emitted as role:'user', causing subagent prompts and system-injected
messages to render as user text in the web UI.

Now all string-content user messages in this path are:
- <task-notification> with summary → converted to role:'event'
- Everything else → marked as sidechain (matched to parent Task tool
  call by the tracer, or harmlessly skipped by the reducer)

This provides a root-level fix that prevents ANY string user message
from the agent output path from leaking as visible user text.

* ci: retrigger CI

* fix(web): remove superseded return-null filter from upstream PR #372

The upstream `return null` filter for <task-notification> and
<system-reminder> (from PR #372) is now superseded by the comprehensive
sidechain upgrade logic. Remove it to avoid short-circuiting the new
task-notification → event conversion.

* refactor(web): remove reducer-side system injection filtering

System-injected messages are now fully handled in normalizeUserOutput()
(normalize layer), so the redundant filtering in reduceTimeline() is no
longer needed. Removing it also eliminates the risk of accidentally
hiding legitimate user messages that happen to start with XML tags.

- Remove SYSTEM_INJECTION_PREFIXES, isSystemInjectedMessage,
  parseTaskNotificationSummary from reducerTimeline.ts
- Remove isClaudeSession plumbing from reducer.ts and SessionChat.tsx
- Simplify reducerTimeline.test.ts to only test pass-through behavior
2026-04-02 14:08:03 +08:00
gaius-codiusandGitHub 845a1001fe feat(web): group sessions by machine and improve group headers (#383) 2026-04-01 18:30:31 +08:00
Junmo KimandGitHub 4eb88c5d7e feat(gemini): support mid-session model change (#379) 2026-04-01 11:15:11 +08:00
Haoqing WangandGitHub 2c20b04bec fix(web): suppress Task tool prompt text from leaking into chat (#372) 2026-03-31 10:46:42 +08:00
Wong ChihungandGitHub 1f67d36c06 feat(models): support new gemini models and codex gpt-5.4-mini (#376) 2026-03-29 21:33:33 +08:00
Haoqing WangandGitHub 2216f98c58 fix(web): render multiline mutation results as code blocks to prevent markdown mis-parsing (#371) 2026-03-27 16:05:10 +08:00
QihanandGitHub 2b133feec5 fix(web): keep mobile views scrollable and new-session actions reachable (#364) 2026-03-26 08:04:58 +08:00
QihanandGitHub 92885ddef0 fix(web): hide unsupported Codex slash commands in remote mode (#357) 2026-03-25 05:38:25 +08:00
QihanandGitHub acda983e02 fix(web): restore mobile scrolling outside Telegram (#358) 2026-03-25 05:37:48 +08:00
a200fe9628 feat(claude): add effort setting parity with model across stack (#353)
Co-authored-by: Xiaoyi <xiaoyizhang@microsoft.com>
2026-03-24 21:15:48 +08:00
ad4df369ea feat(web): add copy button to user messages (#349)
* feat(web): add copy button to user messages

Add a small copy button to user message bubbles for easy text copying,
especially useful on mobile where selecting text is difficult.

- Mobile: button always visible (opacity-60)
- Desktop: button appears on hover
- Uses existing useCopyToClipboard hook with haptic feedback
- Conditionally rendered to avoid empty container spacing

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): use valid CSS property in copy button transition

transition-[opacity,colors] is invalid because 'colors' is not a CSS
property (only Tailwind's utility class 'transition-colors' expands it).
Use 'background-color' instead so the hover background transition
actually works.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-03-24 14:16:19 +08:00
weishu 900ee2eccb fix PWA icon 2026-03-24 12:32:37 +08:00
Haoqing WangandGitHub 9de5bed19f fix(web): add visual indicator for sending message status (#344) 2026-03-23 14:57:10 +08:00
Haoqing WangandGitHub 4c11fbe5f2 fix(web): stop rendering sidechain prompt as user message (#340) 2026-03-23 12:40:03 +08:00