| name | hecate-ui |
| description | Use when working on the Hecate operator UI in `ui/`. Keeps frontend work aligned with Hecate's operator-console workflows, runtime-debugging focus, and React/Vite stack. |
Hecate UI skill
Use this skill for any work inside ui/. Backend work uses ../backend/SKILL.md.
Canonical guidance lives here
../../core/project-context.md — toolchain pin (Bun, React 19, Vite, Vitest), bun run test ≠ bun test warning.
../../core/engineering-standards.md — type-name mirroring with the Go side, design-token discipline, anti-patterns.
../../core/workflow.md — operating loop, planning triggers, commit etiquette (UI agent-doc updates use chore:).
../../core/verification.md — UI verification ladder, snapshot review, done criteria.
Product lens
The Hecate UI should feel like:
- An operator console.
- A runtime control surface.
- A debugging and inspection tool.
It should not feel like:
- A generic SaaS dashboard full of cards.
- A landing page with product-marketing copy.
- A toy chat surface without production context.
Default to utility, orientation, and workflow clarity.
Visual thesis
Calm, technical, and deliberate. Dense enough to be useful, sparse enough to scan. Strong hierarchy, minimal chrome, one clear accent system.
Prefer restrained layout, strong type hierarchy, clear sectioning without over-carding, obvious status and health states, meaningful spacing over decorative surfaces.
Avoid card mosaics, oversized hero copy, decorative gradients behind routine product UI, multiple accent colors fighting for attention, visual noise that hides runtime state.
Consistency guardrails
Before adding or changing UI, find the closest existing precedent and reuse it unless the product behavior truly differs. Hecate's UX should feel like one operator console, not a set of unrelated experiments.
- Reuse shared primitives for recurring patterns:
DropdownPicker, BrandAvatar, CopyableID, TranscriptActivityTimeline, badges, inline errors, and modal/slide-over chrome.
- Keep button height, dropdown rhythm, icon treatment, metadata labels, empty-state spacing, and hover/focus affordances consistent across Chats, Tasks, Connections, Observability, and Usage.
- Long machine identifiers belong behind compact labels with tooltip + copy affordances. Prefer full context in Details or Task Detail over leaking raw ids into list rows.
- If a screen already has an empty, loading, repair, or onboarding precedent, extend that component/copy path instead of adding a second parallel state.
- When making Hecate Chat and External Agent behavior more similar, preserve their real runtime differences: Hecate owns task-backed tools; external agents own ACP-native sessions.
- Add role/name-based tests for shared controls and view-level tests for every state that previously regressed. E2E should cover cross-view navigation, onboarding, and setup/repair flows when a bug came from app composition.
- Before filing a PR or pushing a PR update that touches UI/TypeScript, run the
UI verification ladder:
cd ui && bun run typecheck, bun run lint,
bun run format:check, and bun run test. Add same-change tests for
production UI code, and targeted Playwright coverage when the change is a
workflow, routing, onboarding, or regression fix. If Go files changed too,
run the backend ladder as well.
UX priorities
Every screen should answer the following quickly:
- What system am I looking at?
- What is its current state?
- What can I do here?
- What changed or failed?
- Where do I go next?
When choosing between "pretty" and "operationally clear," choose clarity.
Accessibility baseline
Accessibility is part of the design pass, not a cleanup pass. Every UI change
should preserve keyboard, screen-reader, focus, contrast, and motion ergonomics
unless there is an explicit product reason and a documented follow-up.
- Use semantic HTML first: buttons for actions, anchors for navigation, labels
for form controls, tables for real tables, headings that reflect structure.
- Every interactive control needs a visible focus state and a keyboard path.
Dropdowns, dialogs, popovers, and slideovers must support Escape / outside
dismissal where appropriate and return focus to the trigger.
- Icon-only controls need accessible names. Status chips and color-coded states
need text, not color alone.
- Modal and dialog work must include focus management,
aria-modal, labelled
titles, and non-trapping escape paths.
- Once a destructive confirmation starts its request, keep the dialog modal and
disable confirm, Escape, close, and backdrop dismissal until the request
settles. This prevents operators from editing context that a successful
mutation is about to clear.
- Keep contrast readable in the dark operator theme. Muted text can be quiet,
but it still needs to be legible against panels and borders.
- Respect reduced-motion expectations. Motion should orient; avoid relying on
animation to convey state.
- Add or update tests for accessibility-sensitive behavior when changing UI
primitives: role/name queries, focus movement, disabled states, and keyboard
interactions are preferred over brittle DOM selectors.
Information architecture
Organize the UI around operator jobs, not components:
- Understand local runtime readiness and current configuration.
- Inspect providers and models.
- Run and compare requests.
- Inspect trace and runtime metadata.
- Manage Connections, local cleanup, and policy-adjacent state.
- Inspect usage events and reported cost where available.
Views are task-shaped, not component-shaped. If a page mixes too many concerns, split it.
Section responsibilities
Each section has exactly one job: orient, inspect, compare, edit, or confirm. If a section tries to explain the whole system at once, break it apart.
Hecate-specific UI rules
- Do not add auth, tenant, or account-management UI unless the product model
changes again.
- Provider and model selection exposes local and cloud distinctions clearly.
- In Chats, use the shared agent-picker shell. Hecate is the built-in
choice and owns provider/model selection; its tools toggle switches between
direct model chat and Hecate-owned task execution. Codex, Claude Code,
Cursor, and Grok Build choices create External Agent sessions where
adapter/workspace/native session diagnostics belong.
- Hecate-owned chats store provider/model state on the session and durable
runtime snapshots on each message. Tools-on turns create or continue a backing
task and should show per-turn task links in the transcript. Chats may resolve
pending task approvals inline; Tasks remains canonical for artifacts,
retry/resume, full event history, and patch review. While a task-backed
segment is active, the whole Hecate Chat session is busy: keep
provider/model controls locked to the segment snapshot. If the operator
submits another prompt, queue it locally in the composer and send it after
the task finishes, is stopped, or reaches a terminal approval outcome. Treat
this as browser-local operator state, not a server-side message. Persist one
logical record per queue item (never a stale whole-array rewrite), but give
every mutation a new immutable physical key containing queue-lineage generation,
random revision, and item id. Verify the new revision before retiring the
exact old revision. Merge same-origin same-profile tab changes through
storage events; duplicate surviving revisions make lineage ambiguous, so
quarantine the whole queue until an exact cleanup event lets every row rebuild
from the recovered logical snapshot.
Remove malformed item keys during explicit browser-queue cleanup. Before the message POST starts, synchronously
write through and verify a submitting state plus the current transcript
message-id baseline. Fail closed before dispatch when the durable fence cannot
be confirmed. A durable submitting record recovered after reload or observed
from another tab is a local reconcile_required projection: do not rewrite,
remove, retry, or otherwise mutate that foreign fence. Only an explicit
Check status action may claim an exact matching keyed fence for replay,
without changing its durable payload. Send the queued runtime
snapshot, including tools_enabled, verbatim even if current capability state
has changed. Parse stored submitting and legacy retry markers as
reconcile_required; never auto-resend them. Only known local or pre-commit
failures become retryable. For current keyed ambiguous outcomes, Check
status safely replays the exact key/payload and uses
message_request.committed_message_id; keep it at the FIFO head until a
terminal assistant for that user appears before the next user turn. Observe
SSE and authoritative polling concurrently to close subscription races.
Content/baseline matching is only for legacy unkeyed records. A server-proven
chat.client_request_conflict must preserve typed provenance, bypass content
matching even when text is identical, and remain blocked; the operator
reviews/removes it and a later submission uses a new queue id. Keep the item
blocked with clear manual-review copy when proof is impossible. Preserve FIFO,
and disable editing, removal, retry, and status checks while submitting or a
destructive chat-ownership reservation is active.
- Hecate Chat exposes workspace posture as Managed workspace
(
persistent/ephemeral) versus Current folder (in_place). New
project-free chats explicitly default to managed execution; project-linked
creation reflects the Cairnline Project default. Keep the selector editable
only before a backing task exists, explain that Current folder writes to the
live checkout, and render review/files against the generated managed
workspace returned by the session. External Agent chats do not expose this
selector because ACP sessions use their selected workspace in place.
- Successful keyed message responses expose
message_request.replay and the
exact committed user-message id. A replay can return before its assistant is
terminal: keep that queue item at the FIFO head, observe the live session
stream, and poll the authoritative session as a fallback until a terminal
assistant is proven after that user message and before the next user turn.
Treat missing metadata, missing exact-turn terminal proof, timeout, or stale
destructive-mutation ownership as reconcile_required; never let later
queued work overtake it.
- An admitted External Agent turn is server-owned after its running assistant is
durable. When app hydration or chat selection finds an authoritatively busy
External Agent session, or a session whose durable tail is a user message,
without a locally owned submit, follow that session from the app lifetime:
subscribe to its typed stream, apply the initial and live snapshots, reconcile
approval events, and retry an unexpected stream close with bounded backoff.
Treat an initial idle snapshot after a trailing user message as an admission
gap, not terminal proof. The locally owned submit stream must use the same
bounded reconnect behavior rather than leaving ownership with a dead stream.
After every reconnect, refetch pending approvals because approval events are
not replayed. Abort that local observer on terminal state, chat switch, or
unmount without calling the cancellation endpoint. Do not create a second
observer while the local submit already owns one, and preserve accepted-Stop
fences so a reordered snapshot or approval cannot restore cancelled work.
A passive observer treats
409 chat.session_not_running for a trailing-user
admission gap as authoritative interruption and stops reconnecting. A local
observer paired with a replacement submit retries that response until its
POST is admitted or settles, because the stream can inspect the previous
orphan immediately before the new POST registers its turn.
Treat a session GET 404 as authoritative deletion: install the durable
deletion fence and remove the ghost session instead of retrying indefinitely.
- Treat
POST /chat/sessions/{id}/cancel 202 as a signal acknowledgement,
not a terminal session snapshot. Keep a session/turn-scoped fence after the
acknowledgement, suppress reordered busy snapshots and approval requests,
and settle only from the matching turn stream/POST or a GET begun after the
accepted Stop. Bound the visible cancellation owner so a hanging read cannot
wedge controls, but retain the terminal fence until proof arrives. On a
rejected Stop, remove the provisional fence before launching independent,
epoch-checked session and approval catch-ups. If a retry fails over an older
accepted fence, restore that fence with a renewed bounded settlement window
and keep waiting on the original turn's terminal path.
- Queue storage failures are operator-visible safety state, never a silent
memory-only success. Initial enqueue must return synchronous admission
success before clearing the composer. A failed ready-item edit keeps the new
text visible as blocked/retryable and best-effort removes the older durable
ready payload so reload cannot auto-send stale content. Never remove a stored
submitting/reconcile fence merely because a safer local transition failed to
persist. Mark local failure fields non-durable, pause FIFO drain, and install
unload protection until retry/remove succeeds. Session/project deletion and
explicit all-session queue cleanup must verify removals and surface manual
clear-site-data guidance. Persist the originating
project id in new queue snapshots solely for browser cleanup; project deletion
must inspect the durable per-item store, not only the current tab's session
summaries. Preserve an explicit empty project id for newly project-free
records, reserve an absent id for legacy unknown ownership, and make unknown,
malformed, or unreadable records fail cleanup closed. Quarantine absent-owner
records after any project tombstone because unrelated ownership cannot be
proven. Advance a profile-wide
project-deletion tombstone before cleanup; check it before and after enqueue,
react to its storage event, and purge matching records during initialization
so a late tab cannot resurrect deleted-project prompts. Apply the same
generation-scoped fence to a deleted session, using only its canonical id and
no prompt-bearing metadata. Explicit all-session queue cleanup may advance the
profile-wide queue-lineage epoch before cleanup; the disabled server reset
endpoint does not invoke this browser action.
For an ordinary logical Remove, first write an
immutable tombstone scoped by generation, item id, and SHA-256 of the exact
canonical payload without its storage revision. Suppress only matching
payloads. Preserve a different same-id ready payload as a local queue-id
conflict that says Remove and submit again; do not label it as a server-proven
chat.client_request_conflict. Preserve a different same-id submitting
payload as a fence that explicit Check status can claim. A surviving
tombstoned immutable revision is a known cleanup failure: keep its raw source
fingerprint behind the UI projection so Remove can retry after reload.
Tombstones are prompt-free but equality-revealing and remain until reset.
Stamp every item, item tombstone, project tombstone, and session tombstone with
the generation observed before its write and scope its storage key by that
generation, so old cleanup cannot address a current-generation same-id
replacement. Treat
unreadable or noncanonical epoch/item metadata and an absent epoch paired with
nonzero item generations as blocked/unknown, never as deletion or a virgin
profile. Migrate mutable unscoped/revisionless rows by
copying them to an immutable revision and writing a SHA-256 marker. Do not
get/get/remove the mutable row: a concurrent replacement can land at the
delete boundary. Keep matching legacy rows as suppressed shadows; surface a
changed shadow as conflict. Snapshot raw old-generation item revisions, project and
session tombstones, item tombstones, migration markers, and the prompt-bearing
legacy whole-array key first, remove only unchanged old records after the epoch
changes, then post-audit every keyed namespace and the legacy array.
Stale tabs clear/quarantine only their local view, ignore later
item events, and refuse writes until reload. They must never delete a
same-ID current-generation record owned by a fresh tab.
- Chat attachment drafts are in-memory
File values only. Never put File,
Blob URLs, or base64 into persisted state/localStorage. Hecate-owned Tools-off
turns accept only PNG/JPEG/WebP with explicitly supported image_input;
External Agent turns accept up to four arbitrary non-empty files through the
ACP resource-block path. Apply the shared 5 MiB per-file and 12 MiB combined
limits. Allow drafts to move into an External Agent target, but block Tools-on
switching and any move into a Hecate route that cannot accept every selected
file. Busy-queue submission and chat-session/project switching remain blocked
while drafts exist. Register a beforeunload warning
while visible or submitted-but-unsettled memory-only drafts exist, and
announce draft additions and removals through one polite live region. On
submit, synchronously acquire a tokenized owner and atomically clear the exact
prompt and File snapshot before the first async boundary; later composer
edits are a different turn and visible Remove controls must never mutate the
submitted snapshot. Stored transcript previews must keep
the filename and a load control accessible before deferred loading, reserve
stable dimensions, use the runtime-token-aware API client, create object URLs from fetched Blobs,
revoke every URL, abort body reads that move outside the viewport, and unload
previews again when they move well outside the viewport. Render every
non-image transcript file as inert metadata plus an explicit guarded Download
action; never inline-render active content, and revoke the download object URL
only after the synthetic anchor click has been processed. Transfer focus when
a draft Remove button or stored-preview Load/Retry button unmounts: prefer the
next relevant action, then the attachment picker, loading status, or loaded
image link, but transfer only while focus still belongs to the disappearing
control so delayed loads never steal focus back from the operator. Once
submission begins, keep a single session owner until the turn settles, even
before a newly created session id can be bound. Block compact and every
ownership/session mutation while that owner is live. Project links and
browser-history routes are not escape hatches: keep rendering the last
accepted project and replace a rejected cross-project route while an attachment
draft or turn owns the current context. While an attachment turn is live, reject
even an explicit text-only follow-up without clearing it or creating a
browser queue record; the operator may send that retained composer text only
after the attachment response reaches a known outcome. Never auto-queue newer
unsent composer text. On a definite rejection, restore the original prompt
and Files without overwriting that newer text. After an attachment
message POST becomes ambiguous, reconcile with an authoritative session GET:
never auto-resend; retain server drafts for network/proxy ambiguity, but
delete and restore local Files after a known pre-commit rejection or a
Hecate-shaped HTTP error whose successful GET proves no commit. Attachment
uploads are a separate commit boundary with no idempotency key or draft-list
recovery endpoint: treat transport failures and every upload 5xx as
ambiguous, including Hecate-shaped errors, restore the exact local prompt and
Files, delete only previously acknowledged drafts, and never auto-retry.
Retry a failed draft DELETE once without logging attachment ids. If cleanup still
fails, preserve the typed submission error, restore the local Files, and add
an explicit warning that retained server copies may consume draft quota until
a later upload reclaims them after 24 hours; direct the operator to that
triggered reclamation or immediate chat deletion, never automatic expiry.
- Individual chat deletion and project deletion are destructive chat-ownership
mutations. Route every entry point through a shared tokenized
ownership reservation
acquired synchronously before the backend request, release it on every
outcome, then fence chat state centrally after success. While it is held,
reject attachment draft additions and attachment-turn acquisition, pause queued delivery,
and block competing chat/session ownership changes. Explicit and first-message
session creation must hold the reciprocal create reservation so destructive
acquisition fails until the server create settles. Fence deferred
stream/tool-continuation writes against the current queue-lineage generation
and session/project tombstones after every await. Before writing a
returned session into summaries, active state, or per-session target/tools maps,
reject and tombstone it when that
generation changed or its requested/returned project is tombstoned. Ordinary
navigation may still leave a successfully created session in the sidebar.
After the backend confirms a single-chat delete, write and verify a
generation-scoped, prompt-free durable session tombstone before removing any
matching browser-queue record. Queue reads, writes, enqueue admission,
submitting-fence checks, storage events, reload migration, and queue cleanup
must honor that tombstone so another tab cannot resurrect work for the
deleted session. Verify removal of every matching durable record before
tombstoning local chat state or closing its confirmation modal. If either the
fence or cleanup cannot be verified, retain the row as a retry surface and
direct the operator to free browser storage or clear Hecate site data; the
backend DELETE is idempotent for that retry.
- Hecate-owned chat slash commands are local UI shortcuts, not External Agent
ACP commands. Keep project-shaping commands (
/proposal, /plan, /work,
/handoff, /review) on the Project Assistant proposal/confirmation rail;
navigation commands such as /diff, /model, /settings, /status,
/task, /project, and /connections may open Hecate UI surfaces but must
not send prompt text or mutate project records directly.
- Use the shared
features/transcript primitives for runtime storytelling.
Task Detail and Hecate Chat should share TranscriptActivityTimeline labels
and Details grouping instead of growing separate task/activity renderers.
Task Detail may add Task-specific advanced disclosures, but keep that debug
layer out of Chats unless the operator explicitly asks for it.
- Transcript rows should have one source of truth for noisy details. Do not
repeat captured command/read output both in the compact row and in the output
preview card. Do not render raw captured diffs in the transcript when the
message already has a workspace-changes badge or the side-panel diff viewer.
Group repetitive command rows into a single expandable summary that reveals
commands and captured output together.
- Keep workspace review and workspace browsing as separate UI surfaces. The
workspace changes panel's Review tab owns changed-file diffs, copy, and
discard actions; the Files tab owns the full workspace tree. Keep the full
tree collapsed by default and expand matching directories only when the
operator searches or opens them. When the workspace panel is visible, refresh
it after an active chat/agent turn settles so the operator sees live changes
without pressing Refresh; keep the explicit Refresh action for manual
rechecks and recovery. Bind every discard confirmation and request to the
opaque revision from the exact reviewed diff, fail closed when it is absent,
and disable discard while agent work is queued, running, or awaiting
approval. Treat the content-derived token as sensitive operational metadata:
it can reveal equality with a known complete unstaged tracked patch, so never
persist or log it. The backend remains the authority for revision drift,
active-work conflicts, and staged-state refusal. When workspace review or
discard returns
422 invalid_request because the scoped workspace has staged
changes, never retain a previous revision or render a false clean state. Tell
the operator to unstage the changes, refresh, review the newly visible
index-to-worktree patch, and confirm again. Do not imply that this surface
reviews staged or untracked layers.
- External Agent sessions store their workspace and native ACP session id. New
UI affordances should preserve that continuity instead of treating every
prompt as a one-off subprocess.
- Model capability badges are guidance and guardrails for tools, not a reason
to hide plain chat. Hecate Chat should remain available when a model is
routable; task-backed tools-on turns require
tool_calling="basic" or
parallel. Unknown local/custom models should show a clear capability
indicator, fall back to direct model chat rather than failing the whole
transcript, and suggest choosing a known tool-capable model for task-backed
turns. Put the fallback state in the chat header/status line, not as a noisy
composer warning. Connections shows observed provider/catalog capability
metadata, not a global "tools on/off" override editor. For a ready model
whose tool support is unknown, Connections may offer an explicit Verify
tool support action. Keep it opt-in and visibly capable of incurring one
provider request; never trigger it from page load, model refresh, or Chat
send. Render only the safe tool_verification outcome/times/reason, refresh
the shared model list after it settles, and do not offer it for Auto,
unready, or already-known models.
- Hecate image attachments are independent of the Tools toggle. Keep the
PNG/JPEG/WebP and confirmed
image_input gates in both direct-model and
task-backed modes; never reintroduce UI copy that asks operators to disable
tools merely to attach an image.
- Stale selected-model readiness is a composition blocker, not a post-send
error toast. If the selected model is not in the current model picker for the
selected route, hide/disable send and show the selected model, provider route,
discovered-model count, health, blocked-by, last-error, and repair steps.
The empty-state "compact" version may be shorter, but it must still include
discovered-model count and at least a short remediation list; don't reduce it
to a dead-end warning. If the backend provides a suggested replacement model,
expose it as an action that switches to the suggested provider/model pair
explicitly; don't silently widen a stale route back to a hidden fallback.
- Chat send blockers should flow through
resolveChatSetupRepairState in
ui/src/lib/chat-setup-readiness.ts. Keep the empty state, composer notice,
and disabled-send copy aligned there instead of adding one-off branches to
ChatView.
- Projects UI keeps top-level data loading, selected-project orchestration, and
mutation dispatch in
ProjectsView; do not add large rendering branches back
to that parent. Project selection and persisted right-panel width live in
useProjectViewController, and Project Assistant orchestration lives in
useProjectAssistantController.
- Project workspace UI is split by surface. The selected-project shell,
onboarding, workspace tabs, work inbox, and empty blocks live behind
ProjectWorkspaceView. Work item detail, assignment rows, handoff-linked
start controls, launch preflight state, and chat-draft shaping live in
ProjectWorkItemDetail. Timeline/decision-log rows live in
ProjectTimelinePanel; attention/health popovers live in
ProjectHealthPanel; project memory/context review lives in
ProjectMemoryPanel; project skill registry UI lives in ProjectSkillsPanel.
Keep rendering and form tests colocated with the component that owns the
surface, and add parent-page tests only when loading, controller wiring, or
mutation orchestration changes.
- Project defaults/root editing lives in
ProjectSettingsPanel plus
CreateProjectWorktreeModal. Agent Preset and project role editing lives in
ProfilesModal and RolesModal, with shared profile/role form mapping in
projectProfilesRoles.ts. Work item, assignment, and handoff editing lives in
ProjectWorkItemModals, ProjectAssignmentModals, and ProjectHandoffModal,
with payload/ref shaping in projectWorkForms.ts; keep project state loading
and mutations in the parent page. Shared project UI string/id helpers live in
projectUtils.ts; display-label helpers live in projectDisplay.ts; status
option lists and form-safe status normalization live in projectWorkForms.ts.
- Project sources are
context_sources: operator-managed provenance metadata
for URLs, local paths, notes, external references, and discovered workspace
guidance. Source add/edit/delete form mapping lives in projectSources.ts.
Render only http/https locators as links; show every other locator as
plain escaped text. Source notes are metadata, not project memory, until the
operator promotes or rewrites them as memory.
- External Agent availability belongs in the picker; optional launch
diagnostics belong in Connections and can also inform picker detail.
Distinguish missing binaries, required remote credentials, auth/billing
problems, unsupported versions, and managed-launcher issues without sending
users to raw logs first. Catalog discovery means only that an eligible app
path was found: render it as Available, never Ready. Missing/rejected
executables and absent required remote credentials are launch blockers.
Cached auth, billing, version, or probe failures are advisory and must not
disable agent selection, New chat, attachments, or Send; the operator may
have repaired the app since that diagnostic ran.
- Opening Chats or Connections must not probe, start, authenticate, or otherwise
execute a discovered app. New chat re-resolves the current executable and
prepares a fresh ACP session for the real chat. Direct ACP peers start during
that setup. Embedded bridges may run bounded provider discovery during setup
while deferring their prompt-serving vendor invocation and prompt-time auth
result until the first message, which is authoritative for that deferred work.
POST /agent-adapters/{id}/probe is an optional disposable diagnostic, not a
prerequisite or launch authority. Its accessible name/help text must say that
it starts a temporary ACP session and may execute the app for diagnostics.
- Route every passive External Agent catalog read—dashboard hydration, manual
Refresh, and the post-diagnostic re-read—through
loadAgentAdapterCatalog. That provider/model slice owns request ordering
and the merge between catalog-owned available/status/error/path/
remote-credential fields and cached diagnostic evidence. Never commit an API
catalog response through the raw setAgentAdapters fixture/projection
setter.
- To smoke-test adapter states without uninstalling local tools, use
just dev-no-agent-adapters or
just dev-agent-adapters 'claude_code=no_auth,codex=ready,cursor_agent=app_missing'.
These fixture env vars are test/development-only and intentionally absent from
.env.example; do not write tests that expect a forced-ready adapter to run a
real session.
- External Agent usage is adapter-reported. Show it as helpful telemetry with the
"reported by adapter · not enforced by Hecate" caveat, never as Hecate-enforced
billing.
- External Agent file changes are already applied to the selected workspace when
Hecate captures them. UI copy should say "inspect" / "revert" / "keep", not
"apply", unless the backend grows a true staged-artifact flow.
- Project assignment UI reads canonical backend contracts.
turn_kind is the
chat turn discriminator, and project assignment runtime links come from
execution_ref / activity linked ids. Do not infer task-backed, direct-model,
or external-agent state from legacy execution_mode, tools_enabled, raw
task_id, raw run_id, or chat_session_id fields. Project rows, activity,
health, and timeline surfaces should use
ui/src/features/projects/projectAssignmentViewModels.ts instead of
reconstructing status/link logic in components. Keep compact assignment row
evidence separate from the full Context Inspector: the row summarizes
canonical refs and warnings, while the inspector renders the persisted packet
sections the agent actually saw.
- Do not add UI "compatibility" fallback chains for contracts that the backend
has already made canonical. If a current feature appears to need old raw
fields or inferred state, update the view model / API contract deliberately
instead of rebuilding the removed fallback in a component.
- Project assignment launch controls must review
/assignments/{assignment_id}/preflight before dispatch. Use the shared
Context Inspector panel for the launch packet, then call start only from the
operator's confirm action. This applies to normal assignment rows and
handoff-linked starts.
- Reviewer follow-through stays handoff-based. A request-review action may
prefill a handoff to a work item's
reviewer_role_ids and carry source
assignment/run/chat/context refs, but creating the follow-up assignment and
starting it remain separate operator actions.
- Review outcomes are
kind="review" collaboration artifacts. The V1 cockpit
entry point is an assignment whose role appears in the work item's
reviewer_role_ids; work without configured reviewer roles should surface
setup guidance instead of a generic record-review button. Keep the record
action separate from follow-up handoff creation; review artifacts may offer an
explicit follow-up assignment action, but it must create/link the handoff
first and leave execution start as a separate operator action. Review
artifacts can carry structured
review_verdict, review_risk, review_follow_up_required, and
reviewed_assignment_id fields for triage, but the UI must not mark work
done, blocked, or dispatched from those fields without an explicit operator
action.
- Work closeout is explicit operator state. The Projects UI may compute and
display closeout readiness from completed assignments, pending handoffs, and
review follow-up artifacts, but marking a work item
done must stay a
deliberate operator action through the work-item update path. The guided
closeout button should stay disabled while blockers remain; the existing
edit-work-item status field is the manual override path.
- Project evidence is generic provenance, not a GitHub/project-management
assumption. Evidence-link artifacts may represent source docs, tickets,
design files, deployments, PRs, meeting notes, local references, or other
operator-provided proof. UI copy should say "evidence" or "source", not
"GitHub" or "code", unless the specific artifact metadata says so.
- Projects are durable work areas, not necessarily repos. Creation and
onboarding should allow a name/purpose without a workspace root; root, Git,
worktree,
AGENTS.md, and skills affordances are code/file-backed options,
not prerequisites for every project.
- Stable provider ordering. Do not sort provider lists by health, blocked state, or availability unless explicitly asked. Fixed alphabetical/preset order within each section.
- Runtime metadata first-class, not tucked in debug crumbs.
- Trace and failure details readable without scanning raw JSON first.
- Cost, cache, routing, and retry behavior visible in plain language.
- Dangerous or privileged actions visually separated from routine actions.
- Short tab labels OK for navigation; the active view uses a more descriptive section header inside the page.
- No duplicate summary surfaces. If data is already visible on the page, prefer hierarchy fixes, clearer labels, or progressive disclosure over adding a second persistent surface that restates the same state.
- Docs-only updates to
ui/AGENTS.md and ui/SKILL.md use chore:, not docs: (project-wide rule in ../../core/workflow.md; restated here because it's UI-doc-adjacent).
Layout guidance
Default app layout: top-level shell + primary navigation/mode switch + main workspace + optional secondary inspector. Layout primitives over card wrappers.
Cards only when the card itself is the interaction boundary:
- A selectable provider target.
- A provider credential or readiness record with contained actions.
- A focused result panel that benefits from separation.
Do not add a new persistent inspector, side rail, dashboard block, or summary panel without explicit user approval first. Improve the existing workspace before expanding the surface area.
Copy guidance
Product UI copy, not marketing copy.
Good labels: Session, Chats, Provider Routing, Runtime Output, Trace, Usage, Policy.
Good supporting copy explains scope, freshness, operator impact, and the next action. Bad supporting copy is hype, mood statements, abstract claims, or a repeated explanation of what Hecate is.
Motion guidance
Motion supports orientation, not decoration. Allowed: view transitions that help users understand context shifts; subtle reveal of runtime output or trace detail; emphasis for status changes or async loading. Avoid ornamental motion or large attention-grabbing effects in core workflows.
Code organization
src/
app/ app shell, top-level orchestration, route/mode switching
AppShell.tsx the chrome (nav, theme, header) — consumes slice hooks directly
App.tsx mounts slice providers + <RootEffects />
state/ the canonical state surface — every UI piece reads from here
runtime.tsx health, session, RTK availability, copy-command transient
chat.tsx chat sessions, composer state, in-flight machinery
projects.tsx project list, active project scope, create/rename/delete
providersAndModels.tsx provider status, presets, model catalog, agent adapters
approvals.tsx pending approvals + agent-chat grants
retention.tsx retention runs + subsystems
usage.tsx cost summary + recent events
settings.tsx server config snapshot + settings error + notice banner
derived.ts cross-slice derived selectors (useChatTarget, useRuntimeDerivedState, ...)
rootEffects.ts <RootEffects /> — dashboard-load, RTK-sync, queued-message-drain
coordinators/
chat.ts submission + lifecycle + targeting + files + approvals (the big one)
providers.ts provider CRUD + readiness actions
dashboard.ts loadDashboard + refreshes
settings.ts runSettingsMutation + setNoticeMessage
agentAdapters.ts adapter credential + probe ops
policy.ts policy rule CRUD
retention.ts runRetention (wires the slice's Result to the notice banner)
wired.ts useWiredXActions — composes the cross-slice param graph once per view
overrides.tsx test-only CoordinatorOverridesContext for action stubs
features/
runs/ TasksView, TaskDetail, NewTaskSlideOver — agent task list + run replay (the headline UI)
chats/ ChatView, ChatSidebar, ChatComposer, ChatHeader, ChatTranscript, ChatSettingsPanel, HecateTaskApprovalsBanner, ...
projects/ ProjectsView, ProjectScopePanel, projectInsights — project identity, cockpit, memory/context, work coordination
transcript/ reusable transcript pieces for Chats and Task Detail
overview/ ConnectYourClient, ObservabilityView — request history + trace drilldown
connections/ ConnectionsPanel — provider readiness, external-agent setup/grants
settings/ SettingsView — local data cleanup / retention controls
providers/ provider catalog/editor components used by Connections
shared/ primitives, pickers, overlays; ui.ts is a compatibility barrel
usage/ UsageView
lib/
api.ts fetch wrappers + streamTaskRun (SSE consumer)
persistedState.ts useState wrapper that mirrors to localStorage with a schema guard
format.ts, markdown.ts, provider-utils.ts, runtime-utils.ts
types/ TypeScript mirrors of Go API types — keep in lockstep with pkg/types/ and internal/api/
chat.ts, task.ts, provider.ts, model.ts, agent-adapter.ts, trace.ts, usage.ts, retention.ts, runtime.ts
test/ shared test setup
runtime-console-test-composer.ts test-only composer that aggregates slices + coordinators into the legacy {state, actions} shape
runtime-console-fixture.ts default fixture state + action stubs
runtime-console-render.tsx withRuntimeConsole(ui, fixture) wraps in slice providers seeded with fixture state
styles.css design tokens, .dropdown-menu rule, animations
There is no src/components/. Reusable primitives live in src/features/shared/; feature-specific components live with their feature.
When a file gets crowded, split by responsibility, not arbitrary line count: view shell vs data hooks; presentation vs transport; domain formatting vs generic utilities.
State + action architecture
Views read slice state and call coordinator actions directly — there is no facade hook. The shape is:
- Slice hooks (
useRuntime, useChat, useSettings, useProvidersAndModels, useApprovals, useRetention, useUsage) — each owns a useReducer-backed state slice and exposes {state, actions}. Views call them and destructure only the fields they use.
- Coordinator hooks (
useChatActions, useProviderActions, useDashboardActions, useSettingsActions, useAgentAdapterActions, usePolicyActions, useRetentionActions) — each owns the cross-slice action implementations for a domain. Most coordinators take a small parameter bag for cross-coordinator wiring; the wired.ts hooks (useWiredSettingsActions, useWiredDashboardActions, useWiredProviderActions, useWiredPolicyActions) resolve that wiring once and are what views typically call.
- Derived selectors (
derived.ts) — useChatTarget, useRuntimeDerivedState, useNewChatAgentID — for cross-slice values that don't belong in any single slice's state.
- Root effects (
rootEffects.ts) — <RootEffects /> is mounted once in App.tsx and owns all cross-slice effects (dashboard-load on mount, approvals catch-up on session switch, RTK-sync, notice auto-dismiss, provider/model default cascade, queued-message-drain). Don't add cross-slice effects inside views.
A typical view looks like:
function MyView() {
const chat = useChat();
const { providers } = useProvidersAndModels().state;
const chatTarget = useChatTarget();
const { submitChat } = useChatActions({ chatTarget, setNoticeMessage });
const { runSettingsMutation } = useWiredSettingsActions();
return <div>...</div>;
}
Tests use withRuntimeConsole(ui, fixture) from src/test/runtime-console-render.tsx — it mounts all slice providers seeded with fixture state and an overrides context for action stubs. The 2284-LOC composition regression suite at src/test/runtime-console-composition.test.tsx exercises slices + coordinators end-to-end via the test-only runtime-console-test-composer.ts. Per-view tests don't need to touch the composer.
State and data rules
- Keep remote data shapes close to the API contracts.
- Normalize only where the UI benefits from it.
- Make loading, empty, and error states explicit.
- Prefer derived display helpers over inline formatting logic scattered across JSX.
- Avoid giant top-level components that fetch, normalize, render, and mutate everything at once.
Chat dictation ownership
ChatDictationControl owns client speech recognition, browser microphone, and
provider-transcription request lifecycle; ChatComposer owns only insertion
into the editable message. Keep these boundaries when changing dictation:
- build one typed route list from the
/hecate/v1/dictation/options provider
snapshot plus the presence of a client Web Speech constructor. When no route
has been saved, prefer the first available provider (the backend orders
providers local first). Preserve an unavailable saved route until the
operator explicitly chooses another one. Never implicitly select the
browser-managed route;
- do not call experimental static Web Speech language-pack or on-device
availability APIs while the composer mounts. Some engines advertise those
methods without a usable speech service and may terminate the renderer when
queried. Do not infer local processing from a browser name or a generic Web
Speech constructor;
- label generic Web Speech as a browser-managed service that may use the browser
vendor's cloud. It is an explicit route, not a fallback, and its audio must
not be represented as entering Hecate;
- detect secure-context and SpeechRecognition support for client routes, and
getUserMedia plus MediaRecorder support for provider recording. Keep
client recognition failures, capture failures, and provider readiness
failures distinct so “unsupported browser,” “permission denied,” and
“connect a transcription provider” remain actionable states;
- explain that speech-to-text routing is independent of the selected chat
model or External Agent. A Claude-only setup may use a client speech route;
it needs a separate provider credential only for provider transcription.
Permission-denied copy must point browser users to site controls and desktop
users to the operating-system microphone privacy settings;
- keep Connections → Speech-to-text route readiness aligned with the same typed
provider-options snapshot: it must show each explicit provider route, its
local/cloud boundary, default model, and bounded repair reason. Client routes
are capability-detected in the composer. Do not treat a Claude Code, Codex,
or other External Agent sign-in as a transcription credential;
- show the exact route and disclosure boundary before recording/listening, and
never silently select a different client service or provider after a failure;
- cap recording duration and client-side bytes, stop every
MediaStreamTrack
on stop/error/unmount/chat switch, abort the HTTP request on unmount, and keep
the route selector disabled while audio is live. Client recognizers need the
equivalent stop/abort cleanup, a stale-event generation fence, and a bounded
finalization watchdog. No dictation audio crosses Tauri IPC;
- insert returned text at the textarea selection with readable boundary
spacing, restore focus/cursor, and never call submit automatically;
- preserve ordinary OS text input: macOS Dictation and Windows voice typing
(
Win+H) work through the focused textarea without a Hecate route. Their
privacy boundary belongs to the OS. Text-to-speech/read-aloud is a separate
output capability and must not be folded into dictation routing;
- keep Web Speech and MediaRecorder/getUserMedia/API mocks in focused component
tests. Test recognition stop/abort, track cleanup, and
unmount-before-disclosure, not just happy transcripts. Keep Playwright paths
for client recognition and provider capture into an editable draft; mock the
recognizer/device stream/recorder rather than depending on CI audio hardware.
The API owns real media sniffing, size/read/concurrency limits, and the provider
generation fence for the provider branch only. Client Web Speech routes do not
call it. The UI disclosure copy must not claim a kind=local custom URL is
enforced loopback egress.
Chat read-aloud ownership
useReadAloud owns the browser speech-synthesis lifecycle for Chat;
ChatTranscript owns the single active response and
TranscriptMessageRow only renders the accessible action. Keep these
boundaries when changing text-to-speech:
- treat read aloud as client playback, not a model, provider, preset, ACP, or
External Agent capability. Every settled assistant response reaches the same
shared transcript path;
- require both Web Speech synthesis and an explicit voice whose
localService property is true. Never leave utterance.voice unset or
silently select a remote voice. Explain how to install or enable a system
voice when none is available;
- revalidate the selected local voice before every chunk and after
voiceschanged. If that exact voice identity disappears, stop and report it;
never switch an active response to another voice or browser default;
- start only from the operator's Read aloud action. Do not auto-read a new
response, mutable streaming text, tool activity, or status changes;
- derive speech text from the persisted visible assistant
content, not DOM
textContent. Flatten Markdown deterministically, speak link labels rather
than destinations, retain inline code and literal JSX/HTML-like source and
entity spelling, replace URI-shaped visible values—including labels, inline
code, and tag-like attribute values—with “link,” mark fenced code as omitted,
and exclude attachments, MCP Apps, activities, diffs, raw output, timing,
usage, context packets, and debug bundles. Keep link parsing in the shared
Markdown parser; do not add a speech-only Markdown grammar or interpret
visible tag-like source as HTML;
- keep one controller for the transcript because
speechSynthesis has a
page-global queue. Starting another response cancels the old generation;
ignore late completion/error events from cancelled utterances and cancel on
chat switch, message invalidation, or unmount;
- bound source parsing, total speech text, and each utterance. If a source bound
cuts a Markdown destination, omit from that unmatched link start. Preserve
the audible truncation notice rather than silently stopping a long answer;
- keep the stable Read aloud toggle label,
aria-pressed, Stop tooltip/icon, a
polite status announcement, keyboard-visible message actions, touch
visibility, and reduced-motion behavior aligned. Text-to-speech needs no
microphone permission;
- route host/voice failures through the existing visible Hecate notice. When
that alert handles the error, clear the read-aloud polite live region so
assistive technology receives exactly one announcement;
- mock both
speechSynthesis and SpeechSynthesisUtterance in focused tests.
Cover remote-only/unsupported hosts, voice refresh, replacement, Stop,
generation races, chat changes, Markdown normalization, and Hecate plus
External Agent integration. Native real-machine voice smoke remains a
separate platform check.
Voice/rate selection, pause/resume, word highlighting, automatic playback,
Task Detail narration, and native Rust fallbacks are outside the initial Chat
control. Add them only with explicit UX and platform contracts.
Build / test commands
| Command | What it does | When to use |
|---|
bun run typecheck | tsc -b — fast type check, no test execution | First sanity check after edits |
bun run lint | Type-aware Oxc lint checks | Before committing |
bun run format:check | Oxfmt formatting check | Before committing |
just format-check | Go + UI + website + docs formatting check | Mixed-surface PRs or CI format failures |
just docs-format-check | Oxfmt Markdown / .mdc formatting check | When UI changes update docs or screenshots |
bun run test | vitest run — full test suite | Before committing |
bun run test:watch | watch mode | During iteration |
bun run format | Oxfmt source formatting | Formatting-only cleanup or after formatter drift |
just format | Local auto-format for Go, UI, website, and docs | Fix formatter drift before pushing |
bun run dev (or just ui-dev from repo root) | Vite dev server on :5173, proxying API to :8765 | Live UI iteration alongside just dev |
Never bun test — it skips testing-library DOM setup and panics with document[isPrepared]. Always bun run test.
Playwright starts Vite with VITE_DISABLE_API_PROXY=1. E2E specs should mock
every API route they depend on; missing mocks should fail quietly as 404s, not
fall through to the Vite dev proxy and spam ECONNREFUSED for :8765.
Oxc config lives at repo root in .oxlintrc.json and is shared by the UI and
website. UI and website lint scripts run oxlint --type-aware, backed by the
oxlint-tsgolint package. The config enables the React, JSX accessibility,
Vitest, import, TypeScript, Unicorn, and Oxc rule families, with current
legacy-noise rules disabled explicitly. Do not loosen the config casually; if a
rule is noisy, name the specific rule and why it is disabled.
Markdown and .mdc docs are formatted by Oxfmt through just docs-format /
just docs-format-check. Keep lychee for link and anchor validation; Oxfmt
does not replace link checking.
Do not mix broad Oxfmt churn into a feature diff unless the task is explicitly a
formatting pass. When only UI source drifted, run bun run format; when the PR
touches multiple surfaces or CI reports a format failure, run just format,
then review the mechanical diff separately from behavior changes.
Test patterns
function setup(overrides: Partial<React.ComponentProps<typeof TaskDetail>> = {}) {
const props: React.ComponentProps<typeof TaskDetail> = {
...overrides,
};
const user = userEvent.setup();
return { props, user, render: () => render(<TaskDetail {...props} />) };
}
When the Go side adds a required prop (e.g. streamModelCallCosts), update the setup helper in the affected *.test.tsx files first — TypeScript will surface every test that needs the new value.
UI gotchas
.dropdown-menu has left: 0 baked into the .dropdown-menu rule in styles.css. When using useFloatingDropdownStyle with align="right", the hook explicitly sets left: "auto" to override. Don't remove that — without it the dropdown stretches viewport-wide.
- Slideover overflow clipping — dropdowns inside
<NewTaskSlideOver> get clipped by the slideover's overflow. Always use useFloatingDropdownStyle (which uses position: fixed to escape) for any dropdown that might appear inside a panel. See ProviderPicker / ModelPicker in shared/ui.tsx for the pattern.
- 404 on stale task IDs —
localStorage may hold a task ID from a prior gateway boot (memory backend resets on restart). TasksView drops the dead row from the list and re-loads. Don't propagate the 404 as an error toast.
- Task refresh belongs to the selected task header — the Tasks list is for
selection and creation. Keep task/run refresh with the selected task header,
next to run controls, so it matches Chat and Project header action placement.
- Runtime activity output stays flat when it is standalone — stdout/stderr
artifacts with no primary tool row should render inline as output previews.
Failed tools keep their richer diagnostics view; don't wrap simple output in
nested
Artifacts -> Output -> preview disclosures.
- Project cockpit action ownership — project-global actions live in the
project header. Needs Attention is a dropdown, Project Settings opens the
shared right-side inspector pattern, and Work Coordination / Timeline /
Memory / Skills are workspace tabs. Needs Attention rows should route to the
matching operator surface: settings for setup gaps, Memory for context and
candidates, Skills for project skill registry issues, Presets/Roles for
broken references, and Work Coordination for assignment/activity issues.
Assignment launch preflight must keep Connections as the provider/model
readiness repair surface while linking project-local defaults back to Project
Settings, Roles, and Agent Presets. Work with Project Assistant Bootstrap
through one reviewable proposal path: UI helpers may refresh guidance/skills
first, but they should still call the normal draft/apply flow rather than
mutating setup directly. New-project onboarding should make setup the primary
path, expose row-level actions for missing purpose/defaults/guidance/roles,
and move first-work creation after setup instead of leading with an open-ended
request box. Work Coordination uses one Work Queue with All /
activity filters plus one selected work-item card; don't split the same work
state across a separate Activity Inbox, Work Items list, and detail card.
Keep the Projects index as a fixed left panel; don't add or restore a
collapsed mini-rail until the navigation pattern is redesigned.
render1() + render2() in the same it block — don't. React Testing Library cleanup runs between tests, not within. Split into two its if you need fresh mounts.
- Cost-ceiling banner — gates on
run.otel_status_message === "cost_ceiling_exceeded" (the specific string). A regression that drops or rewords that string silently breaks the "Raise ceiling & resume" affordance.
- Every gateway response is
{object, data} — lib/api.ts clients must read payload.data.<field>, not payload.<field>. When mocking, copy the real wire shape, not the fields you happen to need; fixtures that skip the envelope hide production bugs.
- Chat snapshots must preserve per-message reference identity. Transcript rows are memoized, so a snapshot that rebuilds every message object re-renders the whole transcript.
reconcileChatSession (in app/state) reuses unchanged message objects across snapshots, and projectVisibleMessage (in features/chats/ChatTranscript.tsx) caches its projection in a WeakMap keyed by message identity — keep both in the path. Replacing the messages array wholesale, or remapping it through a fresh .map each render, silently defeats the memoization and the transcript goes back to re-rendering on every token.
- No auth surfaces in alpha — do not reintroduce token gates, tenant tabs,
or key-management tabs unless the product model changes again.
UI recipes
Add a new SSE-driven UI state field
- Add the field to
types/runtime.ts TaskRunStreamEventData (matching the Go TaskRunStreamEventData shape exactly).
- Accumulate it in
TasksView — new useState, populate inside streamTaskRun's onPayload callback, reset in resetRunDetail.
- Drill via props to
TaskDetail and any consumer.
- Add to the
setup defaults in affected *.test.tsx files.
- Add a focused test asserting the prop reaches the rendered output (see
TaskDetail.test.tsx falls back to streamModelCallCosts... for a template).
Add a paired provider+model picker
Reuse ProviderPicker + ModelPicker from features/shared/ui.tsx. Pass modelWarnings to surface capability hints (e.g. "model lacks tool-calling"). Both pickers use useFloatingDropdownStyle — drop them into a slideover with no extra wrapping.
Refresh a snapshot test
Run bun run test -- -u to update committed snapshots. Review the diff carefully — accidental snapshot churn is the most common silent regression vector.
Done criteria
See ../../core/verification.md.