* 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.
* 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.
* feat(cursor): add support for Cursor Agent CLI integration
- Introduced new command `hapi cursor` to start Cursor Agent sessions.
- Added functionality for resuming sessions and managing permission modes.
- Updated documentation to include Cursor Agent usage and installation instructions.
- Enhanced existing codebase to accommodate Cursor as a recognized agent flavor.
- Implemented local and remote session handling for Cursor Agent.
This update expands HAPI's capabilities by integrating support for the Cursor Agent, allowing users to leverage its features alongside existing agents.
* Remove TODO.md file as it is no longer needed following the integration of Cursor Agent CLI support. This cleanup helps streamline project documentation and reflects the completion of the associated tasks.
* feat(cursor): implement remote mode and fix --hapi-starting-mode
- Consume --hapi-starting-mode in cursor command (do not forward to agent)
- Implement cursorRemoteLauncher: spawn agent -p with stream-json, --trust
- Add cursorEventConverter for NDJSON parsing (system/assistant/tool_call/result)
- Multi-turn via --resume session_id
- Update docs: cursor supports both local and remote modes
Made-with: Cursor
* fix: type error
* fix(cursor): address PR review - model UI, sessionId metadata, duplicate flags
- HappyComposer: use isClaudeFlavor for model mode (cursor has no model modes)
- cursorLocalLauncher: call onSessionFound for resume so cursorSessionId in metadata
- cursorCommand: do not forward parsed flags to cursorArgs (avoid duplicates)
Made-with: Cursor
- Add ReconnectingBanner component to show "Reconnecting..." status
- Track SSE connection state in App.tsx with onDisconnect handler
- Add onBlocked callback to useSendMessage for handling blocked send attempts
- Provide haptic feedback when send is blocked due to no API or session
- Show toast notification when message cannot be sent due to server disconnection
- Add translation keys for reconnecting message and send blocked feedback in en/zh-CN
close#125
Add ability to resume inactive sessions and automatically continue with new
session ID. Includes message merging for preserving conversation history,
UI improvements to allow sending during inactive state, and database schema
migration. close#87
Add a new ModelSelector component that allows users to choose specific model variants for different AI agents. This includes:
- ModelSelector UI component with agent-specific model options
- Model parameter propagation through API, RPC, and spawning layers
- Support for auto model selection (default behavior)
- Localization strings for English and Chinese
- Type definitions for model options per agent
Introduces a new message-window-store module to manage message state with
automatic windowing of visible and pending messages. This replaces manual
React Query cache operations with a centralized, observable state system.
close#39
Key changes:
- New MessageWindowState tracks visible/pending messages with size limits
- Automatic trimming of message windows (400 visible, 200 pending messages)
- Pending message buffering when user scrolls away from bottom
- Centralized status updates for optimistic messages
- Thread component simplified with forwarded scroll and pending callbacks
- Removes message count tracking from components
This improves performance for chats with many messages and provides a
cleaner separation of concerns between UI and state management.
Extract permission mode display logic into shared utilities for better reusability and maintainability. Add PermissionModeTone type and related helpers to centralize mode-based styling rules across components. Update components to use new PermissionModeOption type for consistent permission mode presentation.
Implements comprehensive session lifecycle management with user-friendly interactions:
Rename: Update session metadata.name via PATCH endpoint with conflict detection
Archive: Abort active sessions via DELETE endpoint with validation
Delete: Permanently remove inactive sessions with cascade cleanup
Backend:
- Store.deleteSession() removes session and cascade-deletes messages
- SyncEngine.renameSession() with concurrency error handling
- SyncEngine.deleteSession() with active session validation
- PATCH /sessions/:id for rename, DELETE /sessions/:id for delete
Frontend Components:
- RenameSessionDialog: Text input with auto-focus and error display
- SessionActionMenu: Modal with rename, archive, delete buttons
- ConfirmDialog: Reusable confirmation with error feedback
- SessionHeader: Menu button (⋮) triggering action menu
- SessionList: Long-press detection triggering item actions
Interactions:
- Long-press on session list items (500ms threshold) opens action menu
- Menu button in session header (non-Telegram environments only)
- Confirmation dialogs with descriptive warnings for destructive actions
- Real-time error display in dialogs on operation failure
- Haptic feedback on long-press via usePlatform hook
Accessibility:
- Keyboard support (Enter/Space) for long-press handler
- Focus management in RenameSessionDialog
- Proper ARIA labels and semantic HTML
Implements bidirectional sync of permission/model modes between CLI sessions and web app. Adds Codex-specific permission modes (read-only, safe-yolo, yolo) alongside Claude's modes. Web can now control CLI session state via RPC set-session-config handler, while CLI broadcasts state changes through keep-alive payloads. UI controls are flavor-aware, showing appropriate modes for Claude vs Codex vs Gemini. Type centralization in api/types eliminates circular dependencies.
Implement comprehensive worktree session support allowing users to spawn sessions in temporary git worktrees. Includes backend worktree management, full-stack integration, and refined UI for session type selection.
Backend:
- Add worktree creation/removal utilities with branch management
- Track worktree metadata (basePath, branch, name, path) in session metadata
- Automatic cleanup of worktrees when sessions fail or exit
- Enhanced error handling with stderr tail logging
UI improvements:
- Redesign session type toggle with improved alignment and spacing
- Move worktree description inline with label for cleaner layout
- Add branch name input field that appears when worktree mode selected
- Auto-focus on worktree input when switching modes
- Reduce gap between radio options from gap-3 to gap-1.5
- Update descriptive text and placeholders for clarity
Integration:
- Thread worktree parameters through API client, RPC handlers, and daemon
- Add worktreeEnv utility to read worktree info from environment
- Update session spawning to support both simple and worktree modes
Implements comprehensive ACP backend enabling integration with ACP-compliant agents like Gemini. Includes stdio transport, message handling, permission flow, and registry for agent management. Adds new 'hapi gemini' command to launch ACP agent sessions.
Replace manual state management with TanStack Query (React Query) for more robust server state handling. This refactoring introduces:
- New hooks for queries: useSessions, useSession, useMessages, useMachines
- New hooks for mutations: useSendMessage, useSessionActions, useSpawnSession
- Centralized query client with optimized configuration (5s staleTime, disabled window focus refetch)
- Query key factory for consistent cache invalidation
- Improved message synchronization via socket events with cache updates
- Optimistic updates for message sending with retry capability
- Simplified App.tsx by removing manual state management logic
- Integrated React Query devtools in development mode
This enables automatic cache management, better error handling, and a foundation for more sophisticated data fetching patterns.