From d993b3fde751553597397769547fdcb63190be1c Mon Sep 17 00:00:00 2001 From: marcelormendes Date: Tue, 11 Aug 2026 12:09:29 -0400 Subject: [PATCH 1/2] feat: add unified agent chat --- .../plans/2026-08-10-unified-agent-chat.md | 374 +++++ .../2026-08-10-unified-agent-chat-design.md | 347 +++++ package-lock.json | 1205 +++++++++++++- package.json | 1 + src/components/ui/scroll-area.tsx | 10 +- src/main.ts | 88 +- src/main/chat-images.test.ts | 193 --- src/main/chat-images.ts | 134 -- src/main/flatpak.test.ts | 40 +- src/main/flatpak.ts | 19 - src/main/herdr/conversation-decoder.test.ts | 207 +++ src/main/herdr/conversation-decoder.ts | 431 +++++ src/main/herdr/engine.ts | 108 +- src/main/herdr/event-subscription.test.ts | 64 + src/main/herdr/event-subscription.ts | 59 +- src/main/herdr/snapshot-decoder.test.ts | 87 ++ src/main/herdr/snapshot-decoder.ts | 69 + src/main/ipc-validation.test.ts | 154 +- src/main/ipc-validation.ts | 224 ++- src/preload.ts | 47 +- src/renderer/App.test.tsx | 165 +- src/renderer/App.tsx | 132 +- src/renderer/browser-preview.ts | 29 +- .../chat/ChatPanel.persistence.test.tsx | 90 -- src/renderer/chat/ChatPanel.test.tsx | 1387 ----------------- src/renderer/chat/ChatPanel.tsx | 1133 -------------- ...onversationChatPanel.presentation.test.tsx | 486 ++++++ .../chat/ConversationChatPanel.test.tsx | 412 +++++ src/renderer/chat/ConversationChatPanel.tsx | 687 ++++++++ src/renderer/chat/ConversationTimeline.tsx | 494 ++++++ src/renderer/chat/ansi-text.test.ts | 92 -- src/renderer/chat/ansi-text.ts | 183 --- src/renderer/chat/conversation-model.test.ts | 164 ++ src/renderer/chat/conversation-model.ts | 164 ++ src/renderer/chat/image-attachments.ts | 144 ++ src/renderer/chat/reply-format.test.ts | 165 -- src/renderer/chat/reply-format.ts | 200 --- src/renderer/chat/terminal-menu.test.ts | 93 -- src/renderer/chat/terminal-menu.ts | 77 - src/renderer/chat/transcript-model.test.ts | 639 -------- src/renderer/chat/transcript-model.ts | 370 ----- src/renderer/terminal/TerminalPanel.test.tsx | 30 + src/renderer/terminal/TerminalPanel.tsx | 13 + src/shared/conversation.ts | 252 +++ src/shared/desktop-api.ts | 95 +- src/shared/herdr.ts | 7 + src/shared/ipc.ts | 10 +- 47 files changed, 6387 insertions(+), 5187 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-10-unified-agent-chat.md create mode 100644 docs/superpowers/specs/2026-08-10-unified-agent-chat-design.md delete mode 100644 src/main/chat-images.test.ts delete mode 100644 src/main/chat-images.ts create mode 100644 src/main/herdr/conversation-decoder.test.ts create mode 100644 src/main/herdr/conversation-decoder.ts create mode 100644 src/main/herdr/snapshot-decoder.test.ts delete mode 100644 src/renderer/chat/ChatPanel.persistence.test.tsx delete mode 100644 src/renderer/chat/ChatPanel.test.tsx delete mode 100644 src/renderer/chat/ChatPanel.tsx create mode 100644 src/renderer/chat/ConversationChatPanel.presentation.test.tsx create mode 100644 src/renderer/chat/ConversationChatPanel.test.tsx create mode 100644 src/renderer/chat/ConversationChatPanel.tsx create mode 100644 src/renderer/chat/ConversationTimeline.tsx delete mode 100644 src/renderer/chat/ansi-text.test.ts delete mode 100644 src/renderer/chat/ansi-text.ts create mode 100644 src/renderer/chat/conversation-model.test.ts create mode 100644 src/renderer/chat/conversation-model.ts create mode 100644 src/renderer/chat/image-attachments.ts delete mode 100644 src/renderer/chat/reply-format.test.ts delete mode 100644 src/renderer/chat/reply-format.ts delete mode 100644 src/renderer/chat/terminal-menu.test.ts delete mode 100644 src/renderer/chat/terminal-menu.ts delete mode 100644 src/renderer/chat/transcript-model.test.ts delete mode 100644 src/renderer/chat/transcript-model.ts create mode 100644 src/shared/conversation.ts diff --git a/docs/superpowers/plans/2026-08-10-unified-agent-chat.md b/docs/superpowers/plans/2026-08-10-unified-agent-chat.md new file mode 100644 index 0000000..56c7c7b --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-unified-agent-chat.md @@ -0,0 +1,374 @@ +# Unified Agent Chat Implementation Plan + +**Design:** `docs/superpowers/specs/2026-08-10-unified-agent-chat-design.md` +**Repositories:** Herdr engine and Herdr Desktop +**Execution:** TDD, no commits, no pushes, no PR, no merge, no release + +## Working boundaries + +- Preserve all pre-existing Desktop changes, including the unrelated edit in `src/renderer/chat/terminal-menu.test.ts` and the root `lessons.md` file. +- Keep the git-librarian cache at `/home/marcelorm/.cache/checkouts/github.com/herdrdev/herdr` read-only and clean. +- Implement engine work in `/home/marcelorm/workspace/herdr-unified-chat-engine`, a plain source copy prepared from the cache without `.git`. Compare it to the cache with `diff -ruN --exclude target` for review. +- Do not install binaries globally or modify live provider transcript files. Use fixtures and temporary directories. +- Do not add a dependency unless the standard library and current dependencies cannot meet the contract. + +## Phase 1: Freeze the public contract with failing engine tests + +### Files + +- Engine: `src/api/schema/conversations.rs` (new) +- Engine: `src/api/schema.rs` +- Engine: `src/api/schema/agents.rs` +- Engine: `src/api/schema/panes.rs` +- Engine: `src/api/schema/response.rs` +- Engine: `src/api/schema/events.rs` +- Engine: `src/api/schema/server.rs` +- Engine: `src/api/schema/tests.rs` +- Engine: `docs/next/api/herdr-api.schema.json` + +### Tests first + +1. Add schema serialization tests for `agent.conversation.read`: + - no cursor means newest/tail page; + - `older` plus cursor pages backward; + - `newer` plus cursor returns deltas; + - limit is bounded and invalid cursor/direction combinations are rejected. +2. Add response tests for ordered canonical items, opaque cursors, `has_older`, conversation revision, and session reset identity. +3. Add schema tests for pane/agent conversation availability: `supported`, `unavailable`, `unsupported`, with stable reason codes. +4. Add a pane-scoped `agent.conversation_changed` subscription/event schema test. +5. Add a backward-compatible global `agent_conversations` server capability. Missing capability must decode as false. +6. Add provider-neutral approval response and chunked attachment upload DTO tests, keeping every chunk below the existing request-line cap. +7. Add a separate optional opaque conversation-session identity; path-backed sessions omit the legacy optional `agent_session` field rather than adding a new `kind`. Prove no capable-engine pane, agent, conversation, or event response serializes a transcript path. +8. Feed a capable-engine snapshot through the current released Desktop snapshot decoder and prove old Desktop still boots Terminal while ignoring the new optional identity. + +### Implementation + +- Put canonical DTOs in their own schema module rather than expanding `agents.rs` into a catch-all. +- Keep transcript paths private: no response or event field may serialize a path. +- Use one tagged canonical item enum with common stable ID/order/session/turn metadata and bounded typed payloads. +- Keep the conversation API additive and do not bump Herdr's terminal wire `PROTOCOL_VERSION`; the old-engine Terminal fallback depends on exact protocol compatibility. Regenerate the JSON API schema artifact. + +### Gate + +- New schema tests pass. +- Existing schema snapshot tests pass. +- A serialized conversation response contains no fixture transcript path. + +## Phase 2: Track transcript sources separately from resume references + +### Files + +- Engine: `src/agent_resume.rs` +- Engine: `src/terminal/state.rs` +- Engine: `src/app/api/panes.rs` +- Engine: `src/app/api.rs` +- Engine: persistence/restore files only if transcript ownership must survive a server restart +- Engine: `src/integration/assets/codex/herdr-agent-state.sh` +- Engine: `src/integration/assets/claude/herdr-agent-state.sh` +- Engine: Pi/OMP integration assets only where their reporting contract needs adjustment +- Engine: `tests/cli/hooks.rs` +- Engine: existing terminal-state and integration tests + +### Tests first + +1. Prove a Claude or Codex report can retain both a resume ID and a transcript path. +2. Prove Pi/OMP path resume behavior remains unchanged. +3. Prove session replacement/reset replaces the matching transcript reference atomically. +4. Prove a stale or conflicting provider report cannot attach a transcript to another pane/session owner. +5. Prove public `AgentSessionInfo` still exposes only the correct resume reference and never the transcript path when the resume reference is an ID. +6. Update Codex hook tests to assert its existing `transcript_path` input is reported. +7. Prove capable-engine public agent/session data omits legacy path-backed `agent_session`, adds a separate opaque identity, and internal restore still uses the real path. +8. Prove missing, invalid, stale, and cross-pane integration tokens cannot attach a transcript or publish live activity. + +### Implementation + +- Introduce a separate internal transcript reference owned by terminal/pane state. Do not extend the resume-reference enum to carry two meanings. +- Generate an unguessable per-pane integration token, inject it into the managed process, bind it to pane/provider/process generation, require it for transcript and live-conversation reports, and rotate it on managed process replacement. +- Derive resume and transcript references from the same accepted lifecycle report, so existing source/sequence/foreground authority rules govern both. +- Clear or replace the transcript reference wherever the accepted provider session is cleared or replaced. +- Preserve the transcript reference across persistence only if the corresponding accepted provider session is persisted and validation is repeated on restore. +- Omit the optional public `agent_session` for path-backed refs and add a separate optional opaque conversation-session identity. Do not add a new legacy `kind` value. Keep real resume paths internal and test both compatibility directions: new Desktop with old engine, and released Desktop decoder with capable engine. + +### Gate + +- Existing agent resume/session authority tests remain green. +- New dual-reference and lifecycle tests pass. + +## Phase 3: Build the bounded conversation reader and provider adapters + +### Files + +- Engine: `src/agent_conversation.rs` (public module boundary) +- Engine: `src/agent_conversation/jsonl.rs` +- Engine: `src/agent_conversation/pi.rs` +- Engine: `src/agent_conversation/omp.rs` +- Engine: `src/agent_conversation/codex.rs` +- Engine: `src/agent_conversation/claude.rs` +- Engine: `src/agent_conversation/fixtures/*.jsonl` (small, synthetic/redacted) +- Engine: module-local tests + +### Tests first + +Create minimal native fixtures based on verified provider shapes. Cover: + +- visible user and assistant text; +- commentary versus final response; +- raw `thinking`, Codex reasoning, and reasoning summaries excluded from visible messages; +- tool start/result pairing with one stable item ID; +- running/completed/failed tool state; +- plan/todo normalization; +- file change summaries with bounded path lists; +- completion, interruption, and safe error state; +- duplicate records and rereads; +- partially written final JSONL record; +- malformed complete line; +- source truncation/replacement; +- initial tail page, older page, and newer delta larger than the old Desktop snapshot limit; +- stable IDs/order/cursors across rereads; +- reader generation/reset behavior across engine restart, cache eviction, concurrent older/newer reads, and an unchanged provider session; +- maximum native bytes scanned, maximum returned items, maximum serialized text/detail, and maximum paths. + +### Implementation + +- Use a small provider adapter trait consumed by the conversation reader. Adapters return canonical items; they do not know about Desktop presentation. +- Make public cursors represent canonical conversation position: direction, engine/reader generation, opaque session identity, source fingerprint, and canonical sequence/revision. Keep transcript byte offsets private to durable adapters. Responses are chronological even when the durable reader scans backward from EOF for the initial tail. Stale generation/session/source cursors return `reset_required`. +- Derive stable IDs from provider-native IDs when available and from session identity plus record byte offset/block index otherwise. Never derive identity from mutable display text. +- Cache only validated source identity and incremental reader state. Never cache unbounded native payloads. +- Treat an incomplete final line as pending. Skip and count malformed complete records without blocking later records. +- On inode/identity change or file shrink, invalidate cursors and return a reset. +- Keep Pi and OMP adapters separate but share proven-identical JSONL/content helpers. +- Add authenticated, bounded, coalesced Pi/OMP lifecycle ingestion for message updates, tool start/update/end, plans, approvals, and turn state because their transcripts persist many records only at message completion. Reconcile ephemeral items with durable records by stable provider identity. +- Test more than one full page of live overlay events while JSONL length is unchanged, then durable reconciliation, with no omitted or duplicate canonical items. +- Do not expose raw provider records. Allowlist display fields and cap them before constructing DTOs. + +### Path validation + +- Canonicalize the transcript and provider root. +- Require a regular JSONL file below the expected provider data roots (`.pi`, `.omp`, `.codex`, `.claude`, including their supported configurable roots). +- Reject relative paths, traversal, wrong-provider roots, device files, and symlink escapes. +- Revalidate file identity on every reopen/reset. +- Test allowed default/custom roots and every rejection case with temporary directories. + +### Gate + +- All four adapter suites pass. +- No fixture raw reasoning or transcript path appears in serialized output. +- A page/delta can span more than 500 native lines without loss or duplication. + +## Phase 4: Wire reads, availability, and live change subscription into Herdr + +### Files + +- Engine: `src/app/api/agents.rs` +- Engine: `src/app/api.rs` +- Engine: `src/api/subscriptions.rs` +- Engine: `src/api/server.rs` +- Engine: `src/api/event_hub.rs` only if the existing hub is the correct seam +- Engine: API integration tests under `tests/` + +### Tests first + +1. `agent.conversation.read` resolves the accepted target pane and its engine-local transcript source. +2. Unsupported, no-session, missing-file, invalid-source, and temporarily unreadable cases return stable availability/reason codes rather than raw I/O errors. +3. Initial, older, and newer reads obey cursors, revision, reset, item, and byte bounds. +4. A subscription sends one `agent.conversation_changed` notification when source identity/length changes, without embedding items. +5. Unchanged files do not reparse or emit. +6. Monitoring stops on disconnect and is limited to explicit subscribers. +7. Remote-style API clients receive the same JSON contract without filesystem access. +8. Authenticated live lifecycle reports produce immediate conversation changes and reconcile without duplication when durable JSONL arrives. +9. Engine restart or reader-cache generation change makes old cursors explicitly reset even when the provider session and file are unchanged. +10. `agent.conversation.respond` verifies pane/session/generation/request ownership, accepts only advertised decisions, is idempotent for the same result, and rejects stale/conflicting/resolved requests. + +### Implementation + +- Add one App/API handler delegating to the conversation service. +- Populate pane/agent availability from the accepted agent identity plus validated transcript reference. +- Add an active conversation subscription variant. Its poll path performs a cheap source metadata probe; payload parsing remains in `agent.conversation.read`. +- Accept bounded live lifecycle reports only with the pane's integration token and feed them through the same provider adapter/canonical store. +- Delegate approval responses through explicit provider adapter mappings. If an adapter cannot safely respond, publish the approval as read-only so Desktop opens Terminal instead of guessing input. +- Reuse the existing connection poll lifecycle and error response conventions. +- Return safe reason codes; keep OS paths and detailed I/O diagnostics in local logs only. + +### Gate + +- Targeted API and subscription tests pass. +- Generated schema is current. +- `cargo fmt --check`, `cargo check`, `cargo clippy --all-targets --all-features -- -D warnings`, and `cargo test` pass in the engine working copy. + +## Phase 5: Add the typed Desktop bridge and reducer + +### Files + +- Desktop: `src/shared/herdr.ts` +- Desktop: `src/shared/desktop-api.ts` +- Desktop: `src/shared/events.ts` +- Desktop: `src/main/herdr/engine.ts` +- Desktop: `src/main/herdr/query-decoder.ts` +- Desktop: `src/main/herdr/event-subscription.ts` +- Desktop: corresponding tests +- Desktop: `src/renderer/chat/conversation-model.ts` (new) +- Desktop: `src/renderer/chat/conversation-model.test.ts` (new) + +### Tests first + +1. Strict decoding accepts every canonical item and rejects malformed/unbounded shapes. +2. Missing `agent_conversations` capability decodes as false for older engines. +3. Query mapping covers tail/older/newer reads. +4. Event decoding covers conversation change/reset and ignores unrelated panes. +5. Reducer handles initial page, older prepend, newer append/update, stable ordering, duplicates, session reset, stale response, and reconnect. +6. Optimistic user messages reconcile once and preserve a failed/syncing state when appropriate. +7. A synthetic delta over 500 records remains complete and never invokes `pane.read`. +8. A changed reader generation always resets before applying a lower or otherwise incomparable revision. +9. Legacy path-kind session data is discarded from renderer conversation state. + +### Implementation + +- Define canonical shared types once in `desktop-api.ts`/`herdr.ts`; do not duplicate provider-specific unions in renderer code. +- Add strict size-aware decoders in main. +- Extend the existing Herdr query mapping; do not add a second transport. +- Subscribe only for panes whose Chat timeline is active. Reconnect by rereading from the last cursor/revision and honor reset. +- Keep projection pure: canonical events in, stable turn view-model out. + +### Gate + +- Main/shared/reducer tests pass. +- Remote engine code remains unchanged unless a test proves the generic API tunnel cannot carry the new method/event. + +## Phase 6: Replace the Chat timeline with Herdr-native T3-like behavior + +### Files + +- Desktop: `src/renderer/chat/ChatPanel.tsx` +- Desktop: split focused components such as `ChatTimeline.tsx`, `TurnWorkSummary.tsx`, and `ToolActivityRow.tsx` if `ChatPanel.tsx` would become unwieldy +- Desktop: existing/new chat component tests +- Desktop: `src/renderer/App.tsx` +- Desktop: `src/index.css` only for reusable token-backed utilities that existing classes cannot express + +### Tests first + +1. Active turn shows a self-updating `Working for …` duration and active plan step. +2. Commentary and tool activity preserve chronological order. +3. Repetitive tools group behind `+N tool calls` without losing accessible content. +4. Completed/interrupted/failed work folds to the correct summary while final answer remains visible. +5. Changed files render as a bounded summary. +6. Tool details are keyboard accessible and status is expressed as text. +7. Scroll follows only near the bottom or immediately after local submission; loading older history preserves the viewport anchor. +8. Supported, unavailable, unsupported, and old-engine Chat controls have the correct disabled state and tooltip. +9. Terminal mode remains selectable and unaffected. +10. Pending approvals render allowed provider decisions, submit by stable request ID, and handle idempotent/resolved/stale responses; approvals without a safe responder show `Open Terminal to respond`. + +### Implementation + +- Reuse existing Herdr `Button`, `ScrollArea`, `Tooltip`, surfaces, borders, shadows, status colors, typography, and spacing. +- Use native `
/` styled with Herdr tokens unless an existing disclosure primitive is more appropriate; do not introduce another UI dependency. +- Keep the final assistant answer visually primary. Fold only intermediate commentary/work for settled turns. +- Batch incoming canonical deltas and preserve stable React keys. +- Respect reduced motion and avoid whole-list streaming animations. +- Never infer an approval decision or terminal key from display text. Use only engine-advertised decision IDs; use the explicit Terminal fallback otherwise. + +### Gate + +- Component accessibility/interaction tests pass. +- Responsive layouts remain usable at the existing tested breakpoints. +- Visual inspection confirms Herdr neobrutalism, not T3 styling. + +## Phase 7: Remove the lossy supported-Chat path + +### Files + +- Desktop: `src/renderer/App.tsx` +- Desktop: `src/renderer/chat/ChatPanel.tsx` +- Desktop: `src/renderer/chat/transcript-model.ts` and tests +- Desktop: `src/renderer/chat/ansi-text.ts` / `reply-format.ts` only if no remaining consumer needs them + +### Tests first + +- Add an integration regression asserting that selecting Chat for Pi, OMP, Codex, or Claude performs conversation reads and never calls the pane-output query. +- Assert unsupported/old-engine panes cannot enter Chat and therefore cannot activate snapshot fallback. +- Keep Terminal pane-read and terminal-menu tests green. + +### Implementation + +- Delete the Chat polling, 500-line baseline, prompt/overlap reconstruction, rollover reconciliation, and Chat-only ANSI-thinking extraction once no consumer remains. +- Do not retain a hidden fallback. A temporarily unavailable structured transcript displays an honest unavailable/stale state. +- Preserve unrelated slash-command and prompt/input behavior. Terminal menu selection remains Terminal-only; structured Chat approvals use the new response contract. + +### Gate + +- No supported Chat code path imports or calls `extractPaneResponse`, `applyPaneRead`, or `pane.read`. +- Dead parser code/tests are removed only after confirming no non-Chat consumer. + +## Phase 8: Move image attachments to engine-owned staging + +### Files + +- Engine: conversation/attachment schema, service, API handlers, cleanup, and integration tests +- Desktop: `src/main/chat-images.ts` or its replacement +- Desktop: `src/shared/desktop-api.ts` +- Desktop: `src/main/herdr/engine.ts` +- Desktop: `src/renderer/chat/ChatPanel.tsx` +- Desktop: corresponding tests + +### Tests first + +1. Begin validates pane/session ownership, media type, name, declared size, digest, and quotas. +2. Ordered base64 chunks stay below Herdr's request-line limit and reject reordering, duplication conflicts, overflow, and cross-pane handles. +3. Finish verifies exact size/digest, stores 0600 files in a 0700 engine-owned temporary directory, and returns an opaque handle rather than a path. +4. Prompt resolves only same-pane/session handles and stages provider-visible paths entirely on the engine host. +5. Abort, failed prompt, completed prompt, session replacement, disconnect timeout, and TTL cleanup remove files. +6. Local and remote integration tests use the same tunnel/API flow and never reference a Desktop-local path on the engine host. + +### Implementation + +- Add `agent.attachment.begin`, bounded ordered chunk upload, finish, and abort operations. +- Extend `agent.prompt` with optional attachment handles. Resolve handles and perform the existing provider paste/input behavior inside the engine host. +- Replace Desktop-local path staging for structured Chat. Retain old staging only for a proven non-structured local consumer; otherwise remove it. +- Keep raw bytes and engine paths out of conversation events and renderer state. + +### Gate + +- A maximum-size allowed attachment succeeds through chunking without exceeding request-line bounds. +- All cleanup, ownership, and remote tests pass. + +## Phase 9: Verification and review loop + +### Automated verification + +Engine working copy: + +```bash +cargo fmt --check +cargo check +cargo clippy --all-targets --all-features -- -D warnings +cargo test +``` + +Desktop: + +```bash +npm run typecheck +npm run lint +npm test +npm run site:build +git diff --check +``` + +### Behavioral verification + +1. Build/run the engine working copy without installing it globally and point Desktop's existing engine preference/test harness to that binary. +2. Verify Pi, OMP, Codex, and Claude sessions started from Chat and from Terminal. +3. Re-run a fast, tool-heavy OMP turn equivalent to the live frozen case and prove the final answer arrives. +4. Verify interruption, failed tool, image prompt, long history pagination, user scroll anchoring, provider session reset/resume, and engine reconnect. +5. Verify through the remote-engine tunnel or its real integration harness. +6. Verify an unsupported provider and an older engine show disabled Chat plus working Terminal. +7. Verify a Pi/OMP turn shows tool activity before `message_end`, then reconciles to durable history without duplicate rows. +8. Verify an engine restart during the same provider session forces a clean reader-generation reset. +9. Verify structured and read-only approval flows, including reconnect and stale decisions. + +### Review loop + +- OMP reports exact changed files, tests, and remaining risks without committing. +- Root agent performs a code review across both trees and runs independent targeted tests. +- Every actionable finding goes back to OMP with a concrete failing case. +- Repeat implementation and review until no finding at 80% or greater confidence remains and all required gates are green. +- Stop before any commit, push, PR, merge, release, installation, or external publication. diff --git a/docs/superpowers/specs/2026-08-10-unified-agent-chat-design.md b/docs/superpowers/specs/2026-08-10-unified-agent-chat-design.md new file mode 100644 index 0000000..10b72b4 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-unified-agent-chat-design.md @@ -0,0 +1,347 @@ +# Unified Agent Chat Design + +**Date:** 2026-08-10 +**Status:** Approved after three independent review passes + +## Summary + +Herdr Desktop will provide one provider-neutral Chat experience for Pi, OMP, Codex, and Claude. The interaction and information hierarchy follow the useful parts of T3 Code's chat timeline, while all presentation remains native to Herdr Desktop's existing neobrutalist component library and design tokens. + +The Herdr engine, not the Desktop renderer, will own provider transcript discovery and normalization. Desktop will request a canonical conversation page and subscribe to conversation changes through the existing Herdr JSON API. Terminal mode remains the exact PTY view. Providers without a structured adapter keep Terminal access, while their Chat option is visibly disabled with a tooltip explaining that Chat is not currently supported. + +This removes the root cause of the recurring frozen-chat bug: a bounded terminal snapshot cannot be used as a lossless conversation log. + +## Problem and root cause + +The current Chat implementation polls a 500-line `recent_unwrapped` pane snapshot and reconstructs a reply by searching for the submitted prompt or overlap with the previous snapshot. Its rollover reconciliation only searches a small tail window. With fast full-screen TUIs such as OMP, a redraw can move the prompt and all usable overlap outside both windows between reads. When that happens during a working turn, the parser deliberately retains the previous reply and can remain frozen even though the pane revision and live terminal output continue to advance. + +This behavior was reproduced on the live Omarchy workspace: the OMP terminal reached a completed answer while Desktop Chat remained at an older working frame. Increasing the snapshot size, polling faster, or widening the overlap search only changes how often the failure occurs. None makes rolling terminal frames a durable event source. + +For supported providers, the structured Chat path must not call `pane.read` or infer messages, reasoning, tools, or completion from ANSI output. `pane.read` remains valid only for Terminal mode and other terminal-oriented features. + +## Reference behavior from T3 Code + +T3 Code provides the behavioral reference, not the visual design or application architecture. The relevant behaviors are: + +- A working turn shows an elapsed `Working for …` indicator and the current plan step when available. +- Assistant commentary can appear between compact work rows. +- Tool activity is summarized into stable, compact rows with status, a useful label, and bounded expandable detail. +- Repetitive tool rows may be grouped, with overflow represented as an explicit count. +- After completion or interruption, intermediate work folds into a compact `Worked for …` or `Stopped after …` summary. +- The final assistant response remains directly visible. +- Changed-file information is attached to the completed turn. +- Stable item identity, scroll anchoring, and deliberate follow-output behavior avoid visual jumps. +- Raw provider chain-of-thought is not rendered. + +Herdr will recreate those behaviors with its own components, borders, colors, spacing, typography, motion, and interaction patterns. + +## Goals + +1. Give Pi, OMP, Codex, and Claude the same coherent Chat presentation. +2. Show the full provider session, including turns initiated in the underlying terminal. +3. Represent assistant text, work/tool activity, plans, changed files, interruptions, approvals, and errors without exposing raw chain-of-thought. +4. Work identically through local and remote Herdr engines. +5. Eliminate terminal-snapshot reconstruction from supported Chat providers. +6. Keep long sessions responsive through bounded pages, bounded detail, incremental updates, and stable rendering. +7. Degrade honestly when a provider is unsupported or its transcript is temporarily unavailable. + +## Non-goals + +- Rebuilding T3 Code's wider application, source-control workflow, navigation, settings, or visual system. +- Rendering raw reasoning or hidden model chain-of-thought. +- Inventing a universal provider command protocol. Prompts continue through Herdr's existing agent/terminal input path. +- Providing structured Chat for arbitrary TUIs in the first release. +- Replacing Terminal mode. +- Synchronizing or copying raw transcript files to Desktop. + +## Architecture and ownership + +```text +Provider transcript or structured hook + | + v +Herdr provider adapter -> canonical conversation model -> Herdr JSON API/events + | + local socket or remote tunnel + | + v +Desktop main bridge -> renderer conversation store -> Herdr chat timeline +``` + +### Herdr engine responsibilities + +- Track transcript location separately from the provider's resume identity. A resume ID and a transcript path are different facts and must not be overloaded into a single value. +- Resolve transcript sources on the machine where Herdr runs. Remote filesystem paths never cross into Desktop for direct access. +- Authenticate managed integration reports with a per-pane capability token before accepting transcript or live conversation data. +- Parse provider-native records with a dedicated Pi, OMP, Codex, or Claude adapter. +- Reconcile durable transcript history with bounded live lifecycle events emitted by providers that do not persist partial activity. +- Normalize records into the canonical contract below. +- Apply security and size bounds before values leave the engine. +- Serve paged history and signal incremental changes through the public JSON API. +- Report structured-chat capability and current availability for each pane. +- Keep transcript parsing out of terminal render, agent-detection, and other hot paths. Work is on demand for active readers/subscribers and cached by transcript identity plus file position. + +### Desktop main responsibilities + +- Add typed bridge methods for conversation capability, paged reads, and change subscriptions. +- Reuse the active Herdr API connection, including the existing remote-engine tunnel. +- Validate and bound API responses before forwarding them to the renderer. +- Avoid provider-specific transcript parsing or filesystem access. + +### Desktop renderer responsibilities + +- Keep a normalized per-pane conversation store keyed by provider session identity. +- Merge history pages and incremental refreshes idempotently by stable item ID and sequence. +- Project canonical items into turns and T3-like work summaries. +- Preserve user scroll position; follow output only when the user is already near the bottom or has just submitted a prompt. +- Render with existing Herdr components and tokens only. + +## Transcript identity + +Herdr already receives useful session information from its integrations. Pi and OMP can report an absolute transcript path. Claude reports both a session ID and transcript path. Codex's session hook already receives `transcript_path`, but currently reports only the session ID. + +The implementation will retain two internal facts: + +- **Resume reference:** the provider value used to resume a session. +- **Transcript reference:** the local engine-side source used to read structured conversation records. + +Only sanitized conversation data and opaque session identity are exposed to Desktop. Transcript paths are not part of the public conversation response. + +The current public `AgentSessionInfo` exposes Pi/OMP path resume references. The capable engine retains the real resume reference internally but omits the already-optional legacy `agent_session` field for path-backed sessions. It adds a separate optional opaque conversation-session identity that old Desktop versions ignore and new Desktop uses. Existing `AgentSessionInfo.kind` values are not changed, so the current released Desktop decoder can still load a capable-engine snapshot and run Terminal. Provider-ID session information may remain in the legacy field when it is already a safe opaque value. New Desktop decoding accepts the legacy path shape only from old engines for Terminal compatibility and does not put it into the renderer conversation model. Schema tests must prove capable-engine pane, agent, conversation, and event responses contain no transcript path, and a compatibility test must feed a capable-engine snapshot through the released Desktop decoder. + +Provider hooks must report a transcript path when the provider supplies one, but the shared user socket is not itself a pane authenticator. Herdr generates an unguessable per-pane integration token, injects it only into that managed pane process, and requires it on transcript-source and live-conversation reports. The token is bound to the pane, accepted provider authority, and process generation, and rotates when the managed pane process is replaced. Existing source/sequence/session authority rules still govern session changes inside that process. Invalid, missing, stale, or cross-pane tokens cannot attach a transcript or publish conversation items. + +After authentication, paths must be absolute/canonicalized and match the reporting provider's supported default or configured data roots. Symlink escape, replacement, truncation, and rotation are rejected or handled as explicit source resets rather than allowing arbitrary file reads. + +## Public JSON API contract + +Names remain provider-neutral and follow the existing dot-separated JSON API style. + +### Capability + +Agent/pane information exposes a structured-chat state: + +- `supported`: an adapter exists and a transcript source is available. +- `unavailable`: the provider is supported, but no valid session/transcript is currently available. +- `unsupported`: no structured adapter exists. + +The state includes a machine-readable reason code and may include a short safe message. Desktop maps reason codes to user-facing copy instead of displaying raw engine errors. + +### Read + +`agent.conversation.read` accepts a pane target, an optional opaque page cursor, and a bounded page size. It returns: + +- provider and opaque session identity; +- current capability/availability; +- ordered canonical items; +- next/previous cursor as applicable; +- whether more history exists; +- a revision monotonic only within the returned reader generation; +- a reader generation/reset identity derived from the engine instance, provider session, source fingerprint, and reader-cache generation. + +The first read returns the most recent bounded window so Chat opens quickly. Older history is requested when the user scrolls upward. Refresh reads request records after the last known cursor/revision. Public cursors describe canonical conversation position: direction, reader generation, opaque session identity, source fingerprint, and canonical sequence/revision. Transcript byte offsets remain private durable-adapter state and are never the public position, because live Pi/OMP overlay events can advance without file growth. A cursor from another engine generation, session, replaced source, or evicted incompatible reader state returns an explicit `reset_required` result with the current generation; it is never silently reused. + +### Subscription + +`events.subscribe` gains a pane-scoped `agent.conversation_changed` subscription. Its event carries only pane identity, opaque session identity, reader generation, the new conversation revision, and whether the client must reset. It does not duplicate transcript payloads. Desktop responds by reading the delta through `agent.conversation.read`. + +The subscription monitors only explicitly subscribed conversations. A lightweight metadata poll is acceptable when a portable filesystem notification is unavailable, but it must avoid parsing unchanged files and use the existing connection lifecycle so it stops immediately on unsubscribe/disconnect. + +### Compatibility + +This is an additive JSON API capability. The generated JSON API schema is updated, but Herdr's exact-match terminal wire `PROTOCOL_VERSION` is not incremented because no existing terminal wire value changes. This is required for a new Desktop to bootstrap against an older otherwise-compatible engine. Desktop feature-detects the default-false `agent_conversations` server capability. With an older Herdr engine, Terminal continues to work and Chat is disabled with an `Update Herdr to use Chat` tooltip; it must not query the missing method or silently fall back to snapshot parsing. + +## Canonical conversation model + +Every item has: + +- a deterministic `id` stable across rereads; +- a monotonically ordered `sequence` within the opaque session identity; +- `provider`, `session_id`, and `turn_id` where known; +- a timestamp when the provider supplies one; +- a typed payload. + +Canonical item types: + +1. `user_message` + - Text and attachment metadata safe for display. + - Provider-native image contents or arbitrary local paths are not returned. + +2. `assistant_message` + - `phase`: `commentary` or `final`. + - Renderable text and completion/interruption state. + - Raw reasoning records never map to this item merely because they contain text. + +3. `plan_update` + - Ordered steps with pending, active, completed, or failed status. + +4. `tool_activity` + - Normalized action, concise label, status (`running`, `completed`, `failed`), bounded preview/detail, duration when known, and affected paths when safe. + - Provider payloads, command environments, secrets, oversized outputs, and unbounded diffs are not exposed. + +5. `file_change` + - Path, change kind, and bounded summary/statistics. Content/diff detail is optional and capped. + +6. `approval` + - Stable request ID, safe prompt, explicit allowed decisions, pending/resolved state, selected decision when known, and whether structured response is supported. + +7. `turn_state` + - Started, completed, interrupted, or failed plus timing and safe error summary. + +8. `notice` + - A bounded, provider-neutral warning for skipped/unknown/corrupt records when useful to the user. + +Unknown native records are ignored and counted for diagnostics. They must not crash a page or force an invented presentation. Partially written final JSONL records are retained for the next read. A malformed complete record is skipped with bounded diagnostics, not retried forever. + +## Provider adapters + +Each adapter implements one internal interface: validate source, open/reset source, read a bounded page/delta, normalize durable records, normalize authenticated live lifecycle records, reconcile the two by stable identity, and expose a stable session identity. + +### Pi and OMP + +Pi and OMP use closely related message-oriented JSONL. Their adapters normalize: + +- user and assistant messages; +- assistant `text` as commentary/final according to message/turn completion; +- `toolCall` plus matching `toolResult` as a single stable tool activity; +- plan/todo tools into `plan_update` when structurally recognizable; +- toolResult file paths or edit operations into bounded file-change summaries; +- `thinking` as work timing/status metadata only, never raw visible reasoning. + +Pi and OMP persist many user, assistant, and tool-result records only at message completion, so transcript polling alone cannot power the promised live work timeline. Their managed integrations also publish authenticated, size-bounded, coalesced runtime lifecycle records for message updates, tool start/update/end, plan changes, approvals, and turn state when exposed by the provider SDK. The engine owns their normalization and holds them as an ephemeral overlay. When the durable JSONL record arrives, stable provider message/tool/request IDs reconcile and replace the overlay without duplication. If an integration cannot obtain a native stable ID, Herdr issues a turn-scoped ID and passes it back through the managed reporting context. + +The adapters share parsing helpers only where the native record contracts are actually identical; provider-specific interpretation remains separate. + +### Codex + +The Codex adapter consumes rollout JSONL response items and events. It normalizes visible assistant messages, plan updates, tool/custom-tool calls and outputs, file changes, interruptions, and turn completion. Reasoning and reasoning summaries are not shown as assistant text. The managed session hook is updated to retain the supplied transcript path separately from the Codex thread/resume ID. + +### Claude + +The Claude adapter consumes the main-session JSONL and excludes subagent transcripts unless they are represented as bounded tool activity in the root session. It pairs `tool_use` and `tool_result`, normalizes visible messages and edits, and ignores raw `thinking`. The managed hook's transcript path is retained separately from its session/resume ID. + +## Turn projection and presentation + +Desktop groups canonical items by turn and renders: + +### Active turn + +- A compact neobrutalist `Working for 12s` row using existing status tokens. +- The active plan step, if present. +- Visible assistant commentary in chronological position. +- Stable compact work rows for tools/edits with an icon, label, status, and optional disclosure. +- Repeated low-value activities grouped after a small visible limit with a `+N tool calls` disclosure. + +### Settled turn + +- Intermediate activity collapsed by default into `Worked for 18s`, `Stopped after 18s`, or `Failed after 18s`. +- Expanding the summary reveals the chronological commentary/work rows. +- The final assistant response remains visible outside the collapsed work group. +- A changed-files summary is attached below the response when applicable. + +### Visual constraints + +- Use Herdr's existing `Button`, `ScrollArea`, tooltip, disclosure/collapsible, border, surface, status, typography, and color primitives. +- Keep the current heavy borders, flat shadows, compact geometry, and design-token colors. +- Do not import T3 styling, copy its theme, add a second design system, or use raw one-off colors when a Herdr token exists. +- Motion is limited and respects reduced-motion preferences. Streaming updates must not animate the entire timeline. +- Work detail is keyboard accessible and exposes status through text, not color alone. + +## Chat availability UX + +- `supported`: Chat is enabled. +- `unavailable`: Chat is disabled and gray; hover/focus tooltip explains the safe reason, such as `Start or resume this agent to use Chat`. +- `unsupported`: Chat is disabled and gray; tooltip says `Chat is not currently supported for this provider. Use Terminal instead.` +- old engine/no capability: Chat is disabled and gray; tooltip says `Update Herdr to use structured Chat.` + +The Terminal/Chat switch must be a real disabled control with accessible description, not a clickable option that fails after navigation. + +## Prompting and optimistic state + +Submitting from Chat continues to use the existing Herdr prompt/input mechanism so provider behavior does not diverge between Terminal and Chat. The renderer may show an optimistic user message identified by a client submission ID. When the provider transcript emits the durable user item, the store reconciles it without duplication using submission metadata where available and conservative text/time matching otherwise. + +If prompt delivery fails, the optimistic item is marked failed and remains retryable. If delivery succeeds but transcript ingestion is delayed, the UI shows a syncing state and performs a bounded reread. It never fabricates an assistant reply from terminal output. + +### Approval responses + +`agent.conversation.respond` accepts the pane target, reader generation, opaque session identity, stable approval request ID, and one advertised decision ID. The engine verifies that the request is still pending and belongs to the authenticated active provider session, then delegates to the provider adapter's explicit response mapping. Duplicate delivery of the same decision is idempotent; conflicting, stale, resolved, or unknown requests fail safely and trigger a reread. When a provider exposes an approval but no safe structured responder, the item is read-only and Chat shows `Open Terminal to respond` rather than guessing terminal keys. + +### Local and remote attachments + +Desktop-local staging is insufficient for a remote engine because its absolute paths do not exist on the engine host, and Herdr's JSON request-line cap cannot carry a complete large image. Attachments therefore use an engine-owned bounded upload protocol over the existing API tunnel: + +1. `agent.attachment.begin` binds an upload to a pane/session, declared media type, file name, byte size, and digest, and returns an opaque upload handle. +2. Ordered base64 chunks remain comfortably below the request-line limit and are capped individually and in aggregate. +3. `agent.attachment.finish` verifies size/digest and returns an attachment handle, not a host path. +4. `agent.prompt` accepts those handles; the engine resolves them to 0600 temporary files on its own host, injects provider-visible paths through the existing input behavior, and then sends the prompt. +5. Abort, prompt completion/failure, session replacement, disconnect timeout, and TTL cleanup remove staged data. Per-pane, per-file, and aggregate quotas prevent resource exhaustion. + +Local and remote Desktop use this same path. Conversation responses expose only safe attachment metadata; bytes and engine-host paths are never placed in conversation events. + +## Session changes, truncation, and recovery + +- A new provider session identity atomically resets the pane's conversation store. +- A new engine instance or incompatible reader-cache generation changes the reader generation even when the provider session/file is unchanged. +- A source that shrinks, rotates, changes inode/identity, or becomes invalid emits `reset` and is reopened only after revalidation. +- Duplicate notifications and rereads are harmless because item IDs are stable and merges are idempotent. +- Out-of-order items are sorted by canonical sequence; impossible conflicting IDs trigger a reset/diagnostic rather than corrupting the timeline. +- A temporary read error keeps the last complete timeline visible with a non-blocking stale/error indicator and bounded retry. +- On local/remote reconnect, Desktop presents its last complete timeline while rereading from its cursor only if reader generation, session, and source fingerprint still match; otherwise it atomically resets to the new tail. + +## Performance and resource bounds + +- Initial and history pages have strict item and serialized-byte limits. +- Text, tool previews, details, path lists, and diagnostic counts are individually capped. +- The engine caches provider reader state by validated transcript identity and last byte/record position. +- Unchanged sources are not reparsed. +- Only connected conversation subscribers are watched. +- Desktop virtualizes or otherwise bounds rendering for long sessions while preserving accessible reading order. +- Renderer updates are batched per API delta rather than per text token. + +## Security and privacy + +- Transcript files are read only by the Herdr engine on the machine that owns them. +- Transcript source paths are not sent to Desktop. +- Managed transcript and live-lifecycle reports require an unguessable per-pane integration token and are constrained to its pane, provider authority, and process generation. Socket permissions remain defense in depth, not the authenticator. +- Paths are canonicalized and validated against provider roots/session ownership before reading. +- Tool inputs/outputs are projected through explicit allowlists and byte bounds. Environment variables, credentials, hidden reasoning, raw model payloads, and arbitrary file contents are excluded. +- Unknown record types fail closed: ignore and diagnose rather than display raw JSON. +- Remote mode uses the existing Herdr API tunnel, including chunked attachment staging on the engine host; no Desktop-side SSH file access or broad filesystem grant is introduced. + +## Testing strategy + +### Herdr engine + +- Fixture tests for Pi, OMP, Codex, and Claude with redacted native JSONL covering user text, assistant commentary/final, thinking exclusion, tool pairing, edits, plans, errors, interruptions, partial records, malformed records, truncation, rotation, and session replacement. +- Contract tests for deterministic IDs/order, pagination, byte/item bounds, path redaction, unknown-record behavior, capability reasons, and idempotent deltas. +- Hook tests proving resume identity and transcript reference are both retained without exposing the path publicly. +- Authentication tests for missing/invalid/stale/cross-pane tokens, provider mismatch, process replacement, session authority, and symlink replacement. +- Durable/live reconciliation tests proving Pi/OMP activity streams before message completion and does not duplicate once JSONL catches up. +- Subscription tests proving changes are signaled only for active subscribers and stop on disconnect. +- Reader-generation tests covering engine restart with the same provider session, concurrent history/delta requests, cache eviction, truncation, and file replacement. +- Attachment begin/chunk/finish/abort, quotas, digest, ownership, cleanup, and remote-tunnel integration tests. +- Approval ownership, allowed-decision, idempotency, reconnect, stale-request, and provider-response mapping tests. +- Schema snapshot tests plus a compatibility test proving the additive API does not change the terminal wire protocol. + +### Desktop + +- Reducer tests for history paging, deltas, resets, duplicate notifications, optimistic reconciliation, remote reconnect, and stale/read-error behavior. +- Component tests for active/settled turn projection, commentary ordering, grouped tool rows, changed files, failures, accessible disclosures, and scroll-follow rules. +- Capability tests for supported, temporarily unavailable, unsupported, and old-engine disabled Chat tooltips. +- A regression test proving supported Chat never calls `pane.read`, including a transcript delta larger than the old 500-line/32-line-overlap limits. +- Existing Terminal-mode tests remain green. +- Approval tests cover structured decisions and the read-only `Open Terminal to respond` fallback. +- Attachment tests prove local and remote modes both use opaque engine upload handles rather than Desktop-local paths. + +### End-to-end verification + +- Exercise live Pi, OMP, Codex, and Claude sessions with turns initiated from both Chat and Terminal. +- Verify a fast OMP tool-heavy turn that previously froze. +- Verify local engine, remote engine, reconnect, session reset/resume, long history paging, interruption, failed tool, image prompt, and user-scroll preservation. +- Verify older Herdr and unsupported providers show disabled Chat and retain working Terminal mode. + +## Rollout + +The additive engine API/capability ships before or together with the Desktop consumer without changing the exact-match terminal wire protocol. Desktop gates the new Chat path on the advertised capability. There is no snapshot-parser fallback for supported Chat. The old parser can be removed once no remaining UI path depends on it; Terminal continues to use pane snapshots directly. + +No PR, merge, release, dependency publication, or external write is part of this implementation pass. Those require separate explicit authorization. diff --git a/package-lock.json b/package-lock.json index 3ee27b9..51dd3a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "next-themes": "0.4.6", "react": "19.2.8", "react-dom": "19.2.8", + "react-markdown": "10.1.0", "react-resizable-panels": "4.12.2", "sonner": "2.0.7", "tailwind-merge": "3.6.0", @@ -4482,6 +4483,15 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4500,9 +4510,17 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -4514,6 +4532,15 @@ "@types/node": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -4538,6 +4565,21 @@ "@types/node": "*" } }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/mute-stream": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", @@ -4562,7 +4604,6 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4588,6 +4629,12 @@ "@types/node": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -4970,6 +5017,12 @@ "node": ">=16.20.0" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", @@ -5632,6 +5685,16 @@ "node": ">=0.8" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6083,6 +6146,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -6110,6 +6183,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chardet": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", @@ -6346,6 +6459,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", @@ -6590,7 +6713,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/debounce-fn": { @@ -6623,7 +6745,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6637,6 +6758,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -6809,7 +6943,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6839,6 +6972,19 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/diff": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", @@ -7845,6 +7991,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -8104,6 +8260,12 @@ "express": ">= 4.11" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -8868,6 +9030,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hono": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", @@ -8885,6 +9087,16 @@ "dev": true, "license": "ISC" }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -9119,6 +9331,12 @@ "node": ">=10" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/interpret": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", @@ -9149,6 +9367,30 @@ "node": ">= 0.10" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -9172,6 +9414,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -9224,6 +9476,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-in-ssh": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", @@ -9320,7 +9582,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -10012,6 +10273,16 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -10142,6 +10413,159 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/media-typer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", @@ -10201,35 +10625,477 @@ "node": ">= 8" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -10483,7 +11349,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/murmur-32": { @@ -11020,6 +11885,31 @@ "dev": true, "optional": true }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", @@ -11493,6 +12383,16 @@ "node": ">=6" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -11657,6 +12557,33 @@ "license": "MIT", "peer": true }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -11913,6 +12840,39 @@ "node": ">=8" } }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", @@ -12978,6 +13938,16 @@ "source-map": "^0.6.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -13140,6 +14110,20 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stringify-object": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", @@ -13250,6 +14234,24 @@ "node": ">=0.8.0" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -13621,6 +14623,16 @@ "dev": true, "license": "MIT" }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/trim-repeated": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", @@ -13644,6 +14656,16 @@ "node": ">=0.8.0" } }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-morph": { "version": "26.0.0", "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", @@ -13783,6 +14805,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unique-filename": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", @@ -13809,6 +14850,74 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -13966,6 +15075,34 @@ "node": ">= 0.8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", @@ -14859,6 +15996,16 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 94118ae..f83a062 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "next-themes": "0.4.6", "react": "19.2.8", "react-dom": "19.2.8", + "react-markdown": "10.1.0", "react-resizable-panels": "4.12.2", "sonner": "2.0.7", "tailwind-merge": "3.6.0", diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx index fbf4a94..d0a3a84 100644 --- a/src/components/ui/scroll-area.tsx +++ b/src/components/ui/scroll-area.tsx @@ -6,9 +6,16 @@ import { cn } from '@/lib/utils'; type ScrollAreaProps = React.ComponentProps & { viewportRef?: React.Ref; + onViewportScroll?: React.UIEventHandler; }; -function ScrollArea({ className, children, viewportRef, ...props }: ScrollAreaProps) { +function ScrollArea({ + className, + children, + viewportRef, + onViewportScroll, + ...props +}: ScrollAreaProps) { return ( {children} diff --git a/src/main.ts b/src/main.ts index e103a22..2ca0c04 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,10 +7,9 @@ import { app, BrowserWindow, dialog, ipcMain, Menu, session, shell } from 'elect import started from 'electron-squirrel-startup'; import { APP_NAME, configureApplicationBranding } from '@/main/app-branding'; import { applicationMenuTemplate } from '@/main/application-menu'; -import { stageChatImages } from '@/main/chat-images'; import { DesktopPreferencesStore } from '@/main/desktop-preferences'; import { checkDesktopUpdate } from '@/main/desktop-update'; -import { chatImageStagingDir, hostPathFromSandboxPath, isFlatpakHost } from '@/main/flatpak'; +import { hostPathFromSandboxPath, isFlatpakHost } from '@/main/flatpak'; import { resolveHerdrBinary } from '@/main/herdr/binary-locator'; import { HerdrBinaryPreference } from '@/main/herdr/binary-preference'; import { HerdrEngine, NodeHerdrCommandRunner, NodeHerdrServerLauncher } from '@/main/herdr/engine'; @@ -18,7 +17,13 @@ import { HerdrEventSubscription } from '@/main/herdr/event-subscription'; import { TerminalController } from '@/main/herdr/terminal-controller'; import { TerminalControllerPool } from '@/main/herdr/terminal-controller-pool'; import { - parseChatImageDrafts, + parseConversationAttachmentAbortRequest, + parseConversationAttachmentBeginRequest, + parseConversationAttachmentChunkRequest, + parseConversationAttachmentFinishRequest, + parseConversationPromptRequest, + parseConversationReadRequest, + parseConversationRespondRequest, parseHerdrCommand, parseHerdrQuery, parsePaneId, @@ -68,6 +73,7 @@ let engine = createEngine(herdrBinary); let binaryPreference: HerdrBinaryPreference | null = null; let desktopPreferences: DesktopPreferencesStore | null = null; let preferencesWriteQueue = Promise.resolve(); +const conversationSubscriptions = new Set(); function enqueuePreferencesWrite(operation: () => Promise): Promise { const run = preferencesWriteQueue.then(operation); @@ -140,6 +146,7 @@ const eventSubscription = new HerdrEventSubscription( return { socketPath: result.status.server.socket, paneIds: result.snapshot.panes.map((pane) => pane.pane_id), + conversationPaneIds: eventSubscription.getConversationPaneIds(), }; }, onStateChange: (state) => @@ -293,13 +300,76 @@ function registerIpcHandlers(): void { return engine.query(parseHerdrQuery(candidate)); }); - ipcMain.handle(IPC_CHANNELS.stageChatImages, async (event, candidate: unknown) => { + ipcMain.handle(IPC_CHANNELS.conversationRead, async (event, candidate: unknown) => { assertTrustedSender(event.senderFrame?.url); - // Files are staged at the sandbox-visible path (where the grant mounts - // the host directory); the renderer and the host agent receive the - // host-equivalent paths. - const staged = stageChatImages(chatImageStagingDir(), parseChatImageDrafts(candidate)); - return staged.map((filePath) => hostPathFromSandboxPath(filePath)); + if (demoMode) { + throw new Error('Structured Chat is unavailable in demo mode.'); + } + return engine.conversationRead(parseConversationReadRequest(candidate)); + }); + + ipcMain.handle(IPC_CHANNELS.conversationPrompt, async (event, candidate: unknown) => { + assertTrustedSender(event.senderFrame?.url); + if (demoMode) { + return DEMO_BOOTSTRAP; + } + return trackConnectedSession( + await engine.conversationPrompt(parseConversationPromptRequest(candidate)), + ); + }); + + ipcMain.handle(IPC_CHANNELS.conversationRespond, async (event, candidate: unknown) => { + assertTrustedSender(event.senderFrame?.url); + if (demoMode) { + throw new Error('Structured Chat is unavailable in demo mode.'); + } + return engine.conversationRespond(parseConversationRespondRequest(candidate)); + }); + + ipcMain.handle(IPC_CHANNELS.attachmentBegin, async (event, candidate: unknown) => { + assertTrustedSender(event.senderFrame?.url); + if (demoMode) { + throw new Error('Structured Chat is unavailable in demo mode.'); + } + return engine.conversationAttachmentBegin(parseConversationAttachmentBeginRequest(candidate)); + }); + + ipcMain.handle(IPC_CHANNELS.attachmentChunk, async (event, candidate: unknown) => { + assertTrustedSender(event.senderFrame?.url); + if (demoMode) { + throw new Error('Structured Chat is unavailable in demo mode.'); + } + return engine.conversationAttachmentChunk(parseConversationAttachmentChunkRequest(candidate)); + }); + + ipcMain.handle(IPC_CHANNELS.attachmentFinish, async (event, candidate: unknown) => { + assertTrustedSender(event.senderFrame?.url); + if (demoMode) { + throw new Error('Structured Chat is unavailable in demo mode.'); + } + return engine.conversationAttachmentFinish(parseConversationAttachmentFinishRequest(candidate)); + }); + + ipcMain.handle(IPC_CHANNELS.attachmentAbort, async (event, candidate: unknown) => { + assertTrustedSender(event.senderFrame?.url); + if (demoMode) { + throw new Error('Structured Chat is unavailable in demo mode.'); + } + return engine.conversationAttachmentAbort(parseConversationAttachmentAbortRequest(candidate)); + }); + + ipcMain.handle(IPC_CHANNELS.conversationSubscribe, async (event, candidate: unknown) => { + assertTrustedSender(event.senderFrame?.url); + const paneId = parsePaneId(candidate); + conversationSubscriptions.add(paneId); + eventSubscription.setConversationPanes([...conversationSubscriptions]); + }); + + ipcMain.handle(IPC_CHANNELS.conversationUnsubscribe, async (event, candidate: unknown) => { + assertTrustedSender(event.senderFrame?.url); + const paneId = parsePaneId(candidate); + conversationSubscriptions.delete(paneId); + eventSubscription.setConversationPanes([...conversationSubscriptions]); }); ipcMain.handle(IPC_CHANNELS.readPreferences, async (event) => { diff --git a/src/main/chat-images.test.ts b/src/main/chat-images.test.ts deleted file mode 100644 index e84464c..0000000 --- a/src/main/chat-images.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { - existsSync, - lstatSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - symlinkSync, - utimesSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; - -import { stageChatImages } from '@/main/chat-images'; -import { MAX_CHAT_IMAGE_ATTACHMENTS, MAX_CHAT_IMAGE_TOTAL_BYTES } from '@/shared/desktop-api'; - -const PNG = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; - -describe('chat image staging', () => { - const dirs: string[] = []; - function tempDir() { - const dir = mkdtempSync(join(tmpdir(), 'herdr-chat-images-test-')); - dirs.push(dir); - return dir; - } - afterEach(() => { - for (const dir of dirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('writes each staged image and returns its absolute path', () => { - const dir = tempDir(); - const paths = stageChatImages(dir, [{ extension: 'png', data: PNG }]); - expect(paths).toHaveLength(1); - expect(paths[0]).toMatch(new RegExp(`^${dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/`)); - expect(paths[0]).toMatch(/herdr-desktop-chat-.*\.png$/); - expect(readFileSync(paths[0])).toEqual(Buffer.from(PNG, 'base64')); - }); - - it('stages jpg, gif, webp, and bmp payloads with their own extensions', () => { - const dir = tempDir(); - const paths = stageChatImages(dir, [ - { extension: 'jpg', data: PNG }, - { extension: 'gif', data: PNG }, - { extension: 'webp', data: PNG }, - { extension: 'bmp', data: PNG }, - ]); - expect(paths.map((path) => path.split('.').pop())).toEqual(['jpg', 'gif', 'webp', 'bmp']); - }); - - it('rejects unsupported image extensions', () => { - expect(() => stageChatImages(tempDir(), [{ extension: 'svg', data: PNG }])).toThrow( - /unsupported image extension/i, - ); - }); - - it('rejects malformed base64 payloads', () => { - expect(() => stageChatImages(tempDir(), [{ extension: 'png', data: 'not-base64!' }])).toThrow( - /base64/i, - ); - }); - - it('rejects empty payloads', () => { - expect(() => stageChatImages(tempDir(), [{ extension: 'png', data: '' }])).toThrow(/empty/i); - }); - - it('rejects payloads over the 16 MiB clipboard image limit', () => { - const oversized = Buffer.alloc(16 * 1024 * 1024 + 1, 7).toString('base64'); - expect(() => stageChatImages(tempDir(), [{ extension: 'png', data: oversized }])).toThrow( - /16 MiB/i, - ); - }); - - it('rejects non-canonical base64 lengths and padding', () => { - for (const data of ['A', 'A=', 'AA=', 'AAA==', 'aGVsbG8', 'not-base64!']) { - expect(() => stageChatImages(tempDir(), [{ extension: 'png', data }])).toThrow(/base64/i); - } - }); - - it('rejects more than the maximum attachment count', () => { - const drafts = Array.from({ length: MAX_CHAT_IMAGE_ATTACHMENTS + 1 }, () => ({ - extension: 'png', - data: PNG, - })); - expect(() => stageChatImages(tempDir(), drafts)).toThrow(/attachment limit/i); - }); - - it('rejects batches over the total image byte budget', () => { - const big = Buffer.alloc((MAX_CHAT_IMAGE_TOTAL_BYTES * 3) / 8, 9).toString('base64'); - expect(() => - stageChatImages(tempDir(), [ - { extension: 'png', data: big }, - { extension: 'png', data: big }, - { extension: 'png', data: big }, - ]), - ).toThrow(/total limit/i); - }); - - it('creates the staging directory with user-only permissions', () => { - const dir = tempDir(); - const staging = join(dir, 'staging'); - stageChatImages(staging, [{ extension: 'png', data: PNG }]); - if (process.platform !== 'win32') { - expect(statSync(staging).mode & 0o777).toBe(0o700); - } - }); - - it('writes staged images with user-only permissions', () => { - const dir = tempDir(); - const [path] = stageChatImages(dir, [{ extension: 'png', data: PNG }]); - if (process.platform !== 'win32') { - expect(statSync(path).mode & 0o777).toBe(0o600); - } - }); - - it('refuses a staging path that is a symlink', () => { - if (process.platform === 'win32') { - return; - } - const dir = tempDir(); - const real = join(dir, 'real'); - mkdirSync(real); - const link = join(dir, 'link'); - symlinkSync(real, link); - expect(() => stageChatImages(link, [{ extension: 'png', data: PNG }])).toThrow( - /not a directory/i, - ); - }); - - it('refuses a staging path that is an existing regular file', () => { - const dir = tempDir(); - const path = join(dir, 'occupied'); - writeFileSync(path, 'occupied'); - expect(() => stageChatImages(path, [{ extension: 'png', data: PNG }])).toThrow( - /not a directory/i, - ); - }); - - it('cleans stale regular files but leaves symlinks and directories alone', () => { - const dir = tempDir(); - const stale = join(dir, 'herdr-desktop-chat-stale.png'); - writeFileSync(stale, Buffer.from(PNG, 'base64')); - const old = new Date(Date.now() - 25 * 60 * 60 * 1000); - utimesSync(stale, old, old); - const subdir = join(dir, 'herdr-desktop-chat-subdir'); - mkdirSync(subdir); - utimesSync(subdir, old, old); - let staleLink: string | undefined; - if (process.platform !== 'win32') { - staleLink = join(dir, 'herdr-desktop-chat-link.png'); - symlinkSync(stale, staleLink); - utimesSync(staleLink, old, old); - } - stageChatImages(dir, [{ extension: 'png', data: PNG }]); - expect(existsSync(stale)).toBe(false); - expect(lstatSync(subdir).isDirectory()).toBe(true); - if (staleLink) { - expect(lstatSync(staleLink).isSymbolicLink()).toBe(true); - } - }); - - it('stages repeated images under unique file names', () => { - const dir = tempDir(); - const paths = stageChatImages(dir, [ - { extension: 'png', data: PNG }, - { extension: 'png', data: PNG }, - ]); - expect(new Set(paths).size).toBe(2); - }); - - it('removes stale staged files older than 24 hours when staging again', () => { - const dir = tempDir(); - const stale = join(dir, 'herdr-desktop-chat-stale.png'); - writeFileSync(stale, Buffer.from(PNG, 'base64')); - const old = new Date(Date.now() - 25 * 60 * 60 * 1000); - utimesSync(stale, old, old); - stageChatImages(dir, [{ extension: 'png', data: PNG }]); - expect(existsSync(stale)).toBe(false); - }); - - it('keeps recent staged files when staging again', () => { - const dir = tempDir(); - const recent = join(dir, 'herdr-desktop-chat-recent.png'); - writeFileSync(recent, Buffer.from(PNG, 'base64')); - stageChatImages(dir, [{ extension: 'png', data: PNG }]); - expect(existsSync(recent)).toBe(true); - }); -}); diff --git a/src/main/chat-images.ts b/src/main/chat-images.ts deleted file mode 100644 index 7c41ec5..0000000 --- a/src/main/chat-images.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { chmodSync, lstatSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; - -import { - CHAT_IMAGE_EXTENSIONS, - type ChatImageDraft, - isCanonicalBase64, - MAX_CHAT_IMAGE_ATTACHMENTS, - MAX_CHAT_IMAGE_BYTES, - MAX_CHAT_IMAGE_TOTAL_BYTES, -} from '@/shared/desktop-api'; - -const STALE_IMAGE_AGE_MS = 24 * 60 * 60 * 1000; - -function validExtension(extension: string): string { - const normalized = extension.toLocaleLowerCase(); - if (!(CHAT_IMAGE_EXTENSIONS as readonly string[]).includes(normalized)) { - throw new Error(`Unsupported image extension '${extension}'.`); - } - return normalized; -} - -function decodePayload(data: string): Buffer { - if (data.length === 0) { - throw new Error('Chat image payload is empty.'); - } - if (!isCanonicalBase64(data)) { - throw new Error('Chat image payload is not valid canonical base64.'); - } - const bytes = Buffer.from(data, 'base64'); - if (bytes.length === 0) { - throw new Error('Chat image payload is empty.'); - } - if (bytes.length > MAX_CHAT_IMAGE_BYTES) { - throw new Error( - `Chat image payload exceeds the ${MAX_CHAT_IMAGE_BYTES / 1024 / 1024} MiB limit.`, - ); - } - return bytes; -} - -/** - * The staging directory is desktop-owned and holds pasted images, so it must - * not be a symlink or a file placed by another local user, and it must stay - * readable only by the current user. - */ -function ensureStagingDir(dir: string): void { - let info: ReturnType; - try { - info = lstatSync(dir); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error; - } - mkdirSync(dir, { recursive: true, mode: 0o700 }); - if (process.platform !== 'win32') { - chmodSync(dir, 0o700); - } - return; - } - if (!info.isDirectory()) { - throw new Error(`Chat image staging path is not a directory: ${dir}`); - } - if (typeof process.getuid === 'function' && info.uid !== process.getuid()) { - throw new Error(`Chat image staging directory is not owned by the current user: ${dir}`); - } - if (process.platform !== 'win32') { - chmodSync(dir, 0o700); - } -} - -function writeUniqueFile(dir: string, name: string, extension: string, data: Buffer): string { - for (let attempt = 0; attempt < 100; attempt += 1) { - const path = join(dir, `${name}-${attempt}.${extension}`); - try { - writeFileSync(path, data, { flag: 'wx', mode: 0o600 }); - return path; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'EEXIST') { - throw error; - } - } - } - throw new Error('Failed to allocate a unique chat image staging path.'); -} - -/** Removes only stale regular files; symlinks and directories are left alone. */ -function removeStaleFiles(dir: string): void { - const cutoff = Date.now() - STALE_IMAGE_AGE_MS; - for (const entry of readdirSync(dir)) { - const path = join(dir, entry); - try { - const info = lstatSync(path); - if (info.isFile() && info.mtimeMs < cutoff) { - rmSync(path, { force: true }); - } - } catch { - // A concurrent cleanup may have removed the entry already. - } - } -} - -/** - * Writes chat image payloads to a desktop-owned staging directory and returns - * the absolute path of each staged file. The agent receives these paths as - * bracketed pastes, mirroring how Herdr itself bridges clipboard images: the - * agent CLI reads the file and attaches it to the conversation. - */ -export function stageChatImages(dir: string, drafts: ChatImageDraft[]): string[] { - if (drafts.length > MAX_CHAT_IMAGE_ATTACHMENTS) { - throw new Error(`Chat image batch exceeds the ${MAX_CHAT_IMAGE_ATTACHMENTS} attachment limit.`); - } - const decoded = drafts.map((draft) => ({ - extension: validExtension(draft.extension), - data: decodePayload(draft.data), - })); - const totalBytes = decoded.reduce((sum, item) => sum + item.data.length, 0); - if (totalBytes > MAX_CHAT_IMAGE_TOTAL_BYTES) { - throw new Error( - `Chat image batch exceeds the ${MAX_CHAT_IMAGE_TOTAL_BYTES / 1024 / 1024} MiB total limit.`, - ); - } - ensureStagingDir(dir); - removeStaleFiles(dir); - const paths: string[] = []; - for (let index = 0; index < decoded.length; index += 1) { - const item = decoded[index]; - paths.push( - writeUniqueFile(dir, `herdr-desktop-chat-${Date.now()}-${index}`, item.extension, item.data), - ); - } - return paths; -} diff --git a/src/main/flatpak.test.ts b/src/main/flatpak.test.ts index b183be6..19af76c 100644 --- a/src/main/flatpak.test.ts +++ b/src/main/flatpak.test.ts @@ -1,11 +1,8 @@ -import { existsSync, mkdtempSync, rmSync } from 'node:fs'; -import os, { tmpdir } from 'node:os'; +import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { stageChatImages } from '@/main/chat-images'; import { - chatImageStagingDir, FLATPAK_APP_ID, flatpakHostDataDir, flatpakRemoteSocketDir, @@ -203,41 +200,6 @@ describe('hostInvocation', () => { }); }); -describe('chatImageStagingDir', () => { - it('uses the temporary directory outside Flatpak', () => { - vi.unstubAllEnvs(); - expect(chatImageStagingDir()).toBe(path.join(os.tmpdir(), 'herdr-desktop-chat-images')); - }); - - it('stages at the sandbox XDG_DATA_HOME inside the Herdr Desktop Flatpak', () => { - vi.stubEnv('FLATPAK_ID', FLATPAK_APP_ID); - vi.stubEnv('XDG_DATA_HOME', '/sandbox-data'); - expect(chatImageStagingDir()).toBe(path.join('/sandbox-data', 'herdr-desktop', 'chat-images')); - }); - - it('returns host-visible paths after translation for the renderer and agent', () => { - const sandboxData = mkdtempSync(path.join(tmpdir(), 'fp-sandbox-data-')); - const hostData = mkdtempSync(path.join(tmpdir(), 'fp-host-data-')); - try { - vi.stubEnv('FLATPAK_ID', FLATPAK_APP_ID); - vi.stubEnv('XDG_DATA_HOME', sandboxData); - vi.stubEnv('HOST_XDG_DATA_HOME', hostData); - const staged = stageChatImages(chatImageStagingDir(), [ - { extension: 'png', data: Buffer.from('hello').toString('base64') }, - ]); - const hostVisible = staged.map((filePath) => hostPathFromSandboxPath(filePath)); - expect(hostVisible.length).toBe(1); - expect(hostVisible[0]).toMatch( - new RegExp(`^${hostData}/herdr-desktop/chat-images/herdr-desktop-chat-[\\d-]+\\.png$`), - ); - expect(existsSync(staged[0])).toBe(true); - } finally { - rmSync(sandboxData, { recursive: true, force: true }); - rmSync(hostData, { recursive: true, force: true }); - } - }); -}); - describe('flatpakRemoteSocketDir', () => { it('places remote bridge sockets under the sandbox remote grant directory', () => { vi.stubEnv('FLATPAK_ID', FLATPAK_APP_ID); diff --git a/src/main/flatpak.ts b/src/main/flatpak.ts index 1c26825..980b51b 100644 --- a/src/main/flatpak.ts +++ b/src/main/flatpak.ts @@ -157,25 +157,6 @@ export function hostInvocation( return { program: 'flatpak-spawn', args: flatpakArgs }; } -/** - * Directory used to stage pasted/dropped chat images. Flatpak's private /tmp - * is invisible to host agents, so inside the sandbox images must be staged in - * the granted host-visible directory. The sandbox sees it at - * `$XDG_DATA_HOME/herdr-desktop/chat-images` and the host at - * `$HOST_XDG_DATA_HOME/herdr-desktop/chat-images` — two aliases of the same - * files; staged results are translated to the host form on the way back to - * the renderer/agent. - */ -export function chatImageStagingDir(): string { - if (isFlatpakHost()) { - // The sandbox XDG_DATA_HOME is where the xdg-data grant mounts the host - // directory; staged results are translated to host-visible paths on the - // way back to the renderer/agent. - return path.join(flatpakSandboxDataDir(), 'herdr-desktop', 'chat-images'); - } - return path.join(os.tmpdir(), 'herdr-desktop-chat-images'); -} - /** * Sandbox-visible directory for the remote-engine bridge sockets in Flatpak * mode. The `--filesystem=xdg-data/herdr-desktop:create` parent grant mounts diff --git a/src/main/herdr/conversation-decoder.test.ts b/src/main/herdr/conversation-decoder.test.ts new file mode 100644 index 0000000..5845e7f --- /dev/null +++ b/src/main/herdr/conversation-decoder.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest'; + +import { + decodeAttachmentBeginResult, + decodeConversationChangedEvent, + decodeConversationReadResult, + decodeConversationRespondResult, +} from '@/main/herdr/conversation-decoder'; + +describe('decodeConversationReadResult', () => { + it('decodes the flattened snake-case engine page', () => { + const result = decodeConversationReadResult({ + type: 'agent_conversation_read', + read: { + type: 'page', + page: { + provider: 'pi', + session: { id: 'opaque-session' }, + capability: { availability: 'supported', reason: 'ready' }, + items: [ + { + id: 'item-1', + sequence: 1, + provider: 'pi', + session_id: 'opaque-session', + turn_id: 'turn-1', + type: 'assistant_message', + phase: 'final', + text: 'done', + state: 'completed', + }, + { + id: 'item-2', + sequence: 2, + provider: 'pi', + session_id: 'opaque-session', + type: 'user_message', + text: '', + attachments: [{ media_type: 'image/png', name: 'diagram.png', byte_size: 42 }], + }, + ], + next_cursor: 'cursor-a', + has_older: false, + revision: 2, + reader_generation: 'generation-a', + }, + }, + }); + + expect(result).toEqual({ + type: 'page', + page: expect.objectContaining({ + provider: 'pi', + items: expect.arrayContaining([ + expect.objectContaining({ type: 'assistant_message', text: 'done' }), + expect.objectContaining({ + type: 'user_message', + text: '', + attachments: expect.any(Array), + }), + ]), + next_cursor: 'cursor-a', + }), + }); + }); + + it('accepts reset_required and rejects path-shaped opaque identity', () => { + expect( + decodeConversationReadResult({ + type: 'agent_conversation_read', + read: { + type: 'reset_required', + session: { id: 'opaque-session' }, + reader_generation: 'generation-b', + }, + }), + ).toEqual({ + type: 'reset_required', + session: { id: 'opaque-session' }, + reader_generation: 'generation-b', + }); + + expect(() => + decodeConversationReadResult({ + type: 'agent_conversation_read', + read: { + type: 'reset_required', + session: { id: '/home/user/transcript.jsonl' }, + reader_generation: 'generation-b', + }, + }), + ).toThrow(/invalid conversation/); + }); + + it('rejects malformed or oversized canonical items', () => { + expect(() => + decodeConversationReadResult({ + type: 'agent_conversation_read', + read: { + type: 'page', + page: { + provider: 'pi', + session: { id: 'opaque-session' }, + capability: { availability: 'supported', reason: 'ready' }, + items: [ + { + id: 'item-1', + sequence: 1, + provider: 'pi', + type: 'assistant_message', + phase: 'final', + text: 'x'.repeat(20_000), + state: 'completed', + }, + ], + has_older: false, + revision: 1, + reader_generation: 'generation-a', + }, + }, + }), + ).toThrow(/invalid conversation/); + }); +}); + +describe('conversation response decoders', () => { + it('decodes an idempotent approval response and rejects path-shaped event identities', () => { + expect( + decodeConversationRespondResult({ + type: 'agent_conversation_respond', + result: { + request_id: 'approval-1', + decision_id: 'allow', + accepted: true, + reason: 'already_resolved', + }, + }), + ).toEqual({ + request_id: 'approval-1', + decision_id: 'allow', + accepted: true, + reason: 'already_resolved', + }); + expect( + decodeConversationChangedEvent({ + pane_id: 'w1:p1', + workspace_id: 'w1', + session: { id: 'session-1' }, + reader_generation: 'generation-1', + revision: 4, + reset_required: false, + }), + ).toEqual({ + pane_id: 'w1:p1', + workspace_id: 'w1', + session: { id: 'session-1' }, + reader_generation: 'generation-1', + revision: 4, + reset_required: false, + }); + expect( + decodeConversationChangedEvent({ + pane_id: 'w1:p1', + workspace_id: 'w1', + session: { id: '/tmp/transcript.jsonl' }, + reader_generation: 'generation-1', + revision: 4, + reset_required: false, + }), + ).toBeNull(); + expect( + decodeConversationChangedEvent({ + pane_id: '/tmp/pane', + workspace_id: 'w1', + session: { id: 'session-1' }, + reader_generation: 'generation-1', + revision: 4, + reset_required: false, + }), + ).toBeNull(); + expect( + decodeConversationChangedEvent({ + pane_id: 'w1:p1', + workspace_id: 'x'.repeat(257), + session: { id: 'session-1' }, + reader_generation: 'generation-1', + revision: 4, + reset_required: false, + }), + ).toBeNull(); + }); +}); + +describe('attachment decoders', () => { + it('preserves the opaque upload handle shape used by the IPC contract', () => { + expect( + decodeAttachmentBeginResult({ + type: 'agent_attachment_begin', + upload: { handle: 'upload-1' }, + chunk_size: 8192, + }), + ).toEqual({ + upload: { handle: 'upload-1' }, + chunk_size: 8192, + }); + }); +}); diff --git a/src/main/herdr/conversation-decoder.ts b/src/main/herdr/conversation-decoder.ts new file mode 100644 index 0000000..8b88fd0 --- /dev/null +++ b/src/main/herdr/conversation-decoder.ts @@ -0,0 +1,431 @@ +export { decodeConversationChangedEvent } from '@/shared/conversation'; + +import type { + ApprovalDecision, + AttachmentMetadata, + ConversationCapability, + ConversationItem, + ConversationItemPayload, + ConversationPage, + ConversationReadResult, + ConversationReasonCode, + ConversationRespondReason, + ConversationRespondResult, + ConversationSessionIdentity, + PlanStep, +} from '@/shared/conversation'; + +const MAX_ITEMS = 256; +const MAX_TEXT_LENGTH = 16_384; +const MAX_OPAQUE_LENGTH = 256; +const MAX_PATHS = 64; +const MAX_ATTACHMENTS = 16; +const MAX_DECISIONS = 16; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function boundedString(value: unknown, max = MAX_TEXT_LENGTH): string | null { + return typeof value === 'string' && value.length > 0 && value.length <= max ? value : null; +} + +function optionalString(value: unknown, max = MAX_TEXT_LENGTH): string | undefined | null { + return value === undefined ? undefined : boundedString(value, max); +} + +function uint(value: unknown): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function opaque(value: unknown): string | null { + const result = boundedString(value, MAX_OPAQUE_LENGTH); + return result && !/[\\/]/.test(result) ? result : null; +} + +function safePath(value: unknown): string | null { + const result = boundedString(value); + return result && + !result.startsWith('/') && + !result.startsWith('\\') && + !/(^|[\\/])\.\.([\\/]|$)/.test(result) + ? result + : null; +} + +function invalid(): never { + throw new Error('Herdr returned an invalid conversation response.'); +} + +function decodeSession(value: unknown): ConversationSessionIdentity { + if (!isRecord(value)) { + return invalid(); + } + const id = opaque(value.id); + return id ? { id } : invalid(); +} + +function decodeCapability(value: unknown): ConversationCapability { + if (!isRecord(value)) { + return invalid(); + } + const availability = value.availability; + if ( + availability !== 'supported' && + availability !== 'unavailable' && + availability !== 'unsupported' + ) { + return invalid(); + } + const reasons: ConversationReasonCode[] = [ + 'ready', + 'adapter_missing', + 'no_session', + 'transcript_missing', + 'transcript_invalid', + 'source_unreadable', + ]; + if (!reasons.includes(value.reason as ConversationReasonCode)) { + return invalid(); + } + const message = optionalString(value.message); + if (message === null) { + return invalid(); + } + return { + availability, + reason: value.reason as ConversationReasonCode, + ...(message === undefined ? {} : { message }), + }; +} + +function decodeAttachments(value: unknown): AttachmentMetadata[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value) || value.length > MAX_ATTACHMENTS) { + return invalid(); + } + return value.map((item) => { + if (!isRecord(item)) { + return invalid(); + } + const media_type = boundedString(item.media_type, 128); + const name = boundedString(item.name, 256); + const byte_size = uint(item.byte_size); + return media_type && name && byte_size !== null ? { media_type, name, byte_size } : invalid(); + }); +} + +function decodeSteps(value: unknown): PlanStep[] { + if (!Array.isArray(value) || value.length > MAX_ITEMS) { + return invalid(); + } + return value.map((item) => { + if (!isRecord(item)) { + return invalid(); + } + const label = boundedString(item.label); + const status = item.status; + if ( + !label || + (status !== 'pending' && status !== 'active' && status !== 'completed' && status !== 'failed') + ) { + return invalid(); + } + return { label, status }; + }); +} + +function decodeDecisions(value: unknown): ApprovalDecision[] { + if (!Array.isArray(value) || value.length > MAX_DECISIONS) { + return invalid(); + } + return value.map((item) => { + if (!isRecord(item)) { + return invalid(); + } + const id = opaque(item.id); + const label = boundedString(item.label); + return id && label ? { id, label } : invalid(); + }); +} + +function decodePayload(value: Record): ConversationItemPayload { + switch (value.type) { + case 'user_message': { + const text = + typeof value.text === 'string' && value.text.length <= MAX_TEXT_LENGTH ? value.text : null; + const attachments = decodeAttachments(value.attachments); + return text !== null && (text.length > 0 || attachments.length > 0) + ? { type: value.type, text, attachments } + : invalid(); + } + case 'assistant_message': { + const text = boundedString(value.text); + const phase = value.phase; + const state = value.state; + return text && + (phase === 'commentary' || phase === 'final') && + (state === 'completed' || state === 'interrupted' || state === 'failed') + ? { type: value.type, phase, text, state } + : invalid(); + } + case 'plan_update': + return { type: value.type, steps: decodeSteps(value.steps) }; + case 'tool_activity': { + const action = boundedString(value.action); + const label = boundedString(value.label); + const status = value.status; + const preview = optionalString(value.preview, MAX_TEXT_LENGTH); + const detail = optionalString(value.detail, MAX_TEXT_LENGTH); + const duration_ms = value.duration_ms === undefined ? undefined : uint(value.duration_ms); + const paths = value.paths === undefined ? [] : value.paths; + if ( + !action || + !label || + (status !== 'running' && status !== 'completed' && status !== 'failed') || + preview === null || + detail === null || + duration_ms === null || + !Array.isArray(paths) || + paths.length > MAX_PATHS || + !paths.every((path) => safePath(path)) + ) { + return invalid(); + } + return { + type: value.type, + action, + label, + status, + ...(preview === undefined ? {} : { preview }), + ...(detail === undefined ? {} : { detail }), + ...(duration_ms === undefined ? {} : { duration_ms }), + paths: paths as string[], + }; + } + case 'file_change': { + const path = safePath(value.path); + const change = value.change; + const summary = optionalString(value.summary, MAX_TEXT_LENGTH); + if ( + !path || + !['created', 'modified', 'deleted', 'renamed'].includes(change as string) || + summary === null + ) { + return invalid(); + } + return { + type: value.type, + path, + change: change as 'created' | 'modified' | 'deleted' | 'renamed', + ...(summary === undefined ? {} : { summary }), + }; + } + case 'approval': { + const request_id = opaque(value.request_id); + const prompt = boundedString(value.prompt); + const status = value.status; + const selected_decision = optionalString(value.selected_decision, MAX_OPAQUE_LENGTH); + if ( + !request_id || + !prompt || + (status !== 'pending' && status !== 'resolved') || + typeof value.structured_response !== 'boolean' || + selected_decision === null + ) { + return invalid(); + } + return { + type: value.type, + request_id, + prompt, + decisions: decodeDecisions(value.decisions), + status, + ...(selected_decision === undefined ? {} : { selected_decision }), + structured_response: value.structured_response, + }; + } + case 'turn_state': { + const state = value.state; + const started_ms = value.started_ms === undefined ? undefined : uint(value.started_ms); + const duration_ms = value.duration_ms === undefined ? undefined : uint(value.duration_ms); + const error = optionalString(value.error); + if ( + !['started', 'completed', 'interrupted', 'failed'].includes(state as string) || + started_ms === null || + duration_ms === null || + error === null + ) { + return invalid(); + } + return { + type: value.type, + state: state as 'started' | 'completed' | 'interrupted' | 'failed', + ...(started_ms === undefined ? {} : { started_ms }), + ...(duration_ms === undefined ? {} : { duration_ms }), + ...(error === undefined ? {} : { error }), + }; + } + case 'notice': { + const message = boundedString(value.message); + return message ? { type: value.type, message } : invalid(); + } + default: + return invalid(); + } +} + +function decodeItem(value: unknown): ConversationItem { + if (!isRecord(value)) { + return invalid(); + } + const id = opaque(value.id); + const sequence = uint(value.sequence); + const provider = boundedString(value.provider, 64); + const session_id = value.session_id === undefined ? undefined : opaque(value.session_id); + const turn_id = value.turn_id === undefined ? undefined : opaque(value.turn_id); + const timestamp_ms = value.timestamp_ms === undefined ? undefined : uint(value.timestamp_ms); + if ( + !id || + sequence === null || + !provider || + session_id === null || + turn_id === null || + timestamp_ms === null + ) { + return invalid(); + } + return { + id, + sequence, + provider, + ...(session_id === undefined ? {} : { session_id }), + ...(turn_id === undefined ? {} : { turn_id }), + ...(timestamp_ms === undefined ? {} : { timestamp_ms }), + ...decodePayload(value), + } as ConversationItem; +} + +function decodePage(value: unknown): ConversationPage { + if (!isRecord(value)) { + return invalid(); + } + const provider = boundedString(value.provider, 64); + const reader_generation = opaque(value.reader_generation); + const revision = uint(value.revision); + const next_cursor = value.next_cursor === undefined ? undefined : opaque(value.next_cursor); + const previous_cursor = + value.previous_cursor === undefined ? undefined : opaque(value.previous_cursor); + if ( + !provider || + !reader_generation || + revision === null || + next_cursor === null || + previous_cursor === null || + typeof value.has_older !== 'boolean' || + !Array.isArray(value.items) || + value.items.length > MAX_ITEMS + ) { + return invalid(); + } + return { + provider, + session: decodeSession(value.session), + capability: decodeCapability(value.capability), + items: value.items.map(decodeItem), + ...(next_cursor === undefined ? {} : { next_cursor }), + ...(previous_cursor === undefined ? {} : { previous_cursor }), + has_older: value.has_older, + revision, + reader_generation, + }; +} + +export function decodeConversationRespondResult(value: unknown): ConversationRespondResult { + if (!isRecord(value) || value.type !== 'agent_conversation_respond' || !isRecord(value.result)) { + return invalid(); + } + const request_id = opaque(value.result.request_id); + const decision_id = opaque(value.result.decision_id); + const reasons: ConversationRespondReason[] = [ + 'accepted', + 'already_resolved', + 'stale_request', + 'unknown_request', + 'conflicting_decision', + 'session_mismatch', + ]; + if ( + !request_id || + !decision_id || + typeof value.result.accepted !== 'boolean' || + !reasons.includes(value.result.reason as ConversationRespondReason) + ) { + return invalid(); + } + return { + request_id, + decision_id, + accepted: value.result.accepted, + reason: value.result.reason as ConversationRespondReason, + }; +} + +export function decodeConversationReadResult(value: unknown): ConversationReadResult { + if (!isRecord(value) || value.type !== 'agent_conversation_read' || !isRecord(value.read)) { + return invalid(); + } + if (value.read.type === 'page') { + return { type: 'page', page: decodePage(value.read.page) }; + } + if (value.read.type === 'reset_required') { + if (!isRecord(value.read)) { + return invalid(); + } + return { + type: 'reset_required', + session: decodeSession(value.read.session), + reader_generation: opaque(value.read.reader_generation) ?? invalid(), + }; + } + return invalid(); +} + +export function decodeAttachmentBeginResult(value: unknown): { + upload: { handle: string }; + chunk_size: number; +} { + if ( + !isRecord(value) || + value.type !== 'agent_attachment_begin' || + !isRecord(value.upload) || + typeof value.upload.handle !== 'string' || + value.upload.handle.length === 0 || + value.upload.handle.length > 256 || + !Number.isSafeInteger(value.chunk_size) || + (value.chunk_size as number) <= 0 || + (value.chunk_size as number) > 1024 * 1024 + ) { + return invalid(); + } + return { + upload: { handle: value.upload.handle }, + chunk_size: value.chunk_size as number, + }; +} + +export function decodeAttachmentFinishedResult(value: unknown): { handle: string } { + if ( + !isRecord(value) || + value.type !== 'agent_attachment_finished' || + !isRecord(value.attachment) || + typeof value.attachment.handle !== 'string' || + value.attachment.handle.length === 0 || + value.attachment.handle.length > 256 + ) { + return invalid(); + } + return { handle: value.attachment.handle }; +} diff --git a/src/main/herdr/engine.ts b/src/main/herdr/engine.ts index 0d63c8f..b8bb6f8 100644 --- a/src/main/herdr/engine.ts +++ b/src/main/herdr/engine.ts @@ -2,8 +2,27 @@ import { execFile, spawn } from 'node:child_process'; import { promisify } from 'node:util'; import { hostInvocation, sandboxPathFromHostPath } from '@/main/flatpak'; import { HerdrApiClient } from '@/main/herdr/api-client'; +import { + decodeAttachmentBeginResult, + decodeAttachmentFinishedResult, + decodeConversationReadResult, + decodeConversationRespondResult, +} from '@/main/herdr/conversation-decoder'; import { decodeHerdrQueryResult } from '@/main/herdr/query-decoder'; import { decodeSessionSnapshot } from '@/main/herdr/snapshot-decoder'; +import type { + ConversationAttachmentAbortRequest, + ConversationAttachmentBeginRequest, + ConversationAttachmentBeginResult, + ConversationAttachmentChunkRequest, + ConversationAttachmentFinishRequest, + ConversationPromptRequest, + ConversationReadRequest, + ConversationReadResult, + ConversationRespondRequest, + ConversationRespondResult, + ConversationStagedAttachment, +} from '@/shared/conversation'; import type { EngineUpdateResult, HerdrCommand, @@ -113,7 +132,6 @@ function parseStatus(stdout: string): HerdrStatus { if (typeof value.server.socket === 'string') { value.server.socket = sandboxPathFromHostPath(value.server.socket); } - return value as unknown as HerdrStatus; } @@ -653,6 +671,94 @@ export class HerdrEngine { return decodeHerdrQueryResult(query, result); } + private async conversationSocket(): Promise { + const status = parseStatus((await this.runner.run(['status', '--json'])).stdout); + if (!status.server.running) { + throw new Error('Herdr server is not running.'); + } + if (status.server.compatible === false) { + throw new Error('Herdr server protocol is incompatible.'); + } + return status.server.socket; + } + + async conversationRead(request: ConversationReadRequest): Promise { + const socket = await this.conversationSocket(); + const result = await this.requestClient.request(socket, 'agent.conversation.read', { + target: request.target, + ...(request.cursor === undefined ? {} : { cursor: request.cursor }), + direction: request.direction ?? 'newest', + ...(request.limit === undefined ? {} : { limit: request.limit }), + }); + return decodeConversationReadResult(result); + } + + async conversationPrompt(request: ConversationPromptRequest): Promise { + const socket = await this.conversationSocket(); + await this.requestClient.request(socket, 'agent.prompt', { + target: request.target, + text: request.text, + ...(request.attachments === undefined || request.attachments.length === 0 + ? {} + : { attachments: request.attachments.map(({ handle }) => ({ handle })) }), + }); + return this.bootstrap(); + } + + async conversationRespond( + request: ConversationRespondRequest, + ): Promise { + const socket = await this.conversationSocket(); + const result = await this.requestClient.request(socket, 'agent.conversation.respond', { + target: request.target, + reader_generation: request.reader_generation, + session: request.session, + request_id: request.request_id, + decision_id: request.decision_id, + }); + return decodeConversationRespondResult(result); + } + + async conversationAttachmentBegin( + request: ConversationAttachmentBeginRequest, + ): Promise { + const socket = await this.conversationSocket(); + const result = await this.requestClient.request(socket, 'agent.attachment.begin', { + target: request.target, + media_type: request.media_type, + name: request.name, + byte_size: request.byte_size, + sha256_digest: request.sha256_digest, + }); + return decodeAttachmentBeginResult(result); + } + + async conversationAttachmentChunk(request: ConversationAttachmentChunkRequest): Promise { + const socket = await this.conversationSocket(); + await this.requestClient.request(socket, 'agent.attachment.chunk', { + upload: { handle: request.upload }, + index: request.index, + data_base64: request.data_base64, + }); + } + + async conversationAttachmentFinish( + request: ConversationAttachmentFinishRequest, + ): Promise { + const socket = await this.conversationSocket(); + const result = await this.requestClient.request(socket, 'agent.attachment.finish', { + upload: { handle: request.upload }, + }); + return decodeAttachmentFinishedResult(result); + } + + async conversationAttachmentAbort(request: ConversationAttachmentAbortRequest): Promise { + const socket = await this.conversationSocket(); + await this.requestClient.request(socket, 'agent.attachment.abort', { + upload: { handle: request.upload }, + }); + } + private async readVersion(): Promise { try { return parseStatus((await this.runner.run(['status', '--json'])).stdout).client.version; diff --git a/src/main/herdr/event-subscription.test.ts b/src/main/herdr/event-subscription.test.ts index ae7819a..fa2b11e 100644 --- a/src/main/herdr/event-subscription.test.ts +++ b/src/main/herdr/event-subscription.test.ts @@ -109,6 +109,7 @@ describe('HerdrEventSubscription', () => { const resolveReconnectTarget = vi.fn(async () => ({ socketPath, paneIds: ['w1:p2'], + conversationPaneIds: ['w1:p1', 'w1:p2'], })); const states: string[] = []; const subscription = new HerdrEventSubscription(onEvent, vi.fn(), { @@ -134,7 +135,70 @@ describe('HerdrEventSubscription', () => { type: 'pane.agent_status_changed', pane_id: 'w1:p2', }); + expect(subscriptions[1]).toContainEqual({ + type: 'agent.conversation_changed', + pane_id: 'w1:p2', + }); + expect(subscriptions[1]).not.toContainEqual({ + type: 'agent.conversation_changed', + pane_id: 'w1:p1', + }); expect(states).toContain('reconnecting'); expect(states.at(-1)).toBe('connected'); }); }); + +describe('HerdrEventSubscription conversation panes', () => { + it('does not reopen the subscription when the conversation pane set is unchanged', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'herdr-desktop-events-')); + const socketPath = path.join(directory, 'api.sock'); + let subscribeCount = 0; + const server = createServer((socket) => { + let buffer = ''; + socket.setEncoding('utf8'); + socket.on('data', (chunk) => { + buffer += chunk; + const newline = buffer.indexOf('\n'); + if (newline === -1) { + return; + } + const request = JSON.parse(buffer.slice(0, newline)) as { + id: string; + params: { subscriptions: Array> }; + }; + subscribeCount += 1; + expect(request.params.subscriptions).toContainEqual({ + type: 'agent.conversation_changed', + pane_id: 'w1:p1', + }); + socket.write( + `${JSON.stringify({ id: request.id, result: { type: 'subscription_started' } })}\n`, + ); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + cleanup.push( + () => new Promise((resolve) => server.close(() => resolve())), + () => rm(directory, { recursive: true }), + ); + + const subscription = new HerdrEventSubscription(vi.fn()); + cleanup.push(async () => subscription.close()); + subscription.open(socketPath, ['w1:p1']); + subscription.setConversationPanes(['w1:p1']); + await vi.waitFor(() => expect(subscribeCount).toBeGreaterThanOrEqual(1)); + const countAfterFirst = subscribeCount; + + // Identical set must not tear down and reopen the stream. + subscription.setConversationPanes(['w1:p1']); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(subscribeCount).toBe(countAfterFirst); + + // A changed set reopens with the new pane included. + subscription.setConversationPanes(['w1:p1', 'w1:p2']); + await vi.waitFor(() => expect(subscribeCount).toBeGreaterThan(countAfterFirst)); + }); +}); diff --git a/src/main/herdr/event-subscription.ts b/src/main/herdr/event-subscription.ts index b550dbe..5d35470 100644 --- a/src/main/herdr/event-subscription.ts +++ b/src/main/herdr/event-subscription.ts @@ -1,10 +1,12 @@ import { randomUUID } from 'node:crypto'; import { createConnection, type Socket } from 'node:net'; +import { decodeConversationChangedEvent } from '@/main/herdr/conversation-decoder'; import type { HerdrEventConnectionState, HerdrEventEnvelope } from '@/shared/events'; export interface HerdrEventTarget { socketPath: string; paneIds: string[]; + conversationPaneIds: string[]; } interface HerdrEventSubscriptionOptions { @@ -42,13 +44,20 @@ const lifecycleSubscriptions = [ 'layout.updated', ] as const; -function subscriptionParams(paneIds: string[]): Array> { +function subscriptionParams( + paneIds: string[], + conversationPaneIds: string[], +): Array> { return [ ...lifecycleSubscriptions.map((type) => ({ type })), ...paneIds.flatMap((paneId) => [ { type: 'pane.agent_status_changed', pane_id: paneId }, { type: 'pane.scroll_changed', pane_id: paneId }, ]), + ...conversationPaneIds.map((pane_id) => ({ + type: 'agent.conversation_changed', + pane_id, + })), ]; } @@ -59,13 +68,17 @@ function isRecord(value: unknown): value is Record { function parseEvent(line: string): HerdrEventEnvelope | null { try { const value: unknown = JSON.parse(line); - if (isRecord(value) && typeof value.event === 'string' && isRecord(value.data)) { - return { event: value.event, data: value.data }; + if (!isRecord(value) || typeof value.event !== 'string' || !isRecord(value.data)) { + return null; } + if (value.event === 'agent.conversation_changed') { + const data = decodeConversationChangedEvent(value.data); + return data ? { event: value.event, data: { ...data } } : null; + } + return { event: value.event, data: value.data }; } catch { return null; } - return null; } export class HerdrEventSubscription { @@ -74,6 +87,7 @@ export class HerdrEventSubscription { private reconnectTimer: NodeJS.Timeout | null = null; private reconnectAttempt = 0; private generation = 0; + private conversationPaneIds: string[] = []; constructor( private readonly onEvent: (event: HerdrEventEnvelope) => void, @@ -83,7 +97,12 @@ export class HerdrEventSubscription { open(socketPath: string, paneIds: string[]): void { this.close(); - this.target = { socketPath, paneIds: [...paneIds] }; + const paneSet = new Set(paneIds); + this.target = { + socketPath, + paneIds: [...paneIds], + conversationPaneIds: this.conversationPaneIds.filter((paneId) => paneSet.has(paneId)), + }; this.reconnectAttempt = 0; this.connect(this.target, false, this.generation); } @@ -119,7 +138,9 @@ export class HerdrEventSubscription { `${JSON.stringify({ id: `desktop:events:${randomUUID()}`, method: 'events.subscribe', - params: { subscriptions: subscriptionParams(target.paneIds) }, + params: { + subscriptions: subscriptionParams(target.paneIds, target.conversationPaneIds), + }, })}\n`, ); this.emitState('connected'); @@ -164,7 +185,12 @@ export class HerdrEventSubscription { if (generation !== this.generation || !target) { return; } - this.target = { socketPath: target.socketPath, paneIds: [...target.paneIds] }; + const paneSet = new Set(target.paneIds); + this.target = { + socketPath: target.socketPath, + paneIds: [...target.paneIds], + conversationPaneIds: target.conversationPaneIds.filter((paneId) => paneSet.has(paneId)), + }; this.connect(this.target, true, generation); } catch (error) { if (generation !== this.generation) { @@ -179,6 +205,25 @@ export class HerdrEventSubscription { this.options.onStateChange?.(state); } + getConversationPaneIds(): string[] { + return [...this.conversationPaneIds]; + } + + setConversationPanes(paneIds: readonly string[]): void { + const next = [...new Set(paneIds)]; + const same = + next.length === this.conversationPaneIds.length && + next.every((paneId) => this.conversationPaneIds.includes(paneId)); + if (same) { + return; + } + this.conversationPaneIds = next; + const target = this.target; + if (target) { + this.open(target.socketPath, target.paneIds); + } + } + close(): void { this.generation += 1; if (this.reconnectTimer) { diff --git a/src/main/herdr/snapshot-decoder.test.ts b/src/main/herdr/snapshot-decoder.test.ts new file mode 100644 index 0000000..a7d5379 --- /dev/null +++ b/src/main/herdr/snapshot-decoder.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeSessionSnapshot } from '@/main/herdr/snapshot-decoder'; + +/** + * Released-Desktop decoder compatibility: the capable engine omits the legacy + * `agent_session` field for path-backed sessions and adds an opaque + * `conversation_session`/`conversation_capability` that released Desktop + * versions must ignore. Legacy engines keep `agent_session` with + * `kind: "path"`/`kind: "id"`, which the decoder must still accept for + * Terminal mode. + */ +function baseSnapshot(agentSession: unknown, conversation: unknown): unknown { + return { + version: '0.8.0', + protocol: 20, + focused_workspace_id: 'w1', + focused_tab_id: 'w1:t1', + focused_pane_id: 'w1:p1', + workspaces: [], + tabs: [], + panes: [ + { + pane_id: 'w1:p1', + terminal_id: 'terminal-1', + workspace_id: 'w1', + tab_id: 'w1:t1', + focused: true, + cwd: '/tmp', + agent: 'pi', + title: 'pi', + terminal_title: 'pi', + terminal_title_stripped: 'pi', + display_agent: 'pi', + agent_status: 'working', + state_labels: {}, + tokens: {}, + agent_session: agentSession, + ...(conversation as Record), + scroll: { offset_from_bottom: 0, max_offset_from_bottom: 0, viewport_rows: 24 }, + revision: 1, + }, + ], + layouts: [], + agents: [], + }; +} + +describe('released Desktop decoder vs capable engine snapshots', () => { + it('decodes a capable-engine snapshot where path-backed sessions omit agent_session', () => { + const snapshot = baseSnapshot(undefined, { + conversation_session: { id: 'opaque-random-handle' }, + conversation_capability: { + availability: 'supported', + reason: 'ready', + }, + }); + const decoded = decodeSessionSnapshot(snapshot); + expect(decoded).not.toBeNull(); + expect(decoded?.panes[0].agent_session).toBeUndefined(); + }); + + it('still decodes legacy path-kind agent_session for Terminal compatibility', () => { + const snapshot = baseSnapshot( + { source: 'herdr:pi', agent: 'pi', kind: 'path', value: '/home/u/.pi/s.jsonl' }, + undefined, + ); + const decoded = decodeSessionSnapshot(snapshot); + expect(decoded).not.toBeNull(); + expect(decoded?.panes[0].agent_session).toEqual({ + source: 'herdr:pi', + agent: 'pi', + kind: 'path', + value: '/home/u/.pi/s.jsonl', + }); + }); + + it('still decodes id-kind agent_session (safe opaque provider ids)', () => { + const snapshot = baseSnapshot( + { source: 'herdr:claude', agent: 'claude', kind: 'id', value: 'thread-1' }, + undefined, + ); + const decoded = decodeSessionSnapshot(snapshot); + expect(decoded).not.toBeNull(); + expect(decoded?.panes[0].agent_session?.kind).toBe('id'); + }); +}); diff --git a/src/main/herdr/snapshot-decoder.ts b/src/main/herdr/snapshot-decoder.ts index b1c6bf0..b6e208e 100644 --- a/src/main/herdr/snapshot-decoder.ts +++ b/src/main/herdr/snapshot-decoder.ts @@ -1,3 +1,8 @@ +import type { + ConversationCapability, + ConversationReasonCode, + ConversationSessionIdentity, +} from '@/shared/conversation'; import type { AgentInfo, AgentStatus, @@ -53,6 +58,58 @@ function stringMap(value: unknown): Record | null { return value as Record; } +function opaque(value: unknown): string | null { + return typeof value === 'string' && + value.length > 0 && + value.length <= 256 && + !/[\\/]/.test(value) + ? value + : null; +} + +function conversationSession(value: unknown): ConversationSessionIdentity | null | undefined { + if (value === undefined) { + return undefined; + } + if (!isRecord(value)) { + return null; + } + const id = opaque(value.id); + return id ? { id } : null; +} + +function conversationCapability(value: unknown): ConversationCapability | null | undefined { + if (value === undefined) { + return undefined; + } + if (!isRecord(value)) { + return null; + } + const availability = value.availability; + const reasons: ConversationReasonCode[] = [ + 'ready', + 'adapter_missing', + 'no_session', + 'transcript_missing', + 'transcript_invalid', + 'source_unreadable', + ]; + if ( + (availability !== 'supported' && + availability !== 'unavailable' && + availability !== 'unsupported') || + !reasons.includes(value.reason as ConversationReasonCode) || + (value.message !== undefined && typeof value.message !== 'string') + ) { + return null; + } + return { + availability, + reason: value.reason as ConversationReasonCode, + ...(value.message === undefined ? {} : { message: value.message }), + }; +} + function agentSession(value: unknown): PaneInfo['agent_session'] | null | undefined { if (value === undefined) { return undefined; @@ -159,6 +216,8 @@ function pane(value: unknown): PaneInfo | null { const stateLabels = stringMap(value.state_labels); const tokens = stringMap(value.tokens); const session = agentSession(value.agent_session); + const conversation_session = conversationSession(value.conversation_session); + const conversation_capability = conversationCapability(value.conversation_capability); if ( !isString(value.pane_id) || !isString(value.terminal_id) || @@ -177,6 +236,8 @@ function pane(value: unknown): PaneInfo | null { stateLabels === null || tokens === null || session === null || + conversation_session === null || + conversation_capability === null || !isNonNegativeInteger(value.revision) ) { return null; @@ -219,6 +280,8 @@ function pane(value: unknown): PaneInfo | null { state_labels: stateLabels, tokens, ...(session === undefined ? {} : { agent_session: session }), + ...(conversation_session === undefined ? {} : { conversation_session }), + ...(conversation_capability === undefined ? {} : { conversation_capability }), ...(scroll === undefined ? {} : { scroll }), revision: value.revision, }; @@ -304,6 +367,8 @@ function agent(value: unknown): AgentInfo | null { const stateLabels = stringMap(value.state_labels); const tokens = stringMap(value.tokens); const session = agentSession(value.agent_session); + const conversation_session = conversationSession(value.conversation_session); + const conversation_capability = conversationCapability(value.conversation_capability); const screenDetectionSkipped = value.screen_detection_skipped ?? false; const launchPending = value.launch_pending ?? false; const interactiveReady = value.interactive_ready ?? false; @@ -321,6 +386,8 @@ function agent(value: unknown): AgentInfo | null { stateLabels === null || tokens === null || session === null || + conversation_session === null || + conversation_capability === null || !isString(value.workspace_id) || !isString(value.tab_id) || !isString(value.pane_id) || @@ -349,6 +416,8 @@ function agent(value: unknown): AgentInfo | null { state_labels: stateLabels, tokens, ...(session === undefined ? {} : { agent_session: session }), + ...(conversation_session === undefined ? {} : { conversation_session }), + ...(conversation_capability === undefined ? {} : { conversation_capability }), workspace_id: value.workspace_id, tab_id: value.tab_id, pane_id: value.pane_id, diff --git a/src/main/ipc-validation.test.ts b/src/main/ipc-validation.test.ts index 409b1b2..a0ecc38 100644 --- a/src/main/ipc-validation.test.ts +++ b/src/main/ipc-validation.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { - parseChatImageDrafts, + parseConversationPromptRequest, + parseConversationReadRequest, + parseConversationRespondRequest, parseHerdrCommand, parseHerdrQuery, parsePaneId, @@ -10,11 +12,6 @@ import { parseTerminalResize, parseTerminalScroll, } from '@/main/ipc-validation'; -import { - MAX_CHAT_IMAGE_ATTACHMENTS, - MAX_CHAT_IMAGE_BASE64_LENGTH, - MAX_CHAT_IMAGE_TOTAL_BYTES, -} from '@/shared/desktop-api'; describe('IPC validation', () => { it('accepts the finite Herdr command contract', () => { @@ -368,6 +365,51 @@ describe('IPC validation', () => { expect(parsePaneId('w2:pA')).toBe('w2:pA'); }); + it('validates structured conversation requests without accepting paths', () => { + expect( + parseConversationReadRequest({ + target: 'w1:p1', + direction: 'newer', + cursor: 'cursor-1', + limit: 64, + }), + ).toEqual({ target: 'w1:p1', direction: 'newer', cursor: 'cursor-1', limit: 64 }); + expect( + parseConversationPromptRequest({ + target: 'w1:p1', + text: 'hello', + attachments: [{ handle: 'att-1' }], + }), + ).toEqual({ target: 'w1:p1', text: 'hello', attachments: [{ handle: 'att-1' }] }); + expect( + parseConversationRespondRequest({ + target: 'w1:p1', + reader_generation: 'generation-1', + session: { id: 'session-1' }, + request_id: 'approval-1', + decision_id: 'allow', + }), + ).toEqual({ + target: 'w1:p1', + reader_generation: 'generation-1', + session: { id: 'session-1' }, + request_id: 'approval-1', + decision_id: 'allow', + }); + expect(() => parseConversationReadRequest({ target: 'w1:p1', cursor: '/tmp/file' })).toThrow( + 'Invalid conversation read request.', + ); + expect(() => + parseConversationRespondRequest({ + target: 'w1:p1', + reader_generation: 'generation-1', + session: { id: '/tmp/session' }, + request_id: 'approval-1', + decision_id: 'allow', + }), + ).toThrow('Invalid conversation response request.'); + }); + it('accepts normal terminal input and rejects oversized payloads', () => { expect(parseTerminalInput({ paneId: 'w1:p1', text: 'npm test\n' })).toEqual({ paneId: 'w1:p1', @@ -407,106 +449,6 @@ describe('IPC validation', () => { }); }); -describe('chat image draft validation', () => { - const VALID = [{ extension: 'png', data: 'aGVsbG8=' }]; - - it('accepts a finite array of chat image drafts', () => { - expect(parseChatImageDrafts(VALID)).toEqual(VALID); - expect(parseChatImageDrafts([])).toEqual([]); - expect( - parseChatImageDrafts([ - { extension: 'jpg', data: 'aGVsbG8=' }, - { extension: 'webp', data: 'aGVsbG8=' }, - ]), - ).toEqual([ - { extension: 'jpg', data: 'aGVsbG8=' }, - { extension: 'webp', data: 'aGVsbG8=' }, - ]); - }); - - it('rejects non-array payloads', () => { - expect(() => parseChatImageDrafts({ extension: 'png', data: 'aGVsbG8=' })).toThrow( - 'Invalid chat image drafts.', - ); - expect(() => parseChatImageDrafts('png')).toThrow('Invalid chat image drafts.'); - }); - - it('rejects unknown image extensions', () => { - expect(() => parseChatImageDrafts([{ extension: 'svg', data: 'aGVsbG8=' }])).toThrow( - 'Invalid chat image drafts.', - ); - expect(() => parseChatImageDrafts([{ extension: 'PNG', data: 'aGVsbG8=' }])).toThrow( - 'Invalid chat image drafts.', - ); - }); - - it('rejects drafts with missing or malformed data', () => { - expect(() => parseChatImageDrafts([{ extension: 'png' }])).toThrow( - 'Invalid chat image drafts.', - ); - expect(() => parseChatImageDrafts([{ extension: 'png', data: 42 }])).toThrow( - 'Invalid chat image drafts.', - ); - expect(() => parseChatImageDrafts([{ extension: 'png', data: 'not base64!' }])).toThrow( - 'Invalid chat image drafts.', - ); - }); - - it('rejects drafts over the base64 chat image limit', () => { - const oversized = 'A'.repeat(MAX_CHAT_IMAGE_BASE64_LENGTH + 1); - expect(() => parseChatImageDrafts([{ extension: 'png', data: oversized }])).toThrow( - 'Invalid chat image drafts.', - ); - }); - - it('rejects non-canonical base64 payloads', () => { - for (const data of ['A', 'A=', 'AA=', 'AAA==', 'aGVsbG8']) { - expect(() => parseChatImageDrafts([{ extension: 'png', data }])).toThrow( - 'Invalid chat image drafts.', - ); - } - }); - - it('rejects more than the maximum attachment count', () => { - const drafts = Array.from({ length: MAX_CHAT_IMAGE_ATTACHMENTS + 1 }, () => ({ - extension: 'png', - data: 'aGVsbG8=', - })); - expect(() => parseChatImageDrafts(drafts)).toThrow('Invalid chat image drafts.'); - }); - - it('rejects drafts over the total chat image byte budget', () => { - const big = 'A'.repeat(Math.ceil(MAX_CHAT_IMAGE_TOTAL_BYTES / 3) * 4 + 4); - expect(() => parseChatImageDrafts([{ extension: 'png', data: big }])).toThrow( - 'Invalid chat image drafts.', - ); - }); - - it('accepts a batch whose exact decoded size is the total budget', () => { - // 16 MiB binary with == padding must not be overcounted as 16 MiB + 2. - const data = Buffer.alloc(MAX_CHAT_IMAGE_TOTAL_BYTES / 2).toString('base64'); - const drafts = [ - { extension: 'png', data }, - { extension: 'png', data }, - ]; - expect(parseChatImageDrafts(drafts)).toEqual(drafts); - }); - - it('rejects non-canonical base64 pad bits', () => { - for (const data of ['AB==', 'AAB=']) { - expect(() => parseChatImageDrafts([{ extension: 'png', data }])).toThrow( - 'Invalid chat image drafts.', - ); - } - expect(parseChatImageDrafts([{ extension: 'png', data: 'AA==' }])).toEqual([ - { extension: 'png', data: 'AA==' }, - ]); - expect(parseChatImageDrafts([{ extension: 'png', data: 'AAA=' }])).toEqual([ - { extension: 'png', data: 'AAA=' }, - ]); - }); -}); - describe('parseRemoteEngineTarget', () => { it('parses a valid target', () => { expect(parseRemoteEngineTarget({ enabled: true, host: 'user@host', port: 22025 })).toEqual({ diff --git a/src/main/ipc-validation.ts b/src/main/ipc-validation.ts index 30f25aa..847cf3d 100644 --- a/src/main/ipc-validation.ts +++ b/src/main/ipc-validation.ts @@ -1,19 +1,22 @@ +import type { + ConversationAttachmentAbortRequest, + ConversationAttachmentBeginRequest, + ConversationAttachmentChunkRequest, + ConversationAttachmentFinishRequest, + ConversationPromptRequest, + ConversationReadRequest, + ConversationRespondRequest, +} from '@/shared/conversation'; import { AGENT_KINDS, type AgentViewField, type AgentViewFilter, type AgentViewSort, type AgentViewValue, - base64DecodedLength, - CHAT_IMAGE_EXTENSIONS, - type ChatImageDraft, type HerdrCommand, type HerdrQuery, INTEGRATION_TARGETS, - isCanonicalBase64, MAX_CHAT_IMAGE_ATTACHMENTS, - MAX_CHAT_IMAGE_BASE64_LENGTH, - MAX_CHAT_IMAGE_TOTAL_BYTES, type PaneMoveDestination, } from '@/shared/desktop-api'; import { MAX_REMOTE_ENGINE_PORT, type RemoteEngineTarget } from '@/shared/remote-engine'; @@ -40,6 +43,23 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function isCanonicalBase64(data: string): boolean { + if (data.length === 0 || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) { + return false; + } + const padding = data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0; + const body = data.length - padding; + if (padding === 1 && data[body - 1] === '=') { + return false; + } + if (padding === 0) { + return true; + } + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + const value = alphabet.indexOf(data[body - 1]); + return padding === 2 ? (value & 0x0f) === 0 : (value & 0x03) === 0; +} + function optionalText(value: unknown): value is string | undefined { return value === undefined || (typeof value === 'string' && value.length <= MAX_TEXT_LENGTH); } @@ -78,6 +98,12 @@ function positiveInteger(value: unknown): value is number { return nonNegativeInteger(value) && value > 0; } +function validOpaque(value: unknown): value is string { + return ( + typeof value === 'string' && value.length > 0 && value.length <= 256 && !/[\\/]/.test(value) + ); +} + function validAgentArguments(value: unknown): value is string[] { return ( Array.isArray(value) && @@ -716,6 +742,165 @@ export function parseHerdrQuery(value: unknown): HerdrQuery { throw new Error('Invalid Herdr query.'); } +export function parseConversationReadRequest(value: unknown): ConversationReadRequest { + if ( + isRecord(value) && + typeof value.target === 'string' && + value.target.length > 0 && + value.target.length <= MAX_TEXT_LENGTH && + (value.cursor === undefined || validOpaque(value.cursor)) && + (value.direction === undefined || + value.direction === 'newest' || + value.direction === 'older' || + value.direction === 'newer') && + (value.limit === undefined || (positiveInteger(value.limit) && value.limit <= 256)) + ) { + return { + target: value.target, + ...(value.cursor === undefined ? {} : { cursor: value.cursor }), + ...(value.direction === undefined ? {} : { direction: value.direction }), + ...(value.limit === undefined ? {} : { limit: value.limit }), + }; + } + throw new Error('Invalid conversation read request.'); +} + +export function parseConversationPromptRequest(value: unknown): ConversationPromptRequest { + if ( + !isRecord(value) || + typeof value.target !== 'string' || + value.target.length === 0 || + value.target.length > MAX_TEXT_LENGTH || + typeof value.text !== 'string' || + value.text.length > MAX_TERMINAL_INPUT_LENGTH || + (value.text.length === 0 && value.attachments === undefined) + ) { + throw new Error('Invalid conversation prompt request.'); + } + if (value.attachments === undefined) { + return { target: value.target, text: value.text }; + } + if ( + !Array.isArray(value.attachments) || + value.attachments.length > MAX_CHAT_IMAGE_ATTACHMENTS || + !value.attachments.every((attachment) => isRecord(attachment) && validOpaque(attachment.handle)) + ) { + throw new Error('Invalid conversation prompt request.'); + } + return { + target: value.target, + text: value.text, + attachments: value.attachments as ConversationPromptRequest['attachments'], + }; +} + +export function parseConversationRespondRequest(value: unknown): ConversationRespondRequest { + if ( + isRecord(value) && + typeof value.target === 'string' && + value.target.length > 0 && + value.target.length <= MAX_TEXT_LENGTH && + validOpaque(value.reader_generation) && + isRecord(value.session) && + validOpaque(value.session.id) && + validOpaque(value.request_id) && + validOpaque(value.decision_id) + ) { + return { + target: value.target, + reader_generation: value.reader_generation, + session: { id: value.session.id }, + request_id: value.request_id, + decision_id: value.decision_id, + }; + } + throw new Error('Invalid conversation response request.'); +} + +// Attachment upload bounds mirror the engine's attachment store: +// 8 KiB chunks, 25 MiB per file, and short allowlisted metadata fields. +const MAX_ATTACHMENT_MEDIA_TYPE_LENGTH = 128; +const MAX_ATTACHMENT_NAME_LENGTH = 255; +const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; +const MAX_ATTACHMENT_CHUNK_BYTES = 8 * 1024; +const MAX_ATTACHMENT_CHUNK_BASE64_LENGTH = Math.ceil(MAX_ATTACHMENT_CHUNK_BYTES / 3) * 4; +const MAX_ATTACHMENT_CHUNK_INDEX = 4_096; +const SHA256_HEX = /^[0-9a-f]{64}$/; + +export function parseConversationAttachmentBeginRequest( + value: unknown, +): ConversationAttachmentBeginRequest { + if ( + !isRecord(value) || + typeof value.target !== 'string' || + value.target.length === 0 || + value.target.length > MAX_TEXT_LENGTH || + typeof value.media_type !== 'string' || + value.media_type.length === 0 || + value.media_type.length > MAX_ATTACHMENT_MEDIA_TYPE_LENGTH || + typeof value.name !== 'string' || + value.name.length === 0 || + value.name.length > MAX_ATTACHMENT_NAME_LENGTH || + typeof value.byte_size !== 'number' || + !Number.isSafeInteger(value.byte_size) || + value.byte_size <= 0 || + value.byte_size > MAX_ATTACHMENT_BYTES || + typeof value.sha256_digest !== 'string' || + !SHA256_HEX.test(value.sha256_digest) + ) { + throw new Error('Invalid attachment begin request.'); + } + return { + target: value.target, + media_type: value.media_type, + name: value.name, + byte_size: value.byte_size, + sha256_digest: value.sha256_digest, + }; +} + +export function parseConversationAttachmentChunkRequest( + value: unknown, +): ConversationAttachmentChunkRequest { + if ( + !isRecord(value) || + !validOpaque(value.upload) || + typeof value.index !== 'number' || + !Number.isSafeInteger(value.index) || + value.index < 0 || + value.index > MAX_ATTACHMENT_CHUNK_INDEX || + typeof value.data_base64 !== 'string' || + value.data_base64.length === 0 || + value.data_base64.length > MAX_ATTACHMENT_CHUNK_BASE64_LENGTH || + !isCanonicalBase64(value.data_base64) + ) { + throw new Error('Invalid attachment chunk request.'); + } + return { + upload: value.upload as string, + index: value.index, + data_base64: value.data_base64, + }; +} + +export function parseConversationAttachmentFinishRequest( + value: unknown, +): ConversationAttachmentFinishRequest { + if (!isRecord(value) || !validOpaque(value.upload)) { + throw new Error('Invalid attachment finish request.'); + } + return { upload: value.upload as string }; +} + +export function parseConversationAttachmentAbortRequest( + value: unknown, +): ConversationAttachmentAbortRequest { + if (!isRecord(value) || !validOpaque(value.upload)) { + throw new Error('Invalid attachment abort request.'); + } + return { upload: value.upload as string }; +} + export function parseTerminalOpen(value: unknown): TerminalOpenRequest { if (!isRecord(value) || typeof value.paneId !== 'string' || !PANE_ID.test(value.paneId)) { throw new Error('Invalid terminal pane identifier.'); @@ -793,33 +978,6 @@ export function parseTerminalScroll(value: unknown): TerminalScrollRequest { throw new Error('Invalid terminal scroll.'); } -export function parseChatImageDrafts(value: unknown): ChatImageDraft[] { - if ( - Array.isArray(value) && - value.length <= MAX_CHAT_IMAGE_ATTACHMENTS && - value.every( - (draft) => - isRecord(draft) && - typeof draft.extension === 'string' && - (CHAT_IMAGE_EXTENSIONS as readonly string[]).includes(draft.extension) && - typeof draft.data === 'string' && - draft.data.length > 0 && - draft.data.length <= MAX_CHAT_IMAGE_BASE64_LENGTH && - isCanonicalBase64(draft.data), - ) && - value.reduce( - (sum, draft) => - isRecord(draft) && typeof draft.data === 'string' - ? sum + base64DecodedLength(draft.data) - : sum, - 0, - ) <= MAX_CHAT_IMAGE_TOTAL_BYTES - ) { - return value as unknown as ChatImageDraft[]; - } - throw new Error('Invalid chat image drafts.'); -} - export function parseRemoteEngineTarget(value: unknown): RemoteEngineTarget { if ( !isRecord(value) || diff --git a/src/preload.ts b/src/preload.ts index cb11dd1..f74ac94 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,5 +1,17 @@ import { contextBridge, ipcRenderer } from 'electron'; - +import type { + ConversationAttachmentAbortRequest, + ConversationAttachmentBeginRequest, + ConversationAttachmentBeginResult, + ConversationAttachmentChunkRequest, + ConversationAttachmentFinishRequest, + ConversationPromptRequest, + ConversationReadRequest, + ConversationReadResult, + ConversationRespondRequest, + ConversationRespondResult, + ConversationStagedAttachment, +} from '@/shared/conversation'; import type { DesktopAction, DesktopUpdateInfo, @@ -35,8 +47,37 @@ const api: HerdrDesktopApi = { ipcRenderer.invoke(IPC_CHANNELS.command, command) as Promise, query: (query: HerdrQuery) => ipcRenderer.invoke(IPC_CHANNELS.query, query) as Promise, - stageChatImages: (images) => - ipcRenderer.invoke(IPC_CHANNELS.stageChatImages, images) as Promise, + conversation: Object.freeze({ + read: (request: ConversationReadRequest) => + ipcRenderer.invoke(IPC_CHANNELS.conversationRead, request) as Promise, + prompt: (request: ConversationPromptRequest) => + ipcRenderer.invoke(IPC_CHANNELS.conversationPrompt, request) as Promise, + respond: (request: ConversationRespondRequest) => + ipcRenderer.invoke( + IPC_CHANNELS.conversationRespond, + request, + ) as Promise, + subscribe: (paneId: string) => + ipcRenderer.invoke(IPC_CHANNELS.conversationSubscribe, paneId) as Promise, + unsubscribe: (paneId: string) => + ipcRenderer.invoke(IPC_CHANNELS.conversationUnsubscribe, paneId) as Promise, + attachment: Object.freeze({ + begin: (request: ConversationAttachmentBeginRequest) => + ipcRenderer.invoke( + IPC_CHANNELS.attachmentBegin, + request, + ) as Promise, + chunk: (request: ConversationAttachmentChunkRequest) => + ipcRenderer.invoke(IPC_CHANNELS.attachmentChunk, request) as Promise, + finish: (request: ConversationAttachmentFinishRequest) => + ipcRenderer.invoke( + IPC_CHANNELS.attachmentFinish, + request, + ) as Promise, + abort: (request: ConversationAttachmentAbortRequest) => + ipcRenderer.invoke(IPC_CHANNELS.attachmentAbort, request) as Promise, + }), + }), readPreferences: () => ipcRenderer.invoke(IPC_CHANNELS.readPreferences) as Promise, writePreferences: (preferences: DesktopPreferences) => diff --git a/src/renderer/App.test.tsx b/src/renderer/App.test.tsx index cd2a886..ed4e879 100644 --- a/src/renderer/App.test.tsx +++ b/src/renderer/App.test.tsx @@ -2,33 +2,34 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('@/renderer/chat/ChatPanel', async () => { - const { useState } = await import('react'); +vi.mock('@/renderer/chat/ConversationChatPanel', async () => { + const { useEffect, useState } = await import('react'); + const drafts = new Map(); return { - createChatSessionState: () => ({ - draft: '', - transcript: { messages: [], activeTurnId: null, liveResponseId: null }, - }), - ChatPanel: ({ + ConversationChatPanel: ({ pane, - onPrompt, - onSessionChange, - session, + onOpenTerminal, }: { pane: { display_agent?: string; pane_id: string }; - onPrompt: (target: string, text: string) => void | Promise; - onSessionChange?: (update: (current: { draft: string }) => { draft: string }) => void; - session?: { draft: string }; + onOpenTerminal?: () => void; }) => { + const [draft, setDraft] = useState(() => drafts.get(pane.pane_id) ?? ''); const [promptOutcome, setPromptOutcome] = useState('pending'); + useEffect(() => { + void window.herdr.conversation.read({ target: pane.pane_id, direction: 'newest' }); + }, [pane.pane_id]); return (
Chat with {pane.display_agent} - {session?.draft} + {draft} {promptOutcome} + {onOpenTerminal ? ( + + ) : null}
); }, @@ -150,6 +157,10 @@ const snapshot: SessionSnapshot = { label: 'Desktop UI', display_agent: 'Codex', agent_status: 'working', + conversation_capability: { + availability: 'supported', + reason: 'ready', + }, state_labels: {}, tokens: {}, revision: 1, @@ -207,7 +218,11 @@ const connected: EngineBootstrap = { running: true, version: '0.8.0', protocol: 7, - capabilities: { live_handoff: true, detached_server_daemon: true }, + capabilities: { + live_handoff: true, + detached_server_daemon: true, + agent_conversations: true, + }, compatible: true, socket: '/tmp/herdr.sock', session: 'default', @@ -225,7 +240,28 @@ describe('App', () => { startServer: vi.fn(async () => connected), command: vi.fn(async () => connected), query: vi.fn(async () => ({ type: 'plugin-list' as const, plugins: [] })), - stageChatImages: vi.fn(async () => []), + conversation: { + read: vi.fn(async () => ({ + type: 'reset_required' as const, + session: { id: 'demo' }, + reader_generation: 'demo', + })), + prompt: vi.fn(async () => connected), + respond: vi.fn(async () => ({ + request_id: 'request', + decision_id: 'decision', + accepted: false, + reason: 'unknown_request' as const, + })), + subscribe: vi.fn(async () => undefined), + unsubscribe: vi.fn(async () => undefined), + attachment: { + begin: vi.fn(async () => ({ upload: { handle: 'upload' }, chunk_size: 8192 })), + chunk: vi.fn(async () => undefined), + finish: vi.fn(async () => ({ handle: 'staged' })), + abort: vi.fn(async () => undefined), + }, + }, readPreferences: vi.fn(async () => DEFAULT_DESKTOP_PREFERENCES), writePreferences: vi.fn(async (preferences) => preferences), chooseHerdrBinary: vi.fn(async () => connected), @@ -308,32 +344,97 @@ describe('App', () => { await user.click(screen.getByRole('button', { name: 'Chat view' })); expect(screen.getByTestId('chat-w1:p1')).toBeInTheDocument(); + expect(window.herdr.conversation.read).toHaveBeenCalledWith({ + target: 'w1:p1', + direction: 'newest', + }); + expect(window.herdr.query).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'read-pane-output' }), + ); await user.click(screen.getByRole('button', { name: 'Test send chat' })); - expect(window.herdr.command).toHaveBeenCalledWith({ - type: 'prompt-agent', + expect(window.herdr.conversation.prompt).toHaveBeenCalledWith({ target: 'w1:p1', text: 'Ship the chat', }); await waitFor(() => expect(screen.getByTestId('prompt-outcome')).toHaveTextContent('resolved')); }); - it('rejects the chat prompt promise when the engine command fails', async () => { - const user = userEvent.setup(); - const command = vi.mocked(window.herdr.command); - command.mockImplementation(async (candidate) => { - if (candidate.type === 'prompt-agent') { - throw new Error('engine busy'); - } - return connected; + it('keeps Chat disabled when an older engine omits conversation capability', async () => { + const legacySnapshot: SessionSnapshot = { + ...snapshot, + panes: snapshot.panes.map(({ conversation_capability: _capability, ...pane }) => pane), + }; + const legacyConnected: EngineBootstrap = { ...connected, snapshot: legacySnapshot }; + vi.mocked(window.herdr.bootstrap).mockResolvedValue(legacyConnected); + render(); + + expect(await screen.findByTestId('terminal-w1:p1')).toBeInTheDocument(); + expect(screen.queryByTestId('chat-w1:p1')).not.toBeInTheDocument(); + const chat = screen.getByRole('button', { name: 'Chat view' }); + expect(chat).toBeDisabled(); + fireEvent.focus(chat.parentElement as HTMLElement); + expect(await screen.findByRole('tooltip')).toHaveTextContent( + 'Update Herdr to use structured Chat.', + ); + }); + + it('keeps Chat disabled when the server omits the global conversation capability', async () => { + const legacyConnected: EngineBootstrap = { + ...connected, + status: { + ...connected.status, + server: { + ...connected.status.server, + capabilities: { live_handoff: true, detached_server_daemon: true }, + }, + }, + }; + vi.mocked(window.herdr.bootstrap).mockResolvedValue(legacyConnected); + render(); + + expect(await screen.findByTestId('terminal-w1:p1')).toBeInTheDocument(); + expect(screen.queryByTestId('chat-w1:p1')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Chat view' })).toBeDisabled(); + }); + + it.each([ + [ + { availability: 'unavailable' as const, reason: 'no_session' as const }, + 'Start or resume this agent to use Chat.', + ], + [ + { availability: 'unsupported' as const, reason: 'adapter_missing' as const }, + 'Chat is not currently supported for this provider. Use Terminal instead.', + ], + ])('explains disabled Chat capabilities accessibly', async (capability, explanation) => { + const disabledSnapshot: SessionSnapshot = { + ...snapshot, + panes: snapshot.panes.map((pane) => + pane.pane_id === 'w1:p1' ? { ...pane, conversation_capability: capability } : pane, + ), + }; + vi.mocked(window.herdr.bootstrap).mockResolvedValue({ + ...connected, + snapshot: disabledSnapshot, }); render(); + + const chat = await screen.findByRole('button', { name: 'Chat view' }); + fireEvent.focus(chat.parentElement as HTMLElement); + expect(await screen.findByRole('tooltip')).toHaveTextContent(explanation); + }); + + it('rejects the structured chat prompt promise when delivery fails', async () => { + const user = userEvent.setup(); + const prompt = vi.mocked(window.herdr.conversation.prompt); + prompt.mockRejectedValue(new Error('engine busy')); + render(); await screen.findByTestId('chat-w1:p1'); await user.click(screen.getByRole('button', { name: 'Test send chat' })); - expect(command).toHaveBeenCalledWith({ - type: 'prompt-agent', + expect(prompt).toHaveBeenCalledWith({ target: 'w1:p1', text: 'Ship the chat', }); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6febd20..af0a74b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -84,12 +84,7 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { AgentSidebar } from '@/renderer/agents/AgentSidebar'; -import { - ChatPanel, - type ChatSessionState, - type ChatSessionUpdate, - createChatSessionState, -} from '@/renderer/chat/ChatPanel'; +import { ConversationChatPanel } from '@/renderer/chat/ConversationChatPanel'; import { ShortcutHelpDialog } from '@/renderer/help/ShortcutHelpDialog'; import { WhatsNewDialog } from '@/renderer/help/WhatsNewDialog'; import { Navigator, planTabMove, planWorkspaceMove, ReorderControls } from '@/renderer/navigation'; @@ -125,7 +120,6 @@ import { type DesktopUpdateInfo, type EngineUpdateResult, type HerdrCommand, - type HerdrQueryResult, INTEGRATION_TARGETS, type InstalledPluginInfo, type PluginActionInfo, @@ -137,7 +131,7 @@ import { DEFAULT_REMOTE_ENGINE_PREFERENCE, type DesktopPreferences, } from '@/shared/preferences'; -import type { RemoteEngineStatus, RemoteEngineTarget } from '@/shared/remote-engine'; +import type { RemoteEngineStatus } from '@/shared/remote-engine'; import packageMetadata from '../../package.json'; const INSTALL_URL = 'https://github.com/herdrdev/herdr#installation'; @@ -745,11 +739,7 @@ function PaneStage({ onOpenControls, onClose, onSetSplitRatio, - onPrompt, - onSendInput, - readOutput, - chatSessions, - onChatSessionChange, + structuredChatSupported, showPaneLabels, }: { pane?: PaneInfo; @@ -763,11 +753,7 @@ function PaneStage({ onOpenControls: (pane: PaneInfo) => void; onClose: (pane: PaneInfo) => void; onSetSplitRatio: (tabId: string, path: boolean[], ratio: number) => void; - onPrompt: (target: string, text: string) => void | Promise; - onSendInput: (paneId: string, input: { text?: string; keys?: string[] }) => void | Promise; - readOutput: (paneId: string) => Promise>; - chatSessions: Readonly>; - onChatSessionChange: (paneId: string, update: ChatSessionUpdate) => void; + structuredChatSupported: boolean; showPaneLabels: boolean; }) { const [viewByPane, setViewByPane] = useState>({}); @@ -803,7 +789,16 @@ function PaneStage({ const focused = item.pane_id === pane.pane_id; const hiddenByZoom = Boolean(layout?.zoomed && !focused); const hasAgent = Boolean(item.agent || item.display_agent); - const view = hasAgent ? viewByPane[item.pane_id] || 'chat' : 'terminal'; + const chatCapability = item.conversation_capability; + const chatEnabled = structuredChatSupported && chatCapability?.availability === 'supported'; + const chatUnavailableMessage = + !structuredChatSupported || !chatCapability + ? 'Update Herdr to use structured Chat.' + : (chatCapability.message ?? + (chatCapability.availability === 'unsupported' + ? 'Chat is not currently supported for this provider. Use Terminal instead.' + : 'Start or resume this agent to use Chat.')); + const view = hasAgent && chatEnabled ? viewByPane[item.pane_id] || 'chat' : 'terminal'; return (
- + + + + + + + + {!chatEnabled ? ( + {chatUnavailableMessage} + ) : null} + +
setCloseTarget({ kind: 'pane', @@ -1669,15 +1635,11 @@ function ConnectedShell({ onSetSplitRatio={(tabId, path, ratio) => onCommand({ type: 'set-split-ratio', tabId, path, ratio }) } - onPrompt={(target, text) => runChatCommand({ type: 'prompt-agent', target, text })} - onSendInput={(paneId, input) => - runChatCommand({ type: 'send-pane-input', paneId, ...input }) - } onZoom={(paneId) => onCommand({ type: 'zoom-pane', paneId, mode: 'toggle' })} pane={pane} panes={panes} - readOutput={readPaneOutput} showPaneLabels={preferences.paneLabels} + structuredChatSupported={status.server.capabilities?.agent_conversations === true} /> diff --git a/src/renderer/browser-preview.ts b/src/renderer/browser-preview.ts index 1d556c3..16bda57 100644 --- a/src/renderer/browser-preview.ts +++ b/src/renderer/browser-preview.ts @@ -1,3 +1,10 @@ +import type { + ConversationPromptRequest, + ConversationReadRequest, + ConversationReadResult, + ConversationRespondRequest, + ConversationRespondResult, +} from '@/shared/conversation'; import { DEMO_BOOTSTRAP, demoQueryResult } from '@/shared/demo'; import type { HerdrCommand, @@ -150,7 +157,27 @@ export function createBrowserPreviewApi(): HerdrDesktopApi { desktopListeners.add(listener); return () => desktopListeners.delete(listener); }, - stageChatImages: async () => [], + conversation: { + read: async (_request: ConversationReadRequest): Promise => { + throw new Error('Structured Chat is unavailable in browser preview.'); + }, + prompt: async (_request: ConversationPromptRequest): Promise => connected, + respond: async (_request: ConversationRespondRequest): Promise => { + throw new Error('Structured Chat is unavailable in browser preview.'); + }, + subscribe: async (_paneId: string) => undefined, + unsubscribe: async (_paneId: string) => undefined, + attachment: { + begin: async () => { + throw new Error('Structured Chat is unavailable in browser preview.'); + }, + chunk: async () => undefined, + finish: async () => { + throw new Error('Structured Chat is unavailable in browser preview.'); + }, + abort: async () => undefined, + }, + }, onSessionEvent: (listener) => { sessionListeners.add(listener); return () => sessionListeners.delete(listener); diff --git a/src/renderer/chat/ChatPanel.persistence.test.tsx b/src/renderer/chat/ChatPanel.persistence.test.tsx deleted file mode 100644 index ed4ff52..0000000 --- a/src/renderer/chat/ChatPanel.persistence.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; - -import { - ChatPanel, - type ChatSessionState, - createChatSessionState, -} from '@/renderer/chat/ChatPanel'; -import type { PaneInfo } from '@/shared/herdr'; - -const pane: PaneInfo = { - pane_id: 'w1:p1', - terminal_id: 'terminal-1', - workspace_id: 'w1', - tab_id: 'w1:t1', - focused: true, - agent: 'pi', - display_agent: 'Pi', - agent_status: 'idle', - state_labels: {}, - tokens: {}, - revision: 1, -}; - -function PersistentHarness() { - const [visible, setVisible] = useState(true); - const [session, setSession] = useState(createChatSessionState); - return ( - <> - - {visible ? ( - ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'Ready', - revision: 1, - truncated: false, - }))} - session={session} - /> - ) : null} - - ); -} - -describe('ChatPanel controlled session', () => { - it('preserves the conversation when a pane unmounts during navigation', async () => { - const user = userEvent.setup(); - render(); - await user.type(screen.getByRole('textbox', { name: 'Message Pi' }), 'Keep this turn'); - await user.click(screen.getByRole('button', { name: 'Send message' })); - expect(screen.getByText('Keep this turn')).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Toggle pane' })); - expect(screen.queryByRole('textbox', { name: 'Message Pi' })).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Toggle pane' })); - - expect(screen.getByText('Keep this turn')).toBeInTheDocument(); - }); - - it('preserves pasted images when a pane unmounts during tab navigation', async () => { - const user = userEvent.setup(); - render(); - const image = new File(['image-bytes'], 'portrait.png', { type: 'image/png' }); - - fireEvent.paste(screen.getByRole('textbox', { name: 'Message Pi' }), { - clipboardData: { - items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }], - files: [image], - }, - }); - - expect(await screen.findByRole('img', { name: 'portrait.png' })).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Toggle pane' })); - await user.click(screen.getByRole('button', { name: 'Toggle pane' })); - - expect(screen.getByRole('img', { name: 'portrait.png' })).toBeInTheDocument(); - }); -}); diff --git a/src/renderer/chat/ChatPanel.test.tsx b/src/renderer/chat/ChatPanel.test.tsx deleted file mode 100644 index 81ac744..0000000 --- a/src/renderer/chat/ChatPanel.test.tsx +++ /dev/null @@ -1,1387 +0,0 @@ -import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { ChatPanel } from '@/renderer/chat/ChatPanel'; -import { MAX_CHAT_IMAGE_ATTACHMENTS, MAX_CHAT_IMAGE_BYTES } from '@/shared/desktop-api'; -import type { PaneInfo } from '@/shared/herdr'; - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -const pane: PaneInfo = { - pane_id: 'w1:p1', - terminal_id: 'terminal-1', - workspace_id: 'w1', - tab_id: 'w1:t1', - focused: true, - cwd: '/code/herdr-desktop', - label: 'Desktop implementation', - agent: 'codex', - display_agent: 'Codex', - agent_status: 'working', - state_labels: { working: 'Building chat' }, - tokens: {}, - revision: 42, -}; - -describe('ChatPanel', () => { - it('uses distinct readable colors for thinking and final response text', async () => { - const readOutput = vi.fn(async () => ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'I need to inspect the current theme.\n\nThe palette is ready.', - revision: 43, - truncated: false, - })); - - render( - undefined)} - pane={{ ...pane, agent_status: 'idle' }} - readOutput={readOutput} - />, - ); - - const thinking = await screen.findByText('I need to inspect the current theme.'); - const response = screen.getByText('The palette is ready.'); - - expect(thinking).toHaveClass('border-main', 'text-thinking-foreground'); - expect(thinking).not.toHaveClass('opacity-45'); - expect(response).toHaveClass('text-response-foreground'); - }); - - it('refreshes idle chat immediately when Herdr reports pane activity', async () => { - let sessionEvent: - | ((event: { event: string; data: Record }) => void) - | undefined; - const originalHerdr = window.herdr; - window.herdr = { - onSessionEvent: vi.fn((listener) => { - sessionEvent = listener; - return () => undefined; - }), - } as unknown as typeof window.herdr; - let currentText = 'Ready'; - let currentRevision = 1; - const readOutput = vi.fn(async () => ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: currentText, - revision: currentRevision, - truncated: false, - })); - - render( - , - ); - await screen.findByText('Ready'); - currentText = 'Terminal output started before status detection.'; - currentRevision = 2; - - act(() => { - sessionEvent?.({ event: 'pane.scroll_changed', data: { pane_id: 'w1:p1' } }); - }); - - expect(await screen.findByText(currentText)).toBeInTheDocument(); - expect(readOutput).toHaveBeenCalledTimes(2); - window.herdr = originalHerdr; - }); - - it('follows growing output until the user scrolls up', async () => { - let sessionEvent: - | ((event: { event: string; data: Record }) => void) - | undefined; - const originalHerdr = window.herdr; - window.herdr = { - onSessionEvent: vi.fn((listener) => { - sessionEvent = listener; - return () => undefined; - }), - } as unknown as typeof window.herdr; - let currentText = 'First line'; - let currentRevision = 1; - const readOutput = vi.fn(async () => ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: currentText, - revision: currentRevision, - truncated: false, - })); - - render( - , - ); - await screen.findByText('First line'); - const viewport = document.querySelector('[data-slot="scroll-area-viewport"]'); - if (!viewport) { - throw new Error('Chat scroll viewport was not rendered.'); - } - Object.defineProperties(viewport, { - clientHeight: { configurable: true, value: 200 }, - scrollHeight: { configurable: true, value: 1_000 }, - scrollTop: { configurable: true, value: 800, writable: true }, - }); - const scrollTo = vi.fn(); - viewport.scrollTo = scrollTo; - - currentText = 'Second line from the model'; - currentRevision = 2; - act(() => { - sessionEvent?.({ event: 'pane.scroll_changed', data: { pane_id: 'w1:p1' } }); - }); - await screen.findByText(currentText); - await waitFor(() => expect(scrollTo).toHaveBeenCalledWith({ top: 1_000 })); - - scrollTo.mockClear(); - viewport.scrollTop = 500; - fireEvent.wheel(viewport, { deltaY: -300 }); - fireEvent.scroll(viewport); - currentText = 'Third line while reading history'; - currentRevision = 3; - act(() => { - sessionEvent?.({ event: 'pane.scroll_changed', data: { pane_id: 'w1:p1' } }); - }); - await screen.findByText(currentText); - expect(scrollTo).not.toHaveBeenCalled(); - window.herdr = originalHerdr; - }); - - it('keeps following after content growth emits a scroll event', async () => { - let sessionEvent: - | ((event: { event: string; data: Record }) => void) - | undefined; - const originalHerdr = window.herdr; - window.herdr = { - onSessionEvent: vi.fn((listener) => { - sessionEvent = listener; - return () => undefined; - }), - } as unknown as typeof window.herdr; - let currentText = 'First line'; - let currentRevision = 1; - const readOutput = vi.fn(async () => ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: currentText, - revision: currentRevision, - truncated: false, - })); - - render( - , - ); - await screen.findByText('First line'); - const viewport = document.querySelector('[data-slot="scroll-area-viewport"]'); - if (!viewport) { - throw new Error('Chat scroll viewport was not rendered.'); - } - Object.defineProperties(viewport, { - clientHeight: { configurable: true, value: 200 }, - scrollHeight: { configurable: true, value: 1_000 }, - scrollTop: { configurable: true, value: 800, writable: true }, - }); - const scrollTo = vi.fn(); - viewport.scrollTo = scrollTo; - - // Chromium can emit scroll while streamed content changes its layout. - // Without user scroll intent, that must not disable auto-follow. - viewport.scrollTop = 500; - fireEvent.scroll(viewport); - currentText = 'Second line after a large streamed layout change'; - currentRevision = 2; - act(() => { - sessionEvent?.({ event: 'pane.scroll_changed', data: { pane_id: 'w1:p1' } }); - }); - - await screen.findByText(currentText); - await waitFor(() => expect(scrollTo).toHaveBeenCalledWith({ top: 1_000 })); - window.herdr = originalHerdr; - }); - - it('constrains streamed content to a narrow pane instead of widening the Radix viewport', async () => { - render( - ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: `wide-${'x'.repeat(240)}`, - revision: 43, - truncated: false, - }))} - />, - ); - - await screen.findByText(/wide-/); - const viewport = document.querySelector('[data-slot="scroll-area-viewport"]'); - expect(viewport).toHaveClass('[&>div]:!block', '[&>div]:min-w-0', '[&>div]:max-w-full'); - expect(screen.getByRole('log', { name: 'Conversation with Codex' })).toHaveClass( - 'min-w-0', - 'max-w-full', - 'overflow-hidden', - ); - }); - - it('renders a themed conversation and sends prompts through Herdr', async () => { - const user = userEvent.setup(); - const onPrompt = vi.fn(async () => undefined); - const readOutput = vi.fn(async () => ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'The chat surface is ready.', - revision: 42, - truncated: false, - })); - - render(); - - expect(screen.getByRole('heading', { name: 'Codex' })).toBeInTheDocument(); - expect(screen.getByRole('log', { name: 'Conversation with Codex' })).toHaveAttribute( - 'aria-live', - 'polite', - ); - expect(screen.getByText('working')).toBeInTheDocument(); - const initialReply = await screen.findByText('The chat surface is ready.'); - expect(initialReply).toBeInTheDocument(); - expect(initialReply.closest('[data-slot="agent-reply"]')).toHaveClass('min-w-0', 'max-w-full'); - - await user.type(screen.getByRole('textbox', { name: 'Message Codex' }), 'Polish the header'); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - expect(onPrompt).toHaveBeenCalledWith('w1:p1', 'Polish the header'); - const userMessage = screen.getByText('Polish the header').closest('article'); - expect(userMessage).toHaveClass( - 'bg-accent-surface', - 'border-main', - 'text-accent-surface-foreground', - ); - expect(screen.getByRole('textbox', { name: 'Message Codex' })).toHaveValue(''); - }); - - it('sends slash commands as typed pane input so the agent CLI handles them', async () => { - const user = userEvent.setup(); - const onPrompt = vi.fn(async () => undefined); - const onSendInput = vi.fn(async () => undefined); - const readOutput = vi.fn(async () => ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'ready', - revision: 1, - truncated: false, - })); - - render( - , - ); - - await user.type(screen.getByRole('textbox', { name: 'Message Codex' }), '/compact'); - expect(await screen.findByRole('listbox', { name: 'Slash commands' })).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - expect(onSendInput).toHaveBeenCalledWith('w1:p1', { text: '/compact', keys: ['enter'] }); - expect(onPrompt).not.toHaveBeenCalled(); - expect(screen.getByText('/compact')).toBeInTheDocument(); - }); - - it('renders CLI selection menus as clickable options and answers with key presses', async () => { - const user = userEvent.setup(); - const onSendInput = vi.fn(async () => undefined); - const readOutput = vi.fn(async () => ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: ['>', '', '→ deepseek-v4-flash [opencode-go] ✓', ' k3 [kimi-coding]', ' (1/30)'].join( - '\n', - ), - revision: 7, - truncated: false, - })); - - render( - , - ); - - await screen.findByText('Codex is asking you to choose'); - expect(screen.getByText('1/30')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /k3 \[kimi-coding\]/ })); - expect(onSendInput).toHaveBeenCalledWith('w1:p1', { keys: ['down', 'enter'] }); - - await user.click(screen.getByRole('button', { name: 'Cancel (esc)' })); - expect(onSendInput).toHaveBeenCalledWith('w1:p1', { keys: ['esc'] }); - }); - - it('offers the terminal fallback when an agent is blocked', async () => { - const onOpenTerminal = vi.fn(); - render( - ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'Approval needed.', - revision: 43, - truncated: false, - }))} - />, - ); - - expect(await screen.findByText('This agent needs attention.')).toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', { name: 'Open terminal' })); - expect(onOpenTerminal).toHaveBeenCalledOnce(); - }); - - it('sends with Enter and keeps Shift+Enter for multiline prompts', async () => { - const user = userEvent.setup(); - const onPrompt = vi.fn(async () => undefined); - render( - ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'Ready', - revision: 42, - truncated: false, - }))} - />, - ); - - const composer = screen.getByRole('textbox', { name: 'Message Codex' }); - await user.type(composer, 'Line one{Shift>}{Enter}{/Shift}Line two'); - expect(composer).toHaveValue('Line one\nLine two'); - expect(onPrompt).not.toHaveBeenCalled(); - - await user.keyboard('{Enter}'); - expect(onPrompt).toHaveBeenCalledWith('w1:p1', 'Line one\nLine two'); - }); - - it('renders new agent output after its matching user turn', async () => { - const user = userEvent.setup(); - const previousOutput = { - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'Previous terminal output', - revision: 42, - truncated: false, - }; - let releaseReply!: () => void; - const replyGate = new Promise((resolve) => { - releaseReply = resolve; - }); - const readOutput = vi - .fn() - // Initial refresh, then the fresh baseline captured at submit time. - .mockResolvedValueOnce(previousOutput) - .mockResolvedValueOnce(previousOutput) - .mockImplementation(() => - replyGate.then(() => ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'The requested change is complete.', - revision: 43, - truncated: false, - })), - ); - const { rerender } = render( - , - ); - await screen.findByText('Previous terminal output'); - await user.type(screen.getByRole('textbox', { name: 'Message Codex' }), 'Make the change'); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - rerender( - , - ); - - // The revision-triggered refresh has started; release the reply explicitly - // instead of racing the poll timer. - await waitFor(() => expect(readOutput.mock.calls.length).toBeGreaterThanOrEqual(3)); - await act(async () => { - releaseReply(); - }); - - const userText = screen.getByText('Make the change'); - const responseText = await screen.findByText('The requested change is complete.'); - expect( - userText.compareDocumentPosition(responseText) & Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - expect(screen.queryByText('Previous terminal output')).not.toBeInTheDocument(); - - rerender( - , - ); - await waitFor(() => - expect( - within(responseText.closest('article') as HTMLElement).getByText('idle'), - ).toBeVisible(), - ); - }); - - it('keeps reading an open turn when the agent finishes before a working status is observed', async () => { - const user = userEvent.setup(); - const readyOutput = { - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'Ready', - revision: 42, - truncated: false, - }; - const readOutput = vi - .fn() - // Initial refresh, then the fresh baseline captured at submit time. - .mockResolvedValueOnce(readyOutput) - .mockResolvedValueOnce(readyOutput) - .mockResolvedValue({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'Fast idle reply', - revision: 42, - truncated: false, - }); - - render( - undefined)} - pane={{ ...pane, agent_status: 'idle' }} - readOutput={readOutput} - />, - ); - await screen.findByText('Ready'); - await user.type(screen.getByRole('textbox', { name: 'Message Codex' }), 'Answer quickly'); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - expect(await screen.findByText('Fast idle reply')).toBeInTheDocument(); - expect(readOutput.mock.calls.length).toBeGreaterThanOrEqual(2); - }); - - it('finalizes a still-active turn under its own turn when the next message is sent', async () => { - const user = userEvent.setup(); - const output = (text: string, revision: number) => ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text, - revision, - truncated: false, - }); - let current = output('shell$\n', 1); - const readOutput = vi.fn(async () => current); - - render( - , - ); - const composer = screen.getByRole('textbox', { name: 'Message Codex' }); - - await user.type(composer, 'first prompt'); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - // The first reply lands only after the user already typed the follow-up. - current = output('shell$\nreply-one-done\n', 2); - await user.type(composer, 'second prompt'); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - const replyOne = await screen.findByText('reply-one-done', undefined, { timeout: 4_000 }); - const promptTwo = screen.getByText('second prompt'); - expect( - replyOne.compareDocumentPosition(promptTwo) & Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - - current = output('shell$\nreply-one-done\nreply-two-done', 3); - const replyTwo = await screen.findByText('reply-two-done', undefined, { timeout: 4_000 }); - expect(replyTwo.textContent).not.toContain('reply-one-done'); - }); - - it('keeps the latest successful output visible when a refresh fails', async () => { - const readOutput = vi - .fn() - .mockResolvedValueOnce({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'Stable answer', - revision: 42, - truncated: false, - }) - .mockRejectedValueOnce(new Error('temporary read failure')); - const { rerender } = render( - , - ); - expect(await screen.findByText('Stable answer')).toBeInTheDocument(); - - rerender( - , - ); - - // The revision change re-runs the refresh immediately, so the failure - // surfaces deterministically without depending on the poll timer. - expect(await screen.findByText('Live output could not refresh.')).toBeInTheDocument(); - expect(screen.getByText('Stable answer')).toBeInTheDocument(); - await waitFor(() => expect(readOutput).toHaveBeenCalledTimes(2), { timeout: 3_000 }); - }); -}); - -function imageFile(name = 'paste.png'): File { - return new File(['image-bytes'], name, { type: 'image/png' }); -} - -function clipboardWithImage(file: File) { - return { - clipboardData: { - items: [{ kind: 'file', type: 'image/png', getAsFile: () => file }], - files: [file], - }, - }; -} - -function readOutputStub(text = 'ready') { - return vi.fn(async () => ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text, - revision: 1, - truncated: false, - })); -} - -describe('ChatPanel image attachments', () => { - it('turns a pasted clipboard image into a removable attachment chip', async () => { - const user = userEvent.setup(); - const onPrompt = vi.fn(async () => undefined); - const readOutput = readOutputStub(); - render( - , - ); - - fireEvent.paste( - screen.getByRole('textbox', { name: 'Message Codex' }), - clipboardWithImage(imageFile()), - ); - - const chip = await screen.findByRole('img', { name: 'paste.png' }); - expect(chip.closest('article')).toHaveAttribute('data-slot', 'attachment-chip'); - expect(within(chip.closest('article') as HTMLElement).getByText('[Image #1]')).toBeVisible(); - - await user.click(screen.getByRole('button', { name: 'Remove paste.png' })); - expect(screen.queryByRole('img', { name: 'paste.png' })).not.toBeInTheDocument(); - }); - - it('ignores plain-text pastes without image items', async () => { - const user = userEvent.setup(); - const onPrompt = vi.fn(async () => undefined); - render(); - - const textbox = screen.getByRole('textbox', { name: 'Message Codex' }); - fireEvent.paste(textbox, { clipboardData: { items: [], files: [] } }); - await user.type(textbox, 'plain text'); - expect(screen.queryByRole('img')).not.toBeInTheDocument(); - expect(textbox).toHaveValue('plain text'); - }); - - it('accepts dropped image files as attachments', async () => { - render( - , - ); - - const dropZone = screen.getByRole('textbox', { name: 'Message Codex' }).closest('form'); - if (!dropZone) { - throw new Error('Chat drop zone was not rendered.'); - } - fireEvent.drop(dropZone, { dataTransfer: { files: [imageFile('drop.png')] } }); - - expect(await screen.findByRole('img', { name: 'drop.png' })).toBeInTheDocument(); - }); - - it('stages pasted images, pastes their paths, and prompts the text', async () => { - const user = userEvent.setup(); - const onPrompt = vi.fn(async () => undefined); - const onSendInput = vi.fn(async () => undefined); - const stageImages = vi.fn(async () => ['/tmp/herdr-desktop-chat-1.png']); - render( - , - ); - - const textbox = screen.getByRole('textbox', { name: 'Message Codex' }); - fireEvent.paste(textbox, clipboardWithImage(imageFile())); - await screen.findByRole('img', { name: 'paste.png' }); - await user.type(textbox, 'What does this show?'); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - expect(stageImages).toHaveBeenCalledWith([{ extension: 'png', data: 'aW1hZ2UtYnl0ZXM=' }]); - expect(onSendInput).toHaveBeenCalledWith('w1:p1', { - text: '\x1b[200~/tmp/herdr-desktop-chat-1.png\x1b[201~', - }); - expect(onPrompt).toHaveBeenCalledWith('w1:p1', '[Image #1]\n\nWhat does this show?'); - expect(screen.getByText(/What does this show/)).toHaveTextContent( - '[Image #1] What does this show?', - ); - await waitFor(() => - expect(screen.queryByRole('img', { name: 'paste.png' })).not.toBeInTheDocument(), - ); - }); - - it('submits an image-only turn with its numbered image reference', async () => { - const user = userEvent.setup(); - const onPrompt = vi.fn(async () => undefined); - const onSendInput = vi.fn(async () => undefined); - render( - ['/tmp/staged.png'])} - />, - ); - - fireEvent.paste( - screen.getByRole('textbox', { name: 'Message Codex' }), - clipboardWithImage(imageFile()), - ); - await screen.findByRole('img', { name: 'paste.png' }); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - expect(onSendInput).toHaveBeenCalledWith('w1:p1', { - text: '\x1b[200~/tmp/staged.png\x1b[201~', - }); - expect(onPrompt).toHaveBeenCalledWith('w1:p1', '[Image #1]'); - expect(onSendInput).not.toHaveBeenCalledWith('w1:p1', { keys: ['enter'] }); - }); - - it('keeps the attachment and reports an error when staging fails', async () => { - const user = userEvent.setup(); - const onPrompt = vi.fn(async () => undefined); - render( - { - throw new Error('disk full'); - })} - />, - ); - - const textbox = screen.getByRole('textbox', { name: 'Message Codex' }); - fireEvent.paste(textbox, clipboardWithImage(imageFile())); - await screen.findByRole('img', { name: 'paste.png' }); - await user.type(textbox, 'keep me'); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - expect(await screen.findByText(/could not send this message/i)).toBeInTheDocument(); - expect(textbox).toHaveValue(''); - // The text stays in the conversation and the unstaged image stays attached. - expect(screen.getByText(/keep me/)).toHaveTextContent('[Image #1] keep me'); - expect(screen.getByRole('img', { name: 'paste.png' })).toBeInTheDocument(); - expect(onPrompt).not.toHaveBeenCalled(); - }); - - it('serializes each image paste before the prompt', async () => { - const user = userEvent.setup(); - const onPrompt = vi.fn(async () => undefined); - const gate = deferred(); - const onSendInput = vi.fn((_paneId: string, input: { text?: string; keys?: string[] }) => { - if (input.text?.includes('chat-1')) { - return gate.promise; - } - return Promise.resolve(); - }); - const stageImages = vi.fn(async () => ['/tmp/chat-1.png', '/tmp/chat-2.png']); - render( - , - ); - - const textbox = screen.getByRole('textbox', { name: 'Message Codex' }); - fireEvent.paste(textbox, clipboardWithImage(imageFile('a.png'))); - fireEvent.paste(textbox, clipboardWithImage(imageFile('b.png'))); - await screen.findByRole('img', { name: 'a.png' }); - await user.type(textbox, 'go'); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - // The first paste is issued; the second waits for it to settle. - await waitFor(() => expect(onSendInput).toHaveBeenCalledTimes(1)); - expect(onSendInput).toHaveBeenCalledWith('w1:p1', { - text: '\x1b[200~/tmp/chat-1.png\x1b[201~', - }); - gate.resolve(); - await waitFor(() => expect(onSendInput).toHaveBeenCalledTimes(2)); - expect(onSendInput).toHaveBeenCalledWith('w1:p1', { - text: '\x1b[200~/tmp/chat-2.png\x1b[201~', - }); - await waitFor(() => - expect(onPrompt).toHaveBeenCalledWith('w1:p1', '[Image #1]\n[Image #2]\n\ngo'), - ); - }); - - it('keeps attachments when the prompt send fails after staging', async () => { - const user = userEvent.setup(); - const onPrompt = vi.fn(async () => { - throw new Error('engine busy'); - }); - const onSendInput = vi.fn(async () => undefined); - const stageImages = vi.fn(async () => ['/tmp/staged.png']); - render( - , - ); - - const textbox = screen.getByRole('textbox', { name: 'Message Codex' }); - fireEvent.paste(textbox, clipboardWithImage(imageFile())); - await screen.findByRole('img', { name: 'paste.png' }); - await user.type(textbox, 'look at this'); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - expect(await screen.findByText(/could not send this message/i)).toBeInTheDocument(); - expect(stageImages).toHaveBeenCalledOnce(); - expect(onSendInput).toHaveBeenCalledTimes(1); - expect(screen.getByRole('img', { name: 'paste.png' })).toBeInTheDocument(); - }); - - it('accepts a drag-over with protected drag data followed by a drop of an image file', async () => { - render( - , - ); - - const dropZone = screen.getByRole('textbox', { name: 'Message Codex' }).closest('form'); - expect(dropZone).not.toBeNull(); - // During drag-over the drag store is protected: only types are visible. - fireEvent.dragOver(dropZone as HTMLFormElement, { - dataTransfer: { types: ['Files'], items: [], files: [] }, - }); - fireEvent.drop(dropZone as HTMLFormElement, { - dataTransfer: { files: [imageFile('drop.png')] }, - }); - - expect(await screen.findByRole('img', { name: 'drop.png' })).toBeInTheDocument(); - }); - - it('projects the agent reply for an image-only turn', async () => { - const user = userEvent.setup(); - const onPrompt = vi.fn(async () => undefined); - const onSendInput = vi.fn(async () => undefined); - const readOutput = vi - .fn() - .mockResolvedValueOnce({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'ready', - revision: 1, - truncated: false, - }) - .mockResolvedValueOnce({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'ready\n[image attached]', - revision: 2, - truncated: false, - }) - .mockResolvedValue({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'ready\n[image attached]\nThe diagram shows the layout.', - revision: 3, - truncated: false, - }); - render( - ['/tmp/staged.png'])} - />, - ); - - fireEvent.paste( - screen.getByRole('textbox', { name: 'Message Codex' }), - clipboardWithImage(imageFile()), - ); - await screen.findByRole('img', { name: 'paste.png' }); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - expect(await screen.findByText(/The diagram shows the layout/)).toBeInTheDocument(); - }); - - it('rejects zero-byte and oversized image files with a notice', async () => { - render(); - - const textbox = screen.getByRole('textbox', { name: 'Message Codex' }); - const empty = new File([], 'empty.png', { type: 'image/png' }); - fireEvent.paste(textbox, { - clipboardData: { - items: [{ kind: 'file', type: 'image/png', getAsFile: () => empty }], - files: [empty], - }, - }); - expect(await screen.findByText(/16 MiB/i)).toBeInTheDocument(); - expect(screen.queryByRole('img', { name: 'empty.png' })).not.toBeInTheDocument(); - - const oversized = new File([new ArrayBuffer(MAX_CHAT_IMAGE_BYTES + 1)], 'big.png', { - type: 'image/png', - }); - fireEvent.paste(textbox, { - clipboardData: { - items: [{ kind: 'file', type: 'image/png', getAsFile: () => oversized }], - files: [oversized], - }, - }); - expect(screen.getByText(/16 MiB/i)).toBeInTheDocument(); - expect(screen.queryByRole('img', { name: 'big.png' })).not.toBeInTheDocument(); - }); - - it('revokes staged attachment URLs after a successful send', async () => { - const user = userEvent.setup(); - const revoke = vi.spyOn(URL, 'revokeObjectURL'); - render( - undefined)} - onSendInput={vi.fn(async () => undefined)} - pane={pane} - readOutput={readOutputStub()} - stageImages={vi.fn(async () => ['/tmp/staged.png'])} - />, - ); - - const textbox = screen.getByRole('textbox', { name: 'Message Codex' }); - fireEvent.paste(textbox, clipboardWithImage(imageFile())); - await screen.findByRole('img', { name: 'paste.png' }); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - await waitFor(() => - expect(screen.queryByRole('img', { name: 'paste.png' })).not.toBeInTheDocument(), - ); - expect(revoke).toHaveBeenCalledWith(expect.stringContaining('blob:')); - revoke.mockRestore(); - }); - - it('keeps attachments added while a send is in flight', async () => { - const user = userEvent.setup(); - const gate = deferred(); - const onSendInput = vi.fn(() => gate.promise); - render( - undefined)} - onSendInput={onSendInput} - pane={pane} - readOutput={readOutputStub()} - stageImages={vi.fn(async () => ['/tmp/staged.png'])} - />, - ); - - const textbox = screen.getByRole('textbox', { name: 'Message Codex' }); - fireEvent.paste(textbox, clipboardWithImage(imageFile('first.png'))); - await screen.findByRole('img', { name: 'first.png' }); - await user.click(screen.getByRole('button', { name: 'Send message' })); - await waitFor(() => expect(onSendInput).toHaveBeenCalled()); - - // A second image is dropped while the first send is still pending. - fireEvent.paste(textbox, clipboardWithImage(imageFile('second.png'))); - await screen.findByRole('img', { name: 'second.png' }); - gate.resolve(); - - await waitFor(() => - expect(screen.queryByRole('img', { name: 'first.png' })).not.toBeInTheDocument(), - ); - expect(screen.getByRole('img', { name: 'second.png' })).toBeInTheDocument(); - }); - - it('renders an image-only submission with its numbered image reference', async () => { - const user = userEvent.setup(); - render( - undefined)} - onSendInput={vi.fn(async () => undefined)} - pane={pane} - readOutput={readOutputStub()} - stageImages={vi.fn(async () => ['/tmp/staged.png'])} - />, - ); - - fireEvent.paste( - screen.getByRole('textbox', { name: 'Message Codex' }), - clipboardWithImage(imageFile()), - ); - await screen.findByRole('img', { name: 'paste.png' }); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - expect(await screen.findByText('[Image #1]')).toBeInTheDocument(); - }); - - it('shows a notice when the attachment limit is reached', async () => { - const user = userEvent.setup(); - render(); - - const textbox = screen.getByRole('textbox', { name: 'Message Codex' }); - for (let index = 0; index < MAX_CHAT_IMAGE_ATTACHMENTS + 1; index += 1) { - fireEvent.paste(textbox, clipboardWithImage(imageFile(`img-${index}.png`))); - } - await screen.findByRole('img', { name: 'img-0.png' }); - expect(await screen.findByText(/image limit reached/i)).toBeInTheDocument(); - expect( - screen.queryByRole('img', { name: `img-${MAX_CHAT_IMAGE_ATTACHMENTS}.png` }), - ).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Remove img-0.png' })); - await waitFor(() => expect(screen.queryByText(/image limit reached/i)).not.toBeInTheDocument()); - }); -}); - -describe('ChatPanel thinking color at completion', () => { - it('keeps thinking gray and turns only the final response white when the turn completes', async () => { - const user = userEvent.setup(); - const gray = '\x1b[38;2;128;128;128m'; - const reset = '\x1b[0m'; - const readyOutput = { - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'ready', - revision: 1, - truncated: false, - }; - const workingOutput = { - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: `ready\nFix the layout\n${gray}The user wants the layout fixed.${reset}\n- Here is the fix.`, - revision: 3, - truncated: false, - }; - const completedOutput = { - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - // The final frame collapsed the current thinking block, lost its - // markers, but still carries muted output from an older turn. - text: `${gray}Older turn note.${reset}\nready\nFix the layout\n- Here is the fix.`, - revision: 4, - truncated: false, - }; - let phase: 'ready' | 'working' | 'completed' = 'ready'; - let releaseWorking!: () => void; - let releaseCompleted!: () => void; - const workingGate = new Promise((resolve) => { - releaseWorking = resolve; - }); - const completedGate = new Promise((resolve) => { - releaseCompleted = resolve; - }); - const readOutput = vi.fn(async () => { - if (phase === 'ready') { - return readyOutput; - } - if (phase === 'working') { - await workingGate; - return workingOutput; - } - await completedGate; - return completedOutput; - }); - const { rerender } = render( - undefined)} pane={pane} readOutput={readOutput} />, - ); - await screen.findByText('ready'); - - await user.type(screen.getByRole('textbox', { name: 'Message Codex' }), 'Fix the layout'); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - // While working, the pane streams the thinking block with its gray markers. - phase = 'working'; - rerender( - undefined)} - pane={{ ...pane, revision: 3 }} - readOutput={readOutput} - />, - ); - await act(async () => { - releaseWorking(); - }); - await screen.findByText(/The user wants the layout fixed/); - // The turn completes: the final frame collapses the thinking block. - phase = 'completed'; - rerender( - undefined)} - pane={{ ...pane, agent_status: 'idle', revision: 4 }} - readOutput={readOutput} - />, - ); - await act(async () => { - releaseCompleted(); - }); - - const thinking = await screen.findByText(/The user wants the layout fixed/); - expect(thinking.closest('p')).toHaveClass('text-thinking-foreground'); - const answer = screen.getByText(/- Here is the fix/); - expect(answer.closest('p')).toHaveClass('text-response-foreground'); - }); - it('keeps preformatted thinking blocks gray when the turn completes', async () => { - const user = userEvent.setup(); - const gray = '\x1b[38;2;128;128;128m'; - const reset = '\x1b[0m'; - const table = '┌──────┐\n│ plan │\n└──────┘'; - const readyOutput = { - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'ready', - revision: 1, - truncated: false, - }; - const workingOutput = { - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: `ready\nFix the layout\n${gray}┌──────┐${reset}\n${gray}│ plan │${reset}\n${gray}└──────┘${reset}\n\n- Here is the fix.`, - revision: 3, - truncated: false, - }; - const completedOutput = { - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - // The final frame dropped the thinking markers but kept the table. - text: `ready\nFix the layout\n${table}\n\n- Here is the fix.`, - revision: 4, - truncated: false, - }; - let phase: 'ready' | 'working' | 'completed' = 'ready'; - let releaseWorking!: () => void; - let releaseCompleted!: () => void; - const workingGate = new Promise((resolve) => { - releaseWorking = resolve; - }); - const completedGate = new Promise((resolve) => { - releaseCompleted = resolve; - }); - const readOutput = vi.fn(async () => { - if (phase === 'ready') { - return readyOutput; - } - if (phase === 'working') { - await workingGate; - return workingOutput; - } - await completedGate; - return completedOutput; - }); - const { rerender } = render( - undefined)} pane={pane} readOutput={readOutput} />, - ); - await screen.findByText('ready'); - - await user.type(screen.getByRole('textbox', { name: 'Message Codex' }), 'Fix the layout'); - await user.click(screen.getByRole('button', { name: 'Send message' })); - - phase = 'working'; - rerender( - undefined)} - pane={{ ...pane, revision: 3 }} - readOutput={readOutput} - />, - ); - await act(async () => { - releaseWorking(); - }); - await screen.findByText(/│ plan │/); - - phase = 'completed'; - rerender( - undefined)} - pane={{ ...pane, agent_status: 'idle', revision: 4 }} - readOutput={readOutput} - />, - ); - await act(async () => { - releaseCompleted(); - }); - - // The completed turn's thinking table stays gray; only the answer turns - // white. - const tableBlock = await screen.findByText(/┌──────┐\s+│ plan │\s+└──────┘/); - expect(tableBlock.tagName).toBe('PRE'); - expect(tableBlock).toHaveClass('text-thinking-foreground'); - const answer = screen.getByText(/- Here is the fix/); - expect(answer.closest('p')).toHaveClass('text-response-foreground'); - }); - - describe('slash command menu', () => { - const renderChat = (onSendInput = vi.fn(async () => undefined)) => { - const readOutput = vi.fn(async () => ({ - type: 'pane-output' as const, - paneId: 'w1:p1', - workspaceId: 'w1', - tabId: 'w1:t1', - text: 'ready', - revision: 1, - truncated: false, - })); - render( - undefined)} - onSendInput={onSendInput} - pane={pane} - readOutput={readOutput} - />, - ); - return { onSendInput }; - }; - const textbox = () => - screen.getByRole('textbox', { name: 'Message Codex' }) as HTMLTextAreaElement; - const options = () => screen.queryAllByRole('option'); - - it('opens a command list when the draft starts with a slash', async () => { - const user = userEvent.setup(); - renderChat(); - - await user.type(textbox(), '/'); - const listbox = await screen.findByRole('listbox', { name: 'Slash commands' }); - expect(listbox).toBeInTheDocument(); - expect(options().length).toBeGreaterThan(8); - expect(screen.getByRole('option', { name: /^\/clear\b/ })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: /^\/compact\b/ })).toBeInTheDocument(); - }); - - it('exposes the open menu and active option to assistive technology', async () => { - const user = userEvent.setup(); - renderChat(); - - await user.type(textbox(), '/'); - const listbox = await screen.findByRole('listbox', { name: 'Slash commands' }); - const selected = options().find((option) => option.getAttribute('aria-selected') === 'true'); - - expect(listbox.id).not.toBe(''); - expect(selected?.id).not.toBe(''); - expect(textbox()).toHaveAttribute('aria-controls', listbox.id); - expect(textbox()).toHaveAttribute('aria-expanded', 'true'); - expect(textbox()).toHaveAttribute('aria-activedescendant', selected?.id); - - await user.keyboard('{Escape}'); - expect(textbox()).toHaveAttribute('aria-expanded', 'false'); - expect(textbox()).not.toHaveAttribute('aria-activedescendant'); - }); - - it('filters commands as the user types after the slash', async () => { - const user = userEvent.setup(); - renderChat(); - - await user.type(textbox(), '/co'); - const names = options().map((option) => option.textContent ?? ''); - expect(names.some((name) => name.includes('/compact'))).toBe(true); - expect(names.some((name) => name.includes('/copy'))).toBe(true); - expect(names.some((name) => name.includes('/model'))).toBe(false); - }); - - it('navigates with arrow keys and fills the draft on Enter', async () => { - const user = userEvent.setup(); - const { onSendInput } = renderChat(); - - await user.type(textbox(), '/'); - await screen.findByRole('listbox'); - await user.keyboard('{ArrowDown}'); - const selected = options().find((option) => option.getAttribute('aria-selected') === 'true'); - expect(selected?.textContent).toContain('/new'); - await user.keyboard('{Enter}'); - - expect(textbox().value).toBe('/new'); - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - expect(onSendInput).not.toHaveBeenCalled(); - }); - - it('keeps the keyboard-selected command visible while navigating a long list', async () => { - const user = userEvent.setup(); - renderChat(); - - await user.type(textbox(), '/'); - await screen.findByRole('listbox'); - const target = options()[12]; - const scrollIntoView = vi.fn(); - target.scrollIntoView = scrollIntoView; - - for (let index = 0; index < 12; index += 1) { - await user.keyboard('{ArrowDown}'); - } - - expect(target).toHaveAttribute('aria-selected', 'true'); - expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }); - }); - - it('leaves a trailing space when the command takes an argument', async () => { - const user = userEvent.setup(); - renderChat(); - - await user.type(textbox(), '/mo'); - await screen.findByRole('listbox'); - await user.keyboard('{Enter}'); - - expect(textbox().value).toBe('/model '); - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - }); - - it('closes on Escape and reopens when the draft changes', async () => { - const user = userEvent.setup(); - renderChat(); - - await user.type(textbox(), '/'); - await screen.findByRole('listbox'); - await user.keyboard('{Escape}'); - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - - await user.keyboard('c'); - expect(screen.getByRole('listbox', { name: 'Slash commands' })).toBeInTheDocument(); - }); - - it('does not select a command while the user is composing text', async () => { - const user = userEvent.setup(); - const { onSendInput } = renderChat(); - - await user.type(textbox(), '/'); - await screen.findByRole('listbox'); - fireEvent.keyDown(textbox(), { key: 'Enter', isComposing: true }); - - expect(textbox().value).toBe('/'); - expect(screen.getByRole('listbox', { name: 'Slash commands' })).toBeInTheDocument(); - expect(onSendInput).not.toHaveBeenCalled(); - }); - - it('selects a command by clicking and keeps focus in the composer', async () => { - const user = userEvent.setup(); - renderChat(); - - await user.type(textbox(), '/'); - await screen.findByRole('listbox'); - await user.click(screen.getByRole('option', { name: /^\/compact\b/ })); - - expect(textbox().value).toBe('/compact'); - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - expect(textbox()).toHaveFocus(); - }); - - it('closes when the user types an argument and sends the raw command on Enter', async () => { - const user = userEvent.setup(); - const { onSendInput } = renderChat(); - - await user.type(textbox(), '/model gpt-5.6'); - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - await user.keyboard('{Enter}'); - - expect(onSendInput).toHaveBeenCalledWith('w1:p1', { - text: '/model gpt-5.6', - keys: ['enter'], - }); - }); - }); -}); diff --git a/src/renderer/chat/ChatPanel.tsx b/src/renderer/chat/ChatPanel.tsx deleted file mode 100644 index d244389..0000000 --- a/src/renderer/chat/ChatPanel.tsx +++ /dev/null @@ -1,1133 +0,0 @@ -import { - AlertTriangle, - Bot, - ListChecks, - LoaderCircle, - Send, - SquareTerminal, - X, -} from 'lucide-react'; -import { - type ClipboardEvent, - type DragEvent, - type FormEvent, - useCallback, - useEffect, - useId, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; - -import { Button } from '@/components/ui/button'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Textarea } from '@/components/ui/textarea'; -import { cn } from '@/lib/utils'; -import { parseAnsiLines, responseThinkingLines, stripAnsi } from '@/renderer/chat/ansi-text'; -import { - formatAgentReply, - stripEchoedPrompt, - type ThinkingLines, -} from '@/renderer/chat/reply-format'; -import { - filterSlashCommands, - type SlashCommand, - slashCommandsForAgent, -} from '@/renderer/chat/slash-commands'; -import { detectTerminalMenu, menuSelectionKeys } from '@/renderer/chat/terminal-menu'; -import { - advancePaneOutput, - applyPaneRead, - type ChatTranscriptMessage, - createChatTranscript, - extractPaneResponse, - submitUserMessage, -} from '@/renderer/chat/transcript-model'; -import { statusDotClass } from '@/renderer/status'; -import type { ChatImageDraft, HerdrQueryResult } from '@/shared/desktop-api'; -import { - MAX_CHAT_IMAGE_ATTACHMENTS, - MAX_CHAT_IMAGE_BYTES, - MAX_CHAT_IMAGE_TOTAL_BYTES, -} from '@/shared/desktop-api'; -import type { HerdrEventEnvelope } from '@/shared/events'; -import type { PaneInfo } from '@/shared/herdr'; - -export type PaneOutput = Extract; - -const IMAGE_EXTENSION_BY_TYPE: Record = { - 'image/png': 'png', - 'image/jpeg': 'jpg', - 'image/gif': 'gif', - 'image/webp': 'webp', - 'image/bmp': 'bmp', -}; - -const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'gif', 'webp', 'bmp']); - -const PANE_ACTIVITY_EVENTS = new Set([ - 'pane.output_changed', - 'pane.scroll_changed', - 'pane.agent_status_changed', - 'pane.updated', -]); - -function eventTargetsPane(event: HerdrEventEnvelope, paneId: string): boolean { - if (event.event === 'desktop.resynchronized') { - return true; - } - if (!PANE_ACTIVITY_EVENTS.has(event.event)) { - return false; - } - const nestedPane = event.data.pane; - const nestedPaneId = - typeof nestedPane === 'object' && nestedPane !== null && 'pane_id' in nestedPane - ? (nestedPane as Record).pane_id - : undefined; - return event.data.pane_id === paneId || nestedPaneId === paneId; -} - -export interface ChatAttachment { - readonly id: string; - readonly name: string; - readonly url: string; - readonly blob: Blob; - readonly extension: string; -} - -function imageExtension(file: File): string | undefined { - const fromType = IMAGE_EXTENSION_BY_TYPE[file.type]; - if (fromType) { - return fromType; - } - const match = /\.([a-z0-9]+)$/i.exec(file.name); - if (match) { - const extension = match[1].toLocaleLowerCase(); - if (extension === 'jpeg') { - return 'jpg'; - } - if (IMAGE_EXTENSIONS.has(extension)) { - return extension; - } - } - return undefined; -} - -function imageFiles(dataTransfer: DataTransfer | null): File[] { - if (!dataTransfer) { - return []; - } - const files: File[] = []; - for (const item of Array.from(dataTransfer.items ?? [])) { - if (item.kind === 'file') { - const file = item.getAsFile(); - if (file && imageExtension(file)) { - files.push(file); - } - } - } - if (files.length === 0) { - for (const file of Array.from(dataTransfer.files ?? [])) { - if (imageExtension(file)) { - files.push(file); - } - } - } - return files; -} - -function readBlobAsBase64(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const result = String(reader.result ?? ''); - const comma = result.indexOf(','); - resolve(comma >= 0 ? result.slice(comma + 1) : result); - }; - reader.onerror = () => reject(reader.error ?? new Error('Could not read the image.')); - reader.readAsDataURL(blob); - }); -} - -function bracketedPaste(text: string): string { - // The agent CLI reads a pasted image path and attaches the image, exactly - // like a paste in Herdr's own terminal. - return `\x1b[200~${text}\x1b[201~`; -} - -function imageReference(index: number): string { - return `[Image #${index + 1}]`; -} - -function messageWithImageReferences(text: string, imageCount: number): string { - const references = Array.from({ length: imageCount }, (_, index) => imageReference(index)).join( - '\n', - ); - return [references, text].filter(Boolean).join('\n\n'); -} - -function turnIsComplete(status: PaneInfo['agent_status']): boolean { - return status === 'idle' || status === 'done'; -} - -export interface ChatSessionState { - readonly draft: string; - readonly attachments: readonly ChatAttachment[]; - readonly transcript: ReturnType; - readonly output?: PaneOutput; - readonly baselineRevision?: number; - readonly baselineText?: string; - readonly pollUntil?: number; -} - -export function createChatSessionState(): ChatSessionState { - return { - draft: '', - attachments: [], - transcript: createChatTranscript(), - }; -} - -export type ChatSessionUpdate = (current: ChatSessionState) => ChatSessionState; - -function AgentReply({ - text, - echo, - thinkingLines, - muted, -}: { - text: string; - echo?: string; - thinkingLines?: ThinkingLines; - // While the agent is working, everything it prints is provisional: render - // the whole turn in gray until the final reply lands in white. - muted?: boolean; -}) { - const segments = useMemo(() => { - const occurrences = new Map(); - return stripEchoedPrompt(formatAgentReply(text, { thinkingLines }), echo).map((segment) => { - const seen = (occurrences.get(segment.text) ?? 0) + 1; - occurrences.set(segment.text, seen); - return { ...segment, key: `${segment.kind}:${seen}:${segment.text}` }; - }); - }, [text, echo, thinkingLines]); - if (segments.length === 0) { - return null; - } - return ( -
- {segments.map((segment) => - segment.kind === 'pre' ? ( - // Tables and code keep terminal spacing, so they scroll instead of wrap. -
-            {segment.text}
-          
- ) : ( -

- {segment.text} -

- ), - )} -
- ); -} - -interface ChatPanelProps { - pane: PaneInfo; - readOutput: (paneId: string) => Promise; - onPrompt: (target: string, text: string) => void | Promise; - onSendInput?: (paneId: string, input: { text?: string; keys?: string[] }) => void | Promise; - onOpenTerminal?: () => void; - stageImages?: (images: ChatImageDraft[]) => Promise; - session?: ChatSessionState; - onSessionChange?: (update: ChatSessionUpdate) => void; -} - -export function ChatPanel({ - pane, - readOutput, - onPrompt, - onSendInput, - onOpenTerminal, - stageImages, - session: controlledSession, - onSessionChange, -}: ChatPanelProps) { - const agentName = pane.display_agent || pane.agent || 'Agent'; - const [internalSession, setInternalSession] = useState(createChatSessionState); - const onSessionChangeRef = useRef(onSessionChange); - onSessionChangeRef.current = onSessionChange; - const updateSession = useCallback((update: ChatSessionUpdate) => { - const changeControlledSession = onSessionChangeRef.current; - if (changeControlledSession) { - changeControlledSession(update); - } else { - setInternalSession(update); - } - }, []); - const session = controlledSession || internalSession; - const { draft, transcript, output } = session; - const attachments = session.attachments ?? []; - const [refreshError, setRefreshError] = useState(false); - const [sendError, setSendError] = useState(false); - const [sending, setSending] = useState(false); - const [attachmentNotice, setAttachmentNotice] = useState(); - const submissionSequenceRef = useRef(0); - const scrollViewportRef = useRef(null); - const followOutputRef = useRef(true); - const userScrollIntentRef = useRef(false); - const pointerScrollingRef = useRef(false); - const addAttachments = useCallback( - (files: File[]) => { - if (files.length === 0) { - return; - } - let totalBytes = attachments.reduce((sum, attachment) => sum + attachment.blob.size, 0); - const accepted: ChatAttachment[] = []; - let skippedForCount = 0; - let skippedForSize = 0; - for (const file of files) { - const extension = imageExtension(file); - if (!extension) { - continue; - } - if (file.size === 0 || file.size > MAX_CHAT_IMAGE_BYTES) { - skippedForSize += 1; - continue; - } - if ( - attachments.length + accepted.length >= MAX_CHAT_IMAGE_ATTACHMENTS || - totalBytes + file.size > MAX_CHAT_IMAGE_TOTAL_BYTES - ) { - skippedForCount += 1; - continue; - } - accepted.push({ - id: `${file.name}:${Date.now()}:${Math.random().toString(36).slice(2)}`, - name: file.name, - url: URL.createObjectURL(file), - blob: file, - extension, - }); - totalBytes += file.size; - } - if (accepted.length > 0) { - updateSession((current) => ({ - ...current, - attachments: [...(current.attachments ?? []), ...accepted], - })); - setAttachmentNotice(undefined); - } - if (skippedForSize > 0) { - setAttachmentNotice( - `Images must be between 1 byte and ${MAX_CHAT_IMAGE_BYTES / 1024 / 1024} MiB.`, - ); - } else if (skippedForCount > 0) { - setAttachmentNotice( - skippedForCount === files.length - ? 'Image limit reached — remove an attachment to add more.' - : 'Some images were skipped because the attachment limit was reached.', - ); - } - }, - [attachments, updateSession], - ); - const removeAttachment = useCallback( - (id: string) => { - const removed = attachments.find((attachment) => attachment.id === id); - if (removed) { - URL.revokeObjectURL(removed.url); - } - updateSession((current) => ({ - ...current, - attachments: (current.attachments ?? []).filter((attachment) => attachment.id !== id), - })); - setAttachmentNotice(undefined); - }, - [attachments, updateSession], - ); - const handlePaste = useCallback( - (event: ClipboardEvent) => { - const files = imageFiles(event.clipboardData); - if (files.length === 0) { - return; - } - event.preventDefault(); - addAttachments(files); - }, - [addAttachments], - ); - const handleDrop = useCallback( - (event: DragEvent) => { - const files = imageFiles(event.dataTransfer); - if (files.length === 0) { - return; - } - event.preventDefault(); - addAttachments(files); - }, - [addAttachments], - ); - const handleDragOver = useCallback((event: DragEvent) => { - // During drag-over the drag store is protected: payload data is not - // readable, only the declared types are. Read files only on drop. - const types = Array.from(event.dataTransfer?.types ?? []); - if (!types.includes('Files')) { - return; - } - event.preventDefault(); - event.dataTransfer.dropEffect = 'copy'; - }, []); - const followVersion = `${output?.revision ?? 0}:${transcript.messages.length}`; - - useEffect(() => { - const viewport = scrollViewportRef.current; - if (!viewport) { - return; - } - const markScrollIntent = () => { - userScrollIntentRef.current = true; - }; - const startPointerScroll = () => { - pointerScrollingRef.current = true; - markScrollIntent(); - }; - const stopPointerScroll = () => { - pointerScrollingRef.current = false; - userScrollIntentRef.current = false; - }; - const markKeyboardScrollIntent = (event: KeyboardEvent) => { - if ( - event.key === 'ArrowUp' || - event.key === 'ArrowDown' || - event.key === 'PageUp' || - event.key === 'PageDown' || - event.key === 'Home' || - event.key === 'End' || - event.key === ' ' - ) { - markScrollIntent(); - } - }; - const updateFollowMode = () => { - if (!userScrollIntentRef.current && !pointerScrollingRef.current) { - return; - } - const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; - followOutputRef.current = distanceFromBottom <= 48; - if (!pointerScrollingRef.current) { - userScrollIntentRef.current = false; - } - }; - viewport.addEventListener('wheel', markScrollIntent, { passive: true }); - viewport.addEventListener('pointerdown', startPointerScroll, { passive: true }); - viewport.addEventListener('keydown', markKeyboardScrollIntent); - viewport.addEventListener('scroll', updateFollowMode, { passive: true }); - window.addEventListener('pointerup', stopPointerScroll, { passive: true }); - window.addEventListener('pointercancel', stopPointerScroll, { passive: true }); - return () => { - viewport.removeEventListener('wheel', markScrollIntent); - viewport.removeEventListener('pointerdown', startPointerScroll); - viewport.removeEventListener('keydown', markKeyboardScrollIntent); - viewport.removeEventListener('scroll', updateFollowMode); - window.removeEventListener('pointerup', stopPointerScroll); - window.removeEventListener('pointercancel', stopPointerScroll); - }; - }, []); - - useLayoutEffect(() => { - void followVersion; - const viewport = scrollViewportRef.current; - if (viewport && followOutputRef.current) { - viewport.scrollTo({ top: viewport.scrollHeight }); - } - }, [followVersion]); - - const outputView = useMemo(() => { - const lines = parseAnsiLines(output?.text ?? ''); - const thinking = new Set(); - for (const line of lines) { - if (line.thinking && line.text.trim()) { - thinking.add(line.text.trim()); - } - } - return { plain: lines.map((line) => line.text).join('\n'), thinking }; - }, [output]); - const terminalMenu = useMemo( - () => (onSendInput && output ? detectTerminalMenu(outputView.plain) : null), - [onSendInput, output, outputView.plain], - ); - const answerMenu = useCallback( - (keys: string[]) => { - if (!onSendInput) { - return; - } - void onSendInput(pane.pane_id, { keys }); - // Keep polling so the menu view follows the CLI's screen. - updateSession((current) => ({ - ...current, - pollUntil: Math.max(current.pollUntil ?? 0, Date.now() + 10_000), - })); - }, - [onSendInput, pane.pane_id, updateSession], - ); - - useEffect(() => { - void pane.revision; - let active = true; - let refreshing = false; - let refreshQueued = false; - const refresh = async () => { - if (refreshing) { - refreshQueued = true; - return; - } - refreshing = true; - try { - const next = await readOutput(pane.pane_id); - if (!active) { - return; - } - updateSession((current) => { - const currentOutput = current.output; - const nextOutput = advancePaneOutput(currentOutput, next); - const outputChanged = nextOutput !== currentOutput; - const activeTurnId = current.transcript.activeTurnId; - const activePrompt = current.transcript.messages.find( - (message) => message.role === 'user' && message.turnId === activeTurnId, - )?.text; - const previousResponse = current.transcript.messages.find( - (message) => message.role === 'assistant' && message.turnId === activeTurnId, - ); - const responseText = - current.baselineText !== undefined && activePrompt !== undefined - ? extractPaneResponse( - current.baselineText, - stripAnsi(nextOutput.text), - activePrompt, - previousResponse?.text, - turnIsComplete(pane.agent_status) && - previousResponse?.role === 'assistant' && - previousResponse.status === 'working', - previousResponse?.role === 'assistant' - ? previousResponse.thinkingLines - : undefined, - ) - : ''; - const nextTranscript = responseText - ? applyPaneRead(current.transcript, { - text: responseText, - revision: nextOutput.revision, - status: pane.agent_status, - thinkingLines: responseThinkingLines(nextOutput.text, responseText), - }) - : current.transcript; - const nextPollUntil = - outputChanged && responseText && pane.agent_status !== 'working' - ? Date.now() + 5_000 - : current.pollUntil; - if ( - nextOutput === currentOutput && - nextTranscript === current.transcript && - nextPollUntil === current.pollUntil - ) { - return current; - } - return { - ...current, - output: nextOutput, - transcript: nextTranscript, - pollUntil: nextPollUntil, - }; - }); - setRefreshError(false); - } catch { - if (active) { - setRefreshError(true); - } - } finally { - refreshing = false; - if (active && refreshQueued) { - refreshQueued = false; - void refresh(); - } - } - }; - void refresh(); - const menuVisible = Boolean(terminalMenu); - const stopEvents = window.herdr?.onSessionEvent((event) => { - if (eventTargetsPane(event, pane.pane_id)) { - void refresh(); - } - }); - let pollTimer: number | undefined; - const schedulePoll = () => { - if (!active) { - return; - } - const rapid = - pane.agent_status === 'working' || - menuVisible || - Boolean(transcript.activeTurnId && (session.pollUntil ?? 0) > Date.now()); - pollTimer = window.setTimeout( - () => { - if (!active) { - return; - } - void refresh(); - schedulePoll(); - }, - rapid ? 250 : 1_500, - ); - }; - schedulePoll(); - return () => { - active = false; - stopEvents?.(); - if (pollTimer !== undefined) { - window.clearTimeout(pollTimer); - } - }; - }, [ - pane.agent_status, - pane.pane_id, - pane.revision, - readOutput, - session.pollUntil, - terminalMenu, - transcript.activeTurnId, - updateSession, - ]); - - const [slashMenuSelectedIndex, setSlashMenuSelectedIndex] = useState(0); - const [slashMenuDismissed, setSlashMenuDismissed] = useState(false); - const [slashMenuLocked, setSlashMenuLocked] = useState(false); - const slashMenuId = useId(); - const lastSelectedDraftRef = useRef(''); - const previousDraftRef = useRef(draft); - const slashMenuListRef = useRef(null); - const textareaRef = useRef(null); - - // Mirrors the CLIs' own command menus: open while the draft is a bare - // slash command (no argument yet), filter by what is typed after the - // slash, and close once a space starts an argument. - const commandMenu = useMemo(() => { - if (!onSendInput || slashMenuLocked || slashMenuDismissed) { - return null; - } - const trimmed = draft.trimStart(); - if (!trimmed.startsWith('/') || trimmed.includes(' ')) { - return null; - } - const options = filterSlashCommands(slashCommandsForAgent(pane.agent ?? ''), trimmed.slice(1)); - const selectedIndex = Math.min(slashMenuSelectedIndex, Math.max(0, options.length - 1)); - return { options, selectedIndex }; - }, [draft, onSendInput, pane.agent, slashMenuDismissed, slashMenuLocked, slashMenuSelectedIndex]); - const selectedSlashMenuIndex = commandMenu?.selectedIndex; - - useEffect(() => { - if (selectedSlashMenuIndex === undefined) { - return; - } - slashMenuListRef.current - ?.querySelectorAll('[role="option"]') - .item(selectedSlashMenuIndex) - ?.scrollIntoView({ block: 'nearest' }); - }, [selectedSlashMenuIndex]); - - // Fresh keystrokes reset navigation and dismissal; a selection locks the - // menu shut until the draft changes again. - useEffect(() => { - const previous = previousDraftRef.current; - previousDraftRef.current = draft; - if (draft === previous) { - return; - } - setSlashMenuSelectedIndex(0); - if (draft !== lastSelectedDraftRef.current) { - setSlashMenuLocked(false); - setSlashMenuDismissed(false); - } - }, [draft]); - - const selectSlashCommand = (command: SlashCommand) => { - const selectedDraft = `/${command.name}${command.takesArgument ? ' ' : ''}`; - updateSession((current) => ({ - ...current, - draft: selectedDraft, - })); - lastSelectedDraftRef.current = selectedDraft; - setSlashMenuLocked(true); - textareaRef.current?.focus(); - }; - - const submit = async (event: FormEvent) => { - event.preventDefault(); - const text = draft.trim(); - if ((!text && attachments.length === 0) || sending) { - return; - } - const asCommand = Boolean(onSendInput) && text.startsWith('/'); - const submittedAttachments = attachments.map((attachment) => ({ - id: attachment.id, - url: attachment.url, - })); - const submissionText = messageWithImageReferences(text, submittedAttachments.length); - submissionSequenceRef.current += 1; - const submissionId = `${pane.pane_id}:${Date.now()}:${submissionSequenceRef.current}`; - setSendError(false); - setSending(true); - updateSession((current) => ({ ...current, draft: '' })); - // Refresh the pane before this turn starts: a still-active previous turn - // gets finalized with its real reply, and the new baseline includes that - // reply so it cannot leak into this turn's response. - let freshOutput: PaneOutput | undefined; - try { - freshOutput = await readOutput(pane.pane_id); - } catch { - freshOutput = undefined; - } - updateSession((current) => { - const output = freshOutput ? advancePaneOutput(current.output, freshOutput) : current.output; - const plainOutput = stripAnsi(output?.text ?? ''); - const activeTurnId = current.transcript.activeTurnId; - const activePrompt = current.transcript.messages.find( - (message) => message.role === 'user' && message.turnId === activeTurnId, - )?.text; - const currentResponse = current.transcript.messages.find( - (message) => message.role === 'assistant' && message.turnId === activeTurnId, - )?.text; - const previousResponse = - output && current.baselineText !== undefined && activePrompt !== undefined - ? extractPaneResponse( - current.baselineText, - plainOutput, - activePrompt, - currentResponse, - turnIsComplete(pane.agent_status), - ) - : ''; - const transcript = previousResponse - ? applyPaneRead(current.transcript, { - text: previousResponse, - revision: output?.revision ?? 0, - status: pane.agent_status, - thinkingLines: responseThinkingLines(output?.text ?? '', previousResponse), - }) - : current.transcript; - return { - ...current, - output, - baselineRevision: output?.revision ?? 0, - baselineText: plainOutput, - pollUntil: Date.now() + 30_000, - transcript: submitUserMessage(transcript, { submissionId, text: submissionText }), - }; - }); - try { - const stagedPaths = - submittedAttachments.length === 0 - ? [] - : await stageImages?.( - await Promise.all( - submittedAttachments.map(async (attachment) => { - const found = attachments.find((item) => item.id === attachment.id); - if (!found) { - throw new Error('Image attachment disappeared before sending.'); - } - return { - extension: found.extension, - data: await readBlobAsBase64(found.blob), - }; - }), - ), - ); - if (!stagedPaths) { - throw new Error('Image staging is unavailable.'); - } - for (const path of stagedPaths) { - if (onSendInput) { - await onSendInput(pane.pane_id, { text: bracketedPaste(path) }); - } - } - if (asCommand && onSendInput) { - // Slash commands belong to the agent CLI. Send them as typed input — - // exactly what typing in the terminal does — so the CLI's own command - // handling runs instead of receiving the text as a pasted prompt. - await onSendInput(pane.pane_id, { text, keys: ['enter'] }); - } else { - await onPrompt(pane.pane_id, submissionText); - } - if (submittedAttachments.length > 0) { - // Remove only the attachments that were submitted; anything pasted or - // dropped while the send was in flight stays in the composer. - const submittedIds = new Set(submittedAttachments.map((attachment) => attachment.id)); - updateSession((current) => ({ - ...current, - attachments: (current.attachments ?? []).filter( - (attachment) => !submittedIds.has(attachment.id), - ), - })); - for (const attachment of submittedAttachments) { - URL.revokeObjectURL(attachment.url); - } - } - } catch { - setSendError(true); - } finally { - setSending(false); - } - }; - - const messageCard = (message: ChatTranscriptMessage) => - message.role === 'user' ? ( -
-

- {message.text || 'Image attachment'} -

-
- ) : ( -
-
-
- item.role === 'user' && item.turnId === message.turnId, - )?.text - } - muted={message.status === 'working'} - text={message.text} - thinkingLines={ - message.thinkingLines && message.thinkingLines.length > 0 - ? message.thinkingLines - : outputView.thinking - } - /> -
- ); - - return ( -
-
-
- - -
- {pane.agent_status === 'blocked' ? ( -
-
- ) : null} - - {transcript.messages.length === 0 ? ( -
-
-
- {output?.text ? ( - - ) : ( -

- Send a message to start working with this agent. -

- )} - {output?.truncated ? ( -

- Older terminal output was truncated by Herdr. -

- ) : null} -
- ) : ( - transcript.messages.map(messageCard) - )} - - {transcript.activeTurnId && !transcript.liveResponseId ? ( -
-
- ) : null} - - {refreshError ? ( -

- Live output could not refresh. -

- ) : null} - {sendError ? ( -

- Herdr could not send this message. Your text remains in the conversation. -

- ) : null} -
-
- - {terminalMenu ? ( -
-
-

-

-
- {terminalMenu.options.map((option, index) => ( - - ))} -
-
- - {onOpenTerminal ? ( - - ) : null} -
-
-
- ) : null} -
- {attachmentNotice ? ( -

- {attachmentNotice} -

- ) : null} - {attachments.length > 0 ? ( -
- {attachments.map((attachment, index) => ( -
- {attachment.name} - - {imageReference(index)} - {attachment.name} - - -
- ))} -
- ) : null} -
- {commandMenu ? ( -
- {commandMenu.options.length === 0 ? ( -

No matching commands

- ) : ( - commandMenu.options.map((command, index) => ( - - )) - )} -
- ) : null} -
-