mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
f1accabb29552c57a2375d44e9d701b06f31576d
315
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
be0a41172a |
feat(web): add directory quick session action (#624)
* fix(codex): support app-server plan mode * fix(codex): broaden plan mode compatibility checks * feat(web): add directory quick session action * fix(web): hide quick session action for unknown directory |
||
|
|
089ddad476 | feat: support Codex goal slash command | ||
|
|
94b644c3f3 | feat(web): add image file preview (#623) | ||
|
|
60af9835b4 | feat(web): 优化聚合 tool use 展示与聊天背景设置 (#619) | ||
|
|
088a712f1e |
Stop active Codex child agents on abort (#615)
* fix(cli): stop active codex child agents * chore: refresh bun lockfile for deploy * fix(web): enable stop for active codex child agents * test(cli): cover aborting active codex child agents |
||
|
|
af3491e046 |
feat(web): group consecutive tool-use cards (#604)
* feat(web): group consecutive tool-use cards Add a web-only visible projection that groups consecutive root-level execution tools into expandable cards. Keep approval and question tools standalone, reuse older-history loading on expand, and add regression coverage for grouping and UI behavior. * fix(web): hydrate oldest visible tool group Mark needsOlderHistory on the first visible grouped tool run even when earlier visible blocks are non-tool content, and add regression coverage for the boundary. * fix(web): continue grouped history hydration Decouple ToolGroupCard older-history chaining from the shared loading flag, invalidate stale hydration runs safely, and add regression coverage for multi-page hydration. * fix(web): harden grouped tool hydration - retry incomplete group hydration after transient pagination contention\n- keep approved and denied permissioned tool cards eligible for grouping\n- cover both regressions with targeted web tests * fix(web): keep Codex permission cards standalone - treat CodexPermission as a semantic grouping boundary even after approval\n- keep permissioned execution tools groupable while preserving permission milestones\n- add regression coverage for Codex permission eligibility and boundary behavior * fix(web): narrow incomplete tool-group hydration - only mark groups at the oldest visible boundary as needing older history\n- avoid auto-paginating complete groups behind text, standalone tools, or permission milestones\n- add regression coverage for the adjacent boundary cases |
||
|
|
77f6aae169 |
修复 workspace 浏览页初始化后不加载目录 (#605)
* fix browse workspace root loading * fix workspace root dependency stability * avoid resetting browse state on metadata refresh |
||
|
|
d8f3083c75 |
fix(web): 完善 Files 页面 i18n (#607)
* fix(web): 补全 Files 页面 i18n * fix(web): 修正 Files git 错误聚合翻译 |
||
|
|
3a6574f2b9 | fix(web): compact terminal tool cards by default (#601) | ||
|
|
991b01cd10 |
fix(web): stabilize session history recovery (#593)
* fix(web): stabilize session history recovery * fix(web): split latest and older history generations Separate latest and older async guards in the message window store. Prevent refreshes from wedging load-more state and add a regression test for the overlap. |
||
|
|
841b7cc035 |
Add Codex multi-agent timeline support (#588)
* checkpoint codex multiagent UI state * fix codex multiagent event scoping * fix: stabilize codex subagent timeline * chore: remove codex subagent nesting prompt * test(web): stabilize tool result rendering tests * fix: collapse codex agent trace rows by default * fix(web): keep chat scrolled to bottom * fix(web): backfill agent-run-heavy message loads * fix(codex): fail stuck subagent spawns * fix(codex): preserve wrapped child event scope * fix(codex): surface agent tool completions |
||
|
|
0006d04f9e | feat: support multiple workspace roots (#584) | ||
|
|
e17d7e5995 | fix(web): align Agent tool dialog with TUI ctrl+o expand (#585) | ||
|
|
08d3d9e111 | feat(web): add composer enter behavior setting (#586) | ||
|
|
6df84df756 | fix(hapi): consolidate approved web and Codex recovery fixes (#578) | ||
|
|
8185f0287e | feat(web,hub): cancel queued messages (#568) | ||
|
|
f7a40bd573 |
feat(web): polish chat rendering and fix remote session interactions (#567)
* feat(web): polish chat rendering Refresh the web chat presentation across user messages, tool cards, code blocks, diffs, reasoning, and Mermaid diagrams.\n\nAdd focused regression coverage for bubble/status behavior, code and diff rendering, clipboard output, Mermaid theming, and message-window updates. * fix(web): stabilize chat tool rendering Preserve manual scroll anchors while older messages and tool dialogs update, and align code, diff, and tool result rendering with chat typography. Add chat font-weight settings, ignore local Playwright CLI artifacts, document the Angular commit-message convention, and cover the scroll, result, code, diff, and settings behavior with focused tests. Constraint: User requested committing all current workspace diffs with Angular-style commit messaging Tested: bun run typecheck:web && bun run test:web && git diff --check Co-authored-by: OmX <omx@oh-my-codex.dev> * style(tool-card): polish question and permission card styles Align AskUserQuestion option surfaces and permission action hierarchy with the existing tool card visual language while preserving interaction logic. Extract shared option presentation helpers and theme-driven hover/muted colors to reduce duplication. Constraint: Frontend style-only polish; preserve existing permission and answer submission behavior Rejected: Keep screenshot artifacts in the repo | they are local visual review output, not source Confidence: high Scope-risk: narrow Tested: git diff --check; bun run typecheck:web; bun run test:web; bun run build:web Not-tested: manual cross-browser visual QA beyond local Playwright inspection Co-authored-by: OmX <omx@oh-my-codex.dev> * fix(cli): keep Claude remote plan prompts actionable Handle Claude remote /plan locally so HAPI switches plan permission mode before forwarding any prompt text. This avoids Claude Code treating /plan as an unknown skill and ending with only a ready event. Constraint: Claude SDK result messages are not conversation log entries, so command handling must happen before the prompt reaches Claude.\nRejected: surfacing SDK result summaries in web chat | would expose transport-level summaries broadly instead of fixing the slash-command path.\nConfidence: high\nScope-risk: narrow\nDirective: Keep Claude remote slash commands that alter runtime mode in the CLI special-command parser.\nTested: bun test cli/src/parsers/specialCommands.test.ts; bun typecheck; git diff --check\nNot-tested: Manual GitHub-hosted runner deployment. * fix(web): polish tool result rendering * fix(web): preserve collapsed session order * fix(chat): settle initial thread scroll * fix(settings): remove chat font weight option * fix(web): remove font weight bootstrap code * chore: remove unrelated branch artifacts * test(web): update consumed message invocation test * fix(chat): cancel initial scroll settling on manual scroll --------- Co-authored-by: huhaoyu.hahahu <huhaoyu.hahahu@bytedance.com> Co-authored-by: OmX <omx@oh-my-codex.dev> |
||
|
|
fad5dbbc30 |
fix(web): localize toast messages and keep full session counts (#573)
Normalize hub toast text in the web client for i18n coverage (including Ready for input notifications) and stop deduplicating session rows by agentSessionId so outline/group counts reflect user-visible sessions. Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
136badb86e |
fix(gemini): surface tool_call input on Gemini ACP cards (#562)
* fix(acp): derive tool_call input from kind+title fallback
Gemini 2.5 Flash and 3 Flash Preview omit rawInput entirely on
tool_call events while emitting prose (non-JSON) thoughts. Neither
the existing rawInput path nor JSON-thought hoisting fires, so the
UI shows "Input: null" alongside a perfectly readable title like
"README.md" or "ls -la /tmp".
Add a conservative fallback that maps known kinds to a minimal
input shape:
read -> { file_path: title }
execute -> { command: title }
search -> { pattern: title }
think -> null (topic-update prose has no clean arg mapping)
unknown -> null (no guessing on shapes we have not verified)
Priority: rawInput > hoisted JSON thought > kind+title derive.
Lock the new behaviour with synthetic unit tests (8 cases) and a
real-Gemini fixture suite captured from gemini-3-flash-preview
and gemini-2.5-flash via ACP stdio (4 fixtures, 33/27/13/4 raw
sessionUpdate events). The fixtures double as regression guards
against future ACP handler changes.
* fix(web): suppress duplicate subtitle when equal to tool title
Gemini ACP emits a tool_call whose title field is a human-readable
summary (often the verbatim shell command or file path). Combined with
the kind+title input fallback, an unknown-tool card ends up with the
same string in both the title and subtitle slots — e.g. title
"cat /tmp/hello.txt" over subtitle "cat /tmp/hello.txt".
Add a guard in getToolPresentation's unknown-tool branch: emit
subtitle only when it differs from toolName. The known-tool and
mcp__* branches are unaffected.
* test(acp): align Gemini fixtures to current model set
- Drop gemini-2.5-flash fixtures: the captures came from a model that
is not part of the PR's evidence model set, and re-running the
capture is gated on quota that is not currently available.
- Refresh gemini-3-flash-preview read_file / run_shell fixtures with
a fresh live capture so they reflect the latest ACP shape (e.g.
a `kind: think` tool_call expressing reasoning when the model emits
no agent_thought_chunk).
- Update fixture-replay expectations: read_file no longer requires
reasoning chunks (zero are emitted on this path) and now requires
>= 2 tool_calls (think + read).
* feat(web): promote semantic title for Gemini ACP tool cards
When the unknown-tool ToolCard would render the same string as both
the title and the subtitle, promote a semantic label to the title
slot so the card reads like a sentence:
cat /tmp/hello.txt → Run shell / cat /tmp/hello.txt
README.md → Read file / README.md
*.ts → Search / *.ts
This is a web-only ergonomic change; the underlying ACP message
shape (tool_name = title, input = derived from kind+title) is
unchanged. Builds on the dedup guard so the title-equals-subtitle
case is now handled by promotion rather than by hiding the subtitle.
* fix(acp): derive tool_call.input for kind=edit from locations[0].path
Gemini's write_file and replace tools both surface as ACP tool_call
with kind="edit" and rawInput omitted. The path lives on locations[0]
from the very first event; the title is prose like "Writing to foo.txt"
or "foo.txt: old => new", which is not safely usable as a file_path.
Extend the kind+title fallback to read locations[0].path when kind is
"edit", and synthesize { file_path } from it. Title fallback is
intentionally not used here so we never feed prose into file_path.
Lock the behaviour in with two new fixtures captured live from
gemini-3-flash-preview (write_file and replace) plus two synthetic
unit tests covering the locations-present and locations-empty paths.
* test(acp): add gemini-3.1-pro-preview fixtures for regression coverage
Captured 4 raw ACP `sessionUpdate` sequences from a live
`gemini-3.1-pro-preview` session via the same isolated hub +
runner + spawn pattern used for the existing flash captures
(read_file 31 events / run_shell 83 events / write_file 4 events /
edit_file 11 events).
The pro tier reuses the same kind/title shape as flash:
`rawInput` is omitted on every tool_call across read / execute /
edit kinds, so the kind+title (and locations[0].path for edit)
fallback is exactly what derives the modal Input. Locking these
fixtures in guards against future regressions on a second model.
The fixture-based regression test gains 4 entries (read / shell /
write / edit) mirroring the flash matrix; assertions are unchanged.
ACP handler suite: 53 -> 57 pass.
|
||
|
|
02bc206f8a |
fix(web): polish session search and ordering (#551)
Align session search controls, hide the native search clear button, and keep collapsed session previews ordered by activity while still expanding previews for the selected session. |
||
|
|
d9d7ed6699 |
feat(web): show message metadata (invoke time, duration, model) on click (#555)
* feat(web): show message metadata (invoke time, duration, model) on click
* fix(cli): preserve model field on assistant messages forwarded to hub
`RawMessageSchema` validates the `message` object in Claude Code session
JSONL lines before the cli forwards each message to the hub. Zod's default
parse mode strips fields that the schema does not declare, so the
`message.model` value (e.g. `claude-sonnet-4-6`) was silently removed
before the message reached the hub. The web normalizer reads
`data.message.model` to label assistant blocks, so without this field
every assistant message fell back to a generic "AI Model" label —
defeating the per-message model attribution this PR adds.
Add `model` to `RawMessageSchema` so it survives parse and reaches the
hub intact.
* fix(web): drop dead model shorthand in result envelope normalize
The `result/success` branch in `normalizeAgentRecord` referenced a `model`
identifier that was never declared in the function scope, breaking
`bun typecheck`. The reducer that consumes the resulting `turn-duration`
event does not look at `model` on the event itself, so the shorthand was
dead code. Remove it to restore typecheck.
* refactor(web): simplify turn-duration matcher with findLastIndex
Replace the imperative reverse-scan loops in the `turn-duration` reducer
branch with `findLastIndex`. The previous fallback also had an awkward
double-loop that mutated the matched block in place; using an index plus
a single immutable update keeps the block reference clean and makes the
match priority (id-prefix > tool-call id > last assistant-like) explicit.
Behaviour is unchanged — existing reducer tests cover both the messageId
match and the fallback paths.
* fix(web): preserve per-message model across mid-session model switches
The metadata footer fell back to `Session.model` from chat context when a
message did not carry its own `model`. That session value mutates when
the user switches models mid-session, so older messages were relabeled
with the latest model — including Codex/local assistant paths
(`AGENT_MESSAGE_PAYLOAD_TYPE`) that don't populate `msg.model`.
Drop the mutable-context fallback: pass `messageModel ?? null` to
`MessageMetadata` and let it omit the model line when no per-message
value is available. This is correct behaviour for messages whose
producer didn't record a model, and avoids ever attributing a message
to a model that didn't generate it.
Also remove the now-unused `useHappyChatContext` import in this file.
Add reducer invariants to lock in the data flow:
- `preserves per-message model across mid-session model switches`
- `leaves model undefined when message lacks per-message model`
* fix(web): keep tool-block reference identity when applying turn-duration
`ensureToolBlock` stores the same `ToolCallBlock` instance in both
`toolBlocksById` and `blocks`. The earlier refactor cloned the matched
block via `blocks[foundIndex] = { ...b, durationMs }`, which left the
map pointing at the stale original. A subsequent permission/result
mutation through `ensureToolBlock` would then update the stale map
object while the rendered `blocks` entry never sees the completion or
result, causing tool cards to miss state transitions.
Mutate the matched block in place instead — same in-place pattern the
reducer used before — and gate the assignment on the kinds that carry a
`durationMs` field so TypeScript narrows correctly.
Add an invariant test that fires a `turn-duration` event at a tool-call
block and asserts the rendered block and `toolBlocksById.get(...)`
remain the same object reference.
* fix(web): do not render service_tier as the model id
`MessageMetadata` previously fell back to `usage.service_tier` as the
"model" when no per-message `model` was available, so messages without
their own model id could surface labels like `Model: standard_only` —
service_tier is tier metadata, not a model.
Render the model line only when a real `model` is present; if a
non-`standard` `service_tier` is the only signal, surface it as a
separate `Tier: <tier>` label so it is not mistaken for the model.
The standard tier is the implicit default and is never rendered alone.
Extract the label-building logic into `buildMessageMetadataLabels` so
it can be unit-tested without a DOM. Add tests covering: model present,
model missing with non-standard tier, default standard tier, model with
non-standard tier appended, and the empty-input case.
* fix(web): metadata toggle ignores clicks on nested interactive controls
The bubble-level click handler that opens the metadata footer wraps
interactive descendants — tool-card buttons, retry buttons, dialog
triggers (Radix `role="button"`), and the Markdown code-copy button.
Clicking any of those flips the metadata footer as a side effect, even
when the descendant is the actual target of the user's intent.
Extract the closest-ancestor check into a small `metadataToggle` helper
and route both `AssistantMessage` and `UserMessage` click paths through
it. The toggle bails out when the click target sits inside any
`button`, `a`, `input`, `textarea`, `select`, or `[role="button"]`
ancestor; plain message-body text still toggles as before.
Add unit tests covering: button target, nested span inside a button,
`role="button"` Radix-style trigger, anchor/input/textarea/select form
controls, plain message-body text (no toggle), and a non-HTMLElement
target.
* fix(cli): preserve messageId on system/turn_duration record
`web/src/chat/normalizeAgent.ts` matches each `turn-duration` event to
the assistant block carrying the same `data.messageId`. Claude code
emits that field on the `system/turn_duration` record, but
`RawJSONLinesSchema`'s system branch did not declare `messageId`, so
Zod stripped it before the cli forwarded the record to the hub. The
matcher then fell back to "the last visible block", which is wrong for
interleaved/tool-heavy turns and silently attaches the duration to the
wrong assistant block.
Add `messageId: z.string().optional()` to the system schema so the id
survives parse and reaches the web reducer. Tests cover the preserved
case, the legacy case without `messageId`, and the previously-fixed
`message.model` case so Zod strip regressions on adjacent fields stay
locked in.
* fix(web): metadata toggle accepts SVG event targets
`isClickOnNestedControl` only walked up via `closest` when the click
target was an `HTMLElement`. The copy / retry / Markdown code-copy
buttons render SVG icons, so clicking the icon makes the event target
an `SVGElement` (not an `HTMLElement`) — the guard returned false and
the bubble-level click flipped the metadata footer anyway.
Widen the type check to `Element`, which is the common super-class of
both `HTMLElement` and `SVGElement` and also exposes `closest`. Plain
text targets and non-Element targets still behave as before.
Add a regression test that mounts an icon-only button (`<button><svg>
<path/></svg></button>`) and asserts both the `<svg>` and `<path>`
targets walk up to the enclosing button.
* refactor(cli): rely on Zod passthrough for jsonl envelopes
`RawMessageSchema` and the `system` branch of `RawJSONLinesSchema` were
declared with Zod's default `strip` mode, so any field the cli did not
explicitly enumerate was silently dropped before the hub forwarded the
record. The metadata pipeline lost `message.model` and
`system/turn_duration.messageId` exactly that way, and each gap took a
separate fix.
Switch both schemas to `.passthrough()` so undeclared fields survive
parse and reach the web reducer verbatim. Future SDK additions no
longer require another schema patch.
Add tests asserting that unknown keys on assistant messages and
unknown keys on system records (alongside the existing `messageId`
case) are preserved end-to-end through the schema.
* refactor(web): clean up dead metadata propagation surface
Several knobs were added to thread metadata through the chat tree but
ended up unused or redundant; consolidate them so the data flow has a
single canonical path.
- Drop the unreachable `data.type === 'result' && data.subtype ===
'success'` branch in `normalizeAgentRecord`. Claude's `result`
records are consumed by `claudeRemote` as session-completion signals
and never forwarded to the hub; the cli `RawJSONLinesSchema`
discriminator does not include `result`, so these records are
rejected before they reach `normalizeAgentRecord` either way.
- Stop threading `invokedAt` through the inner `normalizeAssistantOutput`
/ `normalizeUserOutput` / `normalizeAgentRecord` calls. Every caller
in `normalizeDecryptedMessage` already overwrites it via the outer
spread, so the inner copies were dead writes. Set `invokedAt` only at
the outer boundary.
- Remove the `model?: string | null` field from `HappyChatContextValue`
and the `model` prop on `HappyThread` / `SessionChat`. Its only
consumer (`AssistantMessage` mutable-fallback) was removed when the
per-message model attribution fix landed; the prop has no readers
now.
- Match the existing `as Partial<HappyChatMessageMetadata> | undefined`
cast pattern in `AssistantMessage` and `UserMessage` instead of the
non-`Partial` cast that pretended every field was present even when
`custom` is undefined.
- Rename `AgentEvent.turn-duration.messageId` to `targetMessageId` so a
reader does not confuse the duration's target with the surrounding
envelope id; the wire field on Claude's `system/turn_duration` record
stays `messageId` (vendor name) and is mapped at the normalize
boundary.
No behaviour change. All existing tests pass.
* fix(web): turn-duration matcher and cli-output merge precedence
Two reducer-level metadata-correctness bugs surfaced during a hostile
self-review.
1. Turn-duration matcher silently dropped the duration when
`targetMessageId` resolved to a non-duration-bearing block. The
existing pipeline did `findLastIndex(b => b.id === targetId || ...)`
first; if that hit an `agent-event` or `user-text` block (id-prefix
collision), the kind guard at the assignment site failed and the
duration was never attached. The fallback search ran only when the
first pass returned -1, not when the kind check rejected the match.
Fold the kind filter into every search predicate via a typed
`isDurationTarget` helper so the priority `target-bearing match >
tool-call id > last duration-bearing block` is exhaustive.
2. `mergeCliOutputBlocks` had asymmetric metadata precedence between
the command-name block (`prev`) and the stdout follow-up (`block`):
`invokedAt` and `model` preferred prev, but `durationMs` and `usage`
preferred block. Only the command-name block carries first-class
metadata; the stdout follow-up is a synthetic split. Use prev as the
primary source uniformly and fall back to block only when prev is
missing the field.
Tests cover the fallback path on the matcher and both precedence
scenarios on the merger.
* fix(web): preserve tool-call invokedAt across tool-result update
`ensureToolBlock` is called twice for the same tool: first with the
seed from the assistant's tool-use block, then with the seed from the
matching tool-result message. The second call's `seed.invokedAt` came
from the tool-result message and was unconditionally overwriting the
tool-call's original invokedAt. The rendered "Invoke" timestamp on a
tool card therefore showed when the result was processed, contradicting
the column header.
Guard the assignment so the timestamp survives the second call —
`existing.invokedAt ??= seed.invokedAt` semantics — while still letting
the first call set the value when the tool is created. `durationMs`,
`usage`, and `model` continue to overwrite because their values come
from the result message's usage block and are intentionally newer.
Add a regression test that fires a tool-use followed by a tool-result
with a later invokedAt and asserts the tool block keeps the original.
* fix(web): metadata footer UX, accessibility, and label hardening
Bundle the remaining UI surface fixes for the metadata footer.
- Make the bubble interactive only when there is metadata to disclose.
Without the guard, every non-Claude session bubble (Codex / Cursor /
Gemini, none of which populate `model`/`usage`/`durationMs` in the web
layer) showed a pointer cursor and reacted to clicks even though
`MessageMetadata` rendered nothing — false-positive interactivity.
- Add keyboard support: when the bubble is interactive it now exposes
`role="button"`, `tabIndex=0`, `aria-expanded`, and an `Enter`/`Space`
key handler so screen readers and keyboard-only users can disclose
the footer the same way mouse users do.
- Fix nullish-vs-falsy bugs in the label builder: a 0 ms turn or a 0
unix-epoch invokedAt no longer hides their lines. Use explicit
`!= null` / `>= 0` checks.
- Rename the token total to "billable tokens" so the explicit exclusion
of cache I/O is signalled in the label rather than implied by the
number alone.
- Tag the queued/sending status spans with `role="status"` (and an
accessible label) so they are announced by AT and so the metadata
toggle's `closest('button, ..., [role="status"]')` filter does not
accidentally fire when a user clicks a status icon.
- Add the native `<summary>` element and `[role="status"]` to the
toggle's nested-control selector. Tool cards already render their
expandable bodies as `<details><summary>` — clicking the summary now
expands the disclosure without also flipping the metadata footer.
Tests cover: native `<summary>` target, `role="status"` target, the
billable label, durationMs=0 surfaced, invokedAt=0 surfaced,
invokedAt=null/undefined hidden.
* fix(web): expose cli-output metadata via dedicated toggle button
CliOutputBlock renders the entire card as a Dialog trigger <button>, so
the bubble-level click handler on the cli-output branch never opened the
metadata footer by mouse — every click landed inside that button and
isClickOnNestedControl bailed out. The wrapping div with role="button"
was also a nested-interactive a11y anti-pattern.
Drop the wrapper's role/onClick/tabIndex/keyDown on the cli-output
branch and render an explicit "Show metadata" / "Hide metadata" button
beneath the card. The dialog trigger keeps its full hit area; the
metadata footer is now reachable by both mouse and keyboard.
* fix(web): exclude toggle wrapper from nested-control guard
The bubble-level toggle wrappers in AssistantMessage / UserMessage
carry role="button" for keyboard accessibility. Without excluding
currentTarget, closest('[role="button"]') from any inner click matches
the wrapper itself and the toggle bails out — making the metadata
footer unreachable for mouse users (keyboard Enter/Space still worked,
which is why unit tests and bot review missed it).
Walk currentTarget out of the match: a nested control is one whose
closest matching ancestor is *not* the wrapper itself.
* fix(web): apply nested-control guard on keyboard activation too
The mouse path bailed via isClickOnNestedControl, but the keyboard path
on the metadata-toggle wrapper did not. Pressing Enter or Space on a
focused descendant control (e.g. Markdown code-copy button) bubbled the
keydown up and the wrapper toggled metadata alongside the descendant's
own activation.
Generalize the helper to isNestedInteractiveEvent over both
MouseEvent and KeyboardEvent and call it from onMetadataKeyDown in
AssistantMessage and UserMessage.
|
||
|
|
9ee014098a |
feat(opencode): support model selection and mid-session model change (#558)
* refactor(opencode): declare ModelChange capability and add model field to OpencodeMode
Mark opencode flavor as supporting model change by adding Capabilities.ModelChange
to FLAVOR_CAPS.opencode. Add optional `model` field to OpencodeMode so the
set-session-config handler can carry a model alongside the existing permissionMode.
Pure structural change: no behavior change yet. The mid-session model change RPC
and UI wiring follow in subsequent commits, gated by this capability.
* feat(acp): branch setModel by flavor, capture session models metadata, expose getSessionModelsMetadata on AgentBackend interface
Adds an optional `flavor` argument to `AcpSdkBackend.setModel` so it can
dispatch the right `session/*` RPC for each agent flavor without changing
the call site Gemini already uses. Both Gemini and OpenCode wire to
`session/set_model`; the OpenCode response only carries `_meta.opencode`,
so the backend updates the cached `currentModelId` optimistically while
preserving the previously captured `availableModels`.
Captures `availableModels` and `currentModelId` from `session/new` /
`session/load` / `session/set_model` responses into per-session metadata,
exposed as `getSessionModelsMetadata(sessionId)` on the `AgentBackend`
interface so the hub can forward the snapshot to the web client.
* feat(opencode): accept model in set-session-config RPC and forward to launcher
Mirror the Gemini set-session-config handler so the web UI can change the
OpenCode model mid-session. Validates incoming model strings, persists null
("Default") for keepalive metadata, and pushes a keepAlive immediately so
the hub UI reflects the change without waiting for the next 2s tick.
Forward the model through opencodeLoop and into queued OpencodeMode entries
so the launcher can detect a per-batch model change. Add a setModel helper
on OpencodeSession to store the chosen model on the shared session base.
Wire --model up the runner path: parse --model <value> in commands/opencode.ts
and stop excluding opencode in buildCliArgs so the runner spawns OpenCode
with the user-selected initial model.
* feat(opencode): switch model mid-session via ACP RPC
Mirror the Gemini pattern from PR #543: when a user picks a different model
between turns, call backend.setModel with flavor='opencode' so the ACP backend
sends session/set_session_config_option (configId='model') to the running
OpenCode CLI. The next turn then runs against the new model.
The first batch on a fresh session seeds currentBackendModel without firing the
RPC — the OpenCode CLI was launched with that model via --model and there is
nothing to switch yet. If the running build does not implement the RPC we learn
that from the first method-not-found response, latch inline switching off, and
notify the user once. Other errors fall back to the previous model and surface
a one-line failure message.
* feat(hub): expose model selection and discovery for OpenCode sessions
Generalize the /sessions/:id/model guard via supportsModelChange so any flavor
that advertises the ModelChange capability becomes accepted automatically. This
piggybacks on the capability SSOT introduced in PR #400 and turns OpenCode on
without listing flavors inline.
Add a /sessions/:id/opencode-models endpoint that mirrors the existing
codex-models pattern. The endpoint forwards a per-session listOpencodeModels
RPC to the running OpenCode launcher, which returns the availableModels and
currentModelId metadata captured from the ACP session/new and
session/set_session_config_option responses. The web UI consumes this to
render the model dropdown without round-tripping ACP itself.
* feat(web): render OpenCode model dropdown in the chat composer
Mirror the Codex pattern in SessionChat: query /sessions/:id/opencode-models
via a new useOpencodeModels hook and feed the result into the composer's
availableModelOptions. The AssistantChat model dropdown now lists the user's
ollama / mlx / OpenCode Zen models with the same provider/model label that the
ACP server reports, so the picker matches the OpenCode TUI.
Stop falling back to the Claude composer model list when the flavor is
opencode and no custom options are supplied — that fallback briefly surfaced
unrelated Claude models in OpenCode sessions before the RPC response landed.
The NewSession flow keeps an empty MODEL_OPTIONS.opencode for now: model
discovery requires an active OpenCode ACP session, so the dropdown becomes
available once the session boots and stays empty (and hidden) at creation
time.
* feat(cli,hub): add cwd-based OpenCode model discovery RPC
Adds a short-lived `opencode acp` probe that runs `initialize` +
`session/new` against a target cwd to read the `availableModels` /
`currentModelId` snapshot, then tears the subprocess down. Results are
cached for 60s per cwd and concurrent probes coalesce into a single
spawn.
Exposes the probe through:
- `listOpencodeModelsForCwd` JSON-RPC handler on the CLI
- `RpcGateway.listOpencodeModelsForCwd` / `SyncEngine.listOpencodeModelsForCwd`
- `GET /api/machines/:id/opencode-models?cwd=...` on the hub
This lets the web NewSession form discover OpenCode models for a chosen
directory before any session is spawned.
* feat(web): add OpenCode model selector to NewSession with loading and default highlight
Adds a `OpencodeModelSelector` panel that the NewSession form swaps in
when the OpenCode flavor is selected. The panel:
- queries the new `GET /api/machines/:id/opencode-models?cwd=...` endpoint
via `useOpencodeModelsForCwd` (TanStack Query, 60s staleTime, no retry),
- shows a labelled spinner + skeleton rows while discovering,
- renders an inline error with a Retry button when probing fails,
- renders an empty-state message when the directory yields no models,
- highlights the OpenCode-reported `currentModelId` with a "Default" badge
and auto-selects it (or the first option) so the form has a sensible
value if the user hits Enter without scrolling.
Selection is reset whenever the agent / machine / directory changes so a
new probe can establish a fresh default. Directory input is wrapped in
`useDeferredValue` so per-keystroke edits do not spawn a fresh
`opencode acp` probe. The chosen model is forwarded on session spawn
via the existing OpenCode `model` parameter.
Adds en + zh-CN locale strings for the loading / failure / empty / retry
/ default-badge labels.
* fix(cli): guard /machines/:id/opencode-models handler with workspace root check
The machine-scoped `listOpencodeModelsForCwd` RPC handler was registered by
`registerCommonHandlers` without any workspace-root check, so a web client
could pass an arbitrary `cwd` and have the runner spawn an `opencode acp`
subprocess plus a `session/new` against that path. That broke runner
isolation, since peer machine-scoped handlers (`list-directory`,
`spawn-happy-session`) already enforce the configured workspace root.
Re-register the handler in `ApiMachineClient` so it reuses the existing
`resolveForWorkspaceCheck` (realpath-based, with missing-tail walking) and
`isWithinWorkspaceRoot` helpers before delegating to the lower-level probe.
The resolved cwd is forwarded down so symlinked-but-contained paths still
work, while traversal attempts are rejected with the same error shape the
peer handlers use. Added unit tests around the new dispatch path. Addresses
HAPI Bot review on PR #558.
* fix(web): gate opencode model discovery on cwd existence
The new-session form previously enabled `useOpencodeModelsForCwd` as
soon as the OpenCode agent, machine, and any non-empty directory string
were present. Because that hook calls `/machines/:id/opencode-models`
and the CLI handler starts an `opencode acp` probe for that cwd, normal
typing through partial paths could launch expensive 30s OpenCode
subprocesses for non-existent directories before the path-existence
result had validated the final cwd.
Reorder NewSession so `useMachinePathsExists` runs before
`useOpencodeModelsForCwd`, then gate the discovery hook on the
directory having been positively confirmed to exist
(`pathExistence[deferredDirectory] === true`). The decision is
factored into a small pure helper `shouldEnableOpencodeModelDiscovery`
so the contract can be unit-tested without provider scaffolding.
* fix(web): keep current opencode model on shortcut without dynamic options
`getNextModelForFlavor` is invoked by the global Ctrl/Cmd+M shortcut in
`HappyComposer`, which is now active for OpenCode sessions because the
agent backend declares the `ModelChange` capability. When the dynamic
OpenCode model list has not yet been loaded — e.g. the user presses the
shortcut before `/opencode-models` returns — the function received an
`undefined`/empty `customOptions` and fell through to the Claude preset
cycler, which would emit `sonnet`/`opus` for an OpenCode session. The
following turn then attempted `session/set_model` with a Claude model id
that no OpenCode provider can serve.
Add an `opencode` branch that returns the (normalized) current model
unchanged when no dynamic options are available, mirroring the existing
empty-list policy of `getModelOptionsForFlavor`. Unit tests cover the
undefined / empty / null-current-model variants and lock the
no-Claude-fallback contract. Addresses HAPI Bot review on PR #558.
|
||
|
|
7d55bc1456 |
feat(web): float queued messages above composer until invocation (#542)
* refactor: add invoked_at column and propagate via messages-consumed - Bump hub schema to V8: add `invoked_at INTEGER` to messages table - Add `migrateFromV7ToV8` (idempotent ALTER TABLE ADD COLUMN) - Add migration chain entries for V4/V5/V6/V7 → V8 - Expose `StoredMessage.invokedAt: number | null` and `markMessagesInvoked` - Record server-side `Date.now()` in hub on `messages-consumed` socket event - Propagate `invokedAt` through SSE (`messages-consumed` payload) - Update `markMessagesConsumed` in web store to accept and store `invokedAt` - Preserve optimistic `invokedAt` in `mergeMessages` (server echo path) - Add migration unit tests (fresh V8, V7→V8 ALTER, markMessagesInvoked) * feat(web): float queued messages above composer until invocation Show queued (uninvoked) user messages in a dedicated floating bar above the composer instead of inline in the thread timeline. Once the CLI acks the batch via messages-consumed, the bar disappears and the messages appear in the thread at their invocation position (invokedAt ordering). - Add QueuedMessagesBar component: subscribes to message-window-store, filters user messages with invokedAt==null, shows clock icon + text preview; disappears when all messages are invoked - Filter queued messages from thread (visibleMessages), sort by invokedAt ?? createdAt so invoked messages land at the right position - Extend markMessagesConsumed to update server-loaded messages (status undefined) in addition to optimistic (status 'queued'), enabling multi-device and post-refresh scenarios - Remove opacity-60 from UserMessage: queued messages no longer appear in the thread so the dimming branch is unreachable - Include invokedAt in getMessagesPage/getMessagesAfter API responses so the web client can restore floating-bar state after page refresh - Add invokedAt field to DecryptedMessageSchema for shared protocol type * fix(hub,web): make sort use invokedAt and V8 backfill idempotent - compareMessages: prioritize invokedAt/createdAt over seq so invoked messages land at their invocation position rather than their send-time seq position - migrateFromV7ToV8: move backfill outside the ALTER guard so it re-runs if a previous attempt crashed between ALTER and UPDATE before the user_version bump (idempotent WHERE invoked_at IS NULL) * fix(hub,web): cover localId-less messages and live-ack invokedAt - addMessage: messages without a localId have no ack path (markMessagesInvoked matches by localId). Treat them as already-invoked at insert time so they land in the thread instead of sitting in the queued floating bar forever. - markMessagesConsumed: apply the ack even when the message is already 'sent' optimistically, so the live window receives invokedAt instead of waiting until a full refetch. * fix(hub): propagate invokedAt in live message-received SSE payload The SSE `message-received` event omitted `invokedAt` while REST pagination included it, so localId-less CLI/local user messages arrived on the live wire as queued (`invokedAt == null`) and stayed in the floating bar until a full refetch replaced them with the stored row. * fix(hub): propagate invokedAt in CLI socket message-received handler The CLI socket 'message' handler fans out to web via a separate `onWebappEvent` publisher; the previous fix only touched the `MessageService` publisher. Aligns the live SSE payload shape with the REST/page-load shape so localId-less CLI/local user messages with `invokedAt = createdAt` (set in addMessage) reach web filters with the field already populated, instead of being misclassified as queued until a full refetch. * fix(hub,web): add byPosition pagination to fix long-session queued message loss Pagination used seq-based windows, so queued messages with low seq but late invokedAt fell outside the visible window on refresh. Fix by adding a V8 byPosition mode that orders by COALESCE(invoked_at, created_at) DESC, seq DESC with a composite cursor, while keeping the V7 seq path fully intact for backward compatibility. - hub/store/index: add idx_messages_session_position (createSchema + V7→V8 migration) - hub/store/messages: add getMessagesByPosition with composite cursor SQL - hub/store/messageStore: delegate getMessagesByPosition - hub/sync/messageService: add getMessagesPageByPosition with nextBeforeAt response - hub/sync/syncEngine: expose getMessagesPageByPosition - hub/web/routes/messages: byPosition=1 query param dispatches to V8 path - web/types/api: MessagesResponse.page gains optional nextBeforeAt - web/api/client: getMessages gains byPosition + beforeAt options - web/lib/message-window-store: fetchLatestMessages/fetchOlderMessages use V8 composite cursor; fallback to seq cursor when hub returns no nextBeforeAt - hub/store/migration-v8.test: 7 new tests covering position sort, composite cursor pagination, long-session scenario, V7 compat, and index existence * fix(hub,web): re-sort on consume and use position cursor for next fetch - markMessagesConsumed: re-merge with empty list to re-sort by position key after invokedAt is set. A queued user message becomes visible with the consume event; without re-sort it stays at its send-time array slot until the next fetch overwrites it. - getMessagesPageByPosition: pick the cursor from stored[0] (oldest in position order) instead of scanning for minimum seq. With the page already in ascending position order, scanning for min seq could land on a low-seq, late-invoked row that is actually the newest in the page, causing the next older fetch to overlap. * fix(web): trust invokedAt as the only invocation signal and pin cursor pair - visibleMessages predicate (SessionChat + QueuedMessagesBar): drop the status === 'sent' check. status='sent' only means the REST write returned, not that the CLI consumed the message; an optimistic 'sent' with no invokedAt is still queued. invokedAt is the single source of truth for invocation. - byPosition cursor: track oldestPositionSeq alongside oldestPositionAt so the server's cursor pair travels through the next older fetch unchanged. Recomputing beforeSeq from the local window's minimum seq could combine it with a server beforeAt that referred to a different row, causing the SQL cursor to skip or overlap. * fix(hub): include uninvoked local messages in latest page Long sessions can push a queued user message (invokedAt = null, sort key = createdAt) outside the latest position-ordered page once the agent emits more than `limit` later rows. A refresh or secondary client then never receives the row, the floating bar stays empty, and the later `messages-consumed` event only carries localIds — there is no way to materialize the missing row at invocation time. Pin uninvoked local user messages to every latest-page response out-of-band. The pagination cursor still anchors to the position-ordered page rows, so older-page fetches are unaffected. * fix(web): preserve queued messages across trimVisible The visible-window trim drops the oldest entries beyond VISIBLE_WINDOW_SIZE, but a queued user message (invokedAt = null) sorts by send time and is the oldest item. Once a long agent stream pushes it past the window the row is gone from the client store, and the `messages-consumed` SSE carries only localIds — there is no way to restore or reposition the dropped row without a full refetch. Pull queued rows out before slicing the regular budget, then merge them back in. Queued rows are bounded by composer/CLI queue depth and do not meaningfully grow the window. * fix(web): use strict null for queued check and fall back invokedAt - Optimistic message sets invokedAt: null explicitly so the strict-null queued check matches the local opt-in. Pre-V8 hub responses that omit the field (`undefined`) are treated as already-invoked and stay in the thread instead of being misclassified as queued. - markMessagesConsumed: when the consume SyncEvent omits invokedAt (older hub) fall back to client time, otherwise a message that receives an ack with no server timestamp stays queued forever under the new strict-null filter. The persisted server value is still authoritative on next fetch. * fix: comprehensive invokedAt propagation hardening (review feedback batch) Bot review surfaced 11 propagation bugs incrementally; this batch fixes 9 additional adjacent issues found by hostile-review to break the incremental discovery cycle: - legacy DB (user_version=0 with HAPI tables): step ladder runs V1→V8 before createSchema so pre-existing tables get all later columns/indexes - step ladder includes V1/V2/V3 entries; previously V1-V3 DBs threw - mergeSessionMessages collision branch forces invoked_at = created_at so unmergeable rows can't strand in the floating bar - session-end auto-invokes still-queued user messages and broadcasts messages-consumed; the floating bar no longer pins ghost rows after the CLI is gone - trimPending preserves queued rows symmetrically with trimVisible - markMessagesInvoked is first-write-wins; duplicate acks are no-ops rather than re-stamping invoked_at and reordering the thread - markMessagesConsumed migrates just-acked pending entries into the visible thread so non-at-bottom users see their own messages without scrolling - mergeMessages dedup window compares by position key (invokedAt ?? createdAt) instead of createdAt only, so late-invoked optimistic copies don't duplicate the server echo - isQueuedForInvocation centralized in lib/messages.ts (single predicate used by SessionChat, QueuedMessagesBar, and the store) * fix(web): mirror hub's first-write-wins on markMessagesConsumed The hub's markMessagesInvoked is first-write-wins, but the web store was still overwriting any non-null invokedAt with the latest messages-consumed timestamp. A duplicate ack (CLI re-emit) would leave the SQLite row at the original timestamp while live clients moved the message to the duplicate ack time, diverging until refetch. Mirror the guard: only set invokedAt when it is null. * fix: in-scope hostile-review polish Web: - fetchLatestMessages: persist the V8 composite cursor pair on the non-at-bottom branch too. Without this, a refresh while scrolled up dropped the cursor and the next loadMore fell back to V7 seq mode against a V8 hub — same asymmetric class of bug commit 30df6b2 fixed for the at-bottom path. - markMessagesConsumed: tighten the loose-null check on invokedAt to strict null, consistent with isQueuedForInvocation and the rest of the file. The idSet filter already shields V7-stamped rows from this path, but the strict-null contract should not vary by call site. - messages: drop the upsertMessagesInCache export. It has no callers (verified with grep) and is the only user of the InfiniteData / MessagesResponse imports, so the imports go with it. Hub tests: - migration-v8.test.ts: add a session-end auto-invoke test (getUninvokedLocalMessages + markMessagesInvoked clears every queued row and stamps them all with the same invokedAt) and two byPosition union tests covering (1) a low-position queued row pushed out of the latest page is still surfaced via the uninvoked set, and (2) pageRows[0] is the oldest row in the page so the web client can safely anchor the next-older cursor on it. * fix(hub,web): bot-13 polish — atomic SSE on DB success and attachment chip text - sessionHandlers messages-consumed: emit messages-consumed only after markMessagesInvoked succeeds. Otherwise a transient SQLite failure would broadcast an invokedAt that was never persisted; live clients would hide the queued rows while a refresh / secondary client would see them as queued again, diverging the state. - QueuedMessagesBar: fall back to attachment filenames when the message text is empty. The composer / POST /messages allow attachment-only sends; without the fallback those queued messages rendered as blank chips until invocation. |
||
|
|
e76738aa5a |
fix(codex): improve web rendering for Codex events (#544)
* test(codex): add web event rendering harness * fix(codex): surface plan updates in web * fix(codex): render MCP tool calls in web * fix(codex): improve terminal and context display * fix(codex): format token usage events * fix(codex): show status context in web * fix(codex): preserve tool result errors |
||
|
|
a612be50d6 |
Add Codex clear and compact slash commands (#541)
* Add Codex clear and compact slash commands * Stabilize queued thinking event test * Interrupt active Codex turn before slash commands |
||
|
|
9d2dec137b |
feat(codex): support slash controls and skill discovery (#545)
* feat(codex): resolve slash controls before sending to Codex * feat(codex): discover commands and skills * fix(codex): handle slash commands before attachments * fix(codex): block remaining unsupported built-ins |
||
|
|
9c117679f0 |
feat(web): collapse long code and terminal output previews (#546)
* feat(web): collapse long code and terminal output previews * fix(web): keep fallback tool output complete * fix(web): keep raw json details uncollapsed |
||
|
|
7c954ad901 |
feat(web): add sidebar search and per-group preview limit (#547)
* feat(web): add sidebar search and per-group preview limit * test(web): cover session list search previews |
||
|
|
04fbc0d37f |
fix(hub,web): apply selected permission mode when resuming inactive sessions (#540)
Previously, toggling the permission mode on an inactive session had no
effect on resume: the /permission-mode endpoint rejected inactive sessions
(HTTP 409), so the cache was never updated, and the spawned CLI always
received the stored default value.
- Remove the `requireActive` guard from POST /sessions/:id/permission-mode
so inactive sessions can have their in-memory permission mode updated.
- In `SyncEngine.applySessionConfig`, skip the RPC call for inactive
sessions and update the in-memory cache directly; the value is then
available when the session is resumed.
- Accept an optional `{ permissionMode }` body in POST /sessions/:id/resume
and forward it to `resumeSession` (takes precedence over the cached value),
with flavor-compatibility validation.
- Extend `SyncEngine.resumeSession` with an optional `opts` argument so
callers can supply a permission mode override at resume time.
- Update the web client (`api.resumeSession`) and `router.tsx` to pass
`session.permissionMode` in the resume request body.
|
||
|
|
52ec08b6cb |
feat(web): show subagent task trace in tool dialog (#539)
* refactor(web): extract shared task tool helpers * feat(web): show subagent task trace in tool dialog Task tool modals previously showed only Input and Result. This adds a Trace section between them that surfaces the child tool calls already wired through the reducer into block.children. - TraceSection collapses by default when completed, expands when running or error so the relevant state is visible on open - Each child row toggles an inline expand (Input/Result) to avoid nested Dialogs - Header summarises call count, token total and duration via readSummaryFields() typed parser, falling back gracefully when any value is absent - formatTaskChildLabel / TaskStateIcon imported from shared helpers.tsx (extracted in prior refactor commit) — no local duplicates - Task name guard: getTaskTraceChildren returns null for non-Task blocks - children prop renamed to items in TraceSectionInner / TraceChildList (react/no-children-prop anti-pattern removed) - i18n: tool.trace and tool.trace.callsSuffix keys added for en and zh-CN; useTranslation hooked up to header label and calls suffix - 15 unit tests: getTaskTraceChildren (guard, filter, non-Task null), getTraceSummaryText (3 branches), TraceSection (open/close/toggle/ summary/empty) * feat(web): include input view in trace row expand Expanded child rows in the Task trace section now render both an Input section and a Result section, matching the pattern used in the parent ToolCard dialog. Tools with a registered FullInputView use it; all others fall back to a JSON CodeBlock. Closes bot review on PR #539. |
||
|
|
d66547ff46 | Add web conversation outline (#534) | ||
|
|
010dc41369 |
feat: workspace browser with --workspace-root opt-in scoping (#526)
* feat(web): add workspace browser for multi-directory navigation Add /browse route with a folder browser that lets users navigate filesystem directories on connected machines and launch sessions from any folder. Supports saved workspace paths and direct path input. The "Start Session" action pre-fills the NewSession form. - CLI: register machine-level `list-directory` RPC handler - Hub: add POST /machines/:id/list-directory route - Web: add WorkspaceBrowser component with git repo detection - Web: add /browse route with navigation from sessions sidebar - Web: support initialDirectory/initialMachineId in NewSession Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add --workspace-root opt-in scoping for /browse and session spawn Adds a single new flag, \`--workspace-root <path>\` (with \`~\` / \`~/foo\` expansion), on \`hapi runner start\` and \`hapi runner start-sync\`. When set: - The runner reports the path in machine metadata. - The list-directory and spawn-session RPC handlers reject paths outside the root, so the web UI can't escape the configured tree even if someone crafts a request manually. - The /browse page in the web UI auto-opens that root, restricts the breadcrumb / go-up to its subtree, and shows directory entries with git-repo annotations. - The /sessions/new form keeps its existing free-text directory input plus autocomplete + recent-paths chips, and gains a small "Browse" button (next to the input) that opens /browse for picking a folder. - Reconnect-time metadata sync ensures stale records get the field filled in (or cleared when the flag is dropped on a later restart), so the hub state matches the CLI's intent. When unset: - Runner behaves like the legacy hapi (no scoping, no browse feature). - /browse renders an informative state pointing at the flag instead of blocking the user. - The /sessions/new form looks identical to the pre-change behavior; the "Browse" button is hidden. Includes a startup banner so \`runner start-sync\` no longer looks like it hung, and surfaces the workspace-root sync result on stdout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(hub): preserve workspaceRoot when rehydrating machines from store MachineCache.refreshMachine() rebuilt the metadata object from an explicit field allowlist, so any field not in the list (including the new workspaceRoot) was silently dropped on every read — even though it was correctly written to the store. Add workspaceRoot to the zod schema, the Machine interface, and the hand-rolled projection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(web): friendlier empty state on /sessions When there are zero sessions the page used to be a vast blank rectangle with just the "0 sessions in 0 projects" caption. Render a centered empty state instead: a calendar/agenda icon, a short heading and hint, and two buttons — "Start a session" (→ /sessions/new) and "Browse workspace" (→ /browse). SessionList gains an optional onBrowse prop. Router wires it on the sessions page so the secondary button resolves; other callers can leave it unset to hide that button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: document --workspace-root flag in cli/README and root README Add a short paragraph under "Runner management" in cli/README.md explaining what \`--workspace-root\` enables (scoped /browse tree, list/spawn enforcement, tilde expansion) and that omitting it keeps the legacy behavior. Mention the workspace browser in the top-level README's Features list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address PR #526 review feedback Three findings from the review bot: 1. [Major] Workspace-scope check was lexical only. With workspaceRoot = /safe, a symlink such as /safe/out -> /etc would pass the relative- path test and let list-directory / spawn-happy-session reach paths outside the configured root. realpath the workspaceRoot at construction time, and resolve every incoming path through realpath (walking up to the nearest existing parent for spawn targets that haven't been created yet) before the containment check. 2. [Minor] \`hapi runner start --workspace-root\` with no value used to drop the flag silently and start the runner unscoped. Now treats a missing or flag-shaped next argument as an error. 3. [Minor] /sessions/new's "Browse" button always opened /browse using localStorage's last-used machine, ignoring the user's current selection. NewSession already passes machineId in its callback; forward it through the /browse search params and seed WorkspaceBrowser with it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): gate list-directory RPC behind --workspace-root opt-in Without a configured workspaceRoot, isWithinWorkspaceRoot() returns true unconditionally, leaving the new list-directory RPC able to enumerate any path on the runner. The Web UI already hides Browse for these machines, but the backend should enforce the opt-in too. Refuse the RPC up front when no workspace root is configured. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
97be34e21c | Add Codex model selection | ||
|
|
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. |
||
|
|
06adfa32c5 | Add gpt-5.5 | ||
|
|
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> |
||
|
|
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> |
||
|
|
32755f9056 | feat(web): show queued status for messages pending inference (#492) | ||
|
|
d69f6dfc1e | feat(web): make content width responsive on wide screens (#496) | ||
|
|
bec7ac3c84 | feat(web): widen content area from 720px to 960px (#493) (#494) | ||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |