Merge main into feat/patrol-typed-conclusions

Main gained the Assistant retirement (#3126), which also appended notes
to the agent-lifecycle, ai-runtime, api-contracts and storage-recovery
contracts. Keep both: main's notes, then this change's.
This commit is contained in:
rcourtman
2026-10-23 09:36:06 +01:00
320 changed files with 3724 additions and 65855 deletions
+2 -2
View File
@@ -95,7 +95,7 @@ All backend code lives under `cmd/`, `internal/`, and `pkg/`. The binary is asse
- Gated by the `relay` license feature.
7. **AI Subsystem (`internal/ai`)**
- **Pulse Assistant (Chat)**: Interactive LLM-powered chat with infrastructure context. Supports bring-your-own-key (BYOK) providers (OpenAI, Anthropic, Ollama, etc.) plus Claude OAuth.
- **AI runtime (`internal/ai/chat`)**: The governed tool loop behind Patrol investigations and Pulse Mobile chat (the in-app chat window has been retired). Supports bring-your-own-key (BYOK) providers (OpenAI, Anthropic, Ollama, etc.) plus Claude OAuth. External agents reach the same tools through `pulse-mcp`.
- **Pulse Patrol**: Scheduled background analysis that produces findings, predictions, and remediation plans. Autonomy levels: `monitor`, `approval`, `assisted`, `full`.
- **Intelligence Services**: Patterns, correlations, anomalies, baselines, forecasts, and incident recording. All surfaced via `/api/ai/intelligence/*`.
- **Safety gates**: Command execution disabled by default (`--enable-commands` opt-in); circuit breakers and scoped permissions at every layer.
@@ -174,7 +174,7 @@ contracts remain backend building blocks consumed inside platform pages.
### State Management
- **WebSocket store** (`stores/websocket.ts`): Manages the live connection, reactive `State` object, reconnection logic, and per-org switching.
- **Metrics collector** (`stores/metricsCollector.ts`): Buffers time-series data for sparklines and historical charts.
- **AI stores** (`stores/aiChat.ts`, `stores/aiIntelligence.ts`): Manage chat sessions and patrol findings.
- **AI stores** (`stores/aiAvailability.ts`, `stores/aiIntelligence.ts`): Hold whether AI is enabled for the session and the Patrol findings.
- **License store** (`stores/license.ts`): Tracks plan tier, feature flags, and multi-tenant state.
- **System settings store** (`stores/systemSettings.ts`): Caches server-side preferences (for example theme and feature toggles).
+1 -1
View File
@@ -214,6 +214,6 @@ Pulse; verification can be unavailable without proving that nothing ran.
- [Configuration](CONFIGURATION.md) for API tokens and their scopes.
- [API reference](API.md) for the wider Pulse HTTP surface.
- [AI features](AI.md) for how Patrol and the Assistant use the same context.
- [AI features](AI.md) for how Patrol and external agents use the same context.
- `cmd/pulse-mcp/README.md` in the repository for adapter setup examples
covering OpenCode, Claude Desktop, and Claude Code.
+35 -96
View File
@@ -18,19 +18,6 @@ That core is deliberately surfaced with Patrol as the primary built-in operator
These surfaces are built on the same action-driven architecture: the configured LLM owns diagnosis, prioritization, fix reasoning, and action choice; Pulse supplies context, capabilities, safety gates, approval state, and audit trails. Verification is part of the governed action lifecycle rather than a separate model-owned feature.
### Not Just Another Chatbot
Pulse Assistant is a **protocol-driven, safety-gated LLM tool surface** that:
- **Provides governed context** — attaches explicit resource mentions and recent session facts without rewriting user intent
- **Caches session facts** — extracts bounded tool facts to avoid redundant queries during the current conversation
- **Enforces workflow invariants** — FSM prevents dangerous state transitions
- **Supports parallel tool execution** — efficient batch operations with concurrency control
- **Grounds answers in real tool work** — visible tool traces, read-after-write verification, and transcript hygiene prevent unsupported execution claims from being treated as facts
- **Returns structured tool errors** — the model can recover from clear, machine-readable failures
📖 **For a deep technical dive into the Assistant architecture, see [Assistant architecture](ASSISTANT_ARCHITECTURE.md).**
### Not Just Another Alerting System
Pulse Patrol is a **scheduled and event-triggered governed operator** that:
@@ -48,20 +35,25 @@ All while running entirely on your infrastructure with BYOK for complete privacy
🧪 **For independent live-fault qualification, safety gates, model comparison,
and release-claim rules, see [AI_PATROL_QUALIFICATION.md](AI_PATROL_QUALIFICATION.md).**
See [Assistant safety architecture](ASSISTANT_SAFETY.md) for the original safety architecture documentation.
See [Assistant safety architecture](ASSISTANT_SAFETY.md) for the safety gates
the shared AI runtime enforces.
### Assistant And MCP
### Using Pulse from Claude Code, Codex, and other agents
Pulse Assistant and `pulse-mcp` are sibling surfaces over Pulse Intelligence,
not competing implementations, and neither replaces the other. Assistant remains
the in-app Pro surface for current resource/finding/run handoffs, approval
cards, governed action status, and operator-friendly timelines. `pulse-mcp`
owns the external-agent bridge: it fetches `/api/agent/capabilities`, projects
those canonical API capabilities as MCP tools, and preserves the same stable
error envelopes and approval/audit contracts. New operational capabilities
should be added to the canonical API manifest first, then consumed by Assistant
or MCP as appropriate; MCP-only actions and Assistant-only copies of the same
business logic are drift.
Pulse has no in-app chat window. To ask about your infrastructure in plain
language, connect an MCP client such as Claude Code, Codex, or OpenCode to
`pulse-mcp`: open **Settings → Pulse Intelligence → External agents** and
choose **Show connector setup**. `pulse-mcp` fetches `/api/agent/capabilities`,
projects those canonical API capabilities as MCP tools, and preserves the same
stable error envelopes and approval/audit contracts as Patrol. New operational
capabilities are added to the canonical API manifest first; MCP-only copies of
the same business logic are drift.
The tool list is registry-owned at runtime, not hand-maintained here. For the
current inventory, read the live `/api/agent/capabilities` manifest or
`pulse-mcp` `tools/list`. The same manifest carries reusable `workflowPrompts`
metadata, so MCP `prompts/list` clients discover the fleet triage, resource
investigation, and Patrol finding review workflows.
---
@@ -136,10 +128,7 @@ create a second finding lifecycle.
explicitly.
Select an item to see the affected resource, impact, next step, typed evidence,
protection posture, and lifecycle timeline. **Explain with Assistant** appears
only inside selected context and receives policy-shaped summaries and
references. Assistant can explain the item, but it cannot create lifecycle
truth, invent action authority, or hide uncertainty.
protection posture, and lifecycle timeline.
Open items can be acknowledged directly. Suppression is temporary and requires
an operator reason plus a bounded expiry; it never resolves the detector's
@@ -228,7 +217,6 @@ Note: `info` and `watch` level findings are filtered out to reduce noise.
Findings can be managed via the UI or API:
- **Open in Assistant**: Open Pulse Assistant with the finding attached to troubleshoot the issue (on Patrol findings it appears under **Resolve or dismiss**)
- **Resolve**: Mark as fixed (finding will reappear if the issue resurfaces)
- **Dismiss**: Record feedback about this finding; use **Reopen** to undo its dismissal
- **Create rule**: Permanently auto-dismiss future findings for the selected resource and category
@@ -434,74 +422,25 @@ MaybeInvestigateFinding()
---
## Pulse Assistant (Chat)
## Pulse Assistant chat (retired)
Pulse Assistant is a **tool-driven** chat interface. It does not "guess" system state — it calls live tools and reports their outputs.
The in-app Pulse Assistant chat window and its Settings page have been retired.
Patrol is the built-in operator, and the governed tools the chat used are
available to external agents through `pulse-mcp` (see
[Using Pulse from Claude Code, Codex, and other agents](#using-pulse-from-claude-code-codex-and-other-agents)).
An alert's **Have Patrol investigate** button starts a targeted Patrol check on
the alert's resource; its findings appear on the Patrol page.
### The Model's Workflow (Discover → Investigate → Act)
Pulse Mobile keeps its chat tab until the app retires on 31 March 2027. It uses
the chat action level saved before the retirement, and the chat model override
if one was saved, otherwise the default model from **Provider & Models**. The web
settings no longer set the action level or the override; removing the provider
an override names clears it, and Mobile chat then uses the default model.
1. **Discover**: Uses `pulse_query` to find real resources and IDs
2. **Investigate**: Uses `pulse_read` to run bounded, read-only commands and check status/logs
3. **Act** (optional): Uses `pulse_control` for changes, then verifies with a read
### Tool Inventory
The Assistant tool list is registry-owned at runtime, not hand-maintained in
this public overview. Each turn receives an available-tool manifest generated
from Pulse's governed tool registry, including action mode (`read`, `mixed`,
`write`) and approval policy (`scope_only`, `action_plan`). That same registry
feeds the Assistant system prompt, provider tool declarations, tool-result
handling, approval boundaries, and Patrol-only tool filtering.
For the current source-owned inventory, see the native tool registry and
governance projection in `internal/ai/tools/` and
`internal/agentcapabilities/`. For external agents, use the live
`/api/agent/capabilities` manifest or `pulse-mcp` `tools/list`; those surfaces
project the canonical agent capabilities rather than a separate MCP-only tool
table.
The same manifest also carries reusable `workflowPrompts` metadata so Pulse
Assistant-compatible starters and MCP `prompts/list` clients discover the same
fleet triage, resource investigation, and Patrol finding review workflows.
### Safety Gates
The assistant enforces multiple safety gates:
1. **Resolve Before Action** — Action tools cannot operate on resources that weren't first resolved by a read tool
2. **Verification After Write** — After any write, the model must perform a read/status check before providing a final answer
3. **Read/Write Separation** — Read operations route through `pulse_read` (stays in READING state); write operations route through `pulse_control` (enters VERIFYING state)
4. **Grounded Execution Guardrails** — Visible tool traces and read-after-write checks prevent unsupported execution claims from being treated as facts
5. **Approval Mode** — In Controlled mode, every write requires explicit user approval
6. **Execution Context Binding** — Commands execute within the resolved resource's context, not on parent hosts
### Control Levels
| Mode | Behaviour | Availability |
|------|-----------|--------------|
| **Read-only** | Assistant queries and explains; no infrastructure action planning | Community |
| **Ask first** (`controlled`) | Assistant saves infrastructure action plans to **Actions** for your review | Community |
Assistant chat does not execute these plans. Patrol's own mode and Autopilot
policy are separate; changing Chat action mode grants no Patrol authority.
Older saved `autonomous` preferences display as **Ask first**, not as permission
to execute without approval. The stored preference and its existing entitlement
checks are preserved for older clients.
To keep Assistant away from a specific guest, lock it: open the guest, choose
**Manage**, turn on **Never auto-remediate** under Operator overrides, and save.
Pulse then refuses typed actions that target that resource, including
approved ones. Older versions also showed a **Protected guests** list here. It
never excluded a guest from action plans, so it has been removed.
### Reviewing an Assistant action plan
1. Assistant submits a typed action for a resource advertising that capability.
2. Pulse records the plan in **Actions**; planning is not execution.
3. Open **Actions** to check the target, proposed operation, approval policy and expiry.
4. An authorised operator reviews, approves where required, and runs the plan
through the existing action controls. Chat mode cannot bypass these checks.
5. Read the recorded execution and independent verification result. A saved plan
or successful command alone does not establish recovery.
To keep automated actions away from a specific guest, lock it: open the guest,
choose **Manage**, turn on **Never auto-remediate** under Operator overrides,
and save. Pulse then refuses typed actions that target that resource, including
approved ones.
---
@@ -959,7 +898,7 @@ Pulse tracks token usage and costs:
| Issue | Solution |
|-------|----------|
| Assistant or Patrol not responding | Verify provider credentials in **Settings → Pulse Intelligence → Provider & Models** |
| Patrol not responding | Verify provider credentials in **Settings → Pulse Intelligence → Provider & Models** |
| No execution capability | Confirm at least one agent is connected |
| Findings not persisting | Check Pulse has write access to `ai_findings.json` in the config directory |
| Too many findings | This shouldn't happen — please report if it does |
+34 -35
View File
@@ -1,19 +1,20 @@
# Pulse Intelligence Modes and Safety Configuration
This guide covers how to configure Patrol mode, Pulse Assistant command access, and the safety guardrails that apply before Pulse can change infrastructure.
This guide covers how to configure Patrol mode and the safety guardrails that apply before Pulse can change infrastructure.
For a general overview of Pulse Intelligence, see [AI.md](AI.md). For plan-level feature availability, see [PULSE_PRO.md](PULSE_PRO.md).
---
## Two Axes of Control
## Patrol Mode Is the Control
Pulse separates AI permissions into two independent axes:
**Patrol Mode** decides what Patrol may handle automatically after it finds an
issue: watch only, ask before changes, handle safe fixes, or use policy
autopilot. Patrol mode is configured on the **Patrol** page.
1. **Patrol Mode** — What Patrol may handle automatically after it finds an issue: watch only, ask before changes, handle safe fixes, or use policy autopilot.
2. **Assistant Command Access** — Whether the interactive chat assistant can execute commands during a chat session.
Patrol mode is configured on the **Patrol** page. Assistant command access is configured in **Settings → Pulse Intelligence → Assistant**.
The in-app Pulse Assistant chat, and its separate chat action setting, have been
retired. Pulse Mobile's chat keeps the level saved before then until the app
retires on 31 March 2027 (see [Pulse Mobile chat control level](#pulse-mobile-chat-control-level)).
---
@@ -95,43 +96,41 @@ Without the `ai_autofix` capability, the effective Patrol mode is clamped to `mo
---
## Assistant Control Levels
## Pulse Mobile chat control level
Control levels govern interactive Assistant queries and action planning, not
Patrol's autonomy or permission to execute infrastructure work.
The control level governs Pulse Mobile chat queries and action planning, not
Patrol's autonomy or permission to execute infrastructure work. The web
settings page that edited it was retired with the in-app chat; the saved value
still applies to Pulse Mobile until the app retires on 31 March 2027.
| Mode | Key | Query | Plan infrastructure actions | Availability |
|------|-----|:-----:|:---------------------------:|--------------|
| **Read-only** | `read_only` | Yes | No | Community |
| **Ask first** | `controlled` | Yes | For operator review in **Actions** | Community |
Assistant chat does not execute the plans it saves. A legacy saved `autonomous`
preference displays as **Ask first** and keeps its stored value and existing
entitlement checks for older clients. It does not bypass approval. An unrelated
settings save does not rewrite that preference. Unknown values fail closed to
read-only. Patrol mode and Autopilot acknowledgements remain separate.
Chat does not execute the plans it saves. A legacy saved `autonomous`
preference acts as **Ask first** and keeps its stored value and existing
entitlement checks for older clients. It does not bypass approval. Web settings
saves never rewrite that preference. Unknown values fail closed to read-only.
Patrol mode and Autopilot acknowledgements remain separate.
To keep Assistant away from a specific guest, lock it: open the guest, choose
**Manage**, turn on **Never auto-remediate** under Operator overrides, and save.
Pulse then refuses typed actions that target that resource, including
To keep chat and Patrol away from a specific guest, lock it: open the guest,
choose **Manage**, turn on **Never auto-remediate** under Operator overrides,
and save. Pulse then refuses typed actions that target that resource, including
approved ones (see [Keep a Resource Off-Limits](#keep-a-resource-off-limits)).
Older versions also showed a **Protected guests** list here. It never excluded a
guest from action plans, so it has been removed.
### Configuration
**UI:** Settings → Pulse Intelligence → Assistant → Chat action mode
**API:** This intentionally changes Assistant action planning access. Use the same private
administrator header file with `settings:write`; the token must also have
permission to change settings. Prefer the UI for one-off changes, and verify
the saved **Chat action mode** there afterwards. Do not use a write as an
authentication test.
**API only.** This intentionally changes Pulse Mobile chat action planning
access. Use the same private administrator header file with `settings:write`;
the token must also have permission to change settings. Read
`control_level` from `GET /api/settings/ai` afterwards to confirm the saved
value. Do not use a write as an authentication test.
Use the same `pulse_api` helper defined above, with its private response file
and request limits. Supporting PUT in the helper grants no additional access.
If the response is lost, check the saved **Chat action mode** in the UI before
deciding whether another change is needed; do not repeat the write blindly.
If the response is lost, read the saved `control_level` before deciding
whether another change is needed; do not repeat the write blindly.
```bash
pulse_api PUT /api/settings/ai/update <<'JSON'
@@ -141,12 +140,12 @@ JSON
### Reviewing plans (Ask first)
1. Assistant requests a typed action for an explicitly identified resource with
1. Chat requests a typed action for an explicitly identified resource with
the advertised capability.
2. Pulse saves the canonical plan in **Actions** without requesting execution.
3. Review the target, operation, approval policy and expiry in **Actions**.
4. Use the existing authorised approval and run controls; Chat action mode grants
no additional access or automatic approval.
4. Use the existing authorised approval and run controls; the chat control
level grants no additional access or automatic approval.
5. Read the recorded execution and independent verification outcome before
concluding that the operation worked. Do not repeat an uncertain operation.
@@ -186,15 +185,15 @@ If the AI provider experiences consecutive failures, the circuit breaker (`inter
### Discovery-Before-Action
The assistant cannot operate on resources it hasn't first discovered. This prevents hallucinated resource IDs from reaching infrastructure commands.
The AI runtime cannot operate on resources it hasn't first discovered. This prevents hallucinated resource IDs from reaching infrastructure commands.
### Verification-After-Write
After executing any control action, the assistant must verify the result with a read operation before reporting success. This is enforced by the FSM — the assistant cannot return to idle state without verification.
After executing any control action, the AI runtime must verify the result with a read operation before reporting success. This is enforced by the FSM — the runtime cannot return to idle state without verification.
### Keep a Resource Off-Limits
To stop Pulse acting on a specific guest, host or container, open its drawer, choose **Manage**, turn on **Never auto-remediate** under Operator overrides, confirm the prompt, and click **Save overrides**. Pulse then refuses typed actions that target that exact resource: the Assistant declines to plan one when it can read the lock, Patrol will not run one, and the action lifecycle checks the lock again before it runs an action that was already planned, even when approved. The lock covers that resource only, not its host or other resources, and it does not stop read-only investigation. Turn it off and save again when you want Pulse to act on the resource again. A resource whose lifecycle is Retired is blocked the same way until the lifecycle is set back to Active.
To stop Pulse acting on a specific guest, host or container, open its drawer, choose **Manage**, turn on **Never auto-remediate** under Operator overrides, confirm the prompt, and click **Save overrides**. Pulse then refuses typed actions that target that exact resource: chat declines to plan one when it can read the lock, Patrol will not run one, and the action lifecycle checks the lock again before it runs an action that was already planned, even when approved. The lock covers that resource only, not its host or other resources, and it does not stop read-only investigation. Turn it off and save again when you want Pulse to act on the resource again. A resource whose lifecycle is Retired is blocked the same way until the lifecycle is set back to Active.
---
+5
View File
@@ -1,5 +1,10 @@
# Pulse Assistant deep dive
> **Status:** the in-app Assistant chat window has been retired. This runtime
> still runs Patrol investigations, and Pulse Mobile's chat until the app
> retires on 31 March 2027. External agents reach the same governed tools
> through `pulse-mcp`.
How the Assistant's agentic loop executes tool calls, for readers who want
more than the overview in [AI features](AI.md).
+5
View File
@@ -1,5 +1,10 @@
# Pulse Assistant safety architecture
> **Status:** the in-app Assistant chat window has been retired. This runtime
> still runs Patrol investigations, and Pulse Mobile's chat until the app
> retires on 31 March 2027. External agents reach the same governed tools
> through `pulse-mcp`.
Pulse enforces authority at the shared tool and action boundaries. The model
owns interpretation, investigation and action judgment within those boundaries.
A sequence of tool calls cannot establish that a diagnosis is correct.
+1 -1
View File
@@ -1175,7 +1175,7 @@ verification enabled and do not share verbose or trace output.
| `agent:config:read` | Read agent config payloads |
| `agent:manage` | Agent lifecycle/configuration changes and unregistering; not needed by the reporting preset |
| `agent:exec` | Establish agent command WebSocket connections; not a reporting permission |
| `ai:chat` | Use Pulse Assistant chat and read knowledge |
| `ai:chat` | Use Pulse Mobile chat and read knowledge |
| `ai:execute` | Use governed Patrol plans, approvals, actions, and history |
| `settings:read` | Read configuration |
| `settings:write` | Modify configuration, manage tokens and trigger updates |
+1 -1
View File
@@ -176,7 +176,7 @@ keys and old 14-day limit remain in code without changing that policy.
## Patrol Modes
Patrol mode decides how far Pulse can go after Patrol finds something that needs attention. Assistant chat command access is configured separately.
Patrol mode decides how far Pulse can go after Patrol finds something that needs attention.
| Mode | Behavior | Plan |
|---|---|---|
@@ -1,7 +1,7 @@
# VMware vCenter Phase-1 Alerts And Assistant Spec
Last updated: 2026-03-30
Status: IMPLEMENTED (shared alert/history and read-only Assistant paths shipped: `frontend-modern/src/components/Alerts/ThresholdsTableVMwareTab.tsx`, `tests/integration/tests/36-vmware-alert-history-resource-incidents.spec.ts`, `38-vmware-ai-chat-mentions.spec.ts`, `42-vmware-ai-chat-read-recovery.spec.ts`, `internal/ai/tools/control_resource_test.go`; live alarm-fidelity proof on a real `vCenter` is still outstanding). Last reviewed 2026-09-01.
Status: IMPLEMENTED (shared alert/history and read-only Assistant paths shipped: `frontend-modern/src/components/Alerts/ThresholdsTableVMwareTab.tsx`, `tests/integration/tests/36-vmware-alert-history-resource-incidents.spec.ts`, `internal/ai/tools/control_resource_test.go`; the browser chat specs 38 and 42 were retired with the Assistant chat window on 2026-10-22; live alarm-fidelity proof on a real `vCenter` is still outstanding). Last reviewed 2026-09-01.
Governance surfaces:
- `status.json.candidate_lanes.platform-admission-execution`
- `docs/release-control/v6/internal/VMWARE_VSPHERE_PHASE1_EXECUTION_PLAN.md`
@@ -116,20 +116,16 @@ If the automated floor is missing, the manual drill may inform implementation,
but it must not be used as the sole basis for a support claim.
Current repo-local automated proof already covers part of the read-only
boundary:
boundary. The browser chat specs `38-vmware-ai-chat-mentions` and
`42-vmware-ai-chat-read-recovery` were retired with the Assistant chat window
on 2026-10-22; the Go proofs below still cover the backend chat path.
1. `tests/integration/tests/38-vmware-ai-chat-mentions.spec.ts` proves
canonical VMware-backed Assistant mentions stay on shared resource IDs and
shared Assistant routes.
2. `tests/integration/tests/42-vmware-ai-chat-read-recovery.spec.ts` proves a
failed VMware `pulse_read action=logs resource_id=...` path can recover onto
shared `pulse_query` behavior without VMware-local routes.
3. `internal/ai/chat/context_prefetch_additional_test.go` proves VMware-backed
1. `internal/ai/chat/context_prefetch_additional_test.go` proves VMware-backed
guest mentions stay read-only in Assistant context summaries.
4. `internal/ai/chat/service_tooling_test.go` and
2. `internal/ai/chat/service_tooling_test.go` and
`internal/ai/tools/control_resource_test.go` prove shared Assistant/tool
wording stays capability-gated instead of claiming blanket VM control.
5. `internal/api/route_inventory_test.go` and
3. `internal/api/route_inventory_test.go` and
`tests/integration/tests/41-vmware-phase1-exclusion-integrity.spec.ts`
prove the shipped route and browser surface stay out of direct `ESXi`,
recovery, and VMware admin-plane territory.
@@ -292,13 +288,13 @@ Pass when:
3. no control capability or VMware-specific action metadata is exposed as part
of the VMware phase-1 floor
Current automated coverage:
Current automated coverage (the browser chat specs `38-vmware-ai-chat-mentions` and
`42-vmware-ai-chat-read-recovery` were retired with the Assistant chat window
on 2026-10-22; the Go proofs below still cover the backend chat path):
1. `tests/integration/tests/38-vmware-ai-chat-mentions.spec.ts`
2. `tests/integration/tests/42-vmware-ai-chat-read-recovery.spec.ts`
3. `internal/ai/chat/context_prefetch_additional_test.go`
4. `internal/ai/chat/service_tooling_test.go`
5. `internal/ai/tools/control_resource_test.go`
1. `internal/ai/chat/context_prefetch_additional_test.go`
2. `internal/ai/chat/service_tooling_test.go`
3. `internal/ai/tools/control_resource_test.go`
### `VC-7` Exclusion Integrity
@@ -190,10 +190,10 @@ Required proof:
- assistant control remains read-only in both product wording and runtime
capability exposure
Automated proof already landed for part of this slice:
Automated proof already landed for part of this slice. The browser chat specs `38-vmware-ai-chat-mentions` and
`42-vmware-ai-chat-read-recovery` were retired with the Assistant chat window
on 2026-10-22; the Go proofs below still cover the backend chat path.
- `tests/integration/tests/38-vmware-ai-chat-mentions.spec.ts`
- `tests/integration/tests/42-vmware-ai-chat-read-recovery.spec.ts`
- `internal/ai/chat/context_prefetch_additional_test.go`
- `internal/ai/chat/service_tooling_test.go`
- `internal/ai/tools/control_resource_test.go`
+3 -162
View File
@@ -2815,18 +2815,13 @@
"test",
"--",
"--run",
"src/components/AI/Chat/__tests__/AIChat.test.tsx",
"src/components/AI/Chat/__tests__/ChatMessages.test.tsx",
"src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts",
"src/components/patrol/__tests__/ApprovalSection.test.tsx",
"src/components/AI/__tests__/FindingsPanel.test.ts",
"src/pages/__tests__/AIIntelligence.test.tsx",
"src/utils/__tests__/patrolEmptyStatePresentation.test.ts",
"src/utils/__tests__/patrolPagePresentation.test.ts",
"src/utils/__tests__/patrolSummaryPresentation.test.ts",
"src/utils/__tests__/aiChatPresentation.test.ts",
"src/api/__tests__/aiChat.test.ts",
"src/stores/__tests__/aiChat.test.ts"
"src/utils/__tests__/patrolSummaryPresentation.test.ts"
]
},
{
@@ -2901,21 +2896,6 @@
"path": "frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/index.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/FindingsPanel.tsx",
@@ -2951,11 +2931,6 @@
"path": "frontend-modern/src/utils/__tests__/patrolPagePresentation.test.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/utils/aiChatPresentation.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/utils/patrolPagePresentation.ts",
@@ -3005,7 +2980,7 @@
},
{
"id": "RA29",
"summary": "Pulse intelligence comes from the operator-selected large language model using governed Pulse tools over data the model cannot reach on its own: agent-collected machine evidence, canonical APIs, metrics, discovery records, and resource state. Interactive Pulse Assistant chat therefore behaves like a governed LLM tool surface, not a Pulse-authored intent router: the operator's selected model receives the user turn and governed tools, decides whether tools are needed, and Pulse must not use prompt heuristics to force tool_choice, force a named tool, retry because an expected tool was not used, hide tools from the model by keyword detection, rewrite recent-context turns into Pulse-targeted instructions, fuzzy-match plain chat text into resource context before the model acts, synthesize/prefill/auto-submit product-authored Assistant prompts from handoffs, keyword-match prior remediation history into suggested fixes, or generate Patrol handoff attention/decision/remediation guidance before the model reasons. Pulse only provides current context and enforces approvals, canonical resource resolution, explicit budgets, tool policy and independent action verification after that model choice; any model-visible policy block must describe the boundary without naming a required next tool. The chat surface must echo user messages before network/session creation finishes and must not render Pulse-owned explore or internal workflow status cards as assistant output. Generic read/write sequences, proposal acceptance, successful-call counts and failed-call streaks must not stand in for diagnosis, verified recovery or action judgment. Saved history preserves the assistant prose actually streamed.",
"summary": "Pulse intelligence comes from the operator-selected large language model using governed Pulse tools over data the model cannot reach on its own: agent-collected machine evidence, canonical APIs, metrics, discovery records, and resource state. Interactive Pulse Assistant chat therefore behaves like a governed LLM tool surface, not a Pulse-authored intent router: the operator's selected model receives the user turn and governed tools, decides whether tools are needed, and Pulse must not use prompt heuristics to force tool_choice, force a named tool, retry because an expected tool was not used, hide tools from the model by keyword detection, rewrite recent-context turns into Pulse-targeted instructions, fuzzy-match plain chat text into resource context before the model acts, synthesize/prefill/auto-submit product-authored Assistant prompts from handoffs, keyword-match prior remediation history into suggested fixes, or generate Patrol handoff attention/decision/remediation guidance before the model reasons. Pulse only provides current context and enforces approvals, canonical resource resolution, explicit budgets, tool policy and independent action verification after that model choice; any model-visible policy block must describe the boundary without naming a required next tool. Generic read/write sequences, proposal acceptance, successful-call counts and failed-call streaks must not stand in for diagnosis, verified recovery or action judgment. Saved history preserves the assistant prose actually streamed.",
"kind": "invariant",
"blocking_level": "repo-ready",
"proof_type": "automated",
@@ -3041,32 +3016,6 @@
"-count=1"
]
},
{
"id": "assistant-chat-optimistic-echo-tests",
"run": [
"npm",
"--prefix",
"frontend-modern",
"test",
"--",
"--run",
"src/components/AI/Chat/__tests__/useChat.test.ts",
"src/components/AI/Chat/__tests__/MessageItem.test.tsx",
"src/components/AI/Chat/__tests__/activeTurnStatus.test.ts"
]
},
{
"id": "assistant-chat-stream-contract-tests",
"run": [
"npm",
"--prefix",
"frontend-modern",
"test",
"--",
"--run",
"src/api/__tests__/aiChatEvents.test.ts"
]
},
{
"id": "assistant-chat-stream-json-contract-tests",
"run": [
@@ -3098,8 +3047,6 @@
"test",
"--",
"src/components/shared/__tests__/AIModelPicker.test.tsx",
"src/components/AI/Chat/__tests__/ModelSelector.test.tsx",
"src/components/AI/Chat/__tests__/AIChat.test.tsx",
"src/components/shared/__tests__/SearchField.test.tsx"
]
},
@@ -3134,8 +3081,7 @@
"test",
"--",
"--run",
"src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts",
"src/components/AI/Chat/__tests__/AIChat.test.tsx"
"src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts"
]
},
{
@@ -3203,91 +3149,16 @@
"path": "docs/release-control/v6/internal/subsystems/storage-recovery.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/api/__tests__/aiChatEvents.test.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/api/generated/aiChatEvents.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/__tests__/ChatMessages.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/__tests__/ModelSelector.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/__tests__/useChat.test.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/ChatMessages.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/hooks/useChat.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/index.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/MessageItem.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/ModelSelector.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/types.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/FindingsPanel.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/shared/__tests__/AIModelPicker.test.tsx",
@@ -3318,31 +3189,11 @@
"path": "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/stores/__tests__/aiChat.test.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/stores/aiChat.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/utils/aiChatPresentation.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/chat/agentic.go",
@@ -7503,16 +7354,6 @@
"path": "frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/Chat/index.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/AI/FindingsPanel.tsx",
@@ -10166,6 +10166,16 @@ facts as a `DetailSectionTable`; the connection id and identity evidence sit
in a closed Details disclosure. No diagnosis, repair or credential flow
changes: this is presentation only.
The agent profiles settings panel reads AI availability from
`frontend-modern/src/stores/aiAvailability.ts` since 2026-10-22, when the
browser Assistant chat window and its store were retired.
`useAgentProfilesPanelState.ts` still gates only its AI-backed profile
suggestions on that session fact; agent enrolment, profile assignment, and
lifecycle behaviour are unchanged. The same change adds the read-only
`settingsCapabilities.patrolRun` security-status field in
`internal/api/security_status_capabilities.go`, a projection of the Patrol run
route's admin gate; it opens no agent enrolment, command, or lifecycle path.
The Patrol investigation wiring in `internal/api/ai_handlers.go` now passes the
typed conclusion an investigation records with `patrol_conclude_investigation`
to the private orchestrator. It also seeds the orchestrator's concurrency and
File diff suppressed because it is too large Load Diff
@@ -210,24 +210,20 @@ checkpoint controls also assert the breach date; restart controls cover both
durable active state and the JSON mirror, unchanged mirror bytes/mtime, and
legacy unknown-date recovery through a new breach.
### Held breach dates in attention and Assistant
### Held breach dates in attention
`getMetricAlertLastBreachAt` selects a valid timezone-qualified ISO
`metricStatus.lastBreachAt` before a valid legacy `Alert.lastSeen`.
Unknown, malformed, impossible-calendar and Go-zero dates stay undated; the
occurrence start, current poll and recovery start are never substitutes.
Attention, overview/incident hover and active History share this selection.
Relative ages use the caller's clock. Assistant model-only context carries
the normalised UTC ISO date as `Last Breach At` when known, separate from
the retained breach value and live reading/clear rule. The existing compact
Chat attachment is unchanged; no removed detail surface is restored.
Missing live status after restart keeps the existing recorded-message path.
The scoped target and explicit operator-approval boundary are unchanged.
Relative ages use the caller's clock. Missing live status after restart
keeps the existing recorded-message path. The Assistant hand-off that also
carried the date as `Last Breach At` model context was retired with the chat
window on 2026-10-22.
Verification: date-selection/attention controls in `helpers.test.ts`,
`alertAssistantHandoffModel.test.ts` and final-byte desktop/phone production
surface fixture `browser-tests/held-breach-time.cjs`. These are synthetic
source/rendering controls, not native readings, inference, notification
Verification: date-selection/attention controls in `helpers.test.ts`. These
are synthetic source controls, not native readings, inference, notification
provider delivery or restart recovery acceptance.
### Alert card and open incident lead with the live reading — issue #2068
@@ -271,10 +267,11 @@ a screen-reader or speech-input user could not tell rows apart.
start time, since history repeats one alert over time; "Hide timeline for
..." while open, with `aria-expanded`) and Resource with
`getAlertHistoryResourceActionLabel` (rows on one resource share it because
they open the same panel). The Assistant icon names its alert through the
`alerts.assistant.iconLabel` and `alerts.assistant.iconLabelAt` catalog keys
from the host's `subjectLabel` and `subjectTime`; a locked button keeps its
locked explanation and the hover title is unchanged. Each name begins with
they open the same panel). The icon-only Investigate button, which replaced
the Assistant icon on 2026-10-22, names its alert through the
`alerts.investigate.iconLabel` and `alerts.investigate.iconLabelAt` catalog
keys from the host's `subjectLabel` and `subjectTime`, while its hover title
stays the generic action name. Each name begins with
the visible label (`AlertHistoryItemActions.test.tsx`,
`AlertHistoryMobileList.test.tsx`, `InvestigateAlertButton.test.tsx`,
`alertIncidentPresentation.test.ts`).
@@ -975,15 +972,11 @@ default construction path still restores.
58. `internal/alerts/active_lifecycle.go`
59. `internal/alerts/active_cleanup.go`
60. `frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx`
61. `frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts`
62. `frontend-modern/src/components/Alerts/IncidentAssistantHandoffButton.tsx`
63. `frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts`
64. `internal/alerts/storage_override_identity.go`
65. `internal/alerts/unified_eval.go`
66. `frontend-modern/src/components/Alerts/ThresholdsTable.tsx`
67. `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsData.ts`
68. `frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsPlatformData.ts`
69. `frontend-modern/src/utils/alertTargetTypes.ts`
70. `frontend-modern/src/types/alerts.ts`
71. `frontend-modern/src/stores/websocket.ts`
72. `frontend-modern/src/utils/alerts.ts`
@@ -1255,37 +1248,27 @@ timeline says the alert moved instead of "Alert resolved: <breach message>".
`frontend-modern/src/stores/alertsActivation.ts` so browser display colors
consume the same configured alert thresholds and override identity chain as
the alert runtime instead of hard-coded per-surface thresholds.
7. Add or change alert investigation handoffs through
`frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx` and
`frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts`.
On resource-backed active alert cards, Patrol is the primary doer: the
visible primary action must run a manual scoped Patrol trigger such as
"Have Patrol investigate" through the `ai-runtime` manual Patrol route
contract. Pulse Assistant remains a secondary explicit explanation path:
Assistant handoffs must preserve alert context, force request-scoped
approval mode, send bounded model-only handoff context plus structured
resource references through the shared Assistant chat transport, and render a
compact Alerts-owned briefing in the Assistant drawer without transferring
raw command payloads. The labelled Explain action dispatches the shared
explanation request through `aiChatStore.explain`, retaining selected alert
evidence through normal send and retry without replacing the composer draft.
Ordinary context-only opens remain free of inference. The Patrol trigger
and the Assistant explanation request must stay distinct.
8. Add or change Pulse Assistant incident timeline handoffs through
`frontend-modern/src/components/Alerts/IncidentAssistantHandoffButton.tsx`
and `frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts`;
these handoffs must preserve sanitized incident facts and timeline event
summaries, force request-scoped approval mode, send the same sanitized facts
as model-only handoff context plus structured resource references through
the shared Assistant chat transport, and keep raw command/output details in
the incident or approval surface rather than the chat handoff. Incident
handoffs must not add suggested prompt chips or route-owned remediation
instructions; the configured model owns investigation and next-step
reasoning after it receives the context.
7. Add or change alert investigation through
`frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx`.
On resource-backed alerts Patrol is the doer: the action runs a manual
scoped Patrol trigger through the `ai-runtime` manual Patrol route contract
(`POST /api/ai/patrol/run` with the alert's resource, identifier, and type),
and Patrol's findings land on the Patrol page. The button stays hidden when
AI is not configured, when the session cannot start a Patrol run
(`settingsCapabilities.patrolRun`, which mirrors the run route's
RequireAdmin + `ai:execute` gate; auth-free installs pass), and for Pulse
system alerts that have no resource to scope. A click while the request is
in flight starts no second run. Alerts must not grow a second AI path beside it; the Assistant chat
hand-offs that used to sit here were retired on 2026-10-22.
8. Keep alert incident timelines in place through
`frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx`: the
incident panel and timeline cards carry sanitized incident facts, raw
command/output details stay in the incident or approval surface, and the
timeline must not add chat hand-offs, suggested prompt chips, or
route-owned remediation instructions.
9. Add or change alert target resource types through
`internal/alerts/specs/types.go`, `internal/alerts/config/identity.go`,
`internal/alerts/unified_eval.go`, and
`frontend-modern/src/utils/alertTargetTypes.ts`. Supported target types
`internal/alerts/specs/types.go`, `internal/alerts/config/identity.go`, and
`internal/alerts/unified_eval.go`. Supported target types
must share the unified evaluator, the canonical threshold/override identity
chain, and the standard notification delivery path.
10. Add or change the alert notification destinations catalog through
@@ -1570,8 +1553,7 @@ The alert resource-incident panel
dropped its "Open in Infrastructure / Workloads / Storage / Recovery"
cross-jump chip strip on 2026-05-16 when the surrounding platform-first
migration retired broad surface-link chips. The panel now keeps
investigation flow in-place through `IncidentAssistantHandoffButton` and the
shared incident-timeline cards; it must not reintroduce a chip strip that
investigation flow in place through the shared incident-timeline cards; it must not reintroduce a chip strip that
links to the retired top-level routes, and the supporting
`buildResolvedResourceSurfaceLinks` helper was deleted from
`frontend-modern/src/routing/resourceLinks.ts` as part of the same pass.
@@ -3099,14 +3081,12 @@ copy consumed by `frontend-modern/src/pages/Alerts.tsx`,
`frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx`,
`frontend-modern/src/features/alerts/useAlertAcknowledgementState.ts`,
`frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx`,
`frontend-modern/src/components/Alerts/IncidentEventFilters.tsx`,
`frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx`, and
`frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts`.
`frontend-modern/src/components/Alerts/IncidentEventFilters.tsx`, and
`frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx`.
Machine-facing alert identifiers, alert types, resource IDs, resource names,
node names, source-system messages, commands, command output, event payloads,
log text, and the English Assistant model-context labels must remain
untranslated; only the user-visible briefing labels and alert controls may use
the active app locale.
and log text must remain untranslated; only the user-visible alert controls
may use the active app locale.
The Recovery section is a thresholds deep link: `/alerts/thresholds/proxmox#backups`
expands and scrolls to it like `#pbs` and `#pmg` (`useThresholdsTableState.test.tsx`).
@@ -3703,9 +3683,7 @@ names the selected resource through the unified-resource lookup it is given
selected history row carried while no unified record matches. It imports nothing from
`frontend-modern/src/routing/resourceLinks.ts`, and it offers no resource
cross-jump links: investigation continues in place through the incident
timeline cards or hands off to Pulse Assistant through
`IncidentAssistantHandoffButton`, as the retired cross-jump chip note above
describes. Future incident-link work
timeline cards, as the retired cross-jump chip note above describes. Future incident-link work
must not reintroduce a chip set, local infrastructure-link assembly, or
provider-local route strings inside the alert feature shell;
`frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts` pins the panel
@@ -5150,3 +5128,15 @@ metrics target being set to find the same alerts. Inside a guest drawer, a
physical disk's agent temperature alerts (`agent:<id>/disk_temp:<device>`) are
matched through the guest's own agent id. No alert is created, cleared or
retyped by this; it changes which open alerts a drawer lists.
Alert Investigate became a Patrol run on 2026-10-22, when the browser
Assistant chat window was retired. Active alert cards and History rows keep one
Investigate action (`InvestigateAlertButton`), which starts a targeted Patrol
check through `POST /api/ai/patrol/run` with the alert's resource ID,
identifier, and type, then confirms with a toast that points at the Patrol
page. It is offered only to sessions that route accepts, read from the new
`settingsCapabilities.patrolRun` security-status field. The "Ask Pulse Assistant" icon, the incident-timeline Assistant hand-off,
and their hand-off models (`alertAssistantHandoffModel.ts`,
`incidentAssistantHandoffModel.ts`, `IncidentAssistantHandoffButton.tsx`) are
gone, as is the chat-only `alertTargetTypes.ts` resolver. The `alerts.assistant.*`
catalog keys became `alerts.investigate.*` in English, German, and Spanish.
@@ -172,8 +172,7 @@ then checks explicit read-only opt-out and later opt-in remain possible.
field dating `Alert.value`. Older servers and restart-before-evaluation may
omit it. Consumers validate it, then a valid legacy `lastSeen`, without
creating a date from start/poll time. Frontend type and consumer regression
controls are in `types/api.ts`, `features/alerts/__tests__/helpers.test.ts`
and `components/Alerts/__tests__/alertAssistantHandoffModel.test.ts`.
controls are in `types/api.ts` and `features/alerts/__tests__/helpers.test.ts`.
No endpoint, required field or mobile capability changes.
### PMG scope readback preserves explicit all-off settings
@@ -925,11 +924,8 @@ failure retention require their own owner result, not source acceptance.
66b. `pkg/aicontracts/fix_execution.go`
76. `internal/api/ai_intelligence_handlers.go`
77. `frontend-modern/src/api/ai.ts`
78. `frontend-modern/src/api/aiChat.ts`
69a. `frontend-modern/src/api/aiChatDevStreamFixture.ts`
79. `frontend-modern/src/api/patrol.ts`
79a. `internal/api/patrol_objectives.go`
80. `frontend-modern/src/api/generated/aiChatEvents.ts`
81. `internal/api/agent_exec_token_binding.go`
81a. `internal/api/agentbinding/policy.go`
81b. `internal/api/agenttokens/install.go`
@@ -1187,11 +1183,10 @@ candidate or call node setup/save APIs until the operator has approved the
current endpoint, credential path, and collection scope.
Assistant chat `workflow_state` events are a shared AI/API payload boundary.
The backend source of truth is `internal/ai/chat.WorkflowStateData`, generated
to `frontend-modern/src/api/generated/aiChatEvents.ts` by
`scripts/generate-types.go` and pinned by `internal/api/contract_test.go`.
The done event (`internal/ai/chat.DoneData`) follows the same generated
contract and carries, alongside token usage and `context_limit_tokens`, an
The backend source of truth is `internal/ai/chat.WorkflowStateData`, pinned by
`internal/api/contract_test.go`; the browser's generated TypeScript projection
left with the Assistant chat window on 2026-10-22. The done event
(`internal/ai/chat.DoneData`) follows the same contract and carries, alongside token usage and `context_limit_tokens`, an
optional `session_cost_usd`: the backend-estimated cumulative session spend
summed from the operator cost ledger. It is omitted (not zero) whenever any
of the session's recorded models has unknown pricing, so clients must treat
@@ -1241,13 +1236,11 @@ that same manifest. `POST /api/ai/workflow-prompts/render` is an authenticated
`internal/api/router_routes_ai_relay.go`; it accepts the manifest prompt name
plus argument map, validates them against the manifest-owned workflow prompt
catalogue through `internal/agentcapabilities`, and returns only the rendered
Assistant prompt text plus description. Browser
clients must read prompt names, display labels, presentation kind hints,
descriptions, and arguments through the generated `workflowPrompts` manifest
type and call `AIChatAPI.renderWorkflowPrompt` rather than rendering prompt
templates locally. Browser context may carry a preferred workflow prompt name
only as a request to render one manifest-owned starter through that API route;
it is not a transport for prompt text or a reason to bypass argument resolution.
Assistant prompt text plus description. Clients must read prompt names,
display labels, presentation kind hints, descriptions, and arguments through
the generated `workflowPrompts` manifest type and call this route rather than
rendering prompt templates locally. No first-party browser surface renders
starters since the Assistant chat window was retired on 2026-10-22.
MCP `prompts/list` projects those same labels as protocol prompt titles and MCP
`prompts/get` remains the protocol-edge projection over the same shared
workflow-prompt core. Route inventory and AI chat scope tests must pin that
@@ -1452,10 +1445,10 @@ tool availability from local tool-name lists. Do not expose live Assistant
registry availability through the public, cacheable `/api/agent/capabilities`
manifest; that runtime inventory belongs behind the authenticated
`GET /api/ai/assistant/surface-tools` Assistant surface endpoint when the UI
needs it. The Pulse Assistant shell may render a runtime tool-posture chip, but
it must call `AIChatAPI.getAssistantSurfaceTools()` and
`getAgentSurfaceToolPosturePresentation` instead of hard-coding registry tool
names or treating MCP capability names as native Assistant tools. The Agent
needs it. A client that renders runtime tool posture must read that endpoint and
format it through `getAgentSurfaceToolPosturePresentation` instead of
hard-coding registry tool names or treating MCP capability names as native
Assistant tools. The Agent
integrations panel may render MCP tool posture from the static public manifest,
but the request/response capability filtering and `SurfaceToolContract`
construction must be backend-owned in `Manifest.SurfaceToolContracts`; the
@@ -1499,8 +1492,8 @@ filters non-request/response capabilities, and enforces the resolved surface
`tools` affordance before docs, adapters, or UI projections see any tools.
Provider retry progress must use typed fields (`attempt`, `max_attempts`,
`retry_after_ms`) on that same event instead of frontend-only string parsing or
provider-specific ad hoc events; the Assistant UI may format those fields, but
must not invent retry progress that the stream contract did not carry. Assistant
provider-specific ad hoc events; clients may format those fields, but must not invent retry progress that the
stream contract did not carry. Assistant
`provider_start` status text must remain route-neutral (`Waiting for
assistant.`) while selected route identity travels through the typed `provider`
and `model` fields. `provider_retry` status text must describe a selected-route
@@ -1508,45 +1501,9 @@ retry, not a provider fallback or hidden route switch. Assistant chat stream
payloads must not expose automatic provider fallback metadata:
`workflow_state` may carry the selected `provider` and `model` plus same-route
retry fields, but `provider_fallback`, `failed_provider`, `failed_model`,
`next_provider`, and `next_model` are retired from the generated stream event
`next_provider`, and `next_model` are retired from the stream event
contract. Cross-route recovery belongs to explicit failed-turn actions, not
hidden `/api/ai/chat` stream mutation.
The frontend chat stream client may expose a local stream-open lifecycle hook
for UI state, but that hook is not a backend `workflow_state` payload. It must
fire only after `/api/ai/chat` returns an OK event-stream response and before
the first parsed SSE event is delivered to the reducer, allowing the Assistant
drawer to promote local `request_send` status to local `request_wait` without
inventing provider activity.
Assistant local stream fixtures are part of the same frontend API contract:
`frontend-modern/src/api/aiChatDevStreamFixture.ts` may short-circuit only
explicit `/fixture ...` prompts in development or test mode, must emit the same
typed stream event sequence as live chat, and must never open a provider request.
That helper also owns the exported fixture-name and alias catalog consumed by
Assistant command discovery; frontend command surfaces may search and insert
`/fixture` from those exports, but they must not duplicate the fixture registry
or advertise fixture commands in production.
Fixtures used for visible stream proof must pace status/tool/content events
enough for the browser to paint the intermediate state, including keeping the
`/fixture tool-burst` running tool row visible before the matching `tool_end`;
unit tests may explicitly disable that pace, but the live dev fixture must not
collapse to the terminal row.
The `/fixture tool-chain` stream must pace consecutive
`tool_start`/`tool_progress`/`tool_end` transitions across at least two tools,
so browser proof can stop on a completed compact row plus the next live running
row without a provider request.
Fixtures that emit consecutive context/read/query tool events must keep those
events as ordinary typed `tool_start` / `tool_end` activity and must not encode
obsolete grouped-context wording in fixture answer content. The fixture payload
contract proves the stream reducer and transcript renderer against the same
chronological event order a live provider would produce; UI grouping or footer
summaries are not part of the fixture contract.
The provider-retry fixture must exercise selected-route retry by emitting
`provider_retry` for the selected route and completing with that same model; it
must not simulate an automatic switch to a configured gateway or alternate
provider route.
Queue verification fixtures must cover both the active hold turn and the queued
drain turn so UX proof can exercise queued follow-up ordering and tool rows
without consuming external model quota.
Assistant session summarization is a typed compaction API contract, not a
free-form helper response. `POST /api/ai/sessions/{id}/summarize` returns a
browser-safe `ChatSessionCompactionResult` with stable fields for `success`,
@@ -1556,10 +1513,9 @@ browser-safe `ChatSessionCompactionResult` with stable fields for `success`,
`success=false` with `status=not_needed`; transport failures, missing sessions,
provider errors, or active-running sessions remain normal API errors. The
payload must not include the raw transcript, raw provider response, raw tool
input/output, hidden reasoning, secrets, or token values. Frontend consumers
under `frontend-modern/src/api/aiChat.ts` must preserve that typed response and
let the Assistant drawer reload canonical session state instead of rebuilding a
compacted transcript from local browser state.
input/output, hidden reasoning, secrets, or token values. Clients must
preserve that typed response and reload canonical session state instead of
rebuilding a compacted transcript locally.
Infrastructure settings copied agent commands are a shared API/payload boundary
even when the final command string is assembled in the browser. Selected-row
@@ -1895,7 +1851,6 @@ payload shape change when the portal presents compact client rows.
`protected_guests` key from an older client is ignored. Guests Pulse must
not act on are locked through the resource operator state
(`neverAutoRemediate`), which the Assistant honours at planning.
6. `frontend-modern/src/api/aiChat.ts` shared with `ai-runtime`: the Assistant chat frontend client is both the first-party Assistant transport surface and a canonical API payload contract boundary.
7. `frontend-modern/src/api/generated/agentCapabilities.ts` shared with `ai-runtime`: the generated agent capabilities frontend types are both the Pulse Intelligence manifest TypeScript projection and a canonical API payload contract boundary.
8. `frontend-modern/src/api/nodes.ts` shared with `agent-lifecycle`: the shared Proxmox node client is both an agent lifecycle setup/install control surface and a canonical API payload contract boundary.
9. `frontend-modern/src/api/notifications.ts` shared with `notifications`: the notifications frontend client is both a notification delivery control surface and a canonical API payload contract boundary.
@@ -1956,6 +1911,7 @@ payload shape change when the portal presents compact client rows.
update checks without rebuilding upgrade state locally. The frontend may
disable blocked installs, but the backend apply route remains authoritative
and must reject a recomputed `blocked` readiness verdict.
14. `frontend-modern/src/api/workflowPrompts.ts` shared with `ai-runtime`: the workflow prompt activity client is both the AI runtime workflow-prompt telemetry surface and a canonical API payload contract boundary.
15. `frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx` shared with `ai-runtime`, `frontend-primitives`: the External agents settings panel is the optional settings-shell projection of Pulse MCP onboarding, the AI runtime connected-agent onboarding surface, and a presentation consumer of the shared agent capabilities frontend client.
The panel may own settings placement, copy composition, local fallback
rendering, and the user-facing hierarchy that frames Patrol as the primary
@@ -1993,13 +1949,13 @@ payload shape change when the portal presents compact client rows.
Client config follows the same product hierarchy: it may expose
manifest-derived OpenCode and Claude-style wrappers, but the default panel
must lead with Patrol control and scoped-token setup, not raw JSON blocks.
Direct `/settings/pulse-intelligence/assistant#external-agent-setup` links
Direct `/settings/pulse-intelligence/agents#external-agent-setup` links
must scroll and briefly focus the External agents panel after the
Assistant settings layout settles, so the external-agent setup route lands
settings layout settles, so the external-agent setup route lands
on the optional client setup boundary rather than generic token inventory.
Legacy `/settings/security/api#external-agent-setup` and
`/settings/security/api#pulse-mcp-setup` links must remain accepted and
redirect to the canonical Pulse Intelligence Assistant route. Normal API
redirect to the canonical Pulse Intelligence External agents route. Normal API
Access visits keep token management first; token-preset links may still
land on API Access because credential minting remains the API token
contract, not a separate credential or execution surface.
@@ -2322,18 +2278,15 @@ payload shape change when the portal presents compact client rows.
before optional `limit` truncation, and matching may use only safe session
identity, title, timestamps, and handoff-summary fields. Search must not
expose provider-bound model context, stored prompts, raw tool output, or
remediation details, and frontend clients must route searchable picker
requests through the shared `AIChatAPI.listSessions({ search, limit })`
helper rather than inventing a parallel local history endpoint.
remediation details, and clients must search through this endpoint rather
than inventing a parallel history endpoint.
`PATCH /api/ai/sessions/{id}` owns the Assistant session-title mutation
contract. The request body is limited to a user-visible `title`; the
handler must reject empty titles, the chat store must normalize whitespace
and bound persisted title length, and the response must be the updated
browser-safe `ChatSession` projection. Rename must not expose or mutate
stored prompts, messages, provider reasoning, model handoff context,
approvals, action state, or tool evidence. Browser clients must call the
shared `AIChatAPI.renameSession(sessionId, title)` helper so path encoding
and JSON body shape stay canonical.
approvals, action state, or tool evidence.
`POST /api/ai/sessions/{id}/undo` and
`POST /api/ai/sessions/{id}/redo` own the Assistant turn-repair API
contract. Undo removes the latest user-authored turn and all later
@@ -2344,7 +2297,7 @@ payload shape change when the portal presents compact client rows.
undone turn and returns `restored_messages` plus the remaining `can_redo`
state. `GET /api/ai/sessions` and other `ChatSession` projections may
expose only the boolean `can_redo` hint alongside safe title/timestamp,
count, and handoff-summary fields so the drawer can re-enable redo after a
count, and handoff-summary fields so a client can re-enable redo after a
reload without reading the redo stack itself. The projection also carries
a boolean `system` flag for Pulse-owned background sessions (Patrol
detection `patrol-main`, `patrol-eval`, and `investigation-*` runs),
@@ -2358,11 +2311,7 @@ payload shape change when the portal presents compact client rows.
prompt being re-run; retry/regenerate flows send it so a stale retry can
never remove a different turn, and a guard mismatch returns
`success:false` with a browser-safe message rather than an error status.
An absent or empty body preserves the unguarded undo shape. Browser
clients must use the shared
`AIChatAPI.undoLastTurn(sessionId, { expectedPrompt })` and
`AIChatAPI.redoLastTurn(sessionId)` helpers so path encoding, guard
trimming, and response shape stay canonical.
An absent or empty body preserves the unguarded undo shape.
`POST /api/ai/sessions/{id}/steer` owns the mid-turn steering API
contract (`chat.SessionSteerRequest` -> `chat.SessionSteerResult`). The
request carries `prompt` plus an optional `client_message_id`; the
@@ -2373,12 +2322,10 @@ payload shape change when the portal presents compact client rows.
follow-up on the ordinary queue-drain path. Steering cannot carry model
route, control level, or autonomous-mode changes. Confirmation of
delivery arrives only on the session's existing chat stream as a
generated `steer_applied` event (`chat.SteerAppliedData`, in
`aiChatEvents.ts` and the `AIChatStreamEvent` union) echoing
`steer_applied` event (`chat.SteerAppliedData`) echoing
`client_message_id`, the server message id, the prompt, and the turn
index; clients must treat the endpoint's `accepted:true` as inbox
receipt, not delivery. Browser clients must use the shared
`AIChatAPI.steerSession(sessionId, { prompt, clientMessageId })` helper.
receipt, not delivery.
Every Assistant session mutation sub-resource under
`/api/ai/sessions/{id}` (`/abort`, `/summarize`, `/fork`, `/undo`,
`/redo`, `/steer`) accepts only `POST`. Any other method that reaches the
@@ -2421,21 +2368,15 @@ payload shape change when the portal presents compact client rows.
`success` state while keeping internal `tool_result` records out of the
browser transcript. Assistant message history must also preserve the
effective `model` route that produced the answer, including local runtime
routes such as `pulse:local-inventory`. Frontend API types must accept
restored `tool_calls[].input` from backend history as a structured object
as well as legacy/display strings, then normalize it before rendering.
Chat stream events are generated from `internal/ai/chat` payload structs
into `frontend-modern/src/api/generated/aiChatEvents.ts`; agent capability
manifest types are generated from `internal/agentcapabilities` payload
structs into `frontend-modern/src/api/generated/agentCapabilities.ts`.
Those generated projections must stay derived through
`scripts/generate-types.go`, and the chat event union must not include the
retired `explore_status` pre-pass event. Runtime
workflow telemetry may remain a transport event, but browser Assistant
routes such as `pulse:local-inventory`. Agent capability manifest types are
generated from `internal/agentcapabilities` payload structs into
`frontend-modern/src/api/generated/agentCapabilities.ts` and must stay
derived through `scripts/generate-types.go`. The chat stream must not
carry the retired `explore_status` pre-pass event. Runtime workflow
telemetry may remain a transport event, but client-bound Assistant
streams must use `chat.StreamEvent.ClientSafe()` so raw provider
`thinking` payloads and serialized tool-call prose cannot cross the API
boundary. Browser Assistant output is owned by the AI runtime presentation
contract rather than by API-client event typing.
boundary.
Tool execution progress is part of this SSE contract: `tool_start`,
`tool_progress`, `tool_cancel`, and `tool_end` must carry typed payloads
generated from `internal/ai/chat` structs so the frontend can mutate one
@@ -2491,52 +2432,6 @@ payload shape change when the portal presents compact client rows.
fixture must pace its browser-facing status/tool/content events enough to
prove the visible stream contract, while tests may disable that pace to keep
backend proof fast.
The frontend API client may also own an explicit local dev/test Assistant
stream fixture at `frontend-modern/src/api/aiChatDevStreamFixture.ts` for
fast UX iteration against the real stream reducer. That fixture may
short-circuit `frontend-modern/src/api/aiChat.ts` only in Vite dev or test
mode, only for reserved `/fixture ...` prompts, and only by calling the
normal `AIChatAPI.chat` event callback with the generated
`AIChatStreamEvent` union (`session`, `workflow_state`, `thinking`,
`tool_start`, `tool_progress`, `tool_cancel`, `tool_end`, `content`,
`done`). It must not open a backend session, mutate persisted chat history,
add browser-only stream event shapes, or become a production fallback for
provider or VPN failures. At least one fixture-backed Assistant tool
sequence must exercise the OpenCode-parity raw tool-input path by starting
with incomplete provider-style `raw_input`, then mutating the same tool row
with completed arguments through the normal `tool_progress` event.
At least one fixture-backed Assistant tool sequence must exercise the
skipped-tool lifecycle by resolving a visible `tool_start` row through
`tool_cancel` instead of hiding the activity. At least one fixture-backed
Assistant tool sequence must exercise live pending/running tool evidence by
emitting placeholder structured input plus raw provider-style input on
`tool_start`, mutating that same row through `tool_progress`, and pacing the
running state long enough for browser proof to inspect and copy both the
completed input and current progress before `tool_end`. At least one
fixture-backed Assistant tool sequence must exercise successful long
plain-text tool output by emitting enough output lines to prove the browser
drawer renders a bounded collapsed preview, avoids an opaque output badge,
and preserves the full output in expandable details. At least one
fixture-backed Assistant tool sequence must exercise compacted provider artifact
suppression by emitting compacted pre-tool content before a governed tool row
and final normal answer content. At least one fixture-backed Assistant tool
sequence must exercise a buffered `tool_start` -> `tool_end` burst with no
provider delay between the two events, followed by a paced content/terminal
step, so browser proof can verify that fast command activity remains
perceptible without a real provider request. At least one fixture-backed
Assistant tool sequence must exercise consecutive multi-tool activity with a
paced completed first row followed by a live second row, so browser proof can
verify replacement/compaction motion without provider timing. At least one
fixture-backed Assistant sequence must exercise `provider_retry` with `attempt`,
`max_attempts`, and `retry_after_ms` metadata plus a paced retry window, so
browser proof can verify visible retry countdown behavior without a real
provider request, VPN dependency, or API spend. At least one fixture-backed
Assistant sequence must exercise `stream_idle` after selected-provider
startup, so browser proof can verify visible idle liveness without forcing a
real provider to pause on demand. At least one fixture-backed Assistant
sequence must exercise the local prompt-send state by emitting `session`,
then pacing the first backend `workflow_state` long enough for browser proof
to verify immediate visible activity without opening a provider request.
65. `internal/api/ai_handlers.go` shared with `ai-runtime`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary.
The AI settings payload on `/api/settings/ai` carries no cloud-context-privacy
field: cloud context behavior is a fixed posture (real context to cloud, with
@@ -2582,8 +2477,8 @@ payload shape change when the portal presents compact client rows.
Assistant and settings readiness; Patrol-specific wording is reserved for
Patrol preflight, Patrol findings, and Patrol run records. Raw details stay
available only to server logs or redacted governed internal Patrol
evidence. The frontend API client, settings shell, and Assistant drawer
must treat this payload as the canonical provider health contract rather
evidence. The frontend API client and settings shell must treat this
payload as the canonical provider health contract rather
than parsing free-form provider error strings.
66. `internal/api/ai_intelligence_handlers.go` shared with `ai-runtime`: AI intelligence handlers are both an AI runtime control surface and a canonical API payload contract boundary.
67. `internal/api/alerting/notification_queue.go` shared with `notifications`: the notification queue and DLQ handler is both a notification delivery consequence surface and a canonical API payload boundary for operational transition links.
@@ -3748,9 +3643,7 @@ a new API state machine, queue contract, or verification-accounting field.
selected-route `provider_start` message is operator
progress copy and must be active/in-progress wording such as
`<Provider> is starting the response.` while the typed `provider` and `model`
fields carry the exact route identity. The generated
`frontend-modern/src/api/generated/aiChatEvents.ts` type must stay derived
from `internal/ai/chat/types.go` through `scripts/generate-types.go`;
fields carry the exact route identity.
`frontend-modern/src/api/generated/agentCapabilities.ts` must stay
derived from `internal/agentcapabilities`, including manifest surface
contract fields; and frontend API tests must pin
@@ -4797,9 +4690,8 @@ the authoritative analysis outcome.
26. Keep shared AI runtime reads centralized on that same governed contract:
`frontend-modern/src/stores/aiRuntimeState.ts` is the canonical frontend
read owner for `/api/settings/ai` and `/api/ai/models`. AI-owned consumers
such as `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`,
`frontend-modern/src/components/AI/Chat/index.tsx`, and
`frontend-modern/src/components/AI/AICostDashboard.tsx` must reuse that
such as `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`
and `frontend-modern/src/components/AI/AICostDashboard.tsx` must reuse that
shared store for read-side runtime truth, while
`frontend-modern/src/components/Settings/useAISettingsState.ts` remains
the write-side settings owner. Non-AI settings surfaces such as
@@ -7826,8 +7718,8 @@ provider-local routes. Phase 1 must not add public `/api/vmware/resources`,
transport just because the internal poller now projects VMware-backed
resources into the shared canonical inventory.
That same shared API contract now also owns Assistant mention transport for
those canonical resources. `frontend-modern/src/api/aiChat.ts`,
`internal/api/ai_handler.go`, and `internal/api/ai_handlers.go` must preserve
those canonical resources. `internal/api/ai_handler.go`
and `internal/api/ai_handlers.go` must preserve
structured mention payloads for canonical `agent`, `vm`, `storage`, and
`network` resources as shared unified-resource IDs plus shared mention types,
so VMware-backed reads stay on `/api/ai/*` and `/api/resources*` instead of
@@ -9432,10 +9324,8 @@ Pulse-authored attention reason, operator decision, or recommended next step. An
action note from a durable investigation record is context, not remediation
guidance. That governed action artifact metadata must come from the same
structured action reference sent to chat execution, including any recovered live
Patrol approval, so visible and model-only handoffs stay consistent. Patrol's
frontend Assistant drawer briefing must use that same factual frame for
visible handoffs from findings, while the downstream chat
service hydrates live resource state, timeline, and action audit context around
Patrol approval, so visible and model-only handoffs stay consistent. The downstream chat service
hydrates live resource state, timeline, and action audit context around
that same handoff. Backend chat handling must treat matching frontend Patrol
handoff context as secondary to durable finding context, not a replacement for
server-refreshed approval, resource, action, or requester authority. Frontend Patrol
@@ -9453,9 +9343,7 @@ paste raw approval or action command text into a chat prompt.
Patrol remediation-plan or action-artifact Assistant handoffs must pass only safe
status, risk, description, and command-count posture as non-authoritative context
for the configured LLM to critique; raw plan command and rollback command
payloads remain owned by the governed remediation/action APIs and panels. The
frontend Assistant drawer must not turn those artifacts into visible Patrol step
lists, suggested-prompt chips, or a "plan attached" answer. Evidence,
payloads remain owned by the governed remediation/action APIs and panels. Evidence,
confidence, approval risk, prerequisites, recurrence, rollback, and verification
belong in model-only context or governed approval surfaces unless the operator
explicitly asks the LLM for them.
@@ -12587,6 +12475,36 @@ Routes, caller scope/token filtering, cache policy and probe count are unchanged
HTTP statuses, private-body controls, same-credential fixture recovery and wire
attribution. They are not installed permission or guest-recovery acceptance.
The browser Assistant chat window was retired on 2026-10-22. Its frontend
client (`frontend-modern/src/api/aiChat.ts`), the dev stream fixture, and the
generated TypeScript chat event projection left with it; `scripts/generate-types.go`
no longer emits chat stream types. `/api/ai/chat`, the session endpoints, and
the chat stream payloads keep their contracts because Patrol investigations
and Pulse Mobile chat still use them, pinned server-side by
`internal/api/contract_test.go`. The alert Investigate action now calls
`POST /api/ai/patrol/run` with `resource_ids`, `alert_identifier`, and
`alert_type`, and the External agents settings route is
`/settings/pulse-intelligence/agents`.
`/api/security/status` `settingsCapabilities` gained `patrolRun` on 2026-10-22.
`securityStatusCanRunPatrol` answers that route's RequireAdmin + `ai:execute`
gate from the security status snapshot, like the other admin capabilities, so
it validates no token and has no side effect. Where `ensureAdminSession`
differs from `canAccessAdminSurface` it follows the route: an instance
administrator on an org-scoped session is admitted, an organization-bound
token is refused whatever its scopes, and a session cookie beside a token must
belong to an instance administrator. The snapshot's remaining gaps
(recovery-session binding, proxy-over-token precedence, Bearer-only tokens,
the dev bypass beside an explicit token) are shared with every settings
capability. The browser offers alert Investigate only where the field is true. Auth-free installs report no capability
set and the frontend treats them as allowed, as it already does for update
status. `TestContract_SecurityStatusPatrolRunTracksRunRouteGate` keeps the
field and the route in agreement, including a non-admin cookie beside a token;
`TestSecurityStatusPatrolRunRefusesOrgBoundToken` and
`TestSecurityStatusPatrolRunFollowsInstanceAdminOnOrgScopedSession` compare it
with `ensureAdminSession` directly, and the capability enforcement tests list it
with the other admin-only fields.
### Typed investigation conclusion tool (22 October 2026)
`patrol_conclude_investigation` joins `patrol_propose_action` and
@@ -2563,16 +2563,6 @@ pipeline (downloading through restarting, including the `restoring` stage
emitted by update rollback) and it carries no hosted entitlement, org, or
paid-gating semantics; hosted and paid surfaces must not key tenant or
billing behavior off update progress stages.
That same authenticated shell split must also respect shared blocking dialogs:
hosted chrome may not leave the Pulse Assistant launcher or an already-open
assistant drawer interactive behind a modal that currently owns the viewport.
That same hosted chrome rule also covers responsive launcher placement. While
the authenticated shell is using mobile navigation, `frontend-modern/src/AppLayout.tsx`
must keep the closed Pulse Assistant launcher in the authenticated header flow,
where it cannot cover page content or compete with the mobile nav. It must not
reintroduce the desktop right-edge launcher until the desktop shell breakpoint,
otherwise hosted and self-hosted operators lose usable dashboard and
billing-page edge space at tablet widths.
That same authenticated shell contract now also has to distinguish backend
availability from websocket-stream liveness. When hosted runtime health stays
available during a stream reconnect or renegotiation window,
@@ -3745,13 +3735,13 @@ boundary without granting Community private RBAC or changing CI tier membership.
### AI navigation gating stays outside commercial scope
The app shell hides Patrol navigation and Assistant palette commands while the
The app shell hides Patrol navigation and alert Investigate actions while the
`assistantEnabled` session capability is false. The gate reads only that AI
capability: it does not consult entitlements, organization scope, hosted
bootstrap or upgrade state, and it adds no commercial prompt. Paid Patrol
modes stay governed on the Patrol and Actions surfaces, which remain
reachable by route. `App.architecture.test.ts` pins that the gate reads the
same capability as the Assistant launcher.
reachable by route. `App.architecture.test.ts` pins that the shell reads that capability through
`aiAvailabilityStore`.
### Relay retired inside the app
@@ -3801,3 +3791,11 @@ metric/recovery retention remain unchanged. This source change does not deploy
a runtime or change customer subscriptions. Private renderer tests cover formats,
missing/observed-zero distinction, formula-safe CSV and PDF generation. Runtime
hook tests and final-source Pro browser delivery qualify registration/composition.
The browser Assistant chat window was retired on 2026-10-22, so the hosted
shell rules about its launcher placement and modal behaviour went with it.
The chat was sold as a BYOK feature of the free tier ("Pulse Assistant (BYOK
Chat)"), not as a Pro capability, so no paid entitlement changes. Paid Patrol
capabilities stay as they were: Patrol modes, investigations, governed fixes,
and history live on the Patrol and Actions surfaces, and alert Investigate now
starts a targeted Patrol run instead of opening a chat.
@@ -4120,6 +4120,27 @@ release-selection, promotion or rollback boundary is introduced. Full exact-grap
checks, the strict full-graph audit and fresh representative browser rendering
remain necessary before acceptance; a prepared lock or acquisition is not proof.
### Reviewed DOMPurify and Vitest advisory floors (22 Oct 2026)
`frontend-modern/package.json` moves `dompurify` to `^3.4.17` and `vitest` and
`@vitest/coverage-v8` to `^4.1.12`, and `frontend-modern/package-lock.json`
locks those versions. `GHSA-cg2g-xh64-v4q7` (critical `SAFE_FOR_TEMPLATES`
bypass through attached shadow roots), `GHSA-mrv2-3pqr-j5jr` (an `IN_PLACE`
hook that removes a parent or sibling left detached handlers armed) and
`GHSA-c6gr-qvv4-5v3f` (SMIL `attributeName=href` guard skipped under
`application/xhtml+xml`) affect DOMPurify up to 3.4.16, and
`GHSA-qxg6-89mg-rqv8` and `GHSA-xp26-3454-mjgp` affect `@vitest/mocker` below
4.1.12. Published on 22 October 2026, they failed the required frontend
dependency audit on every pull request. DOMPurify ships in the frontend bundle
and the change is a patch release inside the reviewed `^3` range; Vitest is
development-only. The lock also takes the `chai`, `obug`, `std-env` and
`tinyrainbow` versions the new Vitest ranges resolve and one deduplicated
`picomatch` 4.0.7, and leaves the installer, artifact, signing, promotion and
rollback boundary unchanged.
`frontend-modern/src/security/__tests__/dependencySecurity.test.ts` raises the
Vitest floor to 4.1.12 and the reviewed `dompurify` floor to 3.4.17, so a later
downgrade is rejected.
### Reviewed @types/node 26.6.2 refresh
The 2026-09-23 `npm-minor-patch` group (Dependabot #2189) advances the
@@ -8089,6 +8110,13 @@ They prove phase coverage, build/discovery/check failure propagation, passing
tests, explicit skips, examples and fuzz seeds; actual failure, exit zero,
timeout and unmatched test names must fail.
`highlight.js` left the `frontend-modern` dependencies on 2026-10-22. Its only
importer was the retired Assistant chat code-block highlighter, so the
production build already shipped no `vendor-highlight` chunk; the reviewed
dependency floor in `dependencySecurity.test.ts` drops with it. The lockfile
loses only that package entry. Installer, artifact, signing, promotion and
rollback boundaries are unchanged.
## Release-body updater ownership (30 September 2026)
Each executable `/bin/update --version` example in a published body must carry
@@ -1583,7 +1583,6 @@ removal/failure retention and containing publication remain separate evidence.
5. `frontend-modern/src/components/Settings/settingsPanelRegistry.ts`
6. `frontend-modern/src/components/Settings/APIAccessPanel.tsx`
6a. `frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx`
7. `frontend-modern/src/components/Settings/AIChatMaintenanceSection.tsx`
8. `frontend-modern/src/components/Settings/AIModelSelectionSection.tsx`
9. `frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx`
10. `frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx`
@@ -1695,7 +1694,6 @@ removal/failure retention and containing publication remain separate evidence.
111a. `frontend-modern/src/utils/storageSummaryCache.ts`
111. `frontend-modern/src/components/shared/TableCardHeader.tsx`
112. `frontend-modern/src/components/shared/UpgradeLink.tsx`
113. `frontend-modern/src/components/shared/useUpgradeNavigation.ts`
114. `frontend-modern/src/utils/upgradeNavigation.ts`
115. `frontend-modern/src/components/DemoBanner.tsx`
116a. `frontend-modern/src/components/CommercialMigrationBanner.tsx`
@@ -1706,7 +1704,7 @@ removal/failure retention and containing publication remain separate evidence.
119. `frontend-modern/src/stores/licenseCommercial.ts`
120. `frontend-modern/src/useAppRuntimeState.ts`
121. `frontend-modern/src/routing/routePreload.ts`
122. `frontend-modern/src/stores/aiChat.ts`
122. `frontend-modern/src/stores/aiAvailability.ts`
123. `frontend-modern/scripts/header-audit.mjs`
124. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx`
125. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts`
@@ -2226,11 +2224,11 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`,
work, with Patrol as the built-in operator that checks infrastructure,
follows Patrol mode before acting, asks when approval is required, verifies
outcomes, and records history, state that external agents use that same boundary, and make the canonical
`/settings/pulse-intelligence/assistant#external-agent-setup` route land on
`/settings/pulse-intelligence/agents#external-agent-setup` route land on
and briefly focus this panel with setup open rather than leaving the user at
the generic API token inventory. Legacy `/settings/security/api#external-agent-setup` and
`/settings/security/api#pulse-mcp-setup` links must remain accepted and may
redirect to the canonical Pulse Intelligence Assistant route. Normal API
redirect to the canonical Pulse Intelligence External agents route. Normal API
Access visits remain token-management first, and external-agent setup must
not reorder the API token inventory because it no longer lives on that page.
The Assistant settings default must keep setup mechanics behind a `Show connector setup`
@@ -2290,70 +2288,7 @@ AGENT_SURFACE_ID_PULSE_MCP)` and `getAgentSurfaceToolPosturePresentation`,
11. `frontend-modern/src/features/platformPage/PlatformWindowedRows.tsx` shared with `performance-and-scalability`: the shared bounded table-row renderer is both a canonical platform-page primitive and a fleet-scale mounted-DOM performance boundary.
12. `frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts` shared with `performance-and-scalability`: the platform windowing controller is both a canonical frontend scroll primitive and a directional-runway performance hot path.
13. `frontend-modern/src/routing/routePreload.ts` shared with `performance-and-scalability`, `unified-resources`: the app-shell route preload registry is a canonical frontend shell boundary, an authenticated hot-path performance boundary, and the entry point for the unified-resource Actions workspace.
14. `frontend-modern/src/stores/aiChat.ts` shared with `ai-runtime`: the assistant drawer and session store is both an AI runtime control surface and a canonical app-shell presentation boundary.
Assistant session pickers and reloads must restore only safe
`handoff_summary` presentation state from the session list. Loading a plain
session or starting a new conversation must clear stale scoped handoff
briefing state so Patrol and alert context does not visually leak between
conversations. Browser-originated model handoff payloads are one-shot
request seeds: after the first successful chat send, this store must clear
`handoffContext`, `handoffResources`, `handoffActions`, and safe
`handoffMetadata`; it must also clear any preferred workflow prompt request
once the manifest-rendered starter has seeded the composer and the first
scoped send succeeds. The store must preserve the safe visible briefing and
scoped approval-required posture, so later turns rely on backend session
hydration instead of resending stale browser context. Patrol handoffs must not include
Persisted Assistant redo availability is safe session chrome, not transcript
content. The drawer may consume `ChatSession.can_redo` from the backend
session list to re-enable Redo after reload or session refresh, but it must
not read or duplicate the redo stack in frontend state. Undo restores the
editable prompt draft and safe structured send metadata into the composer;
Redo clears that recovered draft only after the backend restores the turn.
Browser-owned session-management comments, command request handling, and
question-answer plumbing in this store and the adjacent chat hook must name
the native drawer surface as Pulse Assistant rather than reviving the
retired generic `Pulse AI` label. Shared model/provider settings guidance
still belongs to Pulse Intelligence > Provider & Models.
Patrol handoffs must not include
safe next-step labels, action kinds, or whitelisted app-route hrefs in
`handoffMetadata`; the drawer must treat
`handoff_summary.requires_approval` as a current pending-decision flag, not a
historical action marker, so completed or rejected handoff actions render as
action context rather than pending approval. A restored Patrol run summary
must remain visibly sourced to Pulse Patrol, restore a `patrol-run` target
plus run ID/type/status/runtime-failure presentation only, and must not
rehydrate model-only runtime failure detail into browser context. New Patrol
run requests follow the same drawer boundary: source-owned context and
briefing copy may show classified, redacted failure summaries for operator
review, but `handoffContext`, `handoffResources`, and `handoffActions` for
run-history context must stay absent from the browser request so the backend
can rebuild model-bound context from the stored Patrol run. Restored
Patrol assessment, Patrol finding, and Patrol control save-failure sessions
follow the same safe-summary rule: the drawer may restore source label,
title, target type, status badge, bounded resource facts, and approval/action
status from `handoff_summary`, but
it must not infer a finding target from bounded action references or
reconstruct hidden model context, provider details, retry payloads, commands,
preflight output, or action results in the browser. If the safe summary
was created by a legacy build that stored Patrol next-step metadata,
recommendation detail, action labels, safe action kind, or whitelisted
app-route href, the session picker plus restored drawer must ignore those
fields rather than carrying them forward as hidden context or visible
recommendation copy.
Session-load and new-conversation transitions must be success-bound: if the
underlying session operation fails, the shared drawer store must not clear or
replace the current scoped handoff context.
Restored Patrol assessment sessions use that same
`patrol-assessment`/`pulse-patrol-assessment` target identity rather than a
retired dashboard target, so restored-session chrome remains source-named;
Patrol no longer opens live assessment handoffs.
The shared `frontend-modern/src/components/shared/AIModelPicker.tsx`
primitive must keep model route presentation delegated to the AI runtime
label helpers. Pulse-owned local Assistant routes such as
`pulse:local-inventory` and `pulse:mock-assistant` are implementation
routes and must render as named choices without secondary raw route IDs,
while external provider route IDs may remain visible where they disambiguate
catalog entries.
14. `frontend-modern/src/stores/aiAvailability.ts` shared with `ai-runtime`: the AI availability store is both the AI runtime session capability and the app-shell gate for Patrol entry points.
15. `frontend-modern/src/utils/platformSupportManifest.generated.ts` shared with `unified-resources`: the generated platform support projection is both a canonical unified-resource platform union boundary and a shared frontend source/platform vocabulary boundary.
It must expose the manifest `surface_kind` field so runtime lenses such as
`docker` are not collapsed back into owning platform semantics.
@@ -2659,14 +2594,8 @@ not a replacement status card, CTA band, or page-local nested card.
Standalone machine row action triggers follow the same rule: the Machines
table owns remove-agent semantics and menu placement, while
`ActionIconButton` owns the compact muted trigger chrome.
AI Chat follows that same boundary for drawer header controls, session row
actions, transcript fallback close/download actions, activity-dock queued
follow-up controls, composer send, footer help/route actions, and compact
dismiss controls: the Assistant owns chat/session behavior and copy, while
`ActionIconButton` owns h-5/h-6/h-7/h-8/h-9 sizing, outline, primary, accent,
warning, info, danger, disabled, title, and focus chrome.
AI Chat message and tool copy controls follow the copy-action boundary:
MessageItem and ToolExecutionBlock own the copied text and timer semantics,
Patrol tool copy controls follow the copy-action boundary:
`ToolExecutionBlock` owns the copied text and timer semantics,
while `CopyValueButton` owns copied-state iconography, disabled handling,
focus, and embedded-row propagation behavior.
Global app-shell prompts are part of the same action boundary.
@@ -3510,36 +3439,26 @@ Agent`), with the plain-language source phrase available through accessible
review destination for a current paid feature, it must scope the operator into the
usage-owned policy ledger rather than plan-selection intent or CTA copy
that frames the flow as monitored-system-cap expansion.
14. Keep assistant availability bootstrap on the shared app-shell boundary.
14. Keep AI availability bootstrap on the shared app-shell boundary.
`frontend-modern/src/useAppRuntimeState.ts`,
`frontend-modern/src/App.tsx`,
`frontend-modern/src/stores/aiChat.ts`, and
`frontend-modern/src/components/AI/Chat/index.tsx` must consume the
`frontend-modern/src/App.tsx`, and
`frontend-modern/src/stores/aiAvailability.ts` must consume the
backend-owned `/api/security/status.sessionCapabilities.assistantEnabled`
fact instead of probing `/api/settings/ai` or `/api/ai/*` during ordinary
route bootstrap. Closed assistant chrome and non-AI settings panels may
not initialize assistant runtime state until an owned assistant or Patrol
surface is actually open. `frontend-modern/src/stores/aiChat.ts` is the
shared drawer shell owner for assistant open/close state, focus handoff,
and tenant-local context/session persistence; the app shell must not fork
that state across `App.tsx`, `AppLayout.tsx`, or page-level helpers.
The same shared shell boundary must keep Pulse Assistant coherent while a
blocking shared dialog owns the viewport: closed launcher affordances must
hide until the dialog clears, and the shell must close any already-open
assistant drawer instead of leaving background assistant controls visibly
active behind the modal. Non-critical app-shell prompts, including
promotional or feedback prompts, must not use the shared blocking dialog
stack because they must not suppress Pulse Assistant access or look like
required operational acknowledgement.
route bootstrap. Non-AI settings panels may not initialize AI runtime state
until an owned Patrol surface is actually open, and the app shell must not
fork the AI-enabled fact across `App.tsx`, `AppLayout.tsx`, or page-level
helpers. Non-critical app-shell prompts, including promotional or feedback
prompts, must not use the shared blocking dialog stack because they must
not look like required operational acknowledgement.
AI-owned frontend surfaces that need shared settings or model-catalog
truth must route those reads through
`frontend-modern/src/stores/aiRuntimeState.ts` rather than each feature
bootstrapping `/api/settings/ai` or `/api/ai/models` independently.
Non-AI settings panels such as
`frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts`
must stay on the app-shell assistant-availability fact instead of
re-reading raw AI settings just to decide whether assistant affordances
should render.
must stay on the app-shell AI availability fact instead of re-reading raw
AI settings just to decide whether AI affordances should render.
15. Keep Patrol shell composition and product-first provider vocabulary on the
shared feature-presentation boundary.
`frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx`,
@@ -3639,23 +3558,15 @@ Agent`), with the plain-language source phrase available through accessible
save-failure state returns, it must stay viewport-bounded, expose an
accessible dialog label, bind any model selector to the shared runtime
settings and model catalog, show readiness context (provider, model,
summary) next to a direct provider-settings action rather than behind
Assistant alone, and describe a saved but not ready response as a saved
configuration issue rather than a failed save. An Assistant handoff from it
must send a source-named, model-only briefing and close the popover so the
shared Assistant drawer is not visually hidden behind feature chrome. When
governed fixes are locked, the Patrol header presents `Watch only` as the
summary) next to a direct provider-settings action, and describe a saved but not ready response as a saved
configuration issue rather than a failed save. When governed fixes are
locked, the Patrol header presents `Watch only` as the
effective mode with no enabled paid choice, and the Patrol state owner
refuses any other level when a save starts. Its autonomy payload
never carries `full_mode_unlocked`: `PUT /api/ai/patrol/autonomy` itself
clears stale full-mode unlock and Autopilot activation state on every save
below Autopilot, so disabled paid controls cannot leak stale permission
into a save. When a
restored Patrol assessment session is attached, the shared Assistant drawer
empty state must
stay aligned with that source-named briefing and must not render generic
cluster/system starter prompts below the Patrol-owned context. The Patrol
feature shell must also consume the Patrol-owned findings source for its
into a save. The Patrol feature shell must also consume the Patrol-owned findings source for its
findings tab, run-scoped findings panels, and tab badges so shared feature
composition does not rebuild Patrol state by filtering the cross-product
unified findings feed.
@@ -4185,114 +4096,16 @@ production table, router and styles; it does not qualify full-app scrolling.
IDs into setup payloads. The shared settings shell should let the backend
resolve the effective BYOK model and then render that returned state rather
than guessing a model in the modal.
Scoped Assistant handoffs keep their approval-required disclosure in
drawer context: `autonomousMode:false` on `AIChatContext` is a drawer-only
flag the chat transport never sends, because `/api/ai/chat` sets
approval-required mode server-side for every request. Dashboard and other
route-owned entry points may open the
Assistant drawer with source context and that flag, but they
must not infer a user task from an ordinary context-only open. Explicit
labelled explanation actions use `aiChatStore.explain` and the shared
explanation dispatcher, which captures the request context, waits for open
initialization, preserves drafts, and acknowledges each request once.
Older request completion must not clear newer handoff context. Neither
path may mutate persistent AI control-level settings or trigger background
Assistant settings/model bootstrap before the drawer is open. Patrol finding handoffs that add structured
investigation-record framing must derive that context through
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`
so shared drawer primitives stay shell-owned rather than becoming a
Patrol-specific diagnosis formatter; shared drawer primitives must not
branch on intent themselves. Patrol-page
surfaces must not add standalone trust strips to shared header or
workspace chrome; high-signal trust facts may feed the Patrol-owned
assessment readout, but shared drawer/chrome primitives stay free of
the `FindingsTrustSummary` shape so adding new trust signals goes
Patrol-page surfaces must not add standalone trust strips to shared header
or workspace chrome; high-signal trust facts may feed the Patrol-owned
assessment readout, but shared chrome primitives stay free of the
`FindingsTrustSummary` shape so adding new trust signals goes
through the contract first rather than per-shell branching.
Patrol refresh controls stay on that same feature-owned shell
boundary: `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`
must make the refresh affordance generation-aware and timeout-bounded, so
a slow supporting intelligence read cannot permanently disable the
stale-data Retry control while Patrol findings and status remain visible.
That feature-owned
presentation helper is the single emitter for investigation-record
`impact` and `rollback` fields: when an investigation record exists but those fields
are empty, the helper emits explicit `Impact not assessed` and
`Rollback not specified` lines into the model-only Patrol finding
prompt context so the operator-visible gap is surfaced to Assistant
rather than hidden, and shared chat primitives stay free of that
placeholder logic. Patrol run-history handoffs must also use that feature helper rather
than a row-local Assistant prompt, so the shared drawer receives only a
generic visible briefing, safe run identity metadata, and
`autonomousMode:false`; the backend rebuilds the bounded model-only run
context, scoped resource references, and runtime failure detail from the
run ID, while the Patrol feature remains the source of run copy and
retry/configuration guidance. Finding handoff action references
may add live pending approval posture only as safe structured metadata:
approval ID, pending status, risk, target, requested/expiry timestamps,
action plan identity, requester identity, approval policy, plan expiry, and
dry-run posture. Those references may be passed through shared chat
transport as `handoff_actions` for model-only refresh, but the shared drawer
stays a generic shell rather than a Patrol summary prompt builder. The
Patrol helper may turn those same safe references into visible action
labels and safety notes for finding-level handoffs, but it must not produce
Patrol-authored suggested prompt chips, recommendation titles,
recommendation reasons, or route-owned next-step actions. Patrol finding
prompts, action labels, and safety notes must describe findings, pending
approvals, and governed action references as evidence for the configured
model, not as a frontend-authored decision tree.
Finding-level drawer opens may also pass one bounded
model-only finding context, one target resource reference, and one
`handoff_actions` reference for a live approval or proposed fix. It must not
expose raw command or execution payloads.
The
drawer may render a generic
context-briefing band from `frontend-modern/src/stores/aiChat.ts`, but
feature-owned helpers must provide compact source labels, primary subject,
status, and governed approval/action artifact metadata while keeping
detailed evidence, safety notes, and model-only finding context outside
drawer chrome. Prompt suggestions, attention-reason copy, and
operator-decision framing must stay out of Patrol drawer chrome.
Patrol finding and action-artifact handoffs must not render suggested prompt
chips in the drawer and must not become another primitive path for raw
approval, command, or rollback command payload text. Missing-detail
queued-fix recovery actions must still provide the feature-owned Patrol
briefing and approval-required disclosure rather than opening the
shared drawer as context-free generic Assistant chat. If a feature-owned
expired-approval recovery action still has structured action artifact metadata,
the shared drawer may receive only safe summary fields and command counts;
raw command text remains outside shared Assistant primitives.
When those feature-owned helpers attach backend model-only context, the
drawer store may carry only bounded handoff text and structured resource
references for the shared chat transport; approval, lifecycle, and command
authority remain with the owning runtime surfaces.
Patrol finding handoffs should still provide that briefing from current
finding facts when a durable Patrol investigation record is not attached
yet, rather than opening the shared drawer as empty generic chat.
When the feature helper adds live approval state to the generic drawer
briefing, it may pass only safe approval metadata into
`AIChatContextBriefing`, including generated approval summaries and command
counts when available; raw approval commands remain owned by the governed
approval/remediation panels. If the generic finding-level helper hydrates
latest investigation detail to recover action artifact context, it may pass only
safe summary fields and command counts into the drawer briefing. Shared
approval-required posture must derive its subject from that briefing or
structured finding context, so Patrol handoffs render as Patrol handoffs or
Patrol findings, and alert handoffs render as alert investigations, rather
than generic dashboard briefs. Patrol approval-row Assistant prompts must
route through the same
feature-owned finding handoff helper rather than hand-written prompt-only
drawer opens: safe approval metadata, action artifact summaries, resource
references, and bounded `handoff_actions` may enter the prompt and context,
but raw command text stays out and the handoff must set the drawer's
`autonomousMode:false` disclosure instead of changing the user's persistent
Assistant control level. Patrol remediation-plan drawer handoffs must use the same
primitive boundary: plan title/status/risk, step labels, and command counts
may enter Assistant context; raw command and rollback command payloads must
stay in the governed remediation/action panel. All Patrol finding
discussion handoffs, including context-only findings without a live approval
or proposed fix, must set `autonomousMode:false` as that drawer-only flag
so the drawer shows the approval-required posture the server enforces,
without mutating the persistent Assistant control setting.
14. Keep shared filter primitives coherent with source-owned option hydration.
Active platform/runtime pages and Settings infrastructure surfaces must keep
canonical options visible in shared filter controls even when current
@@ -4546,9 +4359,8 @@ production table, router and styles; it does not qualify full-app scrolling.
Proxmox scope resolver as a compatibility fallback.
37. Keep shared commercial upgrade navigation typed and destination-aware.
Shared paywall shells and upgrade actions must route internal billing or
cloud destinations through `frontend-modern/src/utils/upgradeNavigation.ts`,
`frontend-modern/src/components/shared/UpgradeLink.tsx`, and
`frontend-modern/src/components/shared/useUpgradeNavigation.ts` instead of
cloud destinations through `frontend-modern/src/utils/upgradeNavigation.ts`
and `frontend-modern/src/components/shared/UpgradeLink.tsx` instead of
guessing from labels, hardcoding `target="_blank"`, or calling
`window.open(...)` from each feature surface. Inline upgrade links may use
`UpgradeLink`; button-styled upgrade CTAs must use `UpgradeButtonLink` so
@@ -4828,13 +4640,14 @@ elsewhere. No new member primitive was introduced — vSphere composition
reuses the same expandable member-row rendering, status presentation, and
merge semantics Proxmox clusters already use.
Assistant availability in the app shell is derived from the
AI availability in the app shell is derived from the
`sessionCapabilities.assistantEnabled` security-status capability, and
`aiChatStore.refreshEnabledFromServer()` is the canonical way to re-derive it
mid-session. AI settings save paths (setup modal, enable toggle, Provider &
Models save) must call it after a successful save so assistant entry points
appear or disappear without a full reload; no surface may flip
`aiChatStore.setEnabled` from settings state directly.
`aiAvailabilityStore.refreshEnabledFromServer()` is the canonical way to
re-derive it mid-session. AI settings save paths (setup modal, enable toggle,
Provider & Models save, provider removal) and organization switches must call
it so Patrol entry points and alert Investigate appear or disappear without a
full reload; no surface may flip `aiAvailabilityStore.setEnabled` from settings
state directly.
Patrol `fix_rejected` presentation is owned by the Patrol/AI finding surfaces
that render the governed-action loop, while the surrounding badge, button,
@@ -5042,18 +4855,6 @@ that mention the Infrastructure settings destination now consume
`frontend-modern/src/utils/infrastructureSettingsPresentation.ts` for the
canonical `Settings → Infrastructure` label and source-strategy copy. Shared
primitives must not fork that string or revive removed nested route labels.
The shared Assistant drawer owns compact source-named approval posture for
governed handoffs. Patrol handoffs render as Patrol, and alert plus alert
incident timeline handoffs render as alert investigations rather than dashboard
briefs. Those same drawer handoffs may carry model-only chat context and
resource references to the backend, but the drawer remains a presentation and
transport owner rather than the source of approval or execution truth. Patrol
briefings must stay simple: source, status, one primary subject, and an optional
safe route link. They must not render remediation step lists, evidence chips,
command summaries, or suggested-prompt chips as drawer chrome. If a
feature-owned briefing includes a safe route-owned `actionHref`, the drawer may
render the briefing action label as a normal app link; that link is navigation
guidance only and must not become approval or execution authority.
`SettingsTab` no longer includes `infrastructure-connections` or
`infrastructure-install`. The single `infrastructure-systems` entry in
@@ -5207,8 +5008,7 @@ storage-only live chart primitive for the same telemetry.
That same shared-primitive floor now also owns upgrade-navigation semantics.
`frontend-modern/src/utils/upgradeNavigation.ts` is the canonical typed
internal-vs-external destination helper, while
`frontend-modern/src/components/shared/UpgradeLink.tsx` and
`frontend-modern/src/components/shared/useUpgradeNavigation.ts` own how shared
`frontend-modern/src/components/shared/UpgradeLink.tsx` owns how shared
paywall surfaces navigate those destinations. Feature shells may request a
commercial destination, but they must not re-decide whether that destination
opens in-app or in a new tab once the shared primitive exists.
@@ -5755,8 +5555,8 @@ interaction across guest, node, Docker/unified-resource, Ceph cluster,
Proxmox Mail Gateway, and shared inline detail panels: pressing anywhere in the subject row collapses the detail,
Enter/Space work through the native button, and lifecycle actions do not bubble
into collapse. Generic `Ask Assistant` and `Copy context` actions do not belong
in object drawer headers: Assistant remains available through the global shell,
and raw context export must not compete with the operational reading path.
in object drawer headers: raw context export must not compete with the
operational reading path.
`frontend-modern/src/components/Workloads/MetricMiniSparkline.tsx` and
`frontend-modern/src/features/proxmox/BackupActivityChart.tsx` render their
hover tooltips through `frontend-modern/src/components/shared/TooltipPortal.tsx`
@@ -6541,20 +6341,8 @@ builds and filters command rows, holds selected menu state via
`createFooterMenuState`, resets selection as the query changes, and routes
keyboard movement and selection through `handleKey`. Pulse adapts that contract
by keeping command construction in the shared palette model and command
execution in the shared palette state / Assistant store instead of moving
execution in the shared palette state instead of moving
editor-specific command routing into the render shell.
Assistant command-palette actions are shell requests, not duplicated chat
logic. New session, session picker, model picker, Undo, and Redo commands must
flow through the shared `frontend-modern/src/stores/aiChat.ts` command request
contract so the drawer owns disabled/loading state, prompt restoration, and
notifications while the palette remains only a searchable command surface.
The command-palette Assistant open command is also contextual shell routing:
`frontend-modern/src/components/shared/useCommandPaletteState.ts` must derive
current-view context through `frontend-modern/src/utils/assistantPageContext.ts`
and pass that context into `aiChatStore.open(...)`, while
`frontend-modern/src/components/shared/commandPaletteModel.ts` owns the
corresponding `Ask about <view>` label. It must not fall back to an empty
generic Assistant open action.
The shared search field now follows that same owner split.
`frontend-modern/src/components/shared/SearchField.tsx` stays the render shell,
`frontend-modern/src/components/shared/useSearchFieldState.ts` owns focused-
@@ -7937,7 +7725,6 @@ representative top-level panels such as
`frontend-modern/src/components/Settings/AISettings.tsx`,
`frontend-modern/src/components/Settings/AIModelSelectionSection.tsx`,
`frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx`,
`frontend-modern/src/components/Settings/AIChatMaintenanceSection.tsx`,
`frontend-modern/src/components/Settings/AISettingsStatusAndActions.tsx`,
`frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx`,
`frontend-modern/src/components/Settings/AISettingsDialogs.tsx`, and
@@ -7945,13 +7732,12 @@ representative top-level panels such as
the canonical AI settings runtime boundary. `AISettings.tsx` is the shell,
`frontend-modern/src/components/Settings/useAISettingsState.ts` owns the
runtime lifecycle and persistence flow, model/provider setup now routes
through `AIModelSelectionSection.tsx`, discovery, budget, timeout, and
permission controls route through `AIRuntimeControlsSection.tsx`, chat
maintenance routes through `AIChatMaintenanceSection.tsx`, and readiness plus
through `AIModelSelectionSection.tsx`, budget and timeout controls route
through `AIRuntimeControlsSection.tsx`, and readiness plus
save/test actions route through `AISettingsStatusAndActions.tsx`.
`AISettingsStatusAndActions.tsx` may expose provider connection status and
test actions only for the Provider & Models page; section pages reuse the save
bar without making Patrol or Assistant look like provider setup
bar without making Patrol or External agents look like provider setup
screens. Future AI settings work must extend those section owners instead of
re-inlining large runtime subsections into the shell.
Provider-specific settings fields inside
@@ -8240,34 +8026,18 @@ activation/recovery routes may still render their owned content, but sidebar
discovery, trial CTAs, plan-review links, plan upsells, and feature upgrade
links must not appear unless an explicit handoff, hosted-mode policy, or active
entitlement says they should.
That same shared app-shell boundary now also owns assistant bootstrap silence
on non-AI routes. `frontend-modern/src/useAppRuntimeState.ts`,
`frontend-modern/src/App.tsx`,
`frontend-modern/src/stores/aiChat.ts`,
`frontend-modern/src/components/AI/Chat/index.tsx` must treat
That same shared app-shell boundary now also owns AI bootstrap silence on
non-AI routes. `frontend-modern/src/useAppRuntimeState.ts`,
`frontend-modern/src/App.tsx`, and
`frontend-modern/src/stores/aiAvailability.ts` must treat
`/api/security/status.sessionCapabilities.assistantEnabled` as the only
general-route assistant availability fact, while closed assistant chrome and
non-AI settings panels stay off `/api/settings/ai` and `/api/ai/*` until an
owned assistant or Patrol surface is actually open. `frontend-modern/src/stores/aiChat.ts`
must therefore stay presentation-only with respect to assistant bootstrap:
org-switch cleanup, keyboard focus, drawer state, and local context/session
persistence belong there, while backend settings/model reads stay on
general-route AI availability fact, while non-AI settings panels stay off
`/api/settings/ai` and `/api/ai/*` until an owned Patrol surface is actually
open. Backend settings/model reads stay on
`frontend-modern/src/stores/aiRuntimeState.ts`. The governed browser proof in
`tests/integration/tests/11-first-session.spec.ts` must continue to assert
that plain settings routes render without assistant bootstrap traffic or
console noise.
When an owned Patrol or alert surface attaches a source-named Assistant
handoff, that same drawer shell must keep the empty conversation state aligned
with the attached briefing as neutral `Context attached` copy instead of
rendering generic cluster/system starter prompts or feature-authored suggested
prompt chips below the source-owned context.
The global Assistant launcher and the command-palette Assistant open command
follow the same contextual rule. They must derive current-view context through
`frontend-modern/src/utils/assistantPageContext.ts`, label the action as asking
about the current monitoring, Patrol, alerts, or settings view, and open the
drawer with that `pulse-view` context attached. They must not call
`aiChatStore.toggle()` or `aiChatStore.open()` without context from the
authenticated shell, because that reintroduces a generic Assistant front door.
that plain settings routes render without AI bootstrap traffic or console
noise.
Shared table, disclosure, and form primitives must also stay explicitly typed
at the browser edge. Summary rows may memoize repeated pending-update reads,
shared buttons must preserve discriminated disclosure props, toggle and a11y
@@ -10076,3 +9846,13 @@ one shape. A host table that scopes keeps its details chevron on phones and
takes the `platform-table-mobile-w-38` identity column (Uptime drops to 10%),
the same allowance the Proxmox node column has for the toggle and dot before
the name.
The browser Assistant chat window was retired on 2026-10-22, taking its
drawer, launcher, command-palette entries, chat action-icon and copy shells,
and the Assistant hand-off buttons on alerts, Patrol, and resource drawers
with it. `frontend-modern/src/stores/aiAvailability.ts` now holds the shell's
AI-enabled fact that the chat store used to carry, and the Patrol tool
transcript block moved to `frontend-modern/src/components/patrol/` with its
`CopyValueButton` composition intact. The External agents settings page lives
at `/settings/pulse-intelligence/agents`; the old `/assistant` path redirects
there.
@@ -438,3 +438,12 @@ store initialization, migration, and read failures stay visible as
as an organization with no roles or users. Local identities and opaque stable
SSO principals are both valid assignment subjects, including principals with
an intentionally empty role set.
The Pulse Intelligence settings panel that hosts Pulse MCP setup is the
External agents panel since 2026-10-22: `settingsPanelRegistryContext.tsx`
registers `AgentIntegrationsPanel` under the `system-ai-agents` tab at
`/settings/pulse-intelligence/agents`, replacing the retired Assistant
settings page. The old `system-ai-assistant` tab id and
`/settings/pulse-intelligence/assistant` path redirect there, so saved links
keep working. Organization-scoped settings ownership, RBAC gating, and the
settings shell's organization presentation rules are unchanged.
@@ -178,7 +178,6 @@ sources, and retains the note as operator context.
27. `frontend-modern/src/utils/patrolRuntimePresentation.ts`
28. `frontend-modern/src/utils/patrolRuntimeActions.ts`
29. `frontend-modern/src/utils/textPresentation.ts`
30. `tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts`
31. `tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts`
32. `frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx`
33. `frontend-modern/src/stores/patrolAttention.ts`
@@ -832,7 +831,6 @@ attention`, `approval needed`, `outcome verified`, `no active work`) instead
5. Add or change Patrol header schedule and runtime presentation through `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`, `frontend-modern/src/utils/aiPatrolSchedulePresentation.ts`, and `frontend-modern/src/utils/patrolRuntimePresentation.ts`.
Patrol must not surface retired hosted-model credit badges or trial-like activation prompts in the normal self-hosted GA app, even when legacy transport fields are still present.
6. Keep Patrol and chat identifier-label presentation aligned through the shared `frontend-modern/src/utils/textPresentation.ts`
7. Keep Patrol and chat stream-matching / mention dedupe aligned through the shared `frontend-modern/src/utils/chatIdentifiers.ts`
8. Keep Patrol transport and payload changes aligned through the governed AI runtime and API contract transport surfaces
## Forbidden Paths
@@ -1003,158 +1001,27 @@ clear`, `Found N new issues`, `Fixed N issues`, `N issues still open`, or
not as a broad automation promise.
8. Keep the Patrol store aligned with the shared structured investigation
record when transport carries one. `frontend-modern/src/stores/aiIntelligence.ts`
may retain `investigationRecord` as data for Assistant handoff and Patrol
presentation, but visible Patrol copy and Assistant handoff context
must flow through the governed Patrol investigation-context helpers. Those
helpers also own the Assistant drawer briefing content for Patrol records,
including the rule that action artifact commands are summarized by count only
and never rendered as raw command text in the handoff surface. When Patrol
hands a pending fix or approval into Assistant, the shared handoff may retain
only structured action references such as approval ID, status, request/expiry
timestamps, action plan identity, fix ID, risk, target, and resource
identity; approval and execution authority stays with the governed
approval/remediation surfaces. Assistant may refresh the referenced
approval's current status for review, and the backend handoff builder must
recover a live pending Patrol investigation-fix approval by finding ID when
the durable record lacks the current approval reference, but Patrol
presentation must still keep command payloads inside governed
approval/remediation context rather than rendering them as handoff copy.
Assistant's governed action artifact metadata must derive from the same
structured handoff action after that recovery, so the briefing cannot
contradict the action reference passed to chat execution while still leaving
next-step reasoning to the configured LLM.
Assistant may also hydrate the referenced action plan or action audit so the
handoff explains current action lifecycle state, requester, capability,
approval policy, plan expiry, preflight/dry-run posture, and terminal
success/failure without treating approval as execution authority or exposing
raw command/execution payloads. Assistant may also
enrich that same handoff with refreshed unified finding and
investigation-record state, canonical resource-policy guidance, current
canonical resource-state and capability context, canonical
resource-relationship context, and recent canonical resource-timeline changes
for explanation; Assistant resolves those state, topology, and timeline
lookups through the current canonical unified-resource model before falling
back to handoff IDs where compatibility requires it.
When a Patrol finding declares root-cause or correlated finding IDs, the
Assistant handoff may also resolve those related findings through the current
unified finding store and summarize them as model-only context. Those
related finding summaries must include current recency and latest lifecycle
facts when present, and may seed their own structured resources for the same
canonical policy, state, topology, and timeline hydration, but Patrol
presentation must treat them as explanation for the current finding rather
than approval, lifecycle, disclosure, or execution authority.
Assistant handoffs from Patrol findings must also include a concise factual
finding briefing derived from the unified finding and structured
investigation record before the detailed finding context, so Assistant
receives current risk, recency, evidence snapshot, verification summary,
conclusion, latest lifecycle event, and governed approval/action artifact
metadata instead of behaving like a generic chat over a pasted incident dump.
Patrol is the scheduled
probe, context assembler, and execution-governance owner; the configured LLM
is the diagnostic and remediation-reasoning owner. Patrol handoffs may
provide system context, resource posture, action posture, and governed tools,
but ordinary context attachment must not synthesize, pre-fill, or submit
chat prompts. The explicitly labelled Explain action starts the shared
evidence-first Assistant request with the selected attention record, bounded
evidence, resource references, and the canonical finding handoff when exactly
one finding is linked. Ambiguous links must not select an arbitrary finding.
Neither path may force active tool use, name a required tool path, show
suggested prompt chips, or present
a Patrol-authored remediation answer for the LLM to execute. Patrol
runs must still call the configured model when deterministic triage is quiet,
and unmatched deterministic signals may be returned as context for another
model pass but must not be converted into Pulse-authored findings after the
model declines to report them. Legacy unified AI-finding integration must
likewise store model-reported findings as evidence and must not auto-generate
remediation plans from finding metadata.
Visible Patrol
Assistant handoffs must not make the operator think Pulse has already
produced the correct fix. The visible Assistant drawer briefing opened from a Patrol
finding must be compact and source-named: current status/risk, one primary
subject, and any approval-required boundary, with richer evidence, action
artifacts, and command counts staying in model-only or governed action
context rather than drawer chrome; prompt suggestions must not be generated
for Patrol handoffs. When a
structured investigation record is not available yet, the same Patrol-owned
helper must still brief the operator from current finding facts such as
active status, severity, recurrence, and loop state instead of opening a
generic empty Assistant drawer. When a live pending Patrol approval exists
for that finding, the visible Assistant briefing may include only safe
approval metadata such as approval ID, pending status, risk, requested time,
expiry, target label, generated approval summary, and command count; it must
not copy the approval command payload into Assistant drawer prose. The
model-only runtime briefing must apply that same
recovered approval reference when adding factual governed action artifact
metadata. The initial prompt for a Patrol finding may include governed action
context when safe metadata is attached, but it must frame that context as
input for the LLM to evaluate rather than as a Patrol-authored remediation
answer. Finding
handoffs must be assembled through the Patrol-owned handoff model so the
prompt, visible briefing, model-only finding context, resource reference,
bounded action reference, and drawer approval-required disclosure stay in
sync. The model-only context may include
current finding status, recurrence, investigation record facts, evidence,
verification, approval state, dry-run posture, existing action artifact
summary, target resource references, and governed action references without
raw command payloads. Patrol action summaries in
`frontend-modern/src/components/patrol/ApprovalSection.tsx` that open
Assistant must follow that same Patrol-owned handoff model rather than a
prompt-only local shortcut: pass approval ID/status/risk/target plus safe
summary/count metadata as review context, attach the target resource
reference, include bounded `handoff_actions` for live approvals or structured
action artifacts when present, mark the drawer's approval-required
disclosure, attach the Patrol-owned visible drawer briefing for the pending approval or
queued-fix recovery state, and never paste the approval command or
action command text into the chat prompt. Existing remediation-plan or
action-plan artifacts follow the same boundary: plan status, risk, and
command counts are allowed as non-authoritative governed action posture for
the LLM to critique, but visible handoffs must not render Patrol step lists
or suggested prompt chips, and command or rollback command text stays in the
governed remediation or approval surface. Generic finding
discussion handoffs for any non-empty Patrol `finding_id`, including
context-only findings and findings that reference a live approval, action
artifact, fix outcome, or remediation plan, run in the approval-required
mode `/api/ai/chat` sets server-side for every request; the browser sends no
execution-mode field, so default autonomous Assistant settings cannot bypass
the Patrol action-governance boundary. The assembled handoff must still pass
through the Assistant runtime's resource-policy sanitizer before prompt
injection, so Patrol-owned prose
cannot leak governed resource names, IDs, aliases, nodes, paths, or
addresses outside the canonical policy boundary.
If a `fix_queued` finding no longer has a live approval payload or detailed
action artifact payload available, the recovery action must still open Assistant
with the same Patrol-owned visible briefing and approval-required posture
from current finding facts; it must not fall back to generic investigation
chat or invite execution from missing command details.
Patrol run-history Assistant handoffs are a separate run-context surface:
the visible drawer must keep the `Patrol run attached` headline, may show the
scoped run subject, safe runtime-failure/action label, and classified
redacted failure summary, and must continue to omit raw provider payloads,
command text, and remediation instructions.
If the live approval is gone but the structured action artifact payload is still
available, the recovery Assistant briefing must carry only safe action artifact
metadata such as description, target, risk, rationale, destructive posture,
and command count. Raw command text remains in the governed remediation or
approval panel, while Assistant gets enough context to explain approval
recovery and risk without becoming an execution surface.
Generic finding-level Assistant handoffs must use that same safe metadata
boundary when the list response lacks a full investigation record: they may
hydrate the latest investigation session to recover action artifact summary,
risk, target, rationale, destructive posture, and command count, but they must
still keep command text out of both the user-authored prompt and visible
Assistant briefing.
If the referenced finding is no longer current, Assistant must drop the
stored handoff instead of continuing from stale Patrol context. Assistant
handoff context must also carry the unified
finding's current lifecycle and recency facts, including active/resolved/
snoozed/dismissed/suppressed state, detection/last-seen/resolution
timestamps, recurrence/regression, and recent lifecycle events, rather than
treating the investigation conclusion as the whole current record. Detailed
lifecycle events in Assistant handoff context must stay bounded, model-only,
and explicitly separated from approval/execution authority. Patrol must keep
the visible finding and drawer briefing tied to the shared investigation
payload rather than forking a Patrol-local lifecycle, policy, topology, or
timeline summary. The inline Patrol investigation surface must also treat a
may retain `investigationRecord` as data for Patrol presentation, and visible
Patrol copy must flow through the governed Patrol investigation-context
helper (`buildPatrolInvestigationRecordPresentation` in
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`),
which summarizes action artifact commands by count only and never renders
raw command text. Approval and execution authority stays with the governed
approval/remediation surfaces. The finding, approval, remediation-plan,
run-history, and assessment hand-offs into the browser Assistant drawer
that this obligation used to govern were retired with the chat window on
2026-10-22, and Patrol surfaces must not grow a chat hand-off back. The
backend chat runtime keeps its finding-context rules for clients that still
send a Patrol `finding_id` (see `ai-runtime`).
Patrol is the scheduled probe, context assembler, and execution-governance
owner; the configured LLM is the diagnostic and remediation-reasoning owner.
Patrol runs must still call the configured model when deterministic triage
is quiet, and unmatched deterministic signals may be returned as context for
another model pass but must not be converted into Pulse-authored findings
after the model declines to report them. Legacy unified AI-finding
integration must likewise store model-reported findings as evidence and must
not auto-generate remediation plans from finding metadata.
The inline Patrol investigation surface must also treat a
structured `investigationRecord` as investigation data, even when the legacy
investigation-detail endpoint has no separate session payload, so it must not
render empty-state copy above a durable Patrol record. The Patrol-owned
@@ -1164,11 +1031,10 @@ clear`, `Found N new issues`, `Fixed N issues`, `N issues still open`, or
renderer surfaces an explicit `Impact not assessed` placeholder whenever
an investigation record exists with no impact text, and a parallel
`Rollback not specified` placeholder whenever rollback is empty, so the
operator-visible gap is conspicuous rather than hidden. The Patrol
finding briefing card built by
operator-visible gap is conspicuous rather than hidden. The record
presentation built by
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`
may include populated impact text in its bounded detail lines but must
omit the placeholder copy from that compact card so it stays tight,
may carry populated impact text but must omit the placeholder copy,
leaving placeholder rendering to the full investigation surface.
`frontend-modern/src/components/AI/FindingsPanel.tsx` is the canonical
renderer for that operator-facing impact text on the unified findings
@@ -1177,7 +1043,7 @@ clear`, `Found N new issues`, `Fixed N issues`, `N issues still open`, or
On non-Patrol findings views, the same panel may surface
`investigation_record.confidence` as a collapsed-row badge beside the
investigation outcome. The default Patrol page keeps that process evidence in
the expanded issue, run history, or Assistant context so current issue rows
the expanded issue or run history so current issue rows
stay readable.
Finding rows must keep their disclosure control separate from inline manual
controls: the row summary may be a keyboard-accessible expand/collapse
@@ -1187,8 +1053,7 @@ clear`, `Found N new issues`, `Fixed N issues`, `N issues still open`, or
`FindingsPanel.tsx` also renders the
`previousResolvedFixSummary` operational-memory field directly on
the expanded finding card with emerald-accented styling, so
operators see "what worked last time" inline without having to
open Assistant. The summary lives on the finding shell
operators see "what worked last time" inline. The summary lives on the finding shell
(`UnifiedFinding`/`Finding`), not on the per-record investigation
presentation; the Patrol-owned investigation-context model must not
absorb it into impact, verification, or rollback (those represent
@@ -1212,31 +1077,22 @@ clear`, `Found N new issues`, `Fixed N issues`, `N issues still open`, or
compact status text. New trust categories must extend the
`FindingsTrustSummary` contract first rather than inventing
per-page keys.
Findings carry contextual Assistant entry points keyed on intent: the
default "Discuss with Assistant" button opens an open-ended chat
handoff, and a parallel "Explain" button opens the same handoff with
a `PatrolAssistantFindingIntent='explain'` seed that asks the LLM to
walk through what we know, why it matters, how confident the
analysis is, what remains uncertain, and what the model would do next. Both
buttons must route through `buildPatrolAssistantFindingHandoff` so
the structured context (investigation record, operational memory,
pending approval, proposed fix, next-step action) is attached
uniformly; only the leading sentence differs by intent. The
badge palette is provided by `getInvestigationConfidenceBadgeClasses`
in `frontend-modern/src/utils/aiFindingPresentation.ts`: high is
reassuringly emphasized, medium is neutral, low is a soft amber.
Findings without an investigation record (or without a recorded
confidence) must show no confidence badge rather than defaulting to
one, mirroring the impact rule that absent metadata is not fabricated.
Expanded Patrol finding cards must keep action density product-grade: one
primary Assistant intent button is visible inline (`Investigate`, `Verify
fix`, or `Explain` based on current finding state), while secondary
Assistant intents and operator management controls live behind compact
in-flow `Assistant` and `Manage` menus. Those menus must render inside the
expanded finding detail rather than as clipped floating panels, and the
Expanded Patrol finding cards keep action density product-grade: one
primary action is visible inline (the finding's governed primary action
when it has one, otherwise `Investigate` for an un-investigated active
finding, which starts Patrol's investigation through
`reinvestigateFinding`), while operator management controls live behind a
compact in-flow `Resolve or dismiss` menu. That menu must render inside the
expanded finding detail rather than as a clipped floating panel, and the
per-finding "Create rule from this" action must remain disabled unless the
finding has both a concrete resource and category for a scoped suppression
rule.
rule. The confidence badge palette is provided by
`getInvestigationConfidenceBadgeClasses` in
`frontend-modern/src/utils/aiFindingPresentation.ts`: high is reassuringly
emphasized, medium is neutral, low is a soft amber. Findings without an
investigation record (or without a recorded confidence) must show no
confidence badge rather than defaulting to one, mirroring the impact rule
that absent metadata is not fabricated.
An actionable active finding must always expose a forward path into the
governed action loop at its own expanded row. Above Watch only, that is the
inline `Investigate` intent. In Watch only, the same expanded-row slot
@@ -1272,35 +1128,11 @@ fix`, or `Explain` based on current finding state), while secondary
than dropping it; runtime-failure findings produced by
`internal/ai/patrol_runtime_failure.go` are the first source to depend
on that path reaching the Patrol page surface.
Patrol no longer opens a whole-surface assessment handoff: the summary card
that offered it and its builder in
`frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` are
gone. Assistant sessions saved from that handoff still restore through the
shared drawer as `handoff_metadata.kind=patrol_assessment` with the
`patrol-assessment` target and `targetId=pulse-patrol-assessment`, never as
generic scoped context or as a single-finding session because one bounded
action reference names a finding. Saved assessment and finding sessions must
not expose or restore Patrol-authored next-step titles, recommendation
detail, action labels, or app-route hrefs through `handoff_summary`; legacy
stored recommendation fields are ignored rather than converted into hidden
context.
Patrol run-history entries may also open Assistant for a selected run, but
that handoff must flow through the same Patrol-owned investigation-context
model rather than a row-local prompt. The browser-visible prompt and drawer
briefing may include only classified, redacted runtime-failure summaries and
safe run identity facts. The browser request must send safe
`handoff_metadata` for the saved-session identity envelope: kind
`patrol_run`, run ID, safe run type/status, and a runtime-failure boolean,
never provider-bound runtime failure detail, analysis text, tool traces, raw
provider payloads, or scoped resource context. Backend Assistant runtime owns
the model-only run context: it resolves the run ID from Patrol history,
rebuilds bounded run facts, scoped resource references, sanitized analysis,
and classified failure detail server-side, and rehydrates the same context
from stored metadata on follow-up turns. The exchange runs in the
server-set approval-required mode; the handoff must present a source-named
visible drawer briefing and
frame Assistant as explanation and next-step review rather than execution or
automatic retry authority.
Assistant sessions saved from the retired Patrol assessment, finding, and
run hand-offs may still exist server-side. Their `handoff_summary` must not
expose or restore Patrol-authored next-step titles, recommendation detail,
action labels, or app-route hrefs; legacy stored recommendation fields are
ignored rather than converted into hidden context.
9. Keep the normal Patrol status summary plain and operator-first rather
than a hero-style or decorative card surface. The default page renders no
status summary card or assessment strip today: the header control row
@@ -1751,8 +1583,7 @@ Proxmox monitoring lens before Patrol or Assistant chrome, while the stable
Patrol tab may still expose the open-work count. Calm-day copy such as `No
current issues` and checked-resource coverage belongs inside the Patrol route
after the operator chooses Patrol; the monitor lens must not become a hidden
Patrol all-clear page, and Assistant launchers must stay scoped to the current
surface.
Patrol all-clear page.
The readiness contract now applies before Patrol work is admitted, not only
after a page render: recoverable Patrol provider/model settings saves must
persist and echo structured readiness cause metadata, manual run requests must
@@ -1942,10 +1773,8 @@ model`), and that action must use the canonical Pulse Intelligence
route `/settings/pulse-intelligence/patrol` rather than the legacy
`/settings/system-ai` compatibility alias. Runtime/setup findings in the
default current-issues flow must stay primary-action-only: they should not add
a visible `Open in Assistant` secondary button or a generic `Manage` menu
beside the direct setup action.
Assistant can still receive runtime context from the broader Patrol/Assistant
handoff paths, but a setup impairment should not read as a menu of workflows.
a secondary chat button or a generic `Manage` menu beside the direct setup
action; a setup impairment should not read as a menu of workflows.
When setup is the only active Patrol work, the Patrol page should collapse that
state into one `Fix Patrol setup` workspace task with the direct
`Open Provider & Models` action; the readiness banner must not duplicate that
@@ -2948,3 +2777,13 @@ handoff. The attached Assistant context suppresses unrelated empty-chat
starters and recent sessions. Starting a new conversation clears that context
and restores the ordinary welcome surface. Regression and current-browser
qualification are recorded in the customer-journey qualification document.
Patrol stopped handing work to the browser Assistant on 2026-10-22, when the
chat window was retired. Finding cards, approval rows, run history, and the
attention workbench lost their "Discuss", "Explain with Assistant", and
"Ask Assistant" actions; the inline `Investigate` action (a Patrol
re-investigation) and the governed approval controls stay. Alert Investigate
now starts a targeted Patrol run, so alert follow-up lands on the Patrol page.
`patrolInvestigationContextModel.ts` shrank to the investigation-record
presentation that `InvestigationSection` uses, and the Patrol tool transcript
block moved to `frontend-modern/src/components/patrol/ToolExecutionBlock.tsx`.
@@ -1920,30 +1920,16 @@ path. `frontend-modern/src/pages/Dashboard.tsx`,
surfaces for KPI cards, problem-resource rows, or top-infrastructure
identities. New summary cards must live on their owning product route and
prove their data path there.
32. Keep assistant and other globally mounted consumers off deleted dashboard
summary state. Globally mounted helpers such as
`frontend-modern/src/components/AI/Chat/index.tsx` must read the live websocket snapshot or existing unified-resource cache
rather than forcing root navigation to pay for a replacement dashboard
overview transport. When the assistant shell changes presentation,
`frontend-modern/src/utils/aiChatPresentation.ts` must remain the canonical
owner for launcher, drawer, session-menu, and empty-state copy so hot-path
consumers do not grow one-off inline strings or extra state branches alongside
the mounted shell. Blocking shared dialogs must also suppress closed assistant
affordances through the shared dialog runtime instead of leaving the mounted
shell clickable behind another overlay.
Approval presentation inside that mounted assistant shell must stay
state-local to the existing drawer/session state and backend approval
endpoints. Deny/skip failure handling may preserve the pending approval
card, but it must not add polling, resource hydration, or mounted-shell work
to recover UI state.
That same mounted-shell hot path must protect usable width on constrained
viewports. When the shared assistant drawer opens inside
`frontend-modern/src/components/AI/Chat/index.tsx`, it may not shrink the
infrastructure or dashboard operating surface below a workable narrow-width
floor just because the assistant stays mounted in the app shell; below the
canonical dock threshold, the assistant must switch to an overlay drawer so
table filters, grouped rows, and other hot-path controls keep their
existing layout budget instead of paying a second collapse cost.
32. Keep globally mounted consumers off deleted dashboard summary state.
Globally mounted shell helpers must read the live websocket snapshot or
the existing unified-resource cache rather than forcing root navigation to
pay for a replacement dashboard overview transport, and must not grow
one-off inline strings or extra state branches on that hot path. The
browser Assistant drawer this obligation was written for was retired on
2026-10-22, so no chat shell stays mounted in the app shell any more; a
future globally mounted AI surface inherits this same budget, including
the rule that it may not shrink a primary operating surface below a
workable width.
34. Keep the retired dashboard page-header path absent from the compact hot
path. New page headers must stay pure presentation on their owning route
and must not introduce a second data load, widen suspense ownership, or
@@ -4426,3 +4412,14 @@ windowing budget, runway and mounted-row counts are unchanged. The Docker host
drawer loads its history charts lazily inside `Suspense` like the node and
guest drawers, keeping them out of the table chunk, and its action history is
fetched only when Manage opens.
The app shell lost its largest globally mounted consumer on 2026-10-22, when
the browser Assistant chat window was retired: the drawer, its launcher, and
its lazily loaded `Chat` and `vendor-highlight` chunks are gone, and the
production build ships roughly 100 kB less gzipped JavaScript than the main it
branched from. `useAppRuntimeState.ts` now seeds AI availability through
`frontend-modern/src/stores/aiAvailability.ts`, and the Workloads keyboard
handler in `useWorkloadsControlsState.ts` no longer checks for a focused chat
composer. `.bundlesize.json` drops the retired chunks; `useUnifiedResources`
grew to about 10 kB gzipped because Rollup moved the shared filter toolbar
modules into it once the `Chat` chunk stopped sharing them.
@@ -49,14 +49,6 @@
"api-contracts"
]
},
{
"path": "frontend-modern/src/api/aiChat.ts",
"rationale": "the Assistant chat frontend client is both the first-party Assistant transport surface and a canonical API payload contract boundary",
"subsystems": [
"ai-runtime",
"api-contracts"
]
},
{
"path": "frontend-modern/src/api/generated/agentCapabilities.ts",
"rationale": "the generated agent capabilities frontend types are both the Pulse Intelligence manifest TypeScript projection and a canonical API payload contract boundary",
@@ -129,6 +121,14 @@
"deployment-installability"
]
},
{
"path": "frontend-modern/src/api/workflowPrompts.ts",
"rationale": "the workflow prompt activity client is both the AI runtime workflow-prompt telemetry surface and a canonical API payload contract boundary",
"subsystems": [
"ai-runtime",
"api-contracts"
]
},
{
"path": "frontend-modern/src/components/CommercialMigrationBanner.tsx",
"rationale": "the global commercial migration notice is both a cloud-paid entitlement recovery surface and a shared app-shell notice primitive consumer",
@@ -420,8 +420,8 @@
]
},
{
"path": "frontend-modern/src/stores/aiChat.ts",
"rationale": "the assistant drawer and session store is both an AI runtime control surface and a canonical app-shell presentation boundary",
"path": "frontend-modern/src/stores/aiAvailability.ts",
"rationale": "the AI availability store is both the AI runtime session capability and the app-shell gate for Patrol entry points",
"subsystems": [
"ai-runtime",
"frontend-primitives"
@@ -2005,7 +2005,6 @@
"owned_prefixes": [
"cmd/patrol-qualify/",
"cmd/pulse-mcp/",
"frontend-modern/src/components/AI/Chat/",
"internal/agentcapabilities/",
"internal/ai/",
"internal/mutationregistry/",
@@ -2018,15 +2017,13 @@
"docs/AI_PATROL_QUALIFICATION.md",
"frontend-modern/src/api/agentCapabilities.ts",
"frontend-modern/src/api/ai.ts",
"frontend-modern/src/api/aiChat.ts",
"frontend-modern/src/api/generated/agentCapabilities.ts",
"frontend-modern/src/api/patrol.ts",
"frontend-modern/src/api/workflowPrompts.ts",
"frontend-modern/src/components/AI/AICostDashboard.tsx",
"frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx",
"frontend-modern/src/stores/aiChat.ts",
"frontend-modern/src/stores/aiAvailability.ts",
"frontend-modern/src/stores/aiRuntimeState.ts",
"frontend-modern/src/utils/aiChatPresentation.ts",
"frontend-modern/src/utils/aiControlLevelPresentation.ts",
"frontend-modern/src/utils/aiCostPresentation.ts",
"frontend-modern/src/utils/aiProviderHealthPresentation.ts",
"frontend-modern/src/utils/aiProviderPresentation.ts",
@@ -2165,8 +2162,8 @@
"match_prefixes": [],
"match_files": [
"frontend-modern/src/api/ai.ts",
"frontend-modern/src/api/aiChat.ts",
"frontend-modern/src/api/patrol.ts",
"frontend-modern/src/api/workflowPrompts.ts",
"internal/api/ai_handler.go",
"internal/api/ai_handlers.go",
"internal/api/ai_intelligence_handlers.go"
@@ -2207,22 +2204,17 @@
},
{
"id": "ai-chat-runtime-read-model",
"label": "AI chat and shared runtime read model proof",
"match_prefixes": [
"frontend-modern/src/components/AI/Chat/"
],
"label": "AI availability and shared runtime read model proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/stores/aiChat.ts",
"frontend-modern/src/stores/aiAvailability.ts",
"frontend-modern/src/stores/aiRuntimeState.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [
"frontend-modern/src/components/AI/Chat/__tests__/"
],
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/AI/__tests__/AICostDashboard.test.tsx",
"frontend-modern/src/stores/__tests__/aiChat.test.ts",
"tests/integration/tests/42-vmware-ai-chat-read-recovery.spec.ts"
"frontend-modern/src/stores/__tests__/aiAvailability.test.ts"
]
},
{
@@ -2230,16 +2222,12 @@
"label": "AI presentation helper proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/utils/aiChatPresentation.ts",
"frontend-modern/src/utils/aiControlLevelPresentation.ts",
"frontend-modern/src/utils/aiProviderHealthPresentation.ts",
"frontend-modern/src/utils/aiProviderPresentation.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts",
"frontend-modern/src/utils/__tests__/aiControlLevelPresentation.test.ts",
"frontend-modern/src/utils/__tests__/aiProviderHealthPresentation.test.ts",
"frontend-modern/src/utils/__tests__/aiProviderPresentation.test.ts",
"frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts"
@@ -2418,7 +2406,6 @@
"frontend-modern/src/utils/alertSchedulePresentation.ts",
"frontend-modern/src/utils/alertSeverityPresentation.ts",
"frontend-modern/src/utils/alertTabsPresentation.ts",
"frontend-modern/src/utils/alertTargetTypes.ts",
"frontend-modern/src/utils/alertThresholdDefaults.ts",
"frontend-modern/src/utils/alertThresholdsPresentation.ts",
"frontend-modern/src/utils/alertThresholdsSectionPresentation.ts",
@@ -2559,23 +2546,9 @@
"frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts",
"frontend-modern/src/pages/__tests__/Alerts.readOnly.test.tsx",
"frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts",
"frontend-modern/src/utils/__tests__/alertTargetTypes.test.ts",
"scripts/check-incident-request-ownership.mjs"
]
},
{
"id": "alert-target-type-resolution",
"label": "alert target type resolver proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/utils/alertTargetTypes.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/utils/__tests__/alertTargetTypes.test.ts"
]
},
{
"id": "alert-configuration-presentation-helpers",
"label": "alert configuration presentation helper proof",
@@ -3027,17 +3000,15 @@
]
},
{
"id": "ai-chat-stream-event-types",
"label": "AI chat SSE generated type proof",
"id": "generated-api-types",
"label": "generated API type proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/api/generated/aiChatEvents.ts",
"scripts/generate-types.go"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/api/__tests__/aiChatEvents.test.ts",
"internal/api/contract_test.go"
]
},
@@ -5408,7 +5379,6 @@
"frontend-modern/src/components/Infrastructure/useTableWindowing.ts",
"frontend-modern/src/components/Login.tsx",
"frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx",
"frontend-modern/src/components/Settings/AIChatMaintenanceSection.tsx",
"frontend-modern/src/components/Settings/AIModelSelectionSection.tsx",
"frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx",
"frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx",
@@ -5501,7 +5471,7 @@
"frontend-modern/src/index.css",
"frontend-modern/src/pages/Docs.tsx",
"frontend-modern/src/routing/routePreload.ts",
"frontend-modern/src/stores/aiChat.ts",
"frontend-modern/src/stores/aiAvailability.ts",
"frontend-modern/src/stores/updates.ts",
"frontend-modern/src/utils/aiSettingsPresentation.ts",
"frontend-modern/src/utils/diagnosticsPresentation.ts",
@@ -5684,7 +5654,6 @@
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx",
"frontend-modern/src/components/Settings/AIChatMaintenanceSection.tsx",
"frontend-modern/src/components/Settings/AIModelSelectionSection.tsx",
"frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx",
"frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx",
@@ -5862,13 +5831,13 @@
"label": "assistant app-shell bootstrap proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/stores/aiChat.ts"
"frontend-modern/src/stores/aiAvailability.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/__tests__/App.architecture.test.ts",
"frontend-modern/src/stores/__tests__/aiChat.test.ts",
"frontend-modern/src/stores/__tests__/aiAvailability.test.ts",
"tests/integration/tests/11-first-session.spec.ts"
]
},
@@ -7225,7 +7194,6 @@
"frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts",
"frontend-modern/src/utils/__tests__/patrolPagePresentation.test.ts",
"tests/integration/tests/18-patrol-runtime-state.spec.ts",
"tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts",
"tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts",
"tests/integration/tests/91-operational-trust-attention-workbench.spec.ts"
]
@@ -3644,3 +3644,17 @@ weakened. The diagnostic wire/identity and ordinary same-credential recovery
controls in `diagnostics_access_test.go`, `diagnostics_additional_test.go`,
`contract_test.go` and `client_request_test.go` cover these properties; they do
not establish provider or installed acceptance.
The i18n catalogs dropped the retired Assistant alert hand-off strings on
2026-10-22. The `alerts.assistant.*` keys were replaced by user-visible
`alerts.investigate.*` action labels in English, German, and Spanish, and their
entries left the allowed-identical-translation lists in
`frontend-modern/src/i18n/policy.ts`. No alert identifiers, resource names, commands, or machine-facing
text moved into translated copy, and the untranslated-content policy is
otherwise unchanged.
The security status `settingsCapabilities.patrolRun` field (2026-10-22) is a
read-only projection of the existing RequireAdmin + `ai:execute` gate on
`POST /api/ai/patrol/run`. It grants nothing: the route still enforces its own
gate, and the field only stops the browser offering alert Investigate to
sessions that route would refuse.
@@ -2755,7 +2755,7 @@ change. Fake persistent-logger controls in
7. Letting storage `source` aliases or case drift survive in canonical route state; shared storage URLs must rewrite pasted values like `PVE`, `pbs`, or `ALL` to the owned source option values (for example `proxmox-pve`) or the canonical unset state so copied links match the same source filter values the storage toolbar presents
8. Letting explicit storage `all` sentinels survive in canonical route state; shared storage URLs must collapse case- or whitespace-variant `all` values for the managed `node` filter back to the canonical unset state so copied links do not preserve a fake active node filter
9. Reintroducing an aggregate cross-platform Recovery page, a recovery route-state owner, or a shared recovery query serializer in `frontend-modern/src/routing/resourceLinks.ts` without a governed product decision. The aggregate page (`frontend-modern/src/components/Recovery/` with its state owner `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`) was deleted on 2026-05-26, and `RECOVERY_QUERY_PARAMS`, `parseRecoveryLinkSearch`, and `buildRecoveryRouteSearch` were deleted once nothing read them. Recovery rows render in the platform-owned Proxmox Backups and TrueNAS Protection tabs; the Proxmox Backups tab owns its route state through the `/proxmox/backups/:view` segment and `PROXMOX_BACKUPS_QUERY_PARAMS`, and the TrueNAS Protection tab keeps its search and status filter in component state and its sort in local storage, outside the URL.
10. Letting adjacent route state in shared `frontend-modern/src/routing/resourceLinks.ts` reuse storage or Proxmox Backups query names or alter their parse/build behavior. Workload platform scoping, unified-resource storage deep links, `buildStandalonePath()` platform paths, the Kubernetes page's route-local `KUBERNETES_QUERY_PARAMS.cluster` scope, the Patrol control starter (`patrolControlStarter=patrol_control`, with legacy `operationsLoopStarter=patrol_control`, `operationsLoopStarter=patrol_autonomy`, and `operationsLoopStarter=pulse_pro_activation` as compatibility aliases only), and the Pulse Intelligence external-agent setup anchors (`/settings/pulse-intelligence/assistant#external-agent-setup`, with `/settings/security/api#external-agent-setup` and `/settings/security/api#pulse-mcp-setup` as legacy compatibility hashes) share that module, and none of them is recovery state, storage focus, platform scope, or proof of backup or recovery posture.
10. Letting adjacent route state in shared `frontend-modern/src/routing/resourceLinks.ts` reuse storage or Proxmox Backups query names or alter their parse/build behavior. Workload platform scoping, unified-resource storage deep links, `buildStandalonePath()` platform paths, the Kubernetes page's route-local `KUBERNETES_QUERY_PARAMS.cluster` scope, the Patrol control starter (`patrolControlStarter=patrol_control`, with legacy `operationsLoopStarter=patrol_control`, `operationsLoopStarter=patrol_autonomy`, and `operationsLoopStarter=pulse_pro_activation` as compatibility aliases only), and the Pulse Intelligence external-agent setup anchors (`/settings/pulse-intelligence/agents#external-agent-setup`, with `/settings/security/api#external-agent-setup` and `/settings/security/api#pulse-mcp-setup` as legacy compatibility hashes) share that module, and none of them is recovery state, storage focus, platform scope, or proof of backup or recovery posture.
11. Letting recovery row details on the Proxmox Backups or TrueNAS Protection tabs lead with transport-shaped payloads; operator-facing details must summarize outcome, artifact, target, and readable metadata labels first, and raw JSON or provider-specific keys must not lead the primary detail.
12. Letting storage or recovery surfaces invoke retired self-hosted trial acquisition; `POST /api/license/trial/start` and `/auth/trial-activate` must stay closed on the ordinary self-hosted router, and storage/recovery-adjacent billing or support handoffs must not treat trial activation as recovery identity, restore proof, or backup transport state.
13. Treating an `AvailabilityData` facet on a storage resource as backup, protection, or recovery evidence; an agentless probe attached to a NAS, PBS, or datastore resource is monitoring reachability, not backup success, snapshot health, or restore readiness. Storage and recovery must read protection status from the canonical backup and storage resources, not from the availability probe facet.
@@ -7159,6 +7159,15 @@ nodes) now renders through the shared `PlatformHostScopedTableTitle` that the
Docker and vSphere overviews also use; its Storage, Replication, Backups, Ceph
and Mail Gateway tabs, and every storage and recovery rule, are unchanged.
The Pulse Intelligence external-agent setup anchor named in Forbidden Path 10
moved to `/settings/pulse-intelligence/agents#external-agent-setup` on
2026-10-22, when the browser Assistant chat window was retired. It is still
adjacent settings route state in `frontend-modern/src/routing/resourceLinks.ts`
and carries no recovery or storage meaning. The same change adds the read-only
`settingsCapabilities.patrolRun` security-status field in `internal/api`, a
projection of the Patrol run route's admin gate that opens no storage or
recovery path.
The `internal/api` changes for typed Patrol investigation conclusions open no
storage, backup or recovery path. They hand the run's typed conclusion to the
private orchestrator, make investigation concurrency and timeout follow the
@@ -2700,9 +2700,7 @@ the route-backed Patrol control starter helpers, and the `buildStandalonePath`,
Per-platform surfaces and tab specs must
derive every internal link from those builders so the canonical resource
URL vocabulary stays single-sourced; ad hoc string concatenation of
platform routes inside feature directories is not permitted. The canonical
Pulse Intelligence external-agent hash
`/settings/pulse-intelligence/assistant#external-agent-setup` and legacy
platform routes inside feature directories is not permitted. The canonical Pulse Intelligence external-agent hash `/settings/pulse-intelligence/agents#external-agent-setup` and legacy
`/settings/security/api#external-agent-setup` /
`/settings/security/api#pulse-mcp-setup` compatibility hashes may live in
the shared route helper, but they are adjacent settings route state, not
@@ -8695,3 +8693,12 @@ ID within its vCenter connection so two vCenters' `host-12` stay apart; a host
with no VMs gets a scope that names it and matches none. A host table with no
workloads under it (Docker Swarm hosts) keeps opening the drawer on a row
click. No resource identity, status or alert rule changes.
Resource surfaces stopped handing context to the browser Assistant on
2026-10-22, when the chat window was retired: the resource and guest drawer
Assistant context models are gone, the operator lock copy in
`ResourceOperatorStateSection.tsx` no longer names the Assistant, and
`frontend-modern/src/pages/Actions.tsx` reads AI availability from
`frontend-modern/src/stores/aiAvailability.ts`. The external-agent setup hash
in `frontend-modern/src/routing/resourceLinks.ts` moved to the Agents route.
Resource identity, policy, and drawer focus state are unchanged.
+1 -10
View File
@@ -22,9 +22,6 @@
"Proxmox": {
"baselineGzip": 80339
},
"Chat": {
"baselineGzip": 67677
},
"AIIntelligence": {
"baselineGzip": 73662
},
@@ -49,9 +46,6 @@
"TrueNAS": {
"baselineGzip": 17053
},
"vendor-highlight": {
"baselineGzip": 13743
},
"vendor-icons": {
"baselineGzip": 11486
},
@@ -79,14 +73,11 @@
"UpdatesSettingsPanel": {
"baselineGzip": 9687
},
"aiControlLevelPresentation": {
"baselineGzip": 9630
},
"AuditLogPanel": {
"baselineGzip": 8806
},
"useUnifiedResources": {
"baselineGzip": 7595
"baselineGzip": 10267
},
"ActionReviewDialog": {
"baselineGzip": 10414
@@ -1,448 +0,0 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/assistant-controls-proof/browser';
const hash = (f) => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
(async () => {
fs.mkdirSync(output, { recursive: true });
const binding = JSON.parse(
fs.readFileSync('/workspace/tmp/assistant-controls-proof/browser-binding.json'),
);
for (const [file, digest] of Object.entries(binding.content_sha256))
assert.equal(hash(path.join('/workspace', file)), digest);
const version = require('playwright/package.json').version;
assert.equal(
version,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5308, strictPort: true, watch: null },
});
const origin = 'http://127.0.0.1:5308';
const results = [],
screenshots = [],
browserVersions = [];
let browser,
phase = 'server',
browserClosed = false,
serverClosed = false;
const result = () => ({
result: 'passed',
base_sha: binding.base_sha,
content_sha256: binding.content_sha256,
playwrightVersion: version,
browserVersions,
results,
screenshots,
browserClosed,
serverClosed,
scope:
'Actual Settings state/save, Chat menus/scoped context and Docs with synthetic local API transport. No model/action/native persistence or deployed/released acceptance.',
});
try {
await server.listen();
for (const [name, engine, width, height, dark] of [
['desktop-light', chromium, 1365, 900, false],
['phone-dark', webkit, 390, 844, true],
['narrow-light', webkit, 320, 740, false],
]) {
browserClosed = false;
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
browserVersions.push({
name,
engine: engine === chromium ? 'chromium' : 'webkit',
version: browser.version(),
});
const page = await browser.newPage({
viewport: { width, height },
isMobile: width < 500,
hasTouch: width < 500,
locale: 'en-GB',
});
page.setDefaultTimeout(8000);
page.setDefaultNavigationTimeout(60000);
const errors = [],
offOrigin = [],
writes = [],
checks = [],
reads = [];
let saved = 'autonomous',
failSave = false;
await page.addInitScript((dark) => {
localStorage.clear();
const apply = () => document.documentElement.classList.toggle('dark', dark);
if (document.documentElement) apply();
else document.addEventListener('DOMContentLoaded', apply, { once: true });
}, dark);
page.on('pageerror', (e) => errors.push(e.message));
await page.routeWebSocket(/.*/, (s) => s.close());
const settings = () => ({
enabled: true,
configured: false,
model: '',
custom_context: '',
control_level: saved,
protected_guests: ['vm-101'],
patrol_autonomy_level: 'full',
patrol_action_emergency_stop: true,
configured_providers: [],
providers: [],
request_timeout_seconds: 300,
});
await page.route('**/*', async (route) => {
const req = route.request(),
url = new URL(req.url());
if (url.origin !== origin) {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
if (req.method() !== 'GET') {
const entry = { method: req.method(), path: url.pathname, payload: req.postDataJSON() };
writes.push(entry);
if (url.pathname !== '/api/settings/ai/update' || req.method() !== 'PUT')
return route.fulfill({
status: 403,
json: { error: 'Fixture refuses inference, actions and unrelated writes' },
});
if (failSave)
return route.fulfill({ status: 503, json: { error: 'Synthetic save unavailable' } });
if (Object.hasOwn(entry.payload, 'control_level')) saved = entry.payload.control_level;
return route.fulfill({
json: {
...settings(),
control_level: ['autonomous', 'suggest'].includes(saved) ? 'controlled' : saved,
},
});
}
reads.push(url.pathname);
if (url.pathname === '/api/settings/ai') return route.fulfill({ json: settings() });
if (url.pathname === '/api/security/status')
return route.fulfill({
json: {
hasAuthentication: true,
requiresAuth: false,
sessionCapabilities: { assistantEnabled: true },
},
});
if (url.pathname.includes('license') || url.pathname.includes('runtime-capabilities'))
return route.fulfill({ json: { capabilities: [], entitlements: [], limits: [] } });
if (url.pathname === '/api/ai/models') return route.fulfill({ json: { models: [] } });
if (url.pathname === '/api/ai/status')
return route.fulfill({ json: { enabled: true, configured: false, running: true } });
if (url.pathname.endsWith('/sessions')) return route.fulfill({ json: { sessions: [] } });
if (url.pathname === '/api/agent/capabilities')
return route.fulfill({
json: {
version: 1,
capabilities: [],
surfaces: [],
workflowPrompts: [],
mcpAdapter: {},
operatorSurfaces: [],
},
});
return route.fulfill({ json: [] });
});
const check = async (label, action) => {
phase = `${name}: ${label}`;
await action();
checks.push({ label, passed: true });
};
const capture = async (state) => {
const file = `${name}-${state}.png`;
await page.screenshot({ path: path.join(output, file) });
screenshots.push({
file,
sha256: hash(path.join(output, file)),
state,
viewport: { width, height },
});
};
const load = async (surface) =>
page.goto(
`${origin}/browser-tests/assistant-control-compatibility.html?surface=${surface}`,
);
try {
for (const level of ['autonomous', 'suggest', 'controlled', 'read_only', 'unknown']) {
saved = level;
await load('settings');
const select = page.getByLabel('Chat action mode', { exact: true });
const expected = ['autonomous', 'suggest', 'controlled'].includes(level)
? 'controlled'
: 'read_only';
await check(`Settings ${level} projects without granting execution`, async () => {
await select.waitFor();
assert.equal(await select.inputValue(), expected);
assert.equal(await select.locator('option').count(), 2);
const text = await page.locator('main').innerText();
assert.ok(
!text.includes('chat-only') &&
!text.includes('Infrastructure changes stay with Patrol'),
);
if (expected === 'controlled') {
assert.ok(text.includes('Chat does not execute the plan'));
assert.equal(await page.getByLabel(/Protected guests/i).count(), 0);
assert.ok(!text.includes('Protected guests'));
const hint = page.getByTestId('ai-never-auto-remediate-hint');
assert.ok((await hint.innerText()).includes('Never auto-remediate'));
assert.ok(
!(await page
.locator('input')
.evaluateAll((els) => els.some((el) => el.value === 'vm-101'))),
);
} else
assert.ok(
text.includes(
'Assistant can query and explain only. It cannot plan infrastructure actions.',
),
);
assert.ok(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1),
);
});
if (level === 'autonomous' || level === 'read_only') {
await select.scrollIntoViewIfNeeded();
await capture(level === 'read_only' ? 'read-only-settings' : 'legacy-settings');
}
const before = writes.length;
await page.getByRole('button', { name: 'Save Assistant settings', exact: true }).click();
await check(`Unrelated save preserves ${level} and scoped policy`, async () => {
await page.waitForFunction(
() => !document.querySelector('button[disabled]')?.textContent?.includes('Saving'),
);
for (let i = 0; writes.length === before && i < 20; i++) await page.waitForTimeout(50);
assert.equal(writes.length, before + 1);
for (const key of [
'control_level',
'protected_guests',
'patrol_autonomy_level',
'patrol_action_emergency_stop',
])
assert.ok(!Object.hasOwn(writes.at(-1).payload, key));
assert.equal(saved, level);
});
}
saved = 'autonomous';
await load('chat');
const trigger = page.getByRole('button', {
name: 'Assistant chat action mode: Ask first',
exact: true,
});
await check('Legacy Chat has two modes and keyboard-safe review-only copy', async () => {
await trigger.waitFor();
assert.equal(
await page.getByRole('status', { name: 'Assistant chat actions warning' }).count(),
0,
);
await trigger.focus();
await page.keyboard.press('ArrowDown');
const modes = page.getByRole('menuitemradio');
assert.equal(await modes.count(), 2);
const ask = page.getByRole('menuitemradio', { name: /Plans actions for your review/ });
assert.equal(await ask.getAttribute('aria-checked'), 'true');
await page.keyboard.press('Home');
assert.ok(await modes.first().evaluate((el) => el === document.activeElement));
await page.keyboard.press('ArrowUp');
assert.ok(await ask.evaluate((el) => el === document.activeElement));
const bounds = await page.getByRole('menu').boundingBox();
const composer = await page.locator('[data-assistant-control-toolbar]').boundingBox();
assert.ok(
bounds.x >= composer.x - 1 &&
bounds.x + bounds.width <= composer.x + composer.width + 1,
);
assert.ok(bounds.x >= 0 && bounds.x + bounds.width <= width + 1);
await capture('chat-menu');
await page.keyboard.press('Escape');
await page.waitForFunction(
() =>
document.activeElement?.getAttribute('aria-label') ===
'Assistant chat action mode: Ask first',
);
assert.equal(await page.getByRole('menu').count(), 0);
});
await check('Viewport resize closes the menu and reopened bounds fit', async () => {
await trigger.click();
await page.setViewportSize({ width: width + 1, height });
await page.getByRole('menu').waitFor({ state: 'hidden' });
await page.setViewportSize({ width, height });
await trigger.click();
const bounds = await page.getByRole('menu').boundingBox();
assert.ok(bounds.x >= 0 && bounds.x + bounds.width <= width + 1);
await page.keyboard.press('Escape');
});
await check('Scoped alert attachment keeps identity and approval boundary', async () => {
await page.evaluate(() => window.__assistantProof.scoped());
await page.getByText('Approval required before any action.', { exact: true }).waitFor();
assert.equal(
await page.evaluate(() => window.__assistantProof.context().autonomousMode),
false,
);
assert.equal(
await page.evaluate(() => window.__assistantProof.context().handoffResources[0].id),
'vm:lab:101',
);
assert.ok(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1),
);
await capture('scoped-chat');
});
await check('Chat mode save failure retains current mode', async () => {
failSave = true;
await trigger.click();
await page.getByRole('menuitemradio', { name: /Read-only/ }).click();
await trigger.waitFor();
assert.equal(saved, 'autonomous');
failSave = false;
});
await check('Explicit Chat opt-out changes only the mode', async () => {
await trigger.click();
await page.getByRole('menuitemradio', { name: /Read-only/ }).click();
await page
.getByRole('button', { name: 'Assistant chat action mode: Read-only', exact: true })
.waitFor();
assert.deepEqual(writes.at(-1).payload, { control_level: 'read_only' });
assert.equal(saved, 'read_only');
});
saved = 'controlled';
await load('settings');
await page.getByLabel('Chat action mode', { exact: true }).waitFor();
for (const [link, heading, state] of [
['Read Assistant modes', 'Control Levels', 'help-modes'],
['Read control guide', 'Assistant Control Levels', 'help-guide'],
]) {
if (state === 'help-guide') {
await load('settings');
await page.getByLabel('Chat action mode', { exact: true }).waitFor();
}
await check(
`Actual Docs ${heading} describes planning and independent verification`,
async () => {
await page.getByRole('link', { name: link, exact: true }).click();
const h = page.getByRole('heading', { name: heading, exact: true });
await h.waitFor();
await h.scrollIntoViewIfNeeded();
const text = await page.locator('article').innerText();
assert.ok(text.includes('Assistant chat does not execute'));
assert.ok(
text.includes('stored') &&
text.includes('entitlement') &&
text.includes('independent verification') &&
text.includes('Never auto-remediate') &&
text.includes('has been removed'),
);
assert.ok(
!text.includes('executes actions without prompting') &&
!text.includes('executes commands without prompting'),
);
assert.ok(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1),
);
await capture(state);
const tableScroll = await page.locator('article').evaluate((article) => {
const table = [...article.querySelectorAll('table')].find(
(t) => t.innerText.includes('Availability') && t.innerText.includes('Ask first'),
);
if (!table) return { found: false };
const wrapper = table.parentElement;
const overflows = wrapper.scrollWidth > wrapper.clientWidth + 1;
const overflowX = getComputedStyle(wrapper).overflowX;
if (overflows) wrapper.scrollLeft = wrapper.scrollWidth;
const lastCell = table.querySelector('thead tr').lastElementChild;
return {
found: true,
overflows,
overflowX,
scrollLeft: wrapper.scrollLeft,
lastCellRight: lastCell.getBoundingClientRect().right,
wrapperRight: wrapper.getBoundingClientRect().right,
};
});
assert.equal(tableScroll.found, true);
if (tableScroll.overflows) {
assert.equal(tableScroll.overflowX, 'auto');
assert.ok(tableScroll.scrollLeft > 0);
assert.ok(tableScroll.lastCellRight <= tableScroll.wrapperRight + 1);
await capture(`${state}-table-end`);
}
},
);
}
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
assert.ok(writes.every((r) => r.method === 'PUT' && r.path === '/api/settings/ai/update'));
results.push({
name,
viewport: { width, height },
dark,
checks,
writes,
errors,
offOrigin,
});
} catch (error) {
fs.writeFileSync(
path.join(output, 'debug.json'),
JSON.stringify(
{
phase,
errors,
reads,
body: await page.locator('body').innerText(),
buttons: await page.getByRole('button').allTextContents(),
},
null,
2,
),
);
await page.screenshot({ path: path.join(output, 'failed-view.png') });
throw error;
} finally {
await page.close();
await browser.close();
browser = null;
browserClosed = true;
}
}
} catch (error) {
fs.writeFileSync(
path.join(output, 'failure.json'),
JSON.stringify({ phase, error: error.message, results, screenshots }, null, 2),
);
throw error;
} finally {
if (browser) {
await browser.close();
browserClosed = true;
}
await server.close();
serverClosed = true;
}
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result(), null, 2) + '\n');
console.log(
JSON.stringify({
result: 'passed',
groups: results.reduce((n, r) => n + r.checks.length, 0),
screenshots: screenshots.length,
browserClosed,
serverClosed,
}),
);
})().catch((e) => {
console.error(e);
process.exitCode = 1;
});
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Existing Assistant controls verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./assistant-control-compatibility.tsx"></script>
</body>
</html>
@@ -1,59 +0,0 @@
// Actual existing Settings/Chat/Docs surfaces, not a shipped route. Synthetic
// API responses and local settings saves only; no model/action/backend calls.
import { render } from 'solid-js/web';
import { A, Route, Router } from '@solidjs/router';
import { AIAssistantSettings } from '../src/components/Settings/AISettings';
import { AIChat } from '../src/components/AI/Chat';
import Docs from '../src/pages/Docs';
import { aiChatStore } from '../src/stores/aiChat';
import '../src/index.css';
const params = new URLSearchParams(location.search);
window.history.replaceState(
{},
'',
params.get('surface') === 'chat' ? '/proof-chat' : '/settings/ai/assistant',
);
aiChatStore.setEnabled(true);
(window as any).__assistantProof = {
scoped: () =>
aiChatStore.setContext({
autonomousMode: false,
briefing: {
sourceLabel: 'Pulse Alerts',
title: 'Synthetic alert investigation attached',
subject: 'lab-vm',
},
handoffResources: [{ id: 'vm:lab:101', name: 'lab-vm', type: 'vm', node: 'lab' }],
context: { alertIdentifier: 'synthetic-alert-1' },
}),
context: () => aiChatStore.context,
};
function SettingsFixture() {
return (
<main class="mx-auto max-w-4xl bg-surface text-base-content p-4">
<AIAssistantSettings />
<nav class="space-x-3 mt-4">
<A href="/docs/AI#control-levels">Read Assistant modes</A>
<A href="/docs/AI_AUTONOMY#assistant-control-levels">Read control guide</A>
</nav>
</main>
);
}
function ChatFixture() {
aiChatStore.open();
return (
<div class="mx-auto max-w-3xl h-screen bg-surface text-base-content">
<AIChat onClose={() => aiChatStore.close()} />
</div>
);
}
render(
() => (
<Router>
<Route path="/settings/ai/assistant" component={SettingsFixture} />
<Route path="/proof-chat" component={ChatFixture} />
<Route path="/docs/*docPath" component={Docs} />
</Router>
),
document.getElementById('root')!,
);
@@ -1,339 +0,0 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/held-breach-proof/browser';
const runtime = [
'frontend-modern/src/types/api.ts',
'frontend-modern/src/features/alerts/metricAlertPresentation.ts',
'frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts',
];
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
(async () => {
fs.mkdirSync(output, { recursive: true });
const binding = JSON.parse(fs.readFileSync('/workspace/tmp/held-breach-proof/binding.json'));
const hashes = Object.fromEntries(runtime.map((f) => [f, hash(path.join('/workspace', f))]));
assert.deepEqual(hashes, binding.content_sha256);
const version = require('playwright/package.json').version;
assert.equal(
version,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5298, strictPort: true, watch: null },
});
const origin = 'http://127.0.0.1:5298';
const results = [],
screenshots = [];
let browser,
phase = 'server';
try {
await server.listen();
for (const [name, engine, width, height, dark] of [
['chromium-desktop-light', chromium, 1365, 900, false],
['chromium-desktop-dark', chromium, 1365, 900, true],
['webkit-phone-dark', webkit, 390, 844, true],
['webkit-narrow-light', webkit, 320, 740, false],
]) {
phase = name;
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height },
isMobile: width < 500,
hasTouch: width < 500,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(8000);
page.setDefaultNavigationTimeout(60000);
await page.clock.setFixedTime(new Date('2026-10-06T10:52:00Z'));
const errors = [],
requests = [],
offOrigin = [],
writes = [],
checks = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.addInitScript((dark) => {
localStorage.clear();
const apply = () => document.documentElement.classList.toggle('dark', dark);
if (document.documentElement) apply();
else document.addEventListener('DOMContentLoaded', apply, { once: true });
}, dark);
await page.routeWebSocket(/.*/, (socket) => socket.close());
await page.route('**/*', async (route) => {
const req = route.request(),
url = new URL(req.url());
if (url.origin !== origin) {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
requests.push({ path: url.pathname, method: req.method() });
if (req.method() !== 'GET') {
writes.push({ path: url.pathname, method: req.method() });
return route.fulfill({
status: 403,
json: { error: 'Fixture refuses every write/inference/action' },
});
}
if (url.pathname === '/api/alerts/history') return route.fulfill({ json: [] });
if (url.pathname === '/api/alerts/incidents') return route.fulfill({ json: null });
if (url.pathname === '/api/security/status')
return route.fulfill({
json: {
hasAuthentication: true,
requiresAuth: false,
sessionCapabilities: { assistantEnabled: true },
},
});
if (url.pathname.includes('license'))
return route.fulfill({
json: { capabilities: [], entitlements: [], limits: [], max_history_days: 90 },
});
if (url.pathname === '/api/settings/ai')
return route.fulfill({
json: { enabled: true, control_level: 'controlled', providers: [], model: '' },
});
if (url.pathname === '/api/ai/models') return route.fulfill({ json: { models: [] } });
if (url.pathname === '/api/ai/status')
return route.fulfill({ json: { enabled: true, configured: false } });
if (url.pathname.endsWith('/sessions')) return route.fulfill({ json: { sessions: [] } });
return route.fulfill({ json: [] });
});
const check = async (label, action) => {
phase = `${name}: ${label}`;
await action();
checks.push({ label, passed: true });
};
const capture = async (state, locator = page) => {
const file = `${name}-${state}.png`,
target = path.join(output, file);
await locator.screenshot({ path: target, ...(locator === page ? { fullPage: true } : {}) });
screenshots.push({ file, state, viewport: { width, height }, sha256: hash(target) });
};
try {
await page.goto(`${origin}/browser-tests/held-breach-time.html`);
const attention = page.getByRole('region', { name: 'Existing node attention' });
const overview = page.getByRole('region', { name: 'Existing alert overview' });
const history = page.getByRole('region', { name: 'Existing alert history' });
const breachPrefix = 'Last reading at or above 80°C: 80°C';
const assertBreach = async (expected) => {
const p = attention
.locator('p')
.filter({ hasText: /^Temperature|^Last reading: Temperature/ })
.first();
assert.equal((await p.getAttribute('title')).split('\n').at(-1), expected);
assert.equal(
await overview
.locator('p[title]')
.filter({ hasText: /^Temperature|^Last reading: Temperature/ })
.first()
.getAttribute('title'),
expected,
);
if (width < 500)
await history.getByText(expected, { exact: true }).waitFor({ state: 'visible' });
else assert.ok((await history.locator('[title]').all()).length > 0);
};
await check(
'status date beats newer legacy poll in attention, overview and History',
async () => {
await attention
.getByText('Temperature 76°C now, back under the 80°C alert level', { exact: true })
.waitFor();
await assertBreach(`${breachPrefix}, 20 mins ago`);
if (width >= 1024)
assert.ok(
(await history.locator('[title]').evaluateAll((es) => es.map((e) => e.title))).some(
(t) => t.includes('20 mins ago'),
),
);
},
);
await capture('latched');
await check(
'real Assistant handoff carries retained ISO evidence, live reading and approval boundary',
async () => {
await page
.getByRole('region', { name: 'Existing Assistant handoff' })
.getByRole('button')
.click();
const context = await page.evaluate(() => window.__heldBreach.context());
assert.match(context.handoffContext, /Last Breach At: 2026-10-06T10:31:59\.123Z/);
assert.match(context.handoffContext, /Current Value: 76\.0°C/);
assert.match(context.handoffContext, /Last Reading At Or Above Threshold: 80\.0°C/);
assert.match(context.handoffContext, /Operator Boundary:/);
assert.equal(context.targetId, 'lab-minipc');
assert.equal(context.autonomousMode, false);
// Model-only evidence is checked above; production Chat deliberately
// retains its compact attachment rather than exposing detail lines.
await page
.getByRole('button', { name: 'Render attached briefing without inference' })
.click();
const briefing = page.getByRole('region', { name: 'Assistant context' });
await briefing.getByText('Warning temperature on minipc', { exact: true }).waitFor();
assert.doesNotMatch(await briefing.textContent(), /Last reading at or above/);
await capture('assistant', briefing);
await page.evaluate(() => window.__heldBreach.close());
},
);
await check(
'recovery changes the current reading, not the retained breach date',
async () => {
await page.evaluate(() => window.__heldBreach.setScenario('recovering'));
await attention
.getByText('Temperature 72°C now, recovering', { exact: true })
.waitFor();
await assertBreach(`${breachPrefix}, 20 mins ago`);
await attention
.getByText('Clears after 5 minutes at 75°C or lower, 2 minutes so far.', {
exact: true,
})
.waitFor();
},
);
await check('invalid status date falls back to valid legacy date', async () => {
await page.evaluate(() => window.__heldBreach.setScenario('legacy'));
await assertBreach(`${breachPrefix}, 20 mins ago`);
});
await check(
'Go-zero and unknown remain undated on every surface and in Assistant',
async () => {
await page.evaluate(() => window.__heldBreach.setScenario('unknown'));
await assertBreach(breachPrefix);
await page
.getByRole('region', { name: 'Existing Assistant handoff' })
.getByRole('button')
.click();
const context = await page.evaluate(() => window.__heldBreach.context());
assert.doesNotMatch(context.handoffContext, /Last Breach At:/);
await page.evaluate(() => window.__heldBreach.close());
},
);
await capture('unknown');
await check(
'stale evaluation is not now, but still retains the correct breach age',
async () => {
await page.evaluate(() => window.__heldBreach.setScenario('stale'));
await attention
.getByText('Last reading: Temperature 76°C, 12 mins ago', { exact: true })
.waitFor();
await assertBreach(`${breachPrefix}, 20 mins ago`);
},
);
await check(
'restart-before-evaluation keeps the legacy message and fabricates no date',
async () => {
await page.evaluate(() => window.__heldBreach.setScenario('restart'));
await attention.getByText('Node temperature at 80.0°C', { exact: true }).waitFor();
assert.equal(await overview.locator('[title^="Last reading at or above"]').count(), 0);
await page
.getByRole('region', { name: 'Existing Assistant handoff' })
.getByRole('button')
.click();
const context = await page.evaluate(() => window.__heldBreach.context());
assert.doesNotMatch(context.handoffContext, /Last Breach At:/);
assert.ok(context.briefing.detailLines.includes('Message: Node temperature at 80.0°C'));
await page.evaluate(() => window.__heldBreach.close());
},
);
await check('reload retains final-byte date selection', async () => {
await page.goto(`${origin}/browser-tests/held-breach-time.html?case=legacy`);
await assertBreach(`${breachPrefix}, 20 mins ago`);
});
await check(
'no horizontal page overflow, JavaScript errors, external contacts or writes',
async () => {
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1),
false,
);
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
assert.deepEqual(writes, []);
},
);
results.push({
name,
engine: engine === chromium ? 'chromium' : 'webkit',
browserVersion: browser.version(),
width,
height,
dark,
checks,
requests,
errors,
writes,
offOrigin,
});
} finally {
await page.close();
await browser.close();
browser = undefined;
}
}
assert.deepEqual(
Object.fromEntries(runtime.map((f) => [f, hash(path.join('/workspace', f))])),
hashes,
);
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{
result: 'passed',
version,
binding,
hashes,
results,
screenshots,
verifiedAt: new Date().toISOString(),
cleanup: { browserClosed: true, serverClosed: false },
},
null,
2,
),
);
} catch (error) {
fs.writeFileSync(
path.join(output, 'failure.json'),
JSON.stringify(
{ phase, message: error.message, stack: error.stack, results, screenshots },
null,
2,
),
);
throw error;
} finally {
if (browser) await browser.close();
await server.close();
}
const file = path.join(output, 'result.json'),
result = JSON.parse(fs.readFileSync(file));
result.cleanup.serverClosed = true;
fs.writeFileSync(file, JSON.stringify(result, null, 2));
console.log(
JSON.stringify({
result: result.result,
checkGroups: results.reduce((n, r) => n + r.checks.length, 0),
screenshots: screenshots.length,
cleanup: result.cleanup,
runtime: hashes,
}),
);
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Held-alert breach date verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./held-breach-time.tsx"></script>
</body>
</html>
@@ -1,163 +0,0 @@
// Production attention, alert/history, Assistant button/store and chat briefing.
// All observations are synthetic. The fixture consumes the explanation intent
// before mounting Chat, so rendering a briefing never calls a model or action.
import { createSignal, Show } from 'solid-js';
import { render } from 'solid-js/web';
import { Route, Router } from '@solidjs/router';
import { NodeDrawerOverview } from '../src/components/Workloads/NodeDrawerOverview';
import { OverviewTab } from '../src/features/alerts/OverviewTab';
import { AlertHistoryTableSection } from '../src/features/alerts/AlertHistoryTableSection';
import { useAlertHistoryState } from '../src/features/alerts/useAlertHistoryState';
import { InvestigateAlertButton } from '../src/components/Alerts/InvestigateAlertButton';
import { AIChat } from '../src/components/AI/Chat';
import { aiChatStore } from '../src/stores/aiChat';
import type { Alert, Node } from '../src/types/api';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
const node = {
id: 'lab-minipc',
name: 'minipc',
instance: 'lab',
status: 'online',
type: 'node',
cpu: 0,
memory: { total: 1024, used: 256, free: 768, usage: 25 },
disk: { total: 1024, used: 256, free: 768, usage: 25 },
uptime: 3600,
loadAverage: [],
cpuInfo: { model: 'Fixture CPU', cores: 4, sockets: 1, mhz: '2400' },
lastSeen: '2026-10-06T10:51:50Z',
connectionHealth: 'healthy',
} as Node;
const resource = { ...node, type: 'node', platform: 'proxmox' } as unknown as Resource;
function makeAlert(scenario: string): Alert {
const alert: Alert = {
id: 'lab-minipc::metric-threshold:temperature',
type: 'temperature',
level: 'warning',
resourceId: node.id,
resourceName: node.name,
node: node.name,
instance: 'lab',
message: 'Node temperature at 80.0°C',
value: 80,
threshold: 80,
startTime: '2026-10-06T07:57:44Z',
acknowledged: false,
// A newer legacy poll must not replace the evaluated breach date.
lastSeen: '2026-10-06T10:51:59Z',
metricStatus: {
phase: 'latched',
value: 76,
unit: '°C',
observedAt: '2026-10-06T10:51:50Z',
lastBreachAt: '2026-10-06T11:31:59.123456789+01:00',
trigger: 80,
recovery: 75,
recoveryDelaySeconds: 300,
},
};
if (scenario === 'recovering')
alert.metricStatus = {
...alert.metricStatus!,
phase: 'recovering',
value: 72,
recoveryElapsedSeconds: 120,
};
if (scenario === 'legacy') {
alert.metricStatus!.lastBreachAt = 'not-a-date';
alert.lastSeen = '2026-10-06T10:32:00Z';
}
if (scenario === 'unknown') {
alert.metricStatus!.lastBreachAt = '0001-01-01T00:00:00Z';
alert.lastSeen = 'unknown';
}
if (scenario === 'stale') alert.metricStatus!.observedAt = '2026-10-06T10:40:00Z';
if (scenario === 'restart') {
alert.metricStatus = undefined;
alert.lastSeen = undefined;
}
return alert;
}
function Fixture() {
const initial = makeAlert(new URLSearchParams(location.search).get('case') || 'latched');
const [alerts, setAlerts] = createSignal<Record<string, Alert>>({ [initial.id]: initial });
const [showAcknowledged, setShowAcknowledged] = createSignal(true);
const [showChat, setShowChat] = createSignal(false);
const history = useAlertHistoryState({
activeAlerts: alerts,
getResource: () => resource,
allResources: () => [resource],
});
aiChatStore.setEnabled(true);
const readBriefing = () => {
const request = aiChatStore.explanationRequestSignal();
if (request) aiChatStore.ackExplanationRequest(request.id);
setShowChat(true);
};
const closeChat = () => {
setShowChat(false);
aiChatStore.close();
};
(window as any).__heldBreach = {
setScenario: (scenario: string) => {
closeChat();
const next = makeAlert(scenario);
setAlerts({ [next.id]: next });
},
context: () => aiChatStore.context,
close: closeChat,
readBriefing,
};
return (
<main class="mx-auto min-h-screen max-w-5xl space-y-4 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Held-alert breach date evidence</h1>
<p class="text-xs text-muted">
Synthetic readings only. No model, diagnostic or action is executed.
</p>
<section aria-label="Existing node attention">
<NodeDrawerOverview node={node} alerts={Object.values(alerts())} />
</section>
<section aria-label="Existing alert overview">
<OverviewTab
activeAlerts={alerts()}
overrides={[]}
updateAlert={(id, updates) =>
setAlerts((old) => ({ ...old, [id]: { ...old[id], ...updates } }))
}
showAcknowledged={showAcknowledged}
setShowAcknowledged={setShowAcknowledged}
showQuickTip={() => false}
dismissQuickTip={() => {}}
alertsDisabled={() => false}
/>
</section>
<section aria-label="Existing alert history">
<h2 class="font-semibold">History</h2>
<AlertHistoryTableSection state={history} />
</section>
<section aria-label="Existing Assistant handoff">
<InvestigateAlertButton
alert={Object.values(alerts())[0]}
resourceType="node"
variant="full"
/>
</section>
<button type="button" onClick={readBriefing}>
Render attached briefing without inference
</button>
<Show when={showChat()}>
<AIChat onClose={closeChat} />
</Show>
</main>
);
}
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
@@ -1,378 +0,0 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/system-alert-scope-proof';
const runtime = [
'frontend-modern/src/utils/alertScope.ts',
'frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts',
'frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx',
'frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx',
'frontend-modern/src/features/alerts/alertHistoryModel.ts',
'frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx',
];
const systemTypes = [
'backup-evaluation',
'notification-delivery',
'deadman-delivery',
'deadman-monitoring-stalled',
'deadman-interruption',
'deadman-state',
];
const legacy = {
id: 'retained-legacy-system-id',
type: 'notification-delivery',
level: 'warning',
resourceId: 'vm-wrong',
resourceName: 'Pulse',
node: 'wrong-node',
metadata: { systemAlert: true, resourceType: 'vm' },
message: 'Synthetic retained notification delivery outage.',
startTime: '2026-10-04T14:00:00Z',
lastSeen: '2026-10-04T14:30:00Z',
acknowledged: true,
value: 0,
threshold: 0,
};
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
(async () => {
fs.mkdirSync(output, { recursive: true });
const version = require('playwright/package.json').version;
assert.equal(
version,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const hashes = Object.fromEntries(
runtime.map((file) => [file, hash(path.join('/workspace', file))]),
);
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5297, strictPort: true, watch: null },
});
const origin = 'http://127.0.0.1:5297';
const results = [],
screenshots = [];
let browser,
phase = 'server';
try {
await server.listen();
for (const [name, engine, width, dark] of [
['chromium-desktop-light', chromium, 1365, false],
['chromium-desktop-dark', chromium, 1365, true],
['webkit-phone-dark', webkit, 390, true],
['webkit-narrow-light', webkit, 320, false],
]) {
phase = name;
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: width < 500 ? 844 : 900 },
isMobile: width < 500,
hasTouch: width < 500,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(10000);
page.setDefaultNavigationTimeout(60000);
const errors = [],
requests = [],
offOrigin = [],
writes = [],
checks = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.addInitScript((dark) => {
localStorage.clear();
const apply = () => document.documentElement.classList.toggle('dark', dark);
if (document.documentElement) apply();
else document.addEventListener('DOMContentLoaded', apply, { once: true });
}, dark);
await page.routeWebSocket(/.*/, (socket) => socket.close());
await page.route('**/*', async (route) => {
const req = route.request(),
url = new URL(req.url());
if (url.origin !== origin) {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
requests.push({ path: url.pathname, query: url.search, method: req.method() });
if (req.method() !== 'GET') {
writes.push({ path: url.pathname, method: req.method(), body: req.postDataJSON() });
assert.ok(
['/api/alerts/acknowledge', '/api/alerts/unacknowledge'].includes(url.pathname),
'unexpected write',
);
assert.equal(req.method(), 'POST');
assert.deepEqual(req.postDataJSON(), {
alertIdentifier: 'pulse-system-backup-evaluation',
});
return route.fulfill({ json: { success: true } });
}
if (url.pathname === '/api/alerts/history') return route.fulfill({ json: [legacy] });
if (url.pathname === '/api/alerts/incidents') return route.fulfill({ json: null });
if (url.pathname === '/api/notifications/health')
return route.fulfill({
json: {
overall_healthy: true,
queue: {
healthy: true,
status: 'healthy',
pending: 0,
sending: 0,
sent: 0,
failed: 0,
dlq: 0,
attention_required: 0,
reason_codes: [],
completed_retention_days: 7,
dead_letter_retention_days: 30,
counts_are_retention_bounded: true,
retry_attempts_affect_health: false,
terminal_failures_affect_health: true,
failure_classes_7d: {
authentication: 0,
rate_limited: 0,
connectivity: 0,
tls: 0,
configuration: 0,
rejected: 0,
server_error: 0,
unknown: 0,
},
failure_class_window_days: 7,
failure_classes_available: true,
},
},
});
if (url.pathname.includes('license'))
return route.fulfill({
json: { capabilities: [], entitlements: [], limits: [], max_history_days: 90 },
});
if (url.pathname === '/api/security/status')
return route.fulfill({
json: {
hasAuthentication: true,
requiresAuth: false,
sessionCapabilities: { assistantEnabled: true },
},
});
return route.fulfill({ json: [] });
});
const check = async (label, action) => {
phase = `${name}: ${label}`;
await action();
checks.push({ label, passed: true });
};
const capture = async (locator, state) => {
const file = `${name}-${state}.png`;
await locator.screenshot({ path: path.join(output, file) });
screenshots.push({ file, state, width, dark, sha256: hash(path.join(output, file)) });
};
const assertSystemContext = async (id) => {
const context = await page.evaluate(() => window.__systemAlertScope.context());
assert.equal(context.targetId, undefined);
assert.equal(context.targetType, undefined);
assert.deepEqual(context.handoffResources, []);
assert.equal(context.autonomousMode, false);
assert.equal(context.context.guestName, undefined);
assert.equal(context.context.node, undefined);
assert.match(context.handoffContext, /Scope: Pulse itself \(not a monitored resource\)/);
assert.ok(context.handoffContext.includes(`Alert Identifier: ${id}`));
assert.ok(context.handoffContext.includes('Operator Boundary:'));
assert.doesNotMatch(
context.handoffContext,
/Current Value:|Threshold:|wrong-node|vm-wrong/,
);
assert.doesNotMatch(context.briefing.detailLines.join('\n'), /Current value|threshold/);
await page.evaluate(() => window.__systemAlertScope.closeExplanation());
};
try {
await page.goto(`${origin}/browser-tests/system-alert-scope.html`);
const overview = page.getByRole('region', { name: 'Existing alert overview' });
const history = page.getByRole('region', { name: 'Existing alert history' });
const backupCard = page.locator('#alert-pulse-system-backup-evaluation');
await check(
'canonical service warning has no false resource link or policy action',
async () => {
await backupCard.waitFor({ state: 'visible' });
assert.equal(
await overview
.getByText('Notification delivery status is unavailable', { exact: true })
.count(),
0,
);
assert.equal(await backupCard.getByRole('link').count(), 0);
assert.equal(
await backupCard.getByRole('button', { name: 'Have Patrol investigate' }).count(),
0,
);
assert.match(await backupCard.textContent(), /Backup-age alerts were not evaluated/);
await page.waitForFunction(() => window.__systemAlertScope.historyRows().length === 3);
const rows = await page.evaluate(() => window.__systemAlertScope.historyRows());
for (const row of rows.filter((r) => r.systemAlert)) {
assert.equal(row.resourceType, 'Pulse');
assert.equal(row.resourceId, '');
}
assert.equal(rows.find((r) => r.id === 'resource-cpu').resourceType, 'vm');
},
);
await capture(overview, 'backup-warning');
await check('acknowledgement remains active, not recovery', async () => {
await backupCard.getByRole('button', { name: 'Acknowledge', exact: true }).click();
await backupCard.getByText('Acknowledged', { exact: true }).waitFor();
assert.match(await backupCard.textContent(), /Backup-age alerts were not evaluated/);
await backupCard.getByRole('button', { name: 'Unacknowledge', exact: true }).click();
await backupCard.getByRole('button', { name: 'Acknowledge', exact: true }).waitFor();
});
await check('occurrence timeline keeps exact service identity', async () => {
await backupCard.getByRole('button', { name: 'Timeline', exact: true }).click();
await page.waitForFunction(() =>
document
.querySelector('#alert-pulse-system-backup-evaluation')
?.textContent.includes('No incident'),
);
const incident = requests.find((r) => r.path === '/api/alerts/incidents');
assert.ok(incident);
const query = new URLSearchParams(incident.query);
assert.equal(query.get('alertIdentifier'), 'pulse-system-backup-evaluation');
assert.equal(query.get('started_at'), '2026-10-04T15:00:00Z');
await backupCard.getByRole('button', { name: 'Hide Timeline', exact: true }).click();
});
for (const type of systemTypes) {
await check(`${type} explanation is service-scoped and model-only`, async () => {
await page.evaluate((type) => window.__systemAlertScope.updateSystem(type), type);
const card = page.locator(`#alert-pulse-system-${type}`);
await card.waitFor({ state: 'visible' });
assert.equal(await card.getByRole('link').count(), 0);
const button = card.getByRole('button', { name: 'Ask Pulse Assistant', exact: true });
if (width > 500) {
await button.focus();
await page.keyboard.press('Enter');
} else await button.tap();
await assertSystemContext(`pulse-system-${type}`);
});
}
await check(
'legacy system metadata wins over conflicting resource/metric hints',
async () => {
await page.evaluate(() =>
window.__systemAlertScope.updateSystem('future-condition', true),
);
const card = page.locator('#alert-legacy-system-id');
await card.waitFor({ state: 'visible' });
assert.equal(await card.getByRole('link').count(), 0);
assert.doesNotMatch(await card.textContent(), /limit:|wrong-node/);
await card.getByRole('button', { name: 'Ask Pulse Assistant', exact: true }).click();
await assertSystemContext('legacy-system-id');
},
);
await check(
'desktop and phone History reconstruct the retained service marker',
async () => {
const row =
width < 500
? history
.locator('[data-alert-history-row-key]')
.filter({ hasText: legacy.message })
: history.getByRole('row').filter({ hasText: legacy.message });
assert.equal(await row.locator('[data-alert-history-action="resource"]').count(), 0);
await row.getByRole('button', { name: 'Ask Pulse Assistant about this alert' }).click();
await assertSystemContext(legacy.id);
},
);
await capture(history, 'retained-history');
await check(
'monitored VM called Pulse retains its link and targeted affordances',
async () => {
const card = page.locator('#alert-resource-cpu');
assert.equal(
await card.getByRole('link', { name: 'Pulse', exact: true }).getAttribute('href'),
'/proxmox/overview',
);
assert.match(await card.textContent(), /limit: 80%/);
await card
.getByRole('button', { name: 'Have Patrol investigate' })
.waitFor({ state: 'visible' });
},
);
await check(
'authoritative recovery withdraws only the current service warning',
async () => {
await page.evaluate(() => window.__systemAlertScope.recover());
assert.equal(await overview.locator('[id^="alert-legacy"]').count(), 0);
assert.equal(await overview.locator('#alert-resource-cpu').count(), 1);
assert.ok(
(await page.evaluate(() => window.__systemAlertScope.historyRows())).some(
(r) => r.id === legacy.id,
),
);
},
);
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
assert.equal(writes.length, 2);
assert.ok(
!requests.some((r) => /patrol\/run|execute|diagnostic|ai\/chat|guest-agent/.test(r.path)),
);
results.push({
name,
width,
dark,
browser_version: browser.version(),
checks,
requests,
writes,
errors,
offOrigin,
});
} finally {
await browser.close();
browser = null;
}
}
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{ passed: true, playwright_version: version, content_sha256: hashes, results, screenshots },
null,
2,
) + '\n',
);
console.log(
JSON.stringify({
passed: true,
groups: results.length,
checks: results.reduce((n, r) => n + r.checks.length, 0),
screenshots: screenshots.length,
playwright_version: version,
}),
);
} catch (error) {
fs.writeFileSync(
path.join(output, 'failed.json'),
JSON.stringify(
{ passed: false, phase, error: error.message, results, screenshots },
null,
2,
) + '\n',
);
throw error;
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>System-alert scope verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./system-alert-scope.tsx"></script>
</body>
</html>
@@ -1,107 +0,0 @@
// Production alert components/state/router and real Assistant handoff store.
// Synthetic API evidence; no native service, model, diagnostic or command.
import { createSignal, Show } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { OverviewTab } from '../src/features/alerts/OverviewTab';
import { AlertHistoryTableSection } from '../src/features/alerts/AlertHistoryTableSection';
import { useAlertHistoryState } from '../src/features/alerts/useAlertHistoryState';
import { makeSystemAlert } from '../src/features/alerts/__fixtures__/systemAlerts';
import { aiChatStore } from '../src/stores/aiChat';
import type { Alert } from '../src/types/api';
import type { Resource } from '../src/types/resource';
import '../src/index.css';
aiChatStore.setEnabled(true);
const pulseVM = { id: 'vm-pulse', name: 'Pulse', type: 'vm' } as Resource;
const control = makeSystemAlert('cpu', {
id: 'resource-cpu',
resourceId: pulseVM.id,
metadata: { resourceType: 'vm' },
value: 92,
threshold: 80,
message: 'Synthetic CPU reading on the monitored VM called Pulse.',
});
function Fixture() {
const initial = makeSystemAlert();
const [alerts, setAlerts] = createSignal<Record<string, Alert>>({
[initial.id]: initial,
[control.id]: control,
});
const [showAcknowledged, setShowAcknowledged] = createSignal(true);
const history = useAlertHistoryState({
activeAlerts: alerts,
getResource: () => pulseVM,
allResources: () => [pulseVM],
});
(window as any).__systemAlertScope = {
updateSystem: (type: string, legacy = false) => {
const next = makeSystemAlert(
type,
legacy
? {
id: 'legacy-system-id',
resourceId: 'vm-wrong',
node: 'wrong-node',
metadata: { systemAlert: true, resourceType: 'vm' },
threshold: 80,
}
: {},
);
setAlerts({ [next.id]: next, [control.id]: control });
},
recover: () => setAlerts({ [control.id]: control }),
alerts,
historyRows: history.alertData,
context: () => aiChatStore.context,
closeExplanation: () => aiChatStore.close(),
};
return (
<main class="mx-auto min-h-screen max-w-5xl space-y-4 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Pulse system-alert evidence</h1>
<p class="text-xs text-muted">
Synthetic observations. No workload, diagnostic or model is contacted.
</p>
<section aria-label="Existing alert overview">
<OverviewTab
activeAlerts={alerts()}
overrides={[]}
updateAlert={(id, updates) =>
setAlerts((previous) => ({ ...previous, [id]: { ...previous[id], ...updates } }))
}
showAcknowledged={showAcknowledged}
setShowAcknowledged={setShowAcknowledged}
showQuickTip={() => false}
dismissQuickTip={() => {}}
alertsDisabled={() => false}
/>
</section>
<section aria-label="Existing alert history">
<h2 class="mb-2 font-semibold">History</h2>
<AlertHistoryTableSection state={history} />
</section>
<Show when={aiChatStore.isOpenSignal()}>
<section
aria-label="Captured explanation context"
class="rounded-md border border-border p-3"
>
<p class="text-sm">
Test capture of the real explanation store; no Assistant request is executed.
</p>
<pre class="whitespace-pre-wrap break-all text-xs">
{JSON.stringify(aiChatStore.context, null, 2)}
</pre>
</section>
</Show>
</main>
);
}
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,283 @@
{
"version": 1,
"base_sha": "86ff402d118f3fc1010acfe04a4d8fcc9cbe5d9e",
"verified_at": "2026-10-22T23:11:14Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/App.tsx",
"frontend-modern/src/AppLayout.tsx",
"frontend-modern/src/api/ai.ts",
"frontend-modern/src/api/aiChat.ts",
"frontend-modern/src/api/aiChatDevStreamFixture.ts",
"frontend-modern/src/api/generated/aiChatEvents.ts",
"frontend-modern/src/api/patrol.ts",
"frontend-modern/src/api/workflowPrompts.ts",
"frontend-modern/src/components/AI/Chat/ApprovalCard.tsx",
"frontend-modern/src/components/AI/Chat/AssistantCommandHelpDialog.tsx",
"frontend-modern/src/components/AI/Chat/ChatMessages.tsx",
"frontend-modern/src/components/AI/Chat/MentionAutocomplete.tsx",
"frontend-modern/src/components/AI/Chat/MessageItem.tsx",
"frontend-modern/src/components/AI/Chat/ModelSelector.tsx",
"frontend-modern/src/components/AI/Chat/QuestionCard.tsx",
"frontend-modern/src/components/AI/Chat/SlashCommandAutocomplete.tsx",
"frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx",
"frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx",
"frontend-modern/src/components/AI/Chat/activeTurnStatus.ts",
"frontend-modern/src/components/AI/Chat/aiCodeHighlight.ts",
"frontend-modern/src/components/AI/Chat/assistantAnswerText.ts",
"frontend-modern/src/components/AI/Chat/assistantModelRoutes.ts",
"frontend-modern/src/components/AI/Chat/assistantNotifications.ts",
"frontend-modern/src/components/AI/Chat/assistantOutputHygiene.ts",
"frontend-modern/src/components/AI/Chat/assistantPanelWidth.ts",
"frontend-modern/src/components/AI/Chat/assistantSlashCommands.ts",
"frontend-modern/src/components/AI/Chat/assistantTurnSummary.ts",
"frontend-modern/src/components/AI/Chat/composerPaste.ts",
"frontend-modern/src/components/AI/Chat/hooks/useChat.ts",
"frontend-modern/src/components/AI/Chat/hooks/useExplanationRequest.ts",
"frontend-modern/src/components/AI/Chat/index.tsx",
"frontend-modern/src/components/AI/Chat/markdownMorph.ts",
"frontend-modern/src/components/AI/Chat/reasoningSummary.ts",
"frontend-modern/src/components/AI/Chat/recentSessionsModel.ts",
"frontend-modern/src/components/AI/Chat/streamActivityTiming.ts",
"frontend-modern/src/components/AI/Chat/streamEventGrouping.ts",
"frontend-modern/src/components/AI/Chat/transcriptExport.ts",
"frontend-modern/src/components/AI/Chat/types.ts",
"frontend-modern/src/components/AI/Chat/workflowStarters.ts",
"frontend-modern/src/components/AI/Chat/workflowStatusDisplay.ts",
"frontend-modern/src/components/AI/FindingsPanel.tsx",
"frontend-modern/src/components/AI/aiChatUtils.ts",
"frontend-modern/src/components/Alerts/IncidentAssistantHandoffButton.tsx",
"frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx",
"frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx",
"frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts",
"frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts",
"frontend-modern/src/components/Infrastructure/MaintenanceVerificationSection.tsx",
"frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx",
"frontend-modern/src/components/Settings/AIChatMaintenanceSection.tsx",
"frontend-modern/src/components/Settings/AIModelSelectionSection.tsx",
"frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx",
"frontend-modern/src/components/Settings/AISettings.tsx",
"frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx",
"frontend-modern/src/components/Settings/reportingPanelModel.ts",
"frontend-modern/src/components/Settings/settingsHeaderMeta.ts",
"frontend-modern/src/components/Settings/settingsNavCatalog.ts",
"frontend-modern/src/components/Settings/settingsNavigationModel.ts",
"frontend-modern/src/components/Settings/settingsPanelRegistry.ts",
"frontend-modern/src/components/Settings/settingsPanelRegistryContext.tsx",
"frontend-modern/src/components/Settings/useAISettingsState.ts",
"frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts",
"frontend-modern/src/components/Settings/useReportingPanelState.ts",
"frontend-modern/src/components/Settings/useSettingsNavigation.ts",
"frontend-modern/src/components/Workloads/guestAssistantContextModel.ts",
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts",
"frontend-modern/src/components/patrol/ApprovalSection.tsx",
"frontend-modern/src/components/patrol/InvestigationMessages.tsx",
"frontend-modern/src/components/patrol/RunHistoryEntry.tsx",
"frontend-modern/src/components/patrol/ToolExecutionBlock.tsx",
"frontend-modern/src/components/patrol/toolPresentation.ts",
"frontend-modern/src/components/shared/AIModelPicker.tsx",
"frontend-modern/src/components/shared/MobileNavBar.tsx",
"frontend-modern/src/components/shared/commandPaletteModel.ts",
"frontend-modern/src/components/shared/useCommandPaletteState.ts",
"frontend-modern/src/components/shared/useUpgradeNavigation.ts",
"frontend-modern/src/constants/apiScopes.ts",
"frontend-modern/src/content/help/ai.ts",
"frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx",
"frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx",
"frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx",
"frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx",
"frontend-modern/src/features/alerts/MobileAlertHistoryInvestigationDialog.tsx",
"frontend-modern/src/features/alerts/alertHistoryModel.ts",
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
"frontend-modern/src/features/patrol/PatrolSetupPanel.tsx",
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts",
"frontend-modern/src/features/patrol/patrolSetupModel.ts",
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts",
"frontend-modern/src/i18n/messages.de.ts",
"frontend-modern/src/i18n/messages.es.ts",
"frontend-modern/src/i18n/messages.ts",
"frontend-modern/src/i18n/policy.ts",
"frontend-modern/src/index.css",
"frontend-modern/src/pages/Actions.tsx",
"frontend-modern/src/routing/resourceLinks.ts",
"frontend-modern/src/stores/aiAvailability.ts",
"frontend-modern/src/stores/aiChat.ts",
"frontend-modern/src/stores/aiIntelligence.ts",
"frontend-modern/src/stores/sessionPresentationPolicy.ts",
"frontend-modern/src/stores/sessionSettingsCapabilities.ts",
"frontend-modern/src/types/ai.ts",
"frontend-modern/src/types/config.ts",
"frontend-modern/src/useAppRuntimeState.ts",
"frontend-modern/src/utils/aiChatPresentation.ts",
"frontend-modern/src/utils/aiControlLevelPresentation.ts",
"frontend-modern/src/utils/aiCostPresentation.ts",
"frontend-modern/src/utils/aiFindingPresentation.ts",
"frontend-modern/src/utils/aiSettingsPresentation.ts",
"frontend-modern/src/utils/alertFormatters.ts",
"frontend-modern/src/utils/alertTargetTypes.ts",
"frontend-modern/src/utils/alerts.ts",
"frontend-modern/src/utils/assistantPageContext.ts",
"frontend-modern/src/utils/chatIdentifiers.ts",
"frontend-modern/src/utils/resourceAssistantContextModel.ts"
],
"content_sha256": {
"frontend-modern/src/App.tsx": "e72ee427e76a10f4c19888b089dab120d0011985bf27e1f95a4d91ed76cabd0d",
"frontend-modern/src/AppLayout.tsx": "21c3aa8db3010c46557f4057fc2925407b776cce0f837b40ce603d59140dfc67",
"frontend-modern/src/api/ai.ts": "9e39a8d9973a226126cda3c01e31ee752be1bf7c4c4f480b97d47618ac91be9b",
"frontend-modern/src/api/aiChat.ts": "deleted",
"frontend-modern/src/api/aiChatDevStreamFixture.ts": "deleted",
"frontend-modern/src/api/generated/aiChatEvents.ts": "deleted",
"frontend-modern/src/api/patrol.ts": "67d03817e7982d779d156a01f51dc1a180aa1759b80cb415c6be6b2e1b76f559",
"frontend-modern/src/api/workflowPrompts.ts": "27976c493e1bbd43ff0f15f8eae943b1c8e0ba0c13aeb3bc6f65760e088d7501",
"frontend-modern/src/components/AI/Chat/ApprovalCard.tsx": "deleted",
"frontend-modern/src/components/AI/Chat/AssistantCommandHelpDialog.tsx": "deleted",
"frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "deleted",
"frontend-modern/src/components/AI/Chat/MentionAutocomplete.tsx": "deleted",
"frontend-modern/src/components/AI/Chat/MessageItem.tsx": "deleted",
"frontend-modern/src/components/AI/Chat/ModelSelector.tsx": "deleted",
"frontend-modern/src/components/AI/Chat/QuestionCard.tsx": "deleted",
"frontend-modern/src/components/AI/Chat/SlashCommandAutocomplete.tsx": "deleted",
"frontend-modern/src/components/AI/Chat/ThinkingBlock.tsx": "deleted",
"frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx": "deleted",
"frontend-modern/src/components/AI/Chat/activeTurnStatus.ts": "deleted",
"frontend-modern/src/components/AI/Chat/aiCodeHighlight.ts": "deleted",
"frontend-modern/src/components/AI/Chat/assistantAnswerText.ts": "deleted",
"frontend-modern/src/components/AI/Chat/assistantModelRoutes.ts": "deleted",
"frontend-modern/src/components/AI/Chat/assistantNotifications.ts": "deleted",
"frontend-modern/src/components/AI/Chat/assistantOutputHygiene.ts": "deleted",
"frontend-modern/src/components/AI/Chat/assistantPanelWidth.ts": "deleted",
"frontend-modern/src/components/AI/Chat/assistantSlashCommands.ts": "deleted",
"frontend-modern/src/components/AI/Chat/assistantTurnSummary.ts": "deleted",
"frontend-modern/src/components/AI/Chat/composerPaste.ts": "deleted",
"frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "deleted",
"frontend-modern/src/components/AI/Chat/hooks/useExplanationRequest.ts": "deleted",
"frontend-modern/src/components/AI/Chat/index.tsx": "deleted",
"frontend-modern/src/components/AI/Chat/markdownMorph.ts": "deleted",
"frontend-modern/src/components/AI/Chat/reasoningSummary.ts": "deleted",
"frontend-modern/src/components/AI/Chat/recentSessionsModel.ts": "deleted",
"frontend-modern/src/components/AI/Chat/streamActivityTiming.ts": "deleted",
"frontend-modern/src/components/AI/Chat/streamEventGrouping.ts": "deleted",
"frontend-modern/src/components/AI/Chat/transcriptExport.ts": "deleted",
"frontend-modern/src/components/AI/Chat/types.ts": "deleted",
"frontend-modern/src/components/AI/Chat/workflowStarters.ts": "deleted",
"frontend-modern/src/components/AI/Chat/workflowStatusDisplay.ts": "deleted",
"frontend-modern/src/components/AI/FindingsPanel.tsx": "fdf9fd2d00b47ec4a46b001013e6ed4005e34b0b37145386d1a6039200fa9e0b",
"frontend-modern/src/components/AI/aiChatUtils.ts": "3072f576a5945c531c2e497b7a46ab30a0b25790048640a34c95e85ec9b9d45b",
"frontend-modern/src/components/Alerts/IncidentAssistantHandoffButton.tsx": "deleted",
"frontend-modern/src/components/Alerts/IncidentTimelinePanel.tsx": "787bd2edefdd12ba3f464b4ff352e6595664b508035fc6b7662934ba20c02fcb",
"frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx": "a82971374c272ae4bb0c4049c66b6e11c2558df99afde0922db7506368e9021d",
"frontend-modern/src/components/Alerts/alertAssistantHandoffModel.ts": "deleted",
"frontend-modern/src/components/Alerts/incidentAssistantHandoffModel.ts": "deleted",
"frontend-modern/src/components/Infrastructure/MaintenanceVerificationSection.tsx": "321aa5ce41305d23d18922be6fc1820945c3cce773c45dbecc83284db2a7c916",
"frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx": "689d876f2f9135adedb33499602d4a243de5c76b376a5b1a5c3b8b5ef694a4e2",
"frontend-modern/src/components/Settings/AIChatMaintenanceSection.tsx": "deleted",
"frontend-modern/src/components/Settings/AIModelSelectionSection.tsx": "8ddb4ce624605d67aaed1c77b6fe712e2142f581609f0a7161b052578b0b0cee",
"frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx": "0047ddacb376cf79d6b83cc1b91628f122b363130348bafa5984c761657630a7",
"frontend-modern/src/components/Settings/AISettings.tsx": "3348671852cbca68e6e7d8b2f8aad86a6a8ea91f9da85728907345d257169fda",
"frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx": "ad94d888f84a4682d266f129ff7b427f94dea2e1885539b5b2a1f64e88e431b5",
"frontend-modern/src/components/Settings/reportingPanelModel.ts": "875175881a1a0164d14bc153e60a9a8bb8e7fceb3b5a8d2e752719cf77f3e3f6",
"frontend-modern/src/components/Settings/settingsHeaderMeta.ts": "f93aaa9378d7648b6a42a966ea24b19417f90aacff7dadf5170ead9cca831551",
"frontend-modern/src/components/Settings/settingsNavCatalog.ts": "5446e232d7a44e3001d4da6de79bba8c9887af468deab366536a4a7e2a42a50c",
"frontend-modern/src/components/Settings/settingsNavigationModel.ts": "d8d5b803a61f3f4c35708db962ddc1ecf7597c4a005e91ec19648fb3e25ba8e9",
"frontend-modern/src/components/Settings/settingsPanelRegistry.ts": "27e353c45eb1940736cc8f5f2fa102151d581b1b88ab8a3d2584761ab4ea7a49",
"frontend-modern/src/components/Settings/settingsPanelRegistryContext.tsx": "fadb22116ee90484e64fe8672d5bed1573d8d64f8adf61b540005a24c24cc84f",
"frontend-modern/src/components/Settings/useAISettingsState.ts": "e97ece391b19d095c945a18389f810dc5421bbf653779f3d61320e8d11c0ea3e",
"frontend-modern/src/components/Settings/useAgentProfilesPanelState.ts": "b88dfe6cfa536d8536b65c83828e6c5f84a36419515ea29d7e6b5f208bda10f6",
"frontend-modern/src/components/Settings/useReportingPanelState.ts": "7ae66a651760b32c85a167f56ce8886be3f71a766f578af89034363a4353ccf0",
"frontend-modern/src/components/Settings/useSettingsNavigation.ts": "b9dcd0e1c705064725b6a3a6e0b04e4500253112cdd07af25befb67e587a3688",
"frontend-modern/src/components/Workloads/guestAssistantContextModel.ts": "deleted",
"frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts": "0daedd457745a5f74bad7f5e951f7c6a4bcac845ece3d2de59c1704b037d1534",
"frontend-modern/src/components/patrol/ApprovalSection.tsx": "92139695915c2c47be18ec7c8e4f9c5deb6afa50c3e6ffdf16651c975a50606f",
"frontend-modern/src/components/patrol/InvestigationMessages.tsx": "195811b88a0353bb4da12a1ae57547842abc6852602a11f458f5ba985ee0edfa",
"frontend-modern/src/components/patrol/RunHistoryEntry.tsx": "c842eb3c9b1863c6170a352ec9e02965eaad93d3d6cbf0e25a77371119721710",
"frontend-modern/src/components/patrol/ToolExecutionBlock.tsx": "17fd64b050a013d1be783da9ca154402c98c63d52787f8ce407ab1054eae562f",
"frontend-modern/src/components/patrol/toolPresentation.ts": "155d96ffd703352017b7c4196654b315d25b5f2b3eb282f80a39be9d5177bc0d",
"frontend-modern/src/components/shared/AIModelPicker.tsx": "73a06ba8775f0306ea60d764e8a014ce89d4362443dafa78bea33549a03f726c",
"frontend-modern/src/components/shared/MobileNavBar.tsx": "2a7689b464520a646bad3d53ccaed84106ed905cdc29d20dacc946ad74d9c6aa",
"frontend-modern/src/components/shared/commandPaletteModel.ts": "11a47d18c203e99ca4fe0659ca4554f0896c275cb75ad230229dbd7e043e7c54",
"frontend-modern/src/components/shared/useCommandPaletteState.ts": "5ac21ed7c5b100a5756e6a637eac1a29c27e8ee34100c9fd0fc7bdf926d68cf2",
"frontend-modern/src/components/shared/useUpgradeNavigation.ts": "deleted",
"frontend-modern/src/constants/apiScopes.ts": "c66759f3e2122ad5f3b2a7fd9034ef80fdd3bff58671a362cb07f0607663663a",
"frontend-modern/src/content/help/ai.ts": "243b2a084e531c109c6e42a4e79624b962b56ee9c195c29e9bdbda5802e6a65d",
"frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx": "c929b66ebdcc056e262cb93b5cd1ceecb49bfd9b191d69a35f1a08c4afa06f97",
"frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx": "e2c44817251a013fed4f4663412fea2e5b952f551391fbcd043ef39148c431a4",
"frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "3892f338eacde2b0669b2462fc737df8a95510f9148b71901fb12fb7b8e025ee",
"frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx": "bacec4fb0d58c3fd2dce859626df9767171bf26ca6ee1934741428b876dce84e",
"frontend-modern/src/features/alerts/MobileAlertHistoryInvestigationDialog.tsx": "b88c28b9e00787aa17f8e714d0066fa201925ef3a492339c501e86bb74947594",
"frontend-modern/src/features/alerts/alertHistoryModel.ts": "1b41f6e8d1d42a88db0e20b6a2b398c94a5ca4ce81cb7e95f3a63839366b7ea0",
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "5d19f4bd608746abe6ab573e25c9ef9adba2a802095433d013822d2b165bcb3f",
"frontend-modern/src/features/patrol/PatrolSetupPanel.tsx": "7cb868d6c3f8d002c044ab02e3e38544a983786838a72530e46c95326353ecf3",
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts": "142188a1d9c64ac0881ea79d703e93fc912818c33ce028542101ead4b155e139",
"frontend-modern/src/features/patrol/patrolSetupModel.ts": "863a659b40263fcb8bb007d24bd4363ece8284c6cadce9b6fde4369ef54c8a49",
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "29bd1b96b3ef7f44302f413ec8022840d24c207020347ddf57d19ee9c902d988",
"frontend-modern/src/i18n/messages.de.ts": "e927870bd927b27b5467758f6886942e6afef1d1bc75e0e9935dc1c3bc68e039",
"frontend-modern/src/i18n/messages.es.ts": "4251ef01a22604a6317c2223dc0bd5bcc0ef3e14c837097f0123710f3dc2070a",
"frontend-modern/src/i18n/messages.ts": "25a1b37e8900cdf88a0bfd1a3e33b5edcfdfe5774a4bd2c03095c361fa7e2950",
"frontend-modern/src/i18n/policy.ts": "2e1413fffb077884cedebc3ea477bd01b4df32f1d094f3b261f0cd97241d8467",
"frontend-modern/src/index.css": "09ed04d0f2a88c27184eba9226e797985a0774f786623eb581928cedb4bfd4a2",
"frontend-modern/src/pages/Actions.tsx": "f3277e8be19f81afca020238b62106527208a91367d20e5fe734a1e32d40ed3b",
"frontend-modern/src/routing/resourceLinks.ts": "7c48bb2d5cccfcc3c65b35b56b3170504a9be909aa838b5eb602eb0ee632f588",
"frontend-modern/src/stores/aiAvailability.ts": "f1b78ce248568fc013c797b1221574effbc4a71539cbbd4bd4bf95d2816e6754",
"frontend-modern/src/stores/aiChat.ts": "deleted",
"frontend-modern/src/stores/aiIntelligence.ts": "664d8c881270e2a0d98bab354b799e71239327a8ed178ad68e271c2fe31f3a63",
"frontend-modern/src/stores/sessionPresentationPolicy.ts": "3c220d46cce14697bb2b631b6386e6fda4e164e276573052017630d055bf477f",
"frontend-modern/src/stores/sessionSettingsCapabilities.ts": "7279bbe6dbac0e59a42b87698bb3874557fbc82e526d2319353f0905f4dafaf3",
"frontend-modern/src/types/ai.ts": "f8b31a55997c7ce17939bf97bf42c0419878e2af42ec0d12472363bea804b2a6",
"frontend-modern/src/types/config.ts": "464fd8a6f618c3a564f888f59ea14ef09ed7903a2fabf934ae8ab5eea990b97b",
"frontend-modern/src/useAppRuntimeState.ts": "85a42cefa9f121a92652524f117e823848af344a03af9b920fb0cf14778634bc",
"frontend-modern/src/utils/aiChatPresentation.ts": "deleted",
"frontend-modern/src/utils/aiControlLevelPresentation.ts": "deleted",
"frontend-modern/src/utils/aiCostPresentation.ts": "c10444ea568a534178d9b739219e606b4ce546e4b2c6d1f7a79f26795c6b11ca",
"frontend-modern/src/utils/aiFindingPresentation.ts": "4545e3fd08bc09d170d2d2a51216ea28c8af89836dad86ecb2f15a03707414a9",
"frontend-modern/src/utils/aiSettingsPresentation.ts": "daccb31a891291f72e99a2e859c98aa3fa867a3ac10fa9ab0fb2f6ccd78b7bd8",
"frontend-modern/src/utils/alertFormatters.ts": "deleted",
"frontend-modern/src/utils/alertTargetTypes.ts": "deleted",
"frontend-modern/src/utils/alerts.ts": "c39e61c7eb96021a6b321528db46c06786ba428c56fc0f8a80fdcfb774ed0d86",
"frontend-modern/src/utils/assistantPageContext.ts": "deleted",
"frontend-modern/src/utils/chatIdentifiers.ts": "deleted",
"frontend-modern/src/utils/resourceAssistantContextModel.ts": "deleted"
},
"routes": [
"/proxmox/overview",
"/alerts/overview",
"/alerts/history",
"/patrol",
"/patrol/activity",
"/settings/pulse-intelligence/assistant#external-agent-setup",
"/settings?tab=assistant#external-agent-setup",
"/settings/pulse-intelligence/agents#external-agent-setup",
"/settings/pulse-intelligence/provider",
"/settings/security/api"
],
"viewports": [
{
"width": 1280,
"height": 800
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private mock stack (embedded build of this change, mock mode, AI available, admin session) at 1280x800 and 390x844",
"Desktop and phone app shell: no Assistant launcher, no element naming the Assistant; Patrol and Actions utility tabs present",
"Command palette search for \"assist\" returns no matches",
"Active alert cards show \"Have Patrol investigate\"; on phone it sits under the card's More disclosure with Timeline and Monitoring",
"Alert History rows show the shield Investigate icon, named \"Have Patrol investigate <type> on <resource>, <time>\"",
"Old /settings/pulse-intelligence/assistant and ?tab=assistant links land on /settings/pulse-intelligence/agents with the hash kept and Connector setup open, desktop and phone, no horizontal overflow",
"Pulse Intelligence settings nav lists Provider & Models, Patrol, Plans & Billing, External agents",
"Patrol decision detail and expanded finding offer no Assistant action; Resolve or dismiss menu lists note, copy, acknowledge, resolve, snooze, dismiss, remember and rule entries only",
"Provider usage tile reads \"Pulse Mobile chat\"; API token scope picker labels ai:chat \"Pulse Mobile chat\"",
"All API requests during the pass returned 200"
],
"interactions": [
"Signed in with the project dev credentials on the localhost stack",
"Opened the command palette with Cmd+K and searched \"assist\"",
"Clicked \"Have Patrol investigate\" on the backend-sftp alert: POST /api/ai/patrol/run returned 200 and the toast read \"Patrol is investigating backend-sftp. Findings appear on the Patrol page.\"",
"Opened the phone alert card More disclosure",
"Followed both legacy Assistant settings links at desktop and phone widths",
"Opened a Patrol decision, browsed Patrol findings, expanded a finding and opened Resolve or dismiss",
"Opened Custom scopes on API Access"
]
}
+84 -120
View File
@@ -10,8 +10,7 @@
"license": "MIT",
"dependencies": {
"@solidjs/router": "^0.10.10",
"dompurify": "^3.4.15",
"highlight.js": "^11.12.0",
"dompurify": "^3.4.17",
"lucide-solid": "^0.577.0",
"marked": "^17.0.1",
"qrcode": "^1.5.4",
@@ -27,7 +26,7 @@
"@types/qrcode": "^1.5.6",
"@typescript-eslint/eslint-plugin": "^8.71.1",
"@typescript-eslint/parser": "^8.71.1",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/coverage-v8": "^4.1.12",
"eslint": "^9.20.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-solid": "^0.18.1",
@@ -41,7 +40,7 @@
"vite": "^6.4.5",
"vite-plugin-solid": "^2.11.14",
"vite-plugin-sri-gen": "^1.7.4",
"vitest": "^4.1.11"
"vitest": "^4.1.12"
}
},
"node_modules/@adobe/css-tools": {
@@ -2386,14 +2385,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz",
"integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.11",
"@vitest/utils": "4.1.12",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -2407,8 +2406,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.11",
"vitest": "4.1.11"
"@vitest/browser": "4.1.12",
"vitest": "4.1.12"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -2417,16 +2416,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"@vitest/spy": "4.1.12",
"@vitest/utils": "4.1.12",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -2435,13 +2434,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.11",
"@vitest/spy": "4.1.12",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -2462,9 +2461,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2475,13 +2474,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.11",
"@vitest/utils": "4.1.12",
"pathe": "^2.0.3"
},
"funding": {
@@ -2489,14 +2488,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"@vitest/pretty-format": "4.1.12",
"@vitest/utils": "4.1.12",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -2505,9 +2504,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2515,13 +2514,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"@vitest/pretty-format": "4.1.12",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -2830,9 +2829,9 @@
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3087,9 +3086,9 @@
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.16",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
"integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -3820,15 +3819,6 @@
"node": ">= 0.4"
}
},
"node_modules/highlight.js": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz",
"integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
@@ -4889,9 +4879,9 @@
"license": "MIT"
},
"node_modules/obug": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
"integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
@@ -5020,6 +5010,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
@@ -5323,11 +5326,11 @@
"version": "1.6.8",
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.6.8.tgz",
"integrity": "sha512-N7mWAMydj89EnYTHtRpS3LBrAz3J9O6oVrqpuXuvlAHfAUG8WEKqtbAd7SBiJNY5NfCAFhSdyAh7wI4Shjqy4g==",
"peerDependencies": {
"seroval": "^1.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"seroval": "^1.0"
}
},
"node_modules/set-blocking": {
@@ -5410,9 +5413,9 @@
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
"dev": true,
"license": "MIT"
},
@@ -5571,23 +5574,10 @@
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5923,19 +5913,6 @@
}
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitefu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
@@ -5957,19 +5934,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"@vitest/expect": "4.1.12",
"@vitest/mocker": "4.1.12",
"@vitest/pretty-format": "4.1.12",
"@vitest/runner": "4.1.12",
"@vitest/snapshot": "4.1.12",
"@vitest/spy": "4.1.12",
"@vitest/utils": "4.1.12",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -5997,12 +5974,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"@vitest/browser-playwright": "4.1.12",
"@vitest/browser-preview": "4.1.12",
"@vitest/browser-webdriverio": "^4.1.11",
"@vitest/coverage-istanbul": "4.1.12",
"@vitest/coverage-v8": "4.1.12",
"@vitest/ui": "4.1.12",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -6046,19 +6023,6 @@
}
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+3 -4
View File
@@ -53,8 +53,7 @@
},
"dependencies": {
"@solidjs/router": "^0.10.10",
"dompurify": "^3.4.15",
"highlight.js": "^11.12.0",
"dompurify": "^3.4.17",
"lucide-solid": "^0.577.0",
"marked": "^17.0.1",
"qrcode": "^1.5.4",
@@ -80,7 +79,7 @@
"@types/qrcode": "^1.5.6",
"@typescript-eslint/eslint-plugin": "^8.71.1",
"@typescript-eslint/parser": "^8.71.1",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/coverage-v8": "^4.1.12",
"eslint": "^9.20.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-solid": "^0.18.1",
@@ -94,6 +93,6 @@
"vite": "^6.4.5",
"vite-plugin-solid": "^2.11.14",
"vite-plugin-sri-gen": "^1.7.4",
"vitest": "^4.1.11"
"vitest": "^4.1.12"
}
}
@@ -214,6 +214,6 @@ Pulse; verification can be unavailable without proving that nothing ran.
- [Configuration](CONFIGURATION.md) for API tokens and their scopes.
- [API reference](API.md) for the wider Pulse HTTP surface.
- [AI features](AI.md) for how Patrol and the Assistant use the same context.
- [AI features](AI.md) for how Patrol and external agents use the same context.
- `cmd/pulse-mcp/README.md` in the repository for adapter setup examples
covering OpenCode, Claude Desktop, and Claude Code.
+35 -96
View File
@@ -18,19 +18,6 @@ That core is deliberately surfaced with Patrol as the primary built-in operator
These surfaces are built on the same action-driven architecture: the configured LLM owns diagnosis, prioritization, fix reasoning, and action choice; Pulse supplies context, capabilities, safety gates, approval state, and audit trails. Verification is part of the governed action lifecycle rather than a separate model-owned feature.
### Not Just Another Chatbot
Pulse Assistant is a **protocol-driven, safety-gated LLM tool surface** that:
- **Provides governed context** — attaches explicit resource mentions and recent session facts without rewriting user intent
- **Caches session facts** — extracts bounded tool facts to avoid redundant queries during the current conversation
- **Enforces workflow invariants** — FSM prevents dangerous state transitions
- **Supports parallel tool execution** — efficient batch operations with concurrency control
- **Grounds answers in real tool work** — visible tool traces, read-after-write verification, and transcript hygiene prevent unsupported execution claims from being treated as facts
- **Returns structured tool errors** — the model can recover from clear, machine-readable failures
📖 **For a deep technical dive into the Assistant architecture, see [Assistant architecture](ASSISTANT_ARCHITECTURE.md).**
### Not Just Another Alerting System
Pulse Patrol is a **scheduled and event-triggered governed operator** that:
@@ -48,20 +35,25 @@ All while running entirely on your infrastructure with BYOK for complete privacy
🧪 **For independent live-fault qualification, safety gates, model comparison,
and release-claim rules, see [AI_PATROL_QUALIFICATION.md](AI_PATROL_QUALIFICATION.md).**
See [Assistant safety architecture](ASSISTANT_SAFETY.md) for the original safety architecture documentation.
See [Assistant safety architecture](ASSISTANT_SAFETY.md) for the safety gates
the shared AI runtime enforces.
### Assistant And MCP
### Using Pulse from Claude Code, Codex, and other agents
Pulse Assistant and `pulse-mcp` are sibling surfaces over Pulse Intelligence,
not competing implementations, and neither replaces the other. Assistant remains
the in-app Pro surface for current resource/finding/run handoffs, approval
cards, governed action status, and operator-friendly timelines. `pulse-mcp`
owns the external-agent bridge: it fetches `/api/agent/capabilities`, projects
those canonical API capabilities as MCP tools, and preserves the same stable
error envelopes and approval/audit contracts. New operational capabilities
should be added to the canonical API manifest first, then consumed by Assistant
or MCP as appropriate; MCP-only actions and Assistant-only copies of the same
business logic are drift.
Pulse has no in-app chat window. To ask about your infrastructure in plain
language, connect an MCP client such as Claude Code, Codex, or OpenCode to
`pulse-mcp`: open **Settings → Pulse Intelligence → External agents** and
choose **Show connector setup**. `pulse-mcp` fetches `/api/agent/capabilities`,
projects those canonical API capabilities as MCP tools, and preserves the same
stable error envelopes and approval/audit contracts as Patrol. New operational
capabilities are added to the canonical API manifest first; MCP-only copies of
the same business logic are drift.
The tool list is registry-owned at runtime, not hand-maintained here. For the
current inventory, read the live `/api/agent/capabilities` manifest or
`pulse-mcp` `tools/list`. The same manifest carries reusable `workflowPrompts`
metadata, so MCP `prompts/list` clients discover the fleet triage, resource
investigation, and Patrol finding review workflows.
---
@@ -136,10 +128,7 @@ create a second finding lifecycle.
explicitly.
Select an item to see the affected resource, impact, next step, typed evidence,
protection posture, and lifecycle timeline. **Explain with Assistant** appears
only inside selected context and receives policy-shaped summaries and
references. Assistant can explain the item, but it cannot create lifecycle
truth, invent action authority, or hide uncertainty.
protection posture, and lifecycle timeline.
Open items can be acknowledged directly. Suppression is temporary and requires
an operator reason plus a bounded expiry; it never resolves the detector's
@@ -228,7 +217,6 @@ Note: `info` and `watch` level findings are filtered out to reduce noise.
Findings can be managed via the UI or API:
- **Open in Assistant**: Open Pulse Assistant with the finding attached to troubleshoot the issue (on Patrol findings it appears under **Resolve or dismiss**)
- **Resolve**: Mark as fixed (finding will reappear if the issue resurfaces)
- **Dismiss**: Record feedback about this finding; use **Reopen** to undo its dismissal
- **Create rule**: Permanently auto-dismiss future findings for the selected resource and category
@@ -434,74 +422,25 @@ MaybeInvestigateFinding()
---
## Pulse Assistant (Chat)
## Pulse Assistant chat (retired)
Pulse Assistant is a **tool-driven** chat interface. It does not "guess" system state — it calls live tools and reports their outputs.
The in-app Pulse Assistant chat window and its Settings page have been retired.
Patrol is the built-in operator, and the governed tools the chat used are
available to external agents through `pulse-mcp` (see
[Using Pulse from Claude Code, Codex, and other agents](#using-pulse-from-claude-code-codex-and-other-agents)).
An alert's **Have Patrol investigate** button starts a targeted Patrol check on
the alert's resource; its findings appear on the Patrol page.
### The Model's Workflow (Discover → Investigate → Act)
Pulse Mobile keeps its chat tab until the app retires on 31 March 2027. It uses
the chat action level saved before the retirement, and the chat model override
if one was saved, otherwise the default model from **Provider & Models**. The web
settings no longer set the action level or the override; removing the provider
an override names clears it, and Mobile chat then uses the default model.
1. **Discover**: Uses `pulse_query` to find real resources and IDs
2. **Investigate**: Uses `pulse_read` to run bounded, read-only commands and check status/logs
3. **Act** (optional): Uses `pulse_control` for changes, then verifies with a read
### Tool Inventory
The Assistant tool list is registry-owned at runtime, not hand-maintained in
this public overview. Each turn receives an available-tool manifest generated
from Pulse's governed tool registry, including action mode (`read`, `mixed`,
`write`) and approval policy (`scope_only`, `action_plan`). That same registry
feeds the Assistant system prompt, provider tool declarations, tool-result
handling, approval boundaries, and Patrol-only tool filtering.
For the current source-owned inventory, see the native tool registry and
governance projection in `internal/ai/tools/` and
`internal/agentcapabilities/`. For external agents, use the live
`/api/agent/capabilities` manifest or `pulse-mcp` `tools/list`; those surfaces
project the canonical agent capabilities rather than a separate MCP-only tool
table.
The same manifest also carries reusable `workflowPrompts` metadata so Pulse
Assistant-compatible starters and MCP `prompts/list` clients discover the same
fleet triage, resource investigation, and Patrol finding review workflows.
### Safety Gates
The assistant enforces multiple safety gates:
1. **Resolve Before Action** — Action tools cannot operate on resources that weren't first resolved by a read tool
2. **Verification After Write** — After any write, the model must perform a read/status check before providing a final answer
3. **Read/Write Separation** — Read operations route through `pulse_read` (stays in READING state); write operations route through `pulse_control` (enters VERIFYING state)
4. **Grounded Execution Guardrails** — Visible tool traces and read-after-write checks prevent unsupported execution claims from being treated as facts
5. **Approval Mode** — In Controlled mode, every write requires explicit user approval
6. **Execution Context Binding** — Commands execute within the resolved resource's context, not on parent hosts
### Control Levels
| Mode | Behaviour | Availability |
|------|-----------|--------------|
| **Read-only** | Assistant queries and explains; no infrastructure action planning | Community |
| **Ask first** (`controlled`) | Assistant saves infrastructure action plans to **Actions** for your review | Community |
Assistant chat does not execute these plans. Patrol's own mode and Autopilot
policy are separate; changing Chat action mode grants no Patrol authority.
Older saved `autonomous` preferences display as **Ask first**, not as permission
to execute without approval. The stored preference and its existing entitlement
checks are preserved for older clients.
To keep Assistant away from a specific guest, lock it: open the guest, choose
**Manage**, turn on **Never auto-remediate** under Operator overrides, and save.
Pulse then refuses typed actions that target that resource, including
approved ones. Older versions also showed a **Protected guests** list here. It
never excluded a guest from action plans, so it has been removed.
### Reviewing an Assistant action plan
1. Assistant submits a typed action for a resource advertising that capability.
2. Pulse records the plan in **Actions**; planning is not execution.
3. Open **Actions** to check the target, proposed operation, approval policy and expiry.
4. An authorised operator reviews, approves where required, and runs the plan
through the existing action controls. Chat mode cannot bypass these checks.
5. Read the recorded execution and independent verification result. A saved plan
or successful command alone does not establish recovery.
To keep automated actions away from a specific guest, lock it: open the guest,
choose **Manage**, turn on **Never auto-remediate** under Operator overrides,
and save. Pulse then refuses typed actions that target that resource, including
approved ones.
---
@@ -959,7 +898,7 @@ Pulse tracks token usage and costs:
| Issue | Solution |
|-------|----------|
| Assistant or Patrol not responding | Verify provider credentials in **Settings → Pulse Intelligence → Provider & Models** |
| Patrol not responding | Verify provider credentials in **Settings → Pulse Intelligence → Provider & Models** |
| No execution capability | Confirm at least one agent is connected |
| Findings not persisting | Check Pulse has write access to `ai_findings.json` in the config directory |
| Too many findings | This shouldn't happen — please report if it does |
+34 -35
View File
@@ -1,19 +1,20 @@
# Pulse Intelligence Modes and Safety Configuration
This guide covers how to configure Patrol mode, Pulse Assistant command access, and the safety guardrails that apply before Pulse can change infrastructure.
This guide covers how to configure Patrol mode and the safety guardrails that apply before Pulse can change infrastructure.
For a general overview of Pulse Intelligence, see [AI.md](AI.md). For plan-level feature availability, see [PULSE_PRO.md](PULSE_PRO.md).
---
## Two Axes of Control
## Patrol Mode Is the Control
Pulse separates AI permissions into two independent axes:
**Patrol Mode** decides what Patrol may handle automatically after it finds an
issue: watch only, ask before changes, handle safe fixes, or use policy
autopilot. Patrol mode is configured on the **Patrol** page.
1. **Patrol Mode** — What Patrol may handle automatically after it finds an issue: watch only, ask before changes, handle safe fixes, or use policy autopilot.
2. **Assistant Command Access** — Whether the interactive chat assistant can execute commands during a chat session.
Patrol mode is configured on the **Patrol** page. Assistant command access is configured in **Settings → Pulse Intelligence → Assistant**.
The in-app Pulse Assistant chat, and its separate chat action setting, have been
retired. Pulse Mobile's chat keeps the level saved before then until the app
retires on 31 March 2027 (see [Pulse Mobile chat control level](#pulse-mobile-chat-control-level)).
---
@@ -95,43 +96,41 @@ Without the `ai_autofix` capability, the effective Patrol mode is clamped to `mo
---
## Assistant Control Levels
## Pulse Mobile chat control level
Control levels govern interactive Assistant queries and action planning, not
Patrol's autonomy or permission to execute infrastructure work.
The control level governs Pulse Mobile chat queries and action planning, not
Patrol's autonomy or permission to execute infrastructure work. The web
settings page that edited it was retired with the in-app chat; the saved value
still applies to Pulse Mobile until the app retires on 31 March 2027.
| Mode | Key | Query | Plan infrastructure actions | Availability |
|------|-----|:-----:|:---------------------------:|--------------|
| **Read-only** | `read_only` | Yes | No | Community |
| **Ask first** | `controlled` | Yes | For operator review in **Actions** | Community |
Assistant chat does not execute the plans it saves. A legacy saved `autonomous`
preference displays as **Ask first** and keeps its stored value and existing
entitlement checks for older clients. It does not bypass approval. An unrelated
settings save does not rewrite that preference. Unknown values fail closed to
read-only. Patrol mode and Autopilot acknowledgements remain separate.
Chat does not execute the plans it saves. A legacy saved `autonomous`
preference acts as **Ask first** and keeps its stored value and existing
entitlement checks for older clients. It does not bypass approval. Web settings
saves never rewrite that preference. Unknown values fail closed to read-only.
Patrol mode and Autopilot acknowledgements remain separate.
To keep Assistant away from a specific guest, lock it: open the guest, choose
**Manage**, turn on **Never auto-remediate** under Operator overrides, and save.
Pulse then refuses typed actions that target that resource, including
To keep chat and Patrol away from a specific guest, lock it: open the guest,
choose **Manage**, turn on **Never auto-remediate** under Operator overrides,
and save. Pulse then refuses typed actions that target that resource, including
approved ones (see [Keep a Resource Off-Limits](#keep-a-resource-off-limits)).
Older versions also showed a **Protected guests** list here. It never excluded a
guest from action plans, so it has been removed.
### Configuration
**UI:** Settings → Pulse Intelligence → Assistant → Chat action mode
**API:** This intentionally changes Assistant action planning access. Use the same private
administrator header file with `settings:write`; the token must also have
permission to change settings. Prefer the UI for one-off changes, and verify
the saved **Chat action mode** there afterwards. Do not use a write as an
authentication test.
**API only.** This intentionally changes Pulse Mobile chat action planning
access. Use the same private administrator header file with `settings:write`;
the token must also have permission to change settings. Read
`control_level` from `GET /api/settings/ai` afterwards to confirm the saved
value. Do not use a write as an authentication test.
Use the same `pulse_api` helper defined above, with its private response file
and request limits. Supporting PUT in the helper grants no additional access.
If the response is lost, check the saved **Chat action mode** in the UI before
deciding whether another change is needed; do not repeat the write blindly.
If the response is lost, read the saved `control_level` before deciding
whether another change is needed; do not repeat the write blindly.
```bash
pulse_api PUT /api/settings/ai/update <<'JSON'
@@ -141,12 +140,12 @@ JSON
### Reviewing plans (Ask first)
1. Assistant requests a typed action for an explicitly identified resource with
1. Chat requests a typed action for an explicitly identified resource with
the advertised capability.
2. Pulse saves the canonical plan in **Actions** without requesting execution.
3. Review the target, operation, approval policy and expiry in **Actions**.
4. Use the existing authorised approval and run controls; Chat action mode grants
no additional access or automatic approval.
4. Use the existing authorised approval and run controls; the chat control
level grants no additional access or automatic approval.
5. Read the recorded execution and independent verification outcome before
concluding that the operation worked. Do not repeat an uncertain operation.
@@ -186,15 +185,15 @@ If the AI provider experiences consecutive failures, the circuit breaker (`inter
### Discovery-Before-Action
The assistant cannot operate on resources it hasn't first discovered. This prevents hallucinated resource IDs from reaching infrastructure commands.
The AI runtime cannot operate on resources it hasn't first discovered. This prevents hallucinated resource IDs from reaching infrastructure commands.
### Verification-After-Write
After executing any control action, the assistant must verify the result with a read operation before reporting success. This is enforced by the FSM — the assistant cannot return to idle state without verification.
After executing any control action, the AI runtime must verify the result with a read operation before reporting success. This is enforced by the FSM — the runtime cannot return to idle state without verification.
### Keep a Resource Off-Limits
To stop Pulse acting on a specific guest, host or container, open its drawer, choose **Manage**, turn on **Never auto-remediate** under Operator overrides, confirm the prompt, and click **Save overrides**. Pulse then refuses typed actions that target that exact resource: the Assistant declines to plan one when it can read the lock, Patrol will not run one, and the action lifecycle checks the lock again before it runs an action that was already planned, even when approved. The lock covers that resource only, not its host or other resources, and it does not stop read-only investigation. Turn it off and save again when you want Pulse to act on the resource again. A resource whose lifecycle is Retired is blocked the same way until the lifecycle is set back to Active.
To stop Pulse acting on a specific guest, host or container, open its drawer, choose **Manage**, turn on **Never auto-remediate** under Operator overrides, confirm the prompt, and click **Save overrides**. Pulse then refuses typed actions that target that exact resource: chat declines to plan one when it can read the lock, Patrol will not run one, and the action lifecycle checks the lock again before it runs an action that was already planned, even when approved. The lock covers that resource only, not its host or other resources, and it does not stop read-only investigation. Turn it off and save again when you want Pulse to act on the resource again. A resource whose lifecycle is Retired is blocked the same way until the lifecycle is set back to Active.
---
+2 -2
View File
@@ -95,7 +95,7 @@ All backend code lives under `cmd/`, `internal/`, and `pkg/`. The binary is asse
- Gated by the `relay` license feature.
7. **AI Subsystem (`internal/ai`)**
- **Pulse Assistant (Chat)**: Interactive LLM-powered chat with infrastructure context. Supports bring-your-own-key (BYOK) providers (OpenAI, Anthropic, Ollama, etc.) plus Claude OAuth.
- **AI runtime (`internal/ai/chat`)**: The governed tool loop behind Patrol investigations and Pulse Mobile chat (the in-app chat window has been retired). Supports bring-your-own-key (BYOK) providers (OpenAI, Anthropic, Ollama, etc.) plus Claude OAuth. External agents reach the same tools through `pulse-mcp`.
- **Pulse Patrol**: Scheduled background analysis that produces findings, predictions, and remediation plans. Autonomy levels: `monitor`, `approval`, `assisted`, `full`.
- **Intelligence Services**: Patterns, correlations, anomalies, baselines, forecasts, and incident recording. All surfaced via `/api/ai/intelligence/*`.
- **Safety gates**: Command execution disabled by default (`--enable-commands` opt-in); circuit breakers and scoped permissions at every layer.
@@ -174,7 +174,7 @@ contracts remain backend building blocks consumed inside platform pages.
### State Management
- **WebSocket store** (`stores/websocket.ts`): Manages the live connection, reactive `State` object, reconnection logic, and per-org switching.
- **Metrics collector** (`stores/metricsCollector.ts`): Buffers time-series data for sparklines and historical charts.
- **AI stores** (`stores/aiChat.ts`, `stores/aiIntelligence.ts`): Manage chat sessions and patrol findings.
- **AI stores** (`stores/aiAvailability.ts`, `stores/aiIntelligence.ts`): Hold whether AI is enabled for the session and the Patrol findings.
- **License store** (`stores/license.ts`): Tracks plan tier, feature flags, and multi-tenant state.
- **System settings store** (`stores/systemSettings.ts`): Caches server-side preferences (for example theme and feature toggles).
@@ -1,5 +1,10 @@
# Pulse Assistant deep dive
> **Status:** the in-app Assistant chat window has been retired. This runtime
> still runs Patrol investigations, and Pulse Mobile's chat until the app
> retires on 31 March 2027. External agents reach the same governed tools
> through `pulse-mcp`.
How the Assistant's agentic loop executes tool calls, for readers who want
more than the overview in [AI features](AI.md).
@@ -1,5 +1,10 @@
# Pulse Assistant safety architecture
> **Status:** the in-app Assistant chat window has been retired. This runtime
> still runs Patrol investigations, and Pulse Mobile's chat until the app
> retires on 31 March 2027. External agents reach the same governed tools
> through `pulse-mcp`.
Pulse enforces authority at the shared tool and action boundaries. The model
owns interpretation, investigation and action judgment within those boundaries.
A sequence of tool calls cannot establish that a diagnosis is correct.
+1 -1
View File
@@ -1175,7 +1175,7 @@ verification enabled and do not share verbose or trace output.
| `agent:config:read` | Read agent config payloads |
| `agent:manage` | Agent lifecycle/configuration changes and unregistering; not needed by the reporting preset |
| `agent:exec` | Establish agent command WebSocket connections; not a reporting permission |
| `ai:chat` | Use Pulse Assistant chat and read knowledge |
| `ai:chat` | Use Pulse Mobile chat and read knowledge |
| `ai:execute` | Use governed Patrol plans, approvals, actions, and history |
| `settings:read` | Read configuration |
| `settings:write` | Modify configuration, manage tokens and trigger updates |
+1 -1
View File
@@ -176,7 +176,7 @@ keys and old 14-day limit remain in code without changing that policy.
## Patrol Modes
Patrol mode decides how far Pulse can go after Patrol finds something that needs attention. Assistant chat command access is configured separately.
Patrol mode decides how far Pulse can go after Patrol finds something that needs attention.
| Mode | Behavior | Plan |
|---|---|---|
@@ -1,37 +0,0 @@
# Assistant identity browser regression
From the repository root (Node 24):
```sh
npm ci --ignore-scripts
npm ci --ignore-scripts --prefix frontend-modern
npx playwright install chromium
pulse-heavy-run -- node scripts/check-assistant-identity.mjs
```
This standalone Vite fixture imports the current checkout's real `useChat` and
`ChatMessages`, including `MessageItem`, approval cards and tool detail rows.
It is not a production entry point and needs no backend, credentials, provider,
customer captures or live infrastructure. The only replaced API is the chat
stream callback; all browser API/external HTTP requests are blocked and fail the
check. Approval completion is synthetic, not evidence of an authorised action.
Checks at 1440, 900 and 390 pixels cover concurrent same-name invocation IDs,
progress/completion isolation, retaining a sibling approval after completion,
settled rendered results, and shared input/output evidence after removal of a
workflow row. Both evidence rows are expanded and document overflow is checked.
The shared-object journey deliberately retains references, matching the original
regression rather than concealing it with cloned fixtures.
Output defaults to `tmp/assistant-identity`; set `ASSISTANT_IDENTITY_OUTPUT` to
an absolute path to retain a candidate-specific run. A successful run writes
screenshots and `receipt.json` with browser version and SHA-256 source hashes.
Run on the actual backport checkout: a main receipt does not qualify release
content. This is a component/reducer browser proof, not the release admission
attestation, a production-build check, SSE transport qualification, provider
reasoning proof, or installed end-to-end action verification.
Regression sensitivity can be checked by temporarily substituting either
`ChatMessages.tsx` or `hooks/useChat.ts` from the parent of fix `33b852f66b`:
each must fail independently. Restore files afterwards; never admit those
experimental substitutions as candidate content.
@@ -1,72 +0,0 @@
// Synthetic component journey: real reducer and transcript, no application server.
import { createSignal } from 'solid-js';
import { render } from 'solid-js/web';
import { AIChatAPI, type StreamEvent } from '../../src/api/aiChat';
import { useChat } from '../../src/components/AI/Chat/hooks/useChat';
import { ChatMessages } from '../../src/components/AI/Chat/ChatMessages';
import type { ChatMessage } from '../../src/components/AI/Chat/types';
import '../../src/index.css';
let dispatch: (event: StreamEvent) => void;
AIChatAPI.chat = async (_prompt, _session, _model, onEvent) => {
dispatch = onEvent;
await new Promise(() => {}); // Keep stream open until the fixture is disposed.
};
const noOp = () => {};
function Fixture() {
const chat = useChat({ sessionId: 'synthetic-identity' });
const [override, setOverride] = createSignal<ChatMessage[] | null>(null);
const a = { name: 'pulse_query', input: 'client', output: 'client evidence', success: true };
const b = { name: 'pulse_alerts', input: 'alerts', output: 'alert evidence', success: true };
const pendingA = { id: 'a', name: a.name, input: a.input };
const pendingB = { id: 'b', name: b.name, input: b.input };
const workflow = {
type: 'workflow_status' as const,
workflowStatus: { phase: 'provider_start', message: 'Starting' },
};
const message = (extra: Partial<ChatMessage>): ChatMessage => ({
id: 'shared',
role: 'assistant',
content: '',
timestamp: new Date('2026-01-01T00:00:00Z'),
...extra,
});
Object.assign(window, {
identityFixture: {
start: () => {
void chat.sendMessage('Synthetic identity check');
},
fire: (event: StreamEvent) => dispatch(event),
snapshot: () => chat.messages().find((m) => m.role === 'assistant'),
evidence: () => [a, b],
shared: (stage: number) =>
setOverride([
message({
toolCalls: stage === 0 ? [] : stage === 1 ? [a] : [a, b],
pendingTools: stage === 0 ? [pendingA, pendingB] : stage === 1 ? [pendingB] : [],
streamEvents: [
...(stage < 3 ? [workflow] : []),
stage === 0
? { type: 'pending_tool', toolId: 'a', pendingTool: pendingA }
: { type: 'tool', toolId: 'a', tool: a },
stage < 2
? { type: 'pending_tool', toolId: 'b', pendingTool: pendingB }
: { type: 'tool', toolId: 'b', tool: b },
],
}),
]),
},
});
return (
<main style={{ height: '100vh', display: 'flex', 'flex-direction': 'column' }}>
<ChatMessages
messages={override() ?? chat.messages()}
onApprove={(id, approval) => chat.updateApproval(id, approval.toolId, { removed: true })}
onSkip={(id, toolId) => chat.updateApproval(id, toolId, { removed: true })}
onAnswerQuestion={noOp}
onSkipQuestion={noOp}
/>
</main>
);
}
render(() => <Fixture />, document.getElementById('root')!);
@@ -1,10 +0,0 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./fixture.tsx"></script>
</body>
</html>
@@ -89,8 +89,6 @@ const ALLOWLIST = new Set([
'src/utils/auditWebhookPresentation.ts',
'src/utils/auditLogPresentation.ts',
'src/utils/diagnosticsPresentation.ts',
'src/utils/aiChatPresentation.ts',
'src/utils/aiControlLevelPresentation.ts',
'src/utils/aiCostPresentation.ts',
'src/utils/aiSettingsPresentation.ts',
'src/utils/aiPatrolSchedulePresentation.ts',
@@ -621,10 +619,10 @@ const HELPER_RULES = [
'Do not define local overview or AI empty-state copy in component code. Use the shared presentation utilities instead.',
},
{
rule: 'canonical-ai/no-local-chat-empty-state-copy',
regex: /No previous conversations|No matching models\./g,
rule: 'canonical-ai/no-local-model-picker-empty-state-copy',
regex: /No matching models\./g,
message:
'Do not define local AI chat empty-state copy in component code. Use @/utils/aiChatPresentation instead.',
'Do not define local AI model picker empty-state copy in component code. Use @/utils/aiSettingsPresentation instead.',
},
{
rule: 'canonical-relay/no-local-onboarding-copy',
@@ -658,13 +656,6 @@ const HELPER_RULES = [
message:
'Do not define local service summary tone helpers in component code. Use @/utils/serviceHealthPresentation instead.',
},
{
rule: 'canonical-ai/no-local-control-level-presentation-helper',
regex:
/\bconst\s+normalizeControlLevel\s*=|\bform\.controlLevel\s*===\s*['"]autonomous['"][\s\S]{0,320}bg-red-100|\bform\.controlLevel\s*===\s*['"]controlled['"][\s\S]{0,320}bg-amber-100/g,
message:
'Do not define local AI control-level normalization or presentation helpers in component code. Use @/utils/aiControlLevelPresentation instead.',
},
{
rule: 'canonical-ai/no-local-settings-readiness-presentation-helper',
regex:
@@ -1000,7 +1000,6 @@
"export": "Button"
},
"requiredConsumers": [
{ "path": "src/components/AI/Chat/ChatMessages.tsx" },
{ "path": "src/components/patrol/ApprovalBanner.tsx" },
{ "path": "src/components/patrol/ApprovalSection.tsx" },
{ "path": "src/components/ErrorBoundary.tsx" },
@@ -1318,8 +1317,6 @@
{ "path": "src/components/Alerts/AlertResourceTableMobile.tsx" },
{ "path": "src/components/Alerts/AlertResourceTableRow.tsx" },
{ "path": "src/components/Alerts/ResourceTable.tsx" },
{ "path": "src/components/AI/Chat/index.tsx" },
{ "path": "src/components/AI/Chat/MessageItem.tsx" },
{ "path": "src/components/Settings/RolesPanel.tsx" },
{ "path": "src/components/Settings/SSOProvidersPanel.tsx" },
{ "path": "src/features/standalone/AgentsMachinesTable.tsx" }
@@ -1354,27 +1351,6 @@
"text-slate-400 hover:text-white bg-surface hover:bg-slate-700 rounded-full p-1.5"
]
},
{
"path": "src/components/AI/Chat/index.tsx",
"patterns": [
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface disabled:hover:text-muted",
"shrink-0 p-2 hover:text-base-content rounded-md hover:bg-surface-hover transition-colors",
"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 disabled:cursor-wait disabled:opacity-70 dark:text-blue-200 dark:hover:bg-blue-900/60",
"rounded-sm p-1 text-muted opacity-0 transition-opacity hover:bg-blue-100 hover:text-blue-600 focus:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100 dark:hover:bg-blue-900/25 dark:hover:text-blue-300",
"order-2 flex h-9 w-9 shrink-0 items-center justify-center rounded-md hover:text-base-content hover:bg-surface-hover transition-colors sm:order-0",
"flex h-7 w-7 items-center justify-center rounded-md border border-amber-200 bg-surface text-amber-700 transition-colors hover:bg-amber-100 hover:text-amber-900 dark:border-amber-800 dark:bg-amber-950/60 dark:text-amber-200 dark:hover:bg-amber-900/25",
"flex h-7 w-7 items-center justify-center rounded-md text-amber-700 transition-colors hover:bg-amber-100 hover:text-amber-900 dark:text-amber-200 dark:hover:bg-amber-900/25",
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 dark:text-blue-200 dark:hover:bg-blue-900/60",
"flex h-9 w-9 items-center justify-center rounded-md bg-blue-600 text-white shadow-xs transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-45",
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content focus:outline-hidden focus:ring-2 focus:ring-blue-500/30"
]
},
{
"path": "src/components/AI/Chat/MessageItem.tsx",
"patterns": [
"inline-flex h-5 w-5 items-center justify-center rounded-sm text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 focus:bg-blue-100 focus:outline-hidden focus:ring-2 focus:ring-blue-500/30"
]
},
{
"path": "src/features/standalone/AgentsMachinesTable.tsx",
"patterns": [
@@ -1413,23 +1389,13 @@
"export": "CopyValueButton"
},
"requiredConsumers": [
{ "path": "src/components/AI/Chat/MessageItem.tsx" },
{ "path": "src/components/AI/Chat/ToolExecutionBlock.tsx" },
{ "path": "src/components/patrol/ToolExecutionBlock.tsx" },
{ "path": "src/components/Settings/SSOProvidersPanel.tsx" },
{ "path": "src/components/shared/WebInterfaceUrlField.tsx" }
],
"forbiddenPatterns": [
{
"path": "src/components/AI/Chat/MessageItem.tsx",
"patterns": [
"lucide-solid/icons/copy",
"lucide-solid/icons/check';",
"mt-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border-subtle bg-surface text-muted opacity-0 shadow-xs transition-opacity hover:text-base-content",
"ml-auto inline-flex h-7 w-7 items-center justify-center rounded-md border border-border-subtle bg-surface text-muted opacity-0 shadow-xs transition-opacity hover:text-base-content"
]
},
{
"path": "src/components/AI/Chat/ToolExecutionBlock.tsx",
"path": "src/components/patrol/ToolExecutionBlock.tsx",
"patterns": [
"lucide-solid/icons/copy",
"lucide-solid/icons/check';",
@@ -1574,9 +1540,7 @@
{ "path": "src/components/Alerts/WebhookConfigForm.tsx" },
{ "path": "src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx" },
{ "path": "src/components/OrgSwitcher.tsx" },
{ "path": "src/components/Settings/AIChatMaintenanceSection.tsx" },
{ "path": "src/components/Settings/AIModelSelectionSection.tsx" },
{ "path": "src/components/Settings/AIRuntimeControlsSection.tsx" },
{ "path": "src/components/Settings/AgentProfilesPanel.tsx" },
{ "path": "src/components/Settings/AuditLogPanel.tsx" },
{
@@ -3035,8 +2999,6 @@
"export": "SearchField"
},
"requiredConsumers": [
{ "path": "src/components/AI/Chat/AssistantCommandHelpDialog.tsx" },
{ "path": "src/components/AI/Chat/index.tsx" },
{ "path": "src/components/Settings/InfrastructureSourcePicker.tsx" },
{ "path": "src/components/Settings/ResourcePicker.tsx" },
{ "path": "src/components/Settings/UserAssignmentsPanel.tsx" },
@@ -3259,204 +3221,26 @@
]
},
{
"id": "ai-chat-message-queued-action-icon-local-shell",
"category": "action-button",
"summary": "AI Chat queued message edit/remove actions must not recreate local 20px blue icon-button shells; compose ActionIconButton.",
"canonical": {
"path": "src/components/shared/Button.tsx",
"export": "ActionIconButton"
},
"scopes": ["src/components/AI/Chat/MessageItem.tsx"],
"extensions": [".tsx"],
"allPatterns": [
"inline-flex h-5 w-5 items-center justify-center rounded-sm text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 focus:bg-blue-100 focus:outline-hidden focus:ring-2 focus:ring-blue-500/30"
],
"legacyReason": "Retired migration debt. AI Chat queued message icon actions belong to ActionIconButton.",
"allowedPaths": [],
"ignoredPaths": ["src/components/shared/Button.test.tsx"],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/Button.test.tsx",
"src/components/AI/Chat/__tests__/MessageItem.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "ai-chat-action-icon-header-local-shell",
"category": "action-button",
"summary": "AI Chat header icon actions must not recreate local h-8 bordered icon-button shells; compose ActionIconButton.",
"canonical": {
"path": "src/components/shared/Button.tsx",
"export": "ActionIconButton"
},
"scopes": ["src/components/AI/Chat"],
"extensions": [".tsx"],
"allPatterns": [
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content"
],
"legacyReason": "Retired migration debt. AI Chat header icon actions belong to ActionIconButton.",
"allowedPaths": [],
"ignoredPaths": [
"src/components/AI/Chat/__tests__/AIChat.test.tsx",
"src/components/shared/Button.test.tsx"
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/Button.test.tsx",
"src/components/AI/Chat/__tests__/AIChat.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "ai-chat-action-icon-accent-local-shell",
"category": "action-button",
"summary": "AI Chat queued follow-up and session accent icon actions must not recreate local blue h-6/h-7 icon-button shells; compose ActionIconButton.",
"canonical": {
"path": "src/components/shared/Button.tsx",
"export": "ActionIconButton"
},
"scopes": ["src/components/AI/Chat"],
"extensions": [".tsx"],
"allPatterns": [
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950"
],
"legacyReason": "Retired migration debt. AI Chat accent icon actions belong to ActionIconButton.",
"allowedPaths": [],
"ignoredPaths": [
"src/components/AI/Chat/__tests__/AIChat.test.tsx",
"src/components/shared/Button.test.tsx"
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/Button.test.tsx",
"src/components/AI/Chat/__tests__/AIChat.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "ai-chat-action-icon-primary-send-local-shell",
"category": "action-button",
"summary": "AI Chat composer send action must not recreate the local primary icon-button shell; compose ActionIconButton.",
"canonical": {
"path": "src/components/shared/Button.tsx",
"export": "ActionIconButton"
},
"scopes": ["src/components/AI/Chat"],
"extensions": [".tsx"],
"allPatterns": [
"flex h-9 w-9 items-center justify-center rounded-md bg-blue-600 text-white shadow-xs transition-colors hover:bg-blue-700"
],
"legacyReason": "Retired migration debt. AI Chat primary icon actions belong to ActionIconButton.",
"allowedPaths": [],
"ignoredPaths": [
"src/components/AI/Chat/__tests__/AIChat.test.tsx",
"src/components/shared/Button.test.tsx"
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/Button.test.tsx",
"src/components/AI/Chat/__tests__/AIChat.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "ai-chat-action-icon-warning-local-shell",
"category": "action-button",
"summary": "AI Chat transcript fallback warning icon actions must not recreate local amber icon-button shells; compose ActionIconButton.",
"canonical": {
"path": "src/components/shared/Button.tsx",
"export": "ActionIconButton"
},
"scopes": ["src/components/AI/Chat"],
"extensions": [".tsx"],
"allPatterns": [
"flex h-7 w-7 items-center justify-center rounded-md border border-amber-200 bg-surface text-amber-700 transition-colors hover:bg-amber-100"
],
"legacyReason": "Retired migration debt. AI Chat warning icon actions belong to ActionIconButton.",
"allowedPaths": [],
"ignoredPaths": [
"src/components/AI/Chat/__tests__/AIChat.test.tsx",
"src/components/shared/Button.test.tsx"
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/Button.test.tsx",
"src/components/AI/Chat/__tests__/AIChat.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "ai-chat-action-icon-footer-local-shell",
"category": "action-button",
"summary": "AI Chat composer footer icon actions must not recreate local bordered h-7 icon-button shells; compose ActionIconButton.",
"canonical": {
"path": "src/components/shared/Button.tsx",
"export": "ActionIconButton"
},
"scopes": ["src/components/AI/Chat"],
"extensions": [".tsx"],
"allPatterns": [
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border bg-surface text-muted transition-colors hover:border-border hover:bg-surface-hover hover:text-base-content"
],
"legacyReason": "Retired migration debt. AI Chat footer icon actions belong to ActionIconButton.",
"allowedPaths": [],
"ignoredPaths": [
"src/components/AI/Chat/__tests__/AIChat.test.tsx",
"src/components/shared/Button.test.tsx"
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/Button.test.tsx",
"src/components/AI/Chat/__tests__/AIChat.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "ai-chat-message-copy-value-local-shell",
"id": "patrol-tool-copy-value-local-shell",
"category": "copy-action",
"summary": "AI Chat user and assistant message copy controls must not recreate local copied-state icon shells; compose CopyValueButton.",
"summary": "Patrol transcript tool command and detail copy controls must not recreate local copied-state icon shells; compose CopyValueButton.",
"canonical": {
"path": "src/components/shared/Button.tsx",
"export": "CopyValueButton"
},
"scopes": ["src/components/AI/Chat/MessageItem.tsx"],
"extensions": [".tsx"],
"allPatterns": [
"inline-flex h-7 w-7",
"border border-border-subtle bg-surface text-muted opacity-0 shadow-xs transition-opacity hover:text-base-content",
"CopyIcon class=\"h-3.5 w-3.5\""
],
"legacyReason": "Retired migration debt. AI Chat message copy actions belong to CopyValueButton.",
"allowedPaths": [],
"ignoredPaths": ["src/components/shared/Button.test.tsx"],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/Button.test.tsx",
"src/components/AI/Chat/__tests__/MessageItem.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "ai-chat-tool-copy-value-local-shell",
"category": "copy-action",
"summary": "AI Chat tool command and detail copy controls must not recreate local copied-state icon shells; compose CopyValueButton.",
"canonical": {
"path": "src/components/shared/Button.tsx",
"export": "CopyValueButton"
},
"scopes": ["src/components/AI/Chat/ToolExecutionBlock.tsx"],
"scopes": ["src/components/patrol/ToolExecutionBlock.tsx"],
"extensions": [".tsx"],
"allPatterns": [
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-sm border border-border-subtle bg-surface text-muted transition-colors hover:bg-surface-hover hover:text-base-content focus:outline-hidden focus:ring-2 focus:ring-blue-500/30",
"CopyIcon class=\"h-3 w-3\""
],
"legacyReason": "Retired migration debt. AI Chat tool copy actions belong to CopyValueButton.",
"legacyReason": "Retired migration debt. Patrol tool copy actions belong to CopyValueButton.",
"allowedPaths": [],
"ignoredPaths": ["src/components/shared/Button.test.tsx"],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/Button.test.tsx",
"src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx",
"src/components/patrol/__tests__/ToolExecutionBlock.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
+5 -47
View File
@@ -13,7 +13,7 @@ import { DemoBanner } from './components/DemoBanner';
import { CommercialMigrationBanner } from './components/CommercialMigrationBanner';
import { GitHubStarBanner } from './components/GitHubStarBanner';
// Modals are only mounted when opened, so their code can stay out of the
// entry bundle until first use (same pattern as AIChat below).
// entry bundle until first use.
const KeyboardShortcutsModal = lazy(() =>
import('./components/shared/KeyboardShortcutsModal').then((m) => ({
default: m.KeyboardShortcutsModal,
@@ -24,7 +24,6 @@ const CommandPaletteModal = lazy(() =>
default: m.CommandPaletteModal,
})),
);
import { dialogStackHasBlockingDialog } from './components/shared/useDialogState';
import { createTooltipSystem } from './components/shared/Tooltip';
const TokenRevealDialog = lazy(() =>
import('./components/TokenRevealDialog').then((m) => ({ default: m.TokenRevealDialog })),
@@ -34,11 +33,7 @@ const UpdateProgressModal = lazy(() =>
import('./components/UpdateProgressModal').then((m) => ({ default: m.UpdateProgressModal })),
);
import { UpdatesAPI, type UpdateStatus } from './api/updates';
// AIChat is the side-panel chat UI plus its store deps (markdown rendering,
// tool-call formatting, prompt scaffolding). Lazy-load behind aiChatStore.isOpen
// so the entry bundle stays out of "everything users might ever click."
const AIChat = lazy(() => import('./components/AI/Chat').then((m) => ({ default: m.AIChat })));
import { aiChatStore } from './stores/aiChat';
import { aiAvailabilityStore } from './stores/aiAvailability';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { useKioskMode } from '@/hooks/useKioskMode';
import {
@@ -460,7 +455,7 @@ function App() {
useKeyboardShortcuts({
enabled: () => !runtime.needsAuth(),
platformVisibility: platformNavigationVisibility,
patrolVisible: () => aiChatStore.enabled === true,
patrolVisible: () => aiAvailabilityStore.enabled === true,
isShortcutsOpen: shortcutsOpen,
isCommandPaletteOpen: commandPaletteOpen,
onToggleShortcuts: () => {
@@ -475,35 +470,6 @@ function App() {
onCloseCommandPalette: () => setCommandPaletteOpen(false),
});
createEffect(() => {
if (dialogStackHasBlockingDialog() && aiChatStore.isOpenSignal()) {
aiChatStore.close();
}
});
// Setup escape handling for the assistant drawer.
onMount(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Escape closes the drawer only after mounted drawer controls have had
// a chance to claim the key for local flows such as interrupt confirm.
if (e.key === 'Escape' && aiChatStore.isOpen) {
const escapeTarget = e.target instanceof Element ? e.target : null;
const isModelPickerEscape = Boolean(escapeTarget?.closest('[data-ai-model-picker]'));
window.setTimeout(() => {
if (!e.defaultPrevented && !isModelPickerEscape && aiChatStore.isOpen) {
aiChatStore.close();
}
}, 0);
}
};
document.addEventListener('keydown', handleKeyDown);
onCleanup(() => {
document.removeEventListener('keydown', handleKeyDown);
});
});
onCleanup(() => {
appShellRoutePreloadCleanup?.();
});
@@ -554,9 +520,8 @@ function App() {
<GlobalUpdateProgressWatcher />
</Show>
</Show>
{/* Main layout container - flexbox to allow AI panel to push content */}
{/* Main layout container: the content area scrolls independently */}
<div class="flex h-screen overflow-hidden">
{/* Main content area - shrinks when AI panel is open, scrolls independently */}
<div
ref={setAppScrollShellRef}
class={`app-scroll-shell isolate flex-1 min-w-0 overflow-y-scroll text-base-content font-sans py-1 sm:py-6 transition-all duration-300`}
@@ -581,19 +546,13 @@ function App() {
<RouteErrorBoundary>{props.children}</RouteErrorBoundary>
</AppLayout>
</div>
{/* AI Panel - slides in from right, pushes content.
Mounted only when open so the lazy chunk only
downloads on first chat-open click. */}
<Show when={aiChatStore.isOpenSignal()}>
<AIChat onClose={() => aiChatStore.close()} />
</Show>
</div>
<Show when={shortcutsOpen()}>
<KeyboardShortcutsModal
isOpen={shortcutsOpen()}
onClose={() => setShortcutsOpen(false)}
platformVisibility={platformNavigationVisibility}
patrolVisible={() => aiChatStore.enabled === true}
patrolVisible={() => aiAvailabilityStore.enabled === true}
/>
</Show>
<Show when={commandPaletteOpen()}>
@@ -606,7 +565,6 @@ function App() {
<Show when={tokenRevealStore.state() !== null}>
<TokenRevealDialog />
</Show>
{/* AI Assistant Button moved to AppLayout to access kioskMode state */}
<TooltipRoot />
</DarkModeContext.Provider>
</WebSocketContext.Provider>
+5 -58
View File
@@ -1,5 +1,4 @@
import { For, Show, Suspense, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import { Portal } from 'solid-js/web';
import type { JSX } from 'solid-js';
import { A, useLocation, useNavigate } from '@solidjs/router';
import BellIcon from 'lucide-solid/icons/bell';
@@ -7,7 +6,6 @@ import ListChecksIcon from 'lucide-solid/icons/list-checks';
import SettingsIcon from 'lucide-solid/icons/settings';
import Maximize2Icon from 'lucide-solid/icons/maximize-2';
import Minimize2Icon from 'lucide-solid/icons/minimize-2';
import SparklesIcon from 'lucide-solid/icons/sparkles';
import { getPlatformIcon } from '@/features/platformPage/platformIcon';
import {
MobileNavBar,
@@ -26,7 +24,6 @@ import {
type PlatformNavigationVisibility,
type PrimaryPlatformNavId,
} from '@/features/platformNavigation/platformNavigationModel';
import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState';
import { OrgSwitcher } from '@/components/OrgSwitcher';
import { PulseBrandMark } from '@/components/Brand/PulseBrandMark';
import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo';
@@ -35,7 +32,6 @@ import type { Organization } from '@/api/orgs';
import type { VersionInfo } from '@/api/updates';
import type { Alert, State } from '@/types/api';
import { useKioskMode } from '@/hooks/useKioskMode';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { layoutStore } from '@/utils/layout';
import { logger } from '@/utils/logger';
import { getActiveTabForPath } from '@/routing/navigation';
@@ -56,14 +52,13 @@ import {
} from '@/routing/resourceLinks';
import { getKioskModePreference, setKioskMode } from '@/utils/url';
import { updateStore } from '@/stores/updates';
import { aiChatStore } from '@/stores/aiChat';
import { aiAvailabilityStore } from '@/stores/aiAvailability';
import { getActionApprovalBadgePresentation } from '@/features/actions/actionPresentation';
import { actionInboxStore } from '@/stores/actionInbox';
import { patrolAttentionStore } from '@/stores/patrolAttention';
import { isPro } from '@/stores/licenseCommercial';
import { runtimeBranding } from '@/stores/systemSettings';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { getAssistantPageContext } from '@/utils/assistantPageContext';
import type { AppConnectionStatus } from '@/useAppRuntimeState';
import { buildInfrastructureWorkspacePath } from '@/components/Settings/infrastructureWorkspaceModel';
@@ -135,10 +130,6 @@ export function sessionHasSettingsAccess(scopes: string[] | undefined): boolean
return scopes.includes('*') || scopes.includes(SETTINGS_READ_SCOPE);
}
const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0';
const AI_CHAT_MOBILE_LAUNCHER_BUTTON_CLASS =
'group relative flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-surface-hover text-blue-600 transition-colors hover:bg-border hover:text-blue-700 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-blue-400 dark:hover:text-blue-300';
const AI_CHAT_DESKTOP_LAUNCHER_BUTTON_CLASS =
'fixed right-0 top-1/2 z-40 flex min-h-9 min-w-10 -translate-y-1/2 items-center justify-center rounded-l-lg border border-r-0 border-border bg-surface px-2.5 py-2.5 text-blue-600 transition-colors duration-200 hover:bg-surface-hover hover:text-blue-700 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-blue-400 dark:hover:text-blue-300';
function getDesktopUtilityTabAriaLabel(tab: UtilityTab): string {
const count = tab.count ?? 0;
@@ -244,7 +235,6 @@ export function AppLayout(props: AppLayoutProps) {
const navigate = useNavigate();
const location = useLocation();
const kioskMode = useKioskMode();
const viewport = useBreakpoint();
const brandMotionActive = createMemo(() => props.connectionStatus().tone === 'healthy');
const customBrandLogo = createMemo(() =>
runtimeBranding().enabled ? runtimeBranding().logoDataUrl : '',
@@ -257,8 +247,6 @@ export function AppLayout(props: AppLayoutProps) {
const [headerVisible, setHeaderVisible] = createSignal(true);
const [primaryRouteMemoryVersion, setPrimaryRouteMemoryVersion] = createSignal(0);
let headerEl: HTMLDivElement | undefined;
let assistantLauncherEl: HTMLButtonElement | undefined;
let restoreAssistantLauncherFocus = false;
let headerHideTimeout: ReturnType<typeof setTimeout> | undefined;
const clearHeaderHideTimeout = () => {
@@ -449,37 +437,6 @@ export function AppLayout(props: AppLayoutProps) {
const getNavigationActiveTab = () => getActiveTabForPath(location.pathname);
const getActiveTabDesktop = getNavigationActiveTab;
const getActiveTabMobile = getNavigationActiveTab;
const assistantPageContext = createMemo(() => getAssistantPageContext(location.pathname));
const openAssistantFromLauncher = () => {
restoreAssistantLauncherFocus = true;
aiChatStore.open(assistantPageContext().context);
};
const assistantLauncherVisible = createMemo(
() =>
aiChatStore.enabled === true &&
!aiChatStore.isOpenSignal() &&
!kioskMode() &&
!dialogStackHasBlockingDialog(),
);
const renderAssistantLauncher = (className: string) => (
<button
ref={assistantLauncherEl}
type="button"
onClick={openAssistantFromLauncher}
class={className}
title={assistantPageContext().title}
aria-label={assistantPageContext().ariaLabel}
>
<SparklesIcon class="h-5 w-5 shrink-0" />
</button>
);
createEffect(() => {
if (aiChatStore.isOpenSignal() || !restoreAssistantLauncherFocus) return;
restoreAssistantLauncherFocus = false;
queueMicrotask(() => {
if (assistantLauncherEl?.isConnected) assistantLauncherEl.focus();
});
});
const actionApprovalBadge = createMemo(() =>
getActionApprovalBadgePresentation(actionInboxStore.pendingActionCount),
);
@@ -604,10 +561,10 @@ export function AppLayout(props: AppLayoutProps) {
icon: BellIcon,
},
// Patrol cannot run without an enabled, configured AI provider, so its
// tab follows the same session capability as the Assistant launcher.
// Turning AI off in Settings hides every AI entry point (#905); the
// route itself stays reachable for links and the settings handoff.
...(aiChatStore.enabled === true
// tab follows that session capability. Turning AI off in Settings hides
// every AI entry point (#905); the route itself stays reachable for
// links and the settings handoff.
...(aiAvailabilityStore.enabled === true
? [
{
id: 'ai' as const,
@@ -818,9 +775,6 @@ export function AppLayout(props: AppLayoutProps) {
<div
class={`header-controls flex max-w-full flex-wrap items-center gap-1 sm:flex-nowrap sm:gap-2 ${kioskMode() ? '' : 'justify-end sm:col-start-3 sm:col-end-4 sm:w-auto sm:justify-end sm:justify-self-end'}`}
>
<Show when={assistantLauncherVisible() && viewport.isBelow('lg')}>
{renderAssistantLauncher(AI_CHAT_MOBILE_LAUNCHER_BUTTON_CLASS)}
</Show>
<Show when={props.hasAuth() && !props.needsAuth()}>
<div class="flex items-center gap-1 sm:gap-2">
<Show when={props.showOrgSwitcher()}>
@@ -1031,13 +985,6 @@ export function AppLayout(props: AppLayoutProps) {
</div>
</footer>
</Show>
<Show when={assistantLauncherVisible() && viewport.isAtLeast('lg')}>
{/* Portaled out of .app-scroll-shell: as a descendant the scroll
container's own scrollbar hit-tests above the tab's right edge,
leaving the screen-edge click zone dead. */}
<Portal>{renderAssistantLauncher(AI_CHAT_DESKTOP_LAUNCHER_BUTTON_CLASS)}</Portal>
</Show>
</div>
);
}
@@ -234,17 +234,27 @@ describe('Global update progress authorization', () => {
});
describe('App architecture', () => {
it('gates Patrol navigation on the same AI capability as the Assistant launcher (#905)', () => {
expect(appSource).toContain('patrolVisible: () => aiChatStore.enabled === true');
expect(appSource).toContain('patrolVisible={() => aiChatStore.enabled === true}');
it('gates Patrol navigation on the AI availability session capability (#905)', () => {
expect(appSource).toContain('patrolVisible: () => aiAvailabilityStore.enabled === true');
expect(appSource).toContain('patrolVisible={() => aiAvailabilityStore.enabled === true}');
expect(appLayoutSource).toMatch(
/\.\.\.\(aiChatStore\.enabled === true\s*\?\s*\[\s*\{\s*id: 'ai' as const/,
/\.\.\.\(aiAvailabilityStore\.enabled === true\s*\?\s*\[\s*\{\s*id: 'ai' as const/,
);
expect(appRuntimeStateSource).toContain(
'aiChatStore.setEnabled(securityData?.sessionCapabilities?.assistantEnabled === true)',
'aiAvailabilityStore.setEnabled(securityData?.sessionCapabilities?.assistantEnabled === true)',
);
});
it('mounts no Assistant chat window or launcher now the chat is retired', () => {
for (const source of [appSource, appLayoutSource, appRuntimeStateSource]) {
expect(source).not.toContain('aiChatStore');
expect(source).not.toContain('@/stores/aiChat');
expect(source).not.toContain('components/AI/Chat');
}
expect(appLayoutSource).not.toContain('renderAssistantLauncher');
expect(appLayoutSource).not.toContain('getAssistantPageContext');
});
it('keeps first-load connection progress understandable and retryable', () => {
expect(appSource).toContain('fallback={<AppBootstrapStatus />}');
expect(appSource).not.toContain('<div class="text-muted">Loading...</div>');
@@ -371,14 +381,11 @@ describe('App architecture', () => {
expect(appSource).toContain(
"import { AppLayout, sessionHasSettingsAccess } from '@/AppLayout';",
);
expect(appSource).toContain("import { aiChatStore } from './stores/aiChat';");
expect(appSource).toContain("import { aiAvailabilityStore } from './stores/aiAvailability';");
expect(appSource).toContain(
"import { DarkModeContext, WebSocketContext, useWebSocket } from '@/contexts/appRuntime';",
);
expect(appSource).toContain("import { useAppRuntimeState } from '@/useAppRuntimeState';");
expect(appSource).toContain(
"import { dialogStackHasBlockingDialog } from './components/shared/useDialogState';",
);
expect(appSource).toContain("} from '@/utils/appShellScrollRestoration';");
expect(appSource).toContain('const runtime = useAppRuntimeState();');
expect(appSource).toContain('pendingAppShellRestoreTop');
@@ -556,16 +563,6 @@ describe('App architecture', () => {
expect(appSource).not.toContain('const [activeOrgID, setActiveOrgID] = createSignal(');
expect(appSource).not.toContain("import('./api/ai')");
expect(appSource).not.toContain('AIAPI.getSettings()');
expect(appSource).toContain(
'if (dialogStackHasBlockingDialog() && aiChatStore.isOpenSignal()) {',
);
expect(appSource).toContain("if (e.key === 'Escape' && aiChatStore.isOpen) {");
expect(appSource).toContain('window.setTimeout(() => {');
expect(appSource).toContain("closest('[data-ai-model-picker]')");
expect(appSource).toContain(
'if (!e.defaultPrevented && !isModelPickerEscape && aiChatStore.isOpen) {',
);
expect(appSource).toContain('<AIChat onClose={() => aiChatStore.close()} />');
expect(appSource).toContain('showOrgSwitcher={runtime.showOrgSwitcher}');
expect(appSource).toContain("import { WhatsNewCard } from './components/WhatsNewCard';");
expect(appSource).toContain('<WhatsNewCard />');
@@ -670,9 +667,8 @@ describe('App architecture', () => {
expect(appLayoutSource).toContain(
"import { preloadRouteModule } from '@/routing/routePreload';",
);
expect(appLayoutSource).toContain("import { aiChatStore } from '@/stores/aiChat';");
expect(appLayoutSource).toContain(
"import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState';",
"import { aiAvailabilityStore } from '@/stores/aiAvailability';",
);
expect(appLayoutSource).toContain(
"import { buildInfrastructureWorkspacePath } from '@/components/Settings/infrastructureWorkspaceModel';",
@@ -831,15 +827,8 @@ describe('App architecture', () => {
expect(appLayoutSource).not.toContain('await preloadRouteModule(targetRoute);');
expect(appLayoutSource).not.toContain('await preloadRouteModule(tab.route);');
expect(appLayoutSource).toContain('onMouseEnter={() => warmNavigationTarget(');
expect(appLayoutSource).toContain('aiChatStore.enabled === true &&');
expect(appLayoutSource).toContain('!dialogStackHasBlockingDialog()');
expect(appLayoutSource).toContain('onClick={openAssistantFromLauncher}');
expect(appLayoutSource).toContain('getAssistantPageContext');
expect(appLayoutSource).toContain('const AI_CHAT_MOBILE_LAUNCHER_BUTTON_CLASS =');
expect(appLayoutSource).toContain('const AI_CHAT_DESKTOP_LAUNCHER_BUTTON_CLASS =');
expect(appLayoutSource).toContain("viewport.isBelow('lg')");
expect(appLayoutSource).toContain("viewport.isAtLeast('lg')");
expect(appLayoutSource).toContain('fixed right-0 top-1/2');
expect(appLayoutSource).not.toContain('AI_CHAT_MOBILE_LAUNCHER_BUTTON_CLASS');
expect(appLayoutSource).not.toContain('AI_CHAT_DESKTOP_LAUNCHER_BUTTON_CLASS');
expect(appLayoutSource).not.toContain('Pulse Assistant (⌘K)');
expect(appSource).not.toContain("eventBus.on('theme_changed'");
expect(appSource).not.toContain("eventBus.on('websocket_reconnected'");
@@ -847,7 +836,9 @@ describe('App architecture', () => {
expect(appLayoutSource).not.toContain("eventBus.on('theme_changed'");
expect(appLayoutSource).not.toContain("apiFetch('/api/security/status')");
expect(appRuntimeStateSource).toContain('export const useAppRuntimeState = () =>');
expect(appRuntimeStateSource).toContain("import { aiChatStore } from '@/stores/aiChat';");
expect(appRuntimeStateSource).toContain(
"import { aiAvailabilityStore } from '@/stores/aiAvailability';",
);
expect(appRuntimeStateSource).toContain(
'const connectionStatus = createMemo<AppConnectionStatus>(() => {',
);
@@ -907,7 +898,7 @@ describe('App architecture', () => {
expect(appRuntimeStateSource).toContain(
'window.localStorage.getItem(STORAGE_KEYS.REMEMBERED_LOGIN_USERNAME)',
);
expect(appRuntimeStateSource).toContain('aiChatStore.setEnabled(');
expect(appRuntimeStateSource).toContain('aiAvailabilityStore.setEnabled(');
expect(appRuntimeStateSource).toContain('aiIntelligenceStore.loadPatrolFindings()');
expect(appRuntimeStateSource).toContain('aiIntelligenceStore.loadPendingApprovals()');
expect(appRuntimeStateSource).toContain('actionInboxStore.loadPendingActionCount()');
@@ -12,7 +12,7 @@ import {
} from '@/AppLayout';
import { isKioskMode, setKioskMode } from '@/utils/url';
import type { PlatformNavigationVisibility } from '@/features/platformNavigation/platformNavigationModel';
import { aiChatStore } from '@/stores/aiChat';
import { aiAvailabilityStore } from '@/stores/aiAvailability';
import { clearRuntimeBranding, updateRuntimeBrandingFromResponse } from '@/stores/systemSettings';
HTMLElement.prototype.scrollIntoView = vi.fn();
@@ -148,13 +148,11 @@ describe('AppLayout navigation icons', () => {
preloadRouteModuleMock.mockReset();
preloadRouteModuleMock.mockResolvedValue(undefined);
clearRuntimeBranding();
aiChatStore.close();
aiChatStore.setEnabled(true);
aiAvailabilityStore.setEnabled(true);
});
afterEach(() => {
aiChatStore.close();
aiChatStore.setEnabled(false);
aiAvailabilityStore.setEnabled(false);
clearRuntimeBranding();
cleanup();
});
@@ -292,7 +290,7 @@ describe('AppLayout navigation icons', () => {
});
it('hides the Patrol tab on desktop and mobile while AI is off (#905)', () => {
aiChatStore.setEnabled(false);
aiAvailabilityStore.setEnabled(false);
renderLayout();
const desktopNav = screen.getByRole('navigation', { name: 'Primary navigation' });
@@ -512,18 +510,14 @@ describe('AppLayout navigation icons', () => {
expect(document.title).toBe('Settings · Acme Operations');
});
it('keeps the mobile Assistant launcher in header flow instead of covering page content', () => {
it('renders no Assistant launcher with AI enabled on desktop or phone', () => {
renderLayout();
expect(screen.queryByRole('button', { name: /Ask Pulse Assistant/ })).toBeNull();
cleanup();
setViewportWidth(390);
renderLayout();
const launcher = screen.getByRole('button', { name: 'Ask Pulse Assistant about Settings' });
const launcherClass = launcher.getAttribute('class') ?? '';
expect(launcherClass).toContain('h-11');
expect(launcherClass).toContain('w-11');
expect(launcherClass).toContain('rounded-full');
expect(launcherClass).not.toContain('fixed');
expect(launcher.closest('.header-controls')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Ask Pulse Assistant/ })).toBeNull();
});
it('allows mobile header controls to wrap without hiding organization or session actions', () => {
@@ -537,71 +531,6 @@ describe('AppLayout navigation icons', () => {
);
expect(screen.getByRole('button', { name: 'Logout' })).toBeInTheDocument();
});
it('preserves the Assistant edge launcher on desktop', () => {
setViewportWidth(1440);
renderLayout();
const launcher = screen.getByRole('button', { name: 'Ask Pulse Assistant about Settings' });
expect(launcher).toHaveClass('fixed');
expect(launcher).toHaveClass('right-0');
expect(launcher).toHaveClass('top-1/2');
expect(launcher).toHaveClass('-translate-y-1/2');
expect(launcher.closest('.header-controls')).toBeNull();
});
it('opens Assistant with the current route attached', async () => {
const openAssistant = vi.spyOn(aiChatStore, 'open').mockImplementation(() => {});
renderLayout();
await fireEvent.click(
screen.getByRole('button', { name: 'Ask Pulse Assistant about Settings' }),
);
await waitFor(() => {
expect(openAssistant).toHaveBeenCalledWith(
expect.objectContaining({
targetType: 'pulse-view',
targetId: '/settings/infrastructure',
context: expect.objectContaining({
name: 'Settings',
route: '/settings/infrastructure',
surface: 'settings',
}),
briefing: expect.objectContaining({
sourceLabel: 'Current view',
title: 'Settings attached',
statusLabel: 'Context only',
}),
}),
);
});
openAssistant.mockRestore();
});
it('returns focus to the Assistant launcher after chat closes', async () => {
renderLayout();
const launcher = screen.getByRole('button', {
name: 'Ask Pulse Assistant about Settings',
});
launcher.focus();
await fireEvent.click(launcher);
await waitFor(() => {
expect(
screen.queryByRole('button', { name: 'Ask Pulse Assistant about Settings' }),
).not.toBeInTheDocument();
});
aiChatStore.close();
await waitFor(() => {
expect(
screen.getByRole('button', { name: 'Ask Pulse Assistant about Settings' }),
).toHaveFocus();
});
});
});
describe('AppLayout Issue1650 kiosk scope containment', () => {
@@ -623,8 +552,7 @@ describe('AppLayout Issue1650 kiosk scope containment', () => {
window.history.replaceState({}, '', '/settings/infrastructure');
resetPrimaryNavigationRouteMemory();
patrolAttentionMockState.activeCount = 0;
aiChatStore.close();
aiChatStore.setEnabled(false);
aiAvailabilityStore.setEnabled(false);
});
afterEach(() => {
@@ -66,7 +66,9 @@ describe('useAppRuntimeState', () => {
let hasStoredAuthSessionMock: ReturnType<typeof vi.fn>;
let setOrgIDMock: ReturnType<typeof vi.fn>;
let showToastMock: ReturnType<typeof vi.fn>;
let aiChatSetEnabledMock: ReturnType<typeof vi.fn>;
let aiAvailabilitySetEnabledMock: ReturnType<typeof vi.fn>;
let aiAvailabilityRefreshMock: ReturnType<typeof vi.fn>;
let aiAvailabilityClearMock: ReturnType<typeof vi.fn>;
let getSystemSettingsMock: ReturnType<typeof vi.fn>;
let getRuntimeDisplayMock: ReturnType<typeof vi.fn>;
let applyServerModeMock: ReturnType<typeof vi.fn>;
@@ -138,7 +140,9 @@ describe('useAppRuntimeState', () => {
hasStoredAuthSessionMock = vi.fn().mockReturnValue(true);
setOrgIDMock = vi.fn();
showToastMock = vi.fn();
aiChatSetEnabledMock = vi.fn();
aiAvailabilitySetEnabledMock = vi.fn();
aiAvailabilityRefreshMock = vi.fn().mockResolvedValue(undefined);
aiAvailabilityClearMock = vi.fn();
getSystemSettingsMock = vi.fn().mockResolvedValue({ theme: '' });
getRuntimeDisplayMock = vi.fn().mockResolvedValue({ theme: '', fullWidthMode: false });
applyServerModeMock = vi.fn();
@@ -258,9 +262,11 @@ describe('useAppRuntimeState', () => {
loadRuntimeCapabilities: loadLicenseStatusMock,
}));
vi.doMock('@/stores/aiChat', () => ({
aiChatStore: {
setEnabled: aiChatSetEnabledMock,
vi.doMock('@/stores/aiAvailability', () => ({
aiAvailabilityStore: {
setEnabled: aiAvailabilitySetEnabledMock,
refreshEnabledFromServer: aiAvailabilityRefreshMock,
clear: aiAvailabilityClearMock,
},
}));
@@ -416,7 +422,7 @@ describe('useAppRuntimeState', () => {
expect(presentationPolicyModule.sessionPresentationPolicyResolved()).toBe(true);
expect(presentationPolicyModule.presentationPolicyIsDemoMode()).toBe(true);
expect(aiChatSetEnabledMock).toHaveBeenCalledWith(true);
expect(aiAvailabilitySetEnabledMock).toHaveBeenCalledWith(true);
expect(orgsListMock).not.toHaveBeenCalled();
expect(hookState.organizations()).toEqual([
{ id: 'default', displayName: 'Default Organization' },
@@ -908,12 +914,17 @@ describe('useAppRuntimeState', () => {
// Switch to whichever tenant is not already active, so this exercises a
// real switch rather than the no-op early return.
const nextOrg = hookState.activeOrgID() === 'acme' ? 'default' : 'acme';
aiAvailabilityRefreshMock.mockClear();
hookState.handleOrgSwitch(nextOrg);
// Cleared synchronously, before the replacement can arrive: anything that
// renders between the switch and the refetch must not see the outgoing
// tenant's platform tabs.
expect(hookState.platformAdmission()).toBeNull();
// The incoming tenant decides whether Patrol entry points show, and the
// outgoing tenant's answer is dropped first.
expect(aiAvailabilityClearMock).toHaveBeenCalledTimes(1);
expect(aiAvailabilityRefreshMock).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(hookState.platformAdmission()).toMatchObject({ docker: true, proxmox: false });
@@ -296,129 +296,4 @@ describe('AIAPI branch coverage (request shaping + response handling)', () => {
});
});
});
// --------------------------------------------
// approveRemediationPlan
// --------------------------------------------
describe('approveRemediationPlan', () => {
it('POSTs { plan_id } to /api/ai/remediation/approve and returns success + execution', async () => {
const payload = { success: true, execution: { id: 'exec-9' } };
apiFetchJSONMock.mockResolvedValueOnce(payload as any);
const result = await AIAPI.approveRemediationPlan('plan-42');
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/remediation/approve', {
method: 'POST',
body: JSON.stringify({ plan_id: 'plan-42' }),
});
expect(result).toStrictEqual(payload);
});
it('returns the success-only arm when the backend omits execution', async () => {
// The execution field is optional on the return type; cover the branch
// where approval succeeds but no execution is materialized yet.
apiFetchJSONMock.mockResolvedValueOnce({ success: true } as any);
const result = await AIAPI.approveRemediationPlan('plan-43');
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/remediation/approve', {
method: 'POST',
body: JSON.stringify({ plan_id: 'plan-43' }),
});
expect(result).toStrictEqual({ success: true });
expect('execution' in result).toBe(false);
});
it('forwards a failed-approval payload without normalization', async () => {
const payload = { success: false };
apiFetchJSONMock.mockResolvedValueOnce(payload as any);
const result = await AIAPI.approveRemediationPlan('plan-44');
expect(result).toStrictEqual({ success: false });
});
});
// --------------------------------------------
// executeRemediationPlan
// --------------------------------------------
describe('executeRemediationPlan', () => {
it('POSTs { execution_id } to /api/ai/remediation/execute and returns the full result', async () => {
const payload = {
execution_id: 'exec-7',
plan_id: 'plan-7',
status: 'success' as const,
steps_completed: 4,
step_results: [
{
step: 1,
success: true,
output: 'ok',
duration_ms: 12,
run_at: '2026-07-18T12:00:00Z',
},
],
started_at: '2026-07-18T12:00:00Z',
completed_at: '2026-07-18T12:00:01Z',
};
apiFetchJSONMock.mockResolvedValueOnce(payload as any);
const result = await AIAPI.executeRemediationPlan('exec-7');
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/remediation/execute', {
method: 'POST',
body: JSON.stringify({ execution_id: 'exec-7' }),
});
expect(result).toStrictEqual(payload);
});
it('round-trips a partial-status result with optional step_results absent', async () => {
const payload = {
execution_id: 'exec-8',
plan_id: 'plan-8',
status: 'partial' as const,
steps_completed: 2,
error: 'step 3 failed',
};
apiFetchJSONMock.mockResolvedValueOnce(payload as any);
const result = await AIAPI.executeRemediationPlan('exec-8');
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/remediation/execute', {
method: 'POST',
body: JSON.stringify({ execution_id: 'exec-8' }),
});
expect(result).toStrictEqual(payload);
expect('step_results' in result).toBe(false);
});
});
// --------------------------------------------
// rollbackRemediationPlan
// --------------------------------------------
describe('rollbackRemediationPlan', () => {
it('POSTs { execution_id } to /api/ai/remediation/rollback and returns { success }', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ success: true } as any);
const result = await AIAPI.rollbackRemediationPlan('exec-7');
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/remediation/rollback', {
method: 'POST',
body: JSON.stringify({ execution_id: 'exec-7' }),
});
expect(result).toStrictEqual({ success: true });
});
it('forwards a failed rollback payload without normalization', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ success: false } as any);
const result = await AIAPI.rollbackRemediationPlan('exec-9');
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/remediation/rollback', {
method: 'POST',
body: JSON.stringify({ execution_id: 'exec-9' }),
});
expect(result).toStrictEqual({ success: false });
});
});
});
+1 -19
View File
@@ -206,7 +206,7 @@ describe('AIAPI', () => {
);
});
it('encodes dynamic provider, approval, finding, and plan identifiers', async () => {
it('encodes dynamic provider, approval, and finding identifiers', async () => {
apiFetchJSONMock.mockResolvedValue({} as any);
await AIAPI.testProvider('openai/internal');
@@ -220,11 +220,6 @@ describe('AIAPI', () => {
body: JSON.stringify({ model: 'deepseek:deepseek-v4-pro' }),
});
await AIAPI.getRemediationPlan('plan/1?x=1');
expect(apiFetchJSONMock).toHaveBeenCalledWith(
'/api/ai/remediation/plan?plan_id=plan%2F1%3Fx%3D1',
);
await AIAPI.approveInvestigationFix('approval/root');
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/approvals/approval%2Froot/approve', {
method: 'POST',
@@ -278,9 +273,6 @@ describe('AIAPI', () => {
apiFetchJSONMock.mockRejectedValueOnce(paymentRequiredError);
await expect(AIAPI.getPendingApprovals()).resolves.toEqual([]);
apiFetchJSONMock.mockRejectedValueOnce(paymentRequiredError);
await expect(AIAPI.getRemediationPlans()).resolves.toEqual({ plans: [] });
});
it('does not treat status text without canonical error status as payment required', async () => {
@@ -313,16 +305,6 @@ describe('AIAPI', () => {
]);
});
it('normalizes remediation plans from optional or legacy collection payloads', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ plans: [{ id: 'plan-1' }] } as any);
await expect(AIAPI.getRemediationPlans()).resolves.toEqual({
plans: [{ id: 'plan-1' }],
});
apiFetchJSONMock.mockResolvedValueOnce({ executions: [{ id: 'exec-1' }] } as any);
await expect(AIAPI.getRemediationPlans()).resolves.toEqual({ plans: [] });
});
it('returns null only for canonical not-found investigation lookups', async () => {
apiFetchJSONMock.mockRejectedValueOnce(Object.assign(new Error('Not Found'), { status: 404 }));
@@ -1,320 +0,0 @@
/**
* Branch-coverage tests for currently-uncovered AIChatAPI methods and the
* chat() stream's onTrailingParseError handler:
* - AIChatAPI.getStatus
* - AIChatAPI.createSession
* - AIChatAPI.deleteSession
* - AIChatAPI.abortSession
* - AIChatAPI.denyCommand (reason present vs default)
* - AIChatAPI.listAgents
* - AIChatAPI.forkSession
* - chat()'s onTrailingParseError callback (parseable vs unparseable trailing
* buffer)
*
* The transport is mocked with the same harness used by aiChat.test.ts
* (vi.mock('@/utils/apiClient', ...) plus vi.mock('@/utils/logger', ...)).
* These tests intentionally do NOT re-assert anything aiChat.test.ts already
* covers (listSessions normalization, renameSession, undo/redo, steerSession,
* summarizeSession, chat happy-path streaming, fixtures, etc.).
*
* Branch arms exercised here:
* - getStatus returns the apiFetchJSON payload cast to AIStatus
* - createSession issues a bare POST with no body and no Content-Type
* - deleteSession routes through apiFetch (NOT apiFetchJSON), DELETE method
* - deleteSession URL-encodes '/' in the session id (encodeURIComponent arm)
* - abortSession routes through apiFetch, POST to the /abort suffix
* - abortSession URL-encodes '/' in the session id before appending /abort
* - denyCommand truthy-reason branch -> body uses the supplied reason
* - denyCommand falsy-reason branch -> body falls back to 'User skipped':
* * no second argument supplied
* * empty-string reason
* - denyCommand URL-encodes '/' in the approval id
* - listAgents returns the apiFetchJSON payload cast to Agent[]
* - forkSession POSTs to the /fork suffix and URL-encodes the session id
* - onTrailingParseError parseable arm: a trailing 'data:' line without a
* terminating blank line is parsed and dispatched; onEvent receives the
* terminal event and the synthetic close-error is NOT emitted
* - onTrailingParseError unparseable arm: a trailing 'data:' line that fails
* to parse as JSON triggers logger.warn('[AI Chat] Could not parse
* remaining buffer') and then the onComplete handler surfaces the
* synthetic 'closed before the response finished' interruption
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/utils/apiClient', () => ({
apiFetchJSON: vi.fn(),
apiFetch: vi.fn(),
}));
vi.mock('@/utils/logger', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import { AIChatAPI } from '@/api/aiChat';
import { apiFetch, apiFetchJSON } from '@/utils/apiClient';
import { logger } from '@/utils/logger';
describe('AIChatAPI branch coverage', () => {
const apiFetchMock = vi.mocked(apiFetch);
const apiFetchJSONMock = vi.mocked(apiFetchJSON);
beforeEach(() => {
apiFetchMock.mockReset();
apiFetchJSONMock.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe('getStatus', () => {
it('GETs /api/ai/status through apiFetchJSON and returns the AIStatus payload verbatim', async () => {
const status = { running: true, engine: 'pulse-assistant' };
apiFetchJSONMock.mockResolvedValueOnce(status);
await expect(AIChatAPI.getStatus()).resolves.toEqual(status);
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/status');
expect(apiFetchMock).not.toHaveBeenCalled();
});
});
describe('createSession', () => {
it('POSTs to /api/ai/sessions with no body and no headers (only method)', async () => {
const created = {
id: 'session-7',
title: 'New Chat',
created_at: '2026-07-18T10:00:00Z',
updated_at: '2026-07-18T10:00:00Z',
message_count: 0,
};
apiFetchJSONMock.mockResolvedValueOnce(created);
await expect(AIChatAPI.createSession()).resolves.toEqual(created);
// The module passes ONLY { method: 'POST' } — no body, no Content-Type.
// (apiFetchJSON adds its own Content-Type downstream; that is the
// wrapper's concern, not the module's, so it is not asserted here.)
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/sessions', {
method: 'POST',
});
});
});
describe('deleteSession', () => {
it('issues a plain DELETE through apiFetch (no JSON parsing) and resolves void', async () => {
// deleteSession uses apiFetch, NOT apiFetchJSON, because the backend
// returns 204 No Content. Asserting the resolved value is undefined
// confirms the function swallows the response rather than returning it.
apiFetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
await expect(AIChatAPI.deleteSession('session-1')).resolves.toBeUndefined();
expect(apiFetchMock).toHaveBeenCalledWith('/api/ai/sessions/session-1', {
method: 'DELETE',
});
expect(apiFetchJSONMock).not.toHaveBeenCalled();
});
it('URL-encodes slash-bearing session ids in the path', async () => {
apiFetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
await AIChatAPI.deleteSession('session/root');
expect(apiFetchMock).toHaveBeenCalledWith('/api/ai/sessions/session%2Froot', {
method: 'DELETE',
});
});
});
describe('abortSession', () => {
it('POSTs to the /abort suffix through apiFetch and resolves void', async () => {
apiFetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
await expect(AIChatAPI.abortSession('session-1')).resolves.toBeUndefined();
expect(apiFetchMock).toHaveBeenCalledWith('/api/ai/sessions/session-1/abort', {
method: 'POST',
});
expect(apiFetchJSONMock).not.toHaveBeenCalled();
});
it('URL-encodes slash-bearing session ids before appending /abort', async () => {
apiFetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
await AIChatAPI.abortSession('session/root');
// Only the session id segment is encoded — the literal '/abort' suffix
// must remain unencoded so the backend route still matches.
expect(apiFetchMock).toHaveBeenCalledWith('/api/ai/sessions/session%2Froot/abort', {
method: 'POST',
});
});
});
describe('denyCommand', () => {
it('posts the supplied reason body to the deny endpoint', async () => {
const result = { denied: true, message: 'Command denied.' };
apiFetchJSONMock.mockResolvedValueOnce(result);
await expect(AIChatAPI.denyCommand('approval-1', 'Wrong host')).resolves.toEqual(result);
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/approvals/approval-1/deny', {
method: 'POST',
body: JSON.stringify({ reason: 'Wrong host' }),
});
});
it('substitutes the "User skipped" default when no reason is provided', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ denied: true, message: 'ok' });
await AIChatAPI.denyCommand('approval-1');
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/approvals/approval-1/deny', {
method: 'POST',
body: JSON.stringify({ reason: 'User skipped' }),
});
});
it('treats an empty-string reason as absent (falls back to the default)', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ denied: true, message: 'ok' });
await AIChatAPI.denyCommand('approval-1', '');
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/approvals/approval-1/deny', {
method: 'POST',
body: JSON.stringify({ reason: 'User skipped' }),
});
});
it('URL-encodes slash-bearing approval ids in the path', async () => {
apiFetchJSONMock.mockResolvedValueOnce({ denied: true, message: 'ok' });
await AIChatAPI.denyCommand('approval/with/slash');
expect(apiFetchJSONMock).toHaveBeenCalledWith(
'/api/ai/approvals/approval%2Fwith%2Fslash/deny',
expect.objectContaining({ method: 'POST' }),
);
});
});
describe('listAgents', () => {
it('GETs /api/ai/agents and returns the parsed agent array verbatim', async () => {
const agents = [
{
name: 'build',
mode: 'subagent',
native: true,
model: { providerID: 'openrouter', modelID: 'deepseek/deepseek-chat' },
},
{ name: 'code', mode: 'primary', hidden: false },
];
apiFetchJSONMock.mockResolvedValueOnce(agents);
await expect(AIChatAPI.listAgents()).resolves.toEqual(agents);
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/agents');
});
});
describe('forkSession', () => {
it('POSTs to the /fork suffix through apiFetchJSON and returns the forked session', async () => {
const forked = {
id: 'session-fork',
title: 'Fork of session/root',
created_at: '2026-07-18T11:00:00Z',
updated_at: '2026-07-18T11:00:00Z',
message_count: 4,
};
apiFetchJSONMock.mockResolvedValueOnce(forked);
await expect(AIChatAPI.forkSession('session/root')).resolves.toEqual(forked);
// The session id is encoded; the literal '/fork' suffix is not.
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/sessions/session%2Froot/fork', {
method: 'POST',
});
});
});
describe('chat() onTrailingParseError arm', () => {
// Stream chunks are authored WITHOUT a trailing '\n\n' so that
// consumeJSONEventStream leaves them in `buffer` at EOF, which is exactly
// what activates the trailing-buffer branch in src/api/streaming.ts. The
// aiChat.ts onTrailingParseError callback is then either bypassed (when the
// trailing line parses) or invoked (when it does not).
it('parses a trailing "data:" line with no terminator as a final event and skips the synthetic close error', async () => {
const encoder = new TextEncoder();
const read = vi
.fn()
.mockResolvedValueOnce({
done: false,
value: encoder.encode('data: {"type":"done","data":{"session_id":"s-1"}}'),
})
.mockResolvedValueOnce({ done: true, value: undefined });
const releaseLock = vi.fn();
const onEvent = vi.fn();
apiFetchMock.mockResolvedValueOnce({
ok: true,
body: { getReader: () => ({ read, releaseLock }) },
} as unknown as Response);
await AIChatAPI.chat('hello', undefined, undefined, onEvent);
// The trailing line is parsed by the trailing-buffer arm and dispatched
// as a normal event; because 'done' is terminal the stream early-returns
// and the onComplete synthetic-close error must NOT fire.
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'done',
data: { session_id: 's-1' },
}),
);
const errorCalls = onEvent.mock.calls.filter(([event]) => event.type === 'error');
expect(errorCalls).toHaveLength(0);
expect(logger.warn).not.toHaveBeenCalledWith('[AI Chat] Could not parse remaining buffer');
expect(releaseLock).toHaveBeenCalledTimes(1);
});
it('invokes onTrailingParseError (and surfaces a synthetic close error) when the trailing line is unparseable', async () => {
const encoder = new TextEncoder();
const read = vi
.fn()
.mockResolvedValueOnce({
done: false,
value: encoder.encode('data: not valid json'),
})
.mockResolvedValueOnce({ done: true, value: undefined });
const releaseLock = vi.fn();
const onEvent = vi.fn();
apiFetchMock.mockResolvedValueOnce({
ok: true,
body: { getReader: () => ({ read, releaseLock }) },
} as unknown as Response);
await AIChatAPI.chat('hello', undefined, undefined, onEvent);
// The unparseable trailing line triggers aiChat.ts's onTrailingParseError
// handler, whose only effect is a logger.warn call.
expect(logger.warn).toHaveBeenCalledWith('[AI Chat] Could not parse remaining buffer');
// Because the trailing parse failed, no terminal event was delivered and
// onComplete fires, surfacing the synthetic "closed before the response
// finished" interruption that lets the user retry the turn.
expect(onEvent).toHaveBeenCalledWith({
type: 'error',
data: { message: expect.stringContaining('closed before the response finished') },
});
expect(releaseLock).toHaveBeenCalledTimes(1);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -1,297 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
maybeRunAIChatDevStreamFixture,
type AIChatDevStreamFixtureOptions,
} from '@/api/aiChatDevStreamFixture';
import type { AIChatStreamEvent } from '@/api/generated/aiChatEvents';
const flushMicrotasks = async () => {
for (let attempt = 0; attempt < 20; attempt += 1) {
await Promise.resolve();
}
};
const STEP_DELAY_MS = 20;
type TimelineEntry = {
ms: number;
type: string;
phase?: string;
};
const workflowPhase = (event: AIChatStreamEvent): string | undefined =>
event.type === 'workflow_state' ? (event.data as { phase?: string }).phase : undefined;
const collectPacedTimeline = async (prompt: string, model?: string): Promise<TimelineEntry[]> => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const timeline: TimelineEntry[] = [];
let elapsedMs = 0;
const options: AIChatDevStreamFixtureOptions = {
prompt,
model,
stepDelayMs: STEP_DELAY_MS,
onEvent: (event) => {
timeline.push({ ms: elapsedMs, type: event.type, phase: workflowPhase(event) });
},
};
const streamPromise = maybeRunAIChatDevStreamFixture(options);
await flushMicrotasks();
// Advance the fake clock 1ms at a time so each recorded ms-offset reflects the
// exact hold delay applied after the preceding event. Increment before
// advancing so the offset is correct at the moment the timer callback fires.
let guard = 0;
while (vi.getTimerCount() > 0 && guard < 20000) {
guard += 1;
elapsedMs += 1;
await vi.advanceTimersByTimeAsync(1);
}
await expect(streamPromise).resolves.toBe(true);
return timeline;
};
const gapAfterFirstMatching = (
timeline: TimelineEntry[],
predicate: (entry: TimelineEntry) => boolean,
): number => {
const index = timeline.findIndex(predicate);
expect(index).toBeGreaterThanOrEqual(0);
expect(index).toBeLessThan(timeline.length - 1);
return timeline[index + 1].ms - timeline[index].ms;
};
describe('aiChatDevStreamFixture — branch coverage (0723pm)', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('abortError', () => {
it('rejects with a DOM-shaped AbortError and emits nothing when the signal is already aborted', async () => {
const controller = new AbortController();
controller.abort();
const onEvent = vi.fn();
const streamPromise = maybeRunAIChatDevStreamFixture({
prompt: '/fixture tool-burst',
onEvent,
signal: controller.signal,
});
await expect(streamPromise).rejects.toMatchObject({
name: 'AbortError',
message: 'The operation was aborted.',
});
// Early termination: the very first throwIfAborted at the top of the loop
// fires before any event is dispatched to the consumer.
expect(onEvent).not.toHaveBeenCalled();
});
it('catches an abort raised from inside onEvent at the next waitForFixtureStep gate (zero-delay pacing)', async () => {
const controller = new AbortController();
const seen: string[] = [];
const onEvent = vi.fn((event: AIChatStreamEvent) => {
seen.push(event.type);
// Abort once the second event lands; the subsequent waitForFixtureStep
// entry guard must surface the AbortError before event three.
if (seen.length === 2) {
controller.abort();
}
});
const streamPromise = maybeRunAIChatDevStreamFixture({
prompt: '/fixture send-hold',
onEvent,
signal: controller.signal,
});
await expect(streamPromise).rejects.toMatchObject({ name: 'AbortError' });
expect(seen).toEqual(['session', 'workflow_state']);
});
});
describe('handleAbort (mid-stream abort while a paced step is in flight)', () => {
it('tears down the pending wait, rejects with AbortError, registers the listener once, and emits no further events', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const controller = new AbortController();
const addSpy = vi.spyOn(controller.signal, 'addEventListener');
const removeSpy = vi.spyOn(controller.signal, 'removeEventListener');
const onEvent = vi.fn();
const streamPromise = maybeRunAIChatDevStreamFixture({
prompt: '/fixture tool-chain',
stepDelayMs: 100,
onEvent,
signal: controller.signal,
});
// First event (session) is dispatched synchronously before the first
// paced wait, so exactly one listener is now attached to a pending timer.
await flushMicrotasks();
expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual(['session']);
expect(vi.getTimerCount()).toBe(1);
expect(addSpy).toHaveBeenCalledTimes(1);
expect(addSpy).toHaveBeenCalledWith('abort', expect.any(Function), { once: true });
// Consumer aborts mid-wait: handleAbort must run, clear the timer,
// detach its listener, and reject with a fresh abortError.
controller.abort();
expect(vi.getTimerCount()).toBe(0);
expect(removeSpy).toHaveBeenCalledTimes(1);
expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function));
await expect(streamPromise).rejects.toMatchObject({
name: 'AbortError',
message: 'The operation was aborted.',
});
// No further events after the abort.
expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual(['session']);
});
it('does not register an abort listener when no signal is supplied, even with pacing enabled', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const onEvent = vi.fn();
const streamPromise = maybeRunAIChatDevStreamFixture({
prompt: '/fixture tool-burst',
stepDelayMs: 5,
onEvent,
});
await flushMicrotasks();
// No signal -> the optional-chaining addEventListener arm is skipped, but
// a timer is still scheduled for the paced wait.
expect(vi.getTimerCount()).toBe(1);
await vi.runAllTimersAsync();
await expect(streamPromise).resolves.toBe(true);
expect(onEvent.mock.calls[onEvent.mock.calls.length - 1][0].type).toBe('done');
});
});
describe('command resolution early-outs', () => {
it('returns false and emits nothing for a non-fixture prompt', async () => {
const onEvent = vi.fn();
const ran = await maybeRunAIChatDevStreamFixture({
prompt: 'show me the latest alerts',
onEvent,
});
expect(ran).toBe(false);
expect(onEvent).not.toHaveBeenCalled();
});
it('treats a bare "/fixture" as a command but an unknown fixture (error arm)', async () => {
const onEvent = vi.fn();
const ran = await maybeRunAIChatDevStreamFixture({ prompt: '/fixture', onEvent });
expect(ran).toBe(true);
const typesList = onEvent.mock.calls.map(([event]) => event.type);
expect(typesList).toEqual(['session', 'workflow_state', 'error']);
const errorEvent = onEvent.mock.calls[2][0];
expect(errorEvent.type).toBe('error');
expect((errorEvent.data as { message: string }).message).toContain(
'Unknown Assistant fixture',
);
});
});
describe('fixtureStepDelay pacing arms (uncovered sequencing holds)', () => {
it('holds the provider_retry workflow_state for 1600ms (provider-retry fixture)', async () => {
const timeline = await collectPacedTimeline('/fixture provider-retry');
const gap = gapAfterFirstMatching(
timeline,
(entry) => entry.type === 'workflow_state' && entry.phase === 'provider_retry',
);
expect(gap).toBe(1600);
});
it('holds the pending-tool tool_progress for 2200ms and falls back to the default delay otherwise', async () => {
const timeline = await collectPacedTimeline('/fixture pending-tool');
expect(gapAfterFirstMatching(timeline, (entry) => entry.type === 'tool_progress')).toBe(2200);
// Fallthrough arm: `if (normalizedPrompt === '/fixture pending-tool') return defaultDelayMs;`
expect(gapAfterFirstMatching(timeline, (entry) => entry.type === 'session')).toBe(
STEP_DELAY_MS,
);
});
it('holds the send-hold session for 1400ms', async () => {
const timeline = await collectPacedTimeline('/fixture send-hold');
expect(gapAfterFirstMatching(timeline, (entry) => entry.type === 'session')).toBe(1400);
});
it('holds context-group tool_start for 900ms and tool_end for 180ms', async () => {
const timeline = await collectPacedTimeline('/fixture context-group');
expect(gapAfterFirstMatching(timeline, (entry) => entry.type === 'tool_start')).toBe(900);
expect(gapAfterFirstMatching(timeline, (entry) => entry.type === 'tool_end')).toBe(180);
});
it('holds the stream-idle stream_idle workflow_state for 1800ms', async () => {
const timeline = await collectPacedTimeline('/fixture stream-idle');
expect(
gapAfterFirstMatching(
timeline,
(entry) => entry.type === 'workflow_state' && entry.phase === 'stream_idle',
),
).toBe(1800);
});
it('holds the queue-hold stream_idle workflow_state for 10000ms', async () => {
const timeline = await collectPacedTimeline('/fixture queue-hold');
expect(
gapAfterFirstMatching(
timeline,
(entry) => entry.type === 'workflow_state' && entry.phase === 'stream_idle',
),
).toBe(10000);
});
it('holds the workflow-burst provider_start workflow_state for 2400ms', async () => {
const timeline = await collectPacedTimeline('/fixture workflow-burst');
expect(
gapAfterFirstMatching(
timeline,
(entry) => entry.type === 'workflow_state' && entry.phase === 'provider_start',
),
).toBe(2400);
});
it('holds the long-output content event for 1800ms', async () => {
const timeline = await collectPacedTimeline('/fixture long-output');
expect(gapAfterFirstMatching(timeline, (entry) => entry.type === 'content')).toBe(1800);
});
});
describe('model/provider resolution fallbacks', () => {
const collectEvents = async (prompt: string, model?: string) => {
const onEvent = vi.fn();
const ran = await maybeRunAIChatDevStreamFixture({ prompt, model, onEvent });
expect(ran).toBe(true);
return onEvent.mock.calls.map(([event]) => event) as AIChatStreamEvent[];
};
it('falls back to the "dev" provider when the fixture model has no provider separator', async () => {
// providerFromFixtureModel: indexOf(':') <= 0 -> provider '' -> `|| 'dev'`.
const events = await collectEvents('/fixture provider-retry', 'standalone-model');
const retryState = events.find(
(event) =>
event.type === 'workflow_state' &&
(event.data as { phase?: string }).phase === 'provider_retry',
);
expect(retryState).toBeDefined();
expect((retryState!.data as { provider?: string }).provider).toBe('dev');
const done = events.find((event) => event.type === 'done');
expect((done!.data as { model?: string }).model).toBe('standalone-model');
});
it('falls back to the default fixture model when the supplied model is blank whitespace', async () => {
// assistantFixtureModel: model?.trim() || 'dev:assistant-stream-fixture'.
const events = await collectEvents('/fixture provider-retry', ' ');
const done = events.find((event) => event.type === 'done');
expect((done!.data as { model?: string }).model).toBe('dev:assistant-stream-fixture');
});
});
});
@@ -1,163 +0,0 @@
import { describe, expect, it } from 'vitest';
import aiChatEventsSource from '@/api/generated/aiChatEvents.ts?raw';
import type {
AIChatStreamEvent,
DoneData,
SessionData,
ToolCancelData,
ToolProgressData,
ToolStartData,
WorkflowStateData,
} from '@/api/generated/aiChatEvents';
describe('AI chat stream event contract', () => {
it('does not expose the retired explore pre-pass stream event', () => {
expect(aiChatEventsSource).not.toContain('ExploreStatusData');
expect(aiChatEventsSource).not.toContain("type: 'explore_status'");
});
it('exposes the backend-created session event as a typed stream contract', () => {
const session: SessionData = { id: 'sess-stream' };
const event: AIChatStreamEvent = { type: 'session', data: session };
expect(event.data.id).toBe('sess-stream');
expect(aiChatEventsSource).toContain('export interface SessionData');
expect(aiChatEventsSource).toContain("type: 'session'");
expect(aiChatEventsSource).toContain('context_limit_tokens?: number');
});
it('does not expose automatic provider fallback metadata on workflow state events', () => {
expect(aiChatEventsSource).not.toContain('provider_fallback');
expect(aiChatEventsSource).not.toContain('failed_provider?: string');
expect(aiChatEventsSource).not.toContain('failed_model?: string');
expect(aiChatEventsSource).not.toContain('next_provider?: string');
expect(aiChatEventsSource).not.toContain('next_model?: string');
});
it('exposes selected provider and model metadata on workflow state events', () => {
const workflow: WorkflowStateData = {
phase: 'provider_start',
message: 'Waiting for assistant.',
provider: 'openrouter',
model: 'openrouter:qwen/qwen3.7-plus',
};
const event: AIChatStreamEvent = { type: 'workflow_state', data: workflow };
expect(event.data.provider).toBe('openrouter');
expect(event.data.model).toBe('openrouter:qwen/qwen3.7-plus');
expect(aiChatEventsSource).toContain('provider?: string');
expect(aiChatEventsSource).toContain('model?: string');
});
it('exposes provider retry metadata on workflow state events', () => {
const workflow: WorkflowStateData = {
phase: 'provider_retry',
message: 'Selected route connection failed before any output. Retrying.',
state: 'investigating',
attempt: 2,
max_attempts: 2,
retry_after_ms: 200,
};
const event: AIChatStreamEvent = { type: 'workflow_state', data: workflow };
expect(event.data.attempt).toBe(2);
expect(event.data.max_attempts).toBe(2);
expect(event.data.retry_after_ms).toBe(200);
expect(aiChatEventsSource).toContain('attempt?: number');
expect(aiChatEventsSource).toContain('max_attempts?: number');
expect(aiChatEventsSource).toContain('retry_after_ms?: number');
});
it('exposes live tool progress as a typed stream contract', () => {
const progress: ToolProgressData = {
id: 'tool-1',
name: 'pulse_query',
input: '{"action":"topology"}',
raw_input: '{"action":"topology"}',
phase: 'running',
message: 'Reading inventory.',
};
const event: AIChatStreamEvent = { type: 'tool_progress', data: progress };
expect(event.data.phase).toBe('running');
expect(event.data.message).toBe('Reading inventory.');
expect(aiChatEventsSource).toContain('export interface ToolProgressData');
expect(aiChatEventsSource).toContain("type: 'tool_progress'");
});
it('exposes live tool start state as a typed stream contract', () => {
const start: ToolStartData = {
id: 'tool-1',
name: 'pulse_read',
input: '{"action":"exec","command":"ls /dev | wc -l"}',
raw_input: '{"action":"exec","command":"ls /dev | wc -l"}',
phase: 'running',
message: 'Reading target.',
};
const event: AIChatStreamEvent = { type: 'tool_start', data: start };
expect(event.data.phase).toBe('running');
expect(event.data.message).toBe('Reading target.');
expect(aiChatEventsSource).toContain('export interface ToolStartData');
expect(aiChatEventsSource).toContain("type: 'tool_start'");
expect(aiChatEventsSource).toContain('phase?: string');
expect(aiChatEventsSource).toContain('message?: string');
});
it('exposes pending tool cancellation as a typed stream contract', () => {
const cancel: ToolCancelData = {
id: 'tool-1',
name: 'pulse_read',
reason: 'current_resource unavailable',
};
const event: AIChatStreamEvent = { type: 'tool_cancel', data: cancel };
expect(event.data.reason).toBe('current_resource unavailable');
expect(aiChatEventsSource).toContain('export interface ToolCancelData');
expect(aiChatEventsSource).toContain("type: 'tool_cancel'");
});
it('exposes the effective completion model on done events', () => {
const done: DoneData = {
session_id: 'sess-stream',
model: 'deepseek:deepseek-v4-pro',
input_tokens: 904,
output_tokens: 30,
};
const event: AIChatStreamEvent = { type: 'done', data: done };
expect(event.data?.model).toBe('deepseek:deepseek-v4-pro');
expect(aiChatEventsSource).toContain('model?: string');
});
it('exposes mid-turn steering injections as a typed stream contract', () => {
const event: AIChatStreamEvent = {
type: 'steer_applied',
data: {
session_id: 'sess-stream',
message_id: 'srv-1',
client_message_id: 'row-1',
prompt: 'also check pve2',
turn: 2,
},
};
expect(event.data.client_message_id).toBe('row-1');
expect(aiChatEventsSource).toContain('export interface SteerAppliedData');
expect(aiChatEventsSource).toContain("type: 'steer_applied'");
});
it('exposes the estimated cumulative session cost on done events', () => {
const done: DoneData = {
session_id: 'sess-stream',
model: 'deepseek:deepseek-v4-pro',
input_tokens: 904,
output_tokens: 30,
session_cost_usd: 0.0123,
};
const event: AIChatStreamEvent = { type: 'done', data: done };
expect(event.data?.session_cost_usd).toBe(0.0123);
expect(aiChatEventsSource).toContain('session_cost_usd?: number');
});
});
@@ -1,71 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/utils/apiClient', () => ({
apiFetchJSON: vi.fn(),
apiFetch: vi.fn(),
}));
vi.mock('@/utils/logger', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import { AIChatAPI } from '@/api/aiChat';
import { apiFetch } from '@/utils/apiClient';
describe('AIChatAPI mention payloads', () => {
const apiFetchMock = vi.mocked(apiFetch);
beforeEach(() => {
apiFetchMock.mockReset();
});
it('preserves canonical storage mention ids in the shared chat request body', async () => {
apiFetchMock.mockResolvedValueOnce({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockResolvedValueOnce({ done: true, value: undefined }),
releaseLock: vi.fn(),
}),
},
} as unknown as Response);
await AIChatAPI.chat('check @NVMe Primary', undefined, undefined, vi.fn(), undefined, [
{
id: 'storage-vmware-1',
name: 'NVMe Primary',
type: 'storage',
node: 'Lab VC',
},
]);
expect(apiFetchMock).toHaveBeenCalledWith(
'/api/ai/chat',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
body: JSON.stringify({
prompt: 'check @NVMe Primary',
session_id: undefined,
model: undefined,
mentions: [
{
id: 'storage-vmware-1',
name: 'NVMe Primary',
type: 'storage',
node: 'Lab VC',
},
],
}),
}),
);
});
});
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import aiSource from '@/api/ai.ts?raw';
import aiChatSource from '@/api/aiChat.ts?raw';
import agentProfilesSource from '@/api/agentProfiles.ts?raw';
import alertsSource from '@/api/alerts.ts?raw';
import agentMetadataSource from '@/api/agentMetadata.ts?raw';
@@ -88,15 +87,6 @@ describe('API error-status guardrails', () => {
expect(aiSource).not.toContain('} catch {\n return null;');
expect(aiSource).not.toContain('normalizeUnifiedFinding(');
expect(aiChatSource).toContain('assertAPIResponseOK(response,');
expect(aiChatSource).toContain('consumeJSONEventStream<StreamEvent>(response,');
expect(aiChatSource).not.toContain('JSON.parse(');
expect(aiChatSource).not.toContain('TextDecoder');
expect(aiChatSource).not.toContain('getReader(');
expect(aiChatSource).not.toContain('STREAM_TIMEOUT_MS');
expect(aiChatSource).not.toContain('private static encodeSegment(');
expect(aiChatSource).not.toContain('readAPIErrorMessage(');
expect(agentProfilesSource).toContain('withAPIErrorStatusFallback');
expect(agentProfilesSource).toContain('withAPIErrorStatusNull');
expect(agentProfilesSource).toContain('assertAPIResponseOKOrAllowedStatus(');
@@ -188,7 +178,6 @@ describe('API error-status guardrails', () => {
it('routes canonical collection normalization through responseUtils', () => {
expect(aiSource).toContain('arrayOrEmpty<UnifiedFindingRecord>(response.findings)');
expect(aiSource).toContain("objectArrayFieldOrEmpty<RemediationPlan>(data, 'plans')");
expect(aiSource).toContain("objectArrayFieldOrEmpty<ApprovalRequest>(response, 'approvals')");
expect(aiSource).not.toContain('response.approvals || []');
expect(aiSource).not.toContain('Array.isArray(data?.plans)');
@@ -19,7 +19,6 @@ import { SecurityAPI } from '@/api/security';
import { NodesAPI } from '@/api/nodes';
import { NotificationsAPI } from '@/api/notifications';
import { UpdatesAPI } from '@/api/updates';
import { AIChatAPI } from '@/api/aiChat';
import { apiFetch, apiFetchJSON } from '@/utils/apiClient';
describe('API URL encoding', () => {
@@ -72,23 +71,4 @@ describe('API URL encoding', () => {
'/api/updates/plan?version=v1.2.3%26x%3D1&channel=beta%2Fdev',
);
});
it('encodes AI chat session, approval, and question ids', async () => {
apiFetchJSONMock.mockResolvedValue({} as never);
apiFetchMock.mockResolvedValue(new Response(null, { status: 200 }));
await AIChatAPI.getMessages('session/root');
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/sessions/session%2Froot/messages');
await AIChatAPI.approveCommand('approval/root');
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/approvals/approval%2Froot/approve', {
method: 'POST',
});
await AIChatAPI.answerQuestion('question/root', [{ id: 'a', value: 'b' }]);
expect(apiFetchMock).toHaveBeenCalledWith('/api/ai/question/question%2Froot/answer', {
method: 'POST',
body: JSON.stringify({ answers: [{ id: 'a', value: 'b' }] }),
});
});
});
-133
View File
@@ -233,49 +233,6 @@ export class AIAPI {
});
}
// Remediation plans
static async getRemediationPlans(): Promise<RemediationPlansResponse> {
const data = (await withAPIErrorStatusFallback(
apiFetchJSON(`${this.baseUrl}/ai/remediation/plans`),
402,
{ plans: [] },
)) as {
plans?: RemediationPlan[];
executions?: unknown[];
};
return { plans: objectArrayFieldOrEmpty<RemediationPlan>(data, 'plans') };
}
static async getRemediationPlan(planId: string): Promise<RemediationPlan> {
const search = new URLSearchParams({ plan_id: planId });
return apiFetchJSON(
`${this.baseUrl}/ai/remediation/plan?${search.toString()}`,
) as Promise<RemediationPlan>;
}
static async approveRemediationPlan(
planId: string,
): Promise<{ success: boolean; execution?: { id: string } }> {
return apiFetchJSON(`${this.baseUrl}/ai/remediation/approve`, {
method: 'POST',
body: JSON.stringify({ plan_id: planId }),
}) as Promise<{ success: boolean; execution?: { id: string } }>;
}
static async executeRemediationPlan(executionId: string): Promise<RemediationExecutionResult> {
return apiFetchJSON(`${this.baseUrl}/ai/remediation/execute`, {
method: 'POST',
body: JSON.stringify({ execution_id: executionId }),
}) as Promise<RemediationExecutionResult>;
}
static async rollbackRemediationPlan(executionId: string): Promise<{ success: boolean }> {
return apiFetchJSON(`${this.baseUrl}/ai/remediation/rollback`, {
method: 'POST',
body: JSON.stringify({ execution_id: executionId }),
}) as Promise<{ success: boolean }>;
}
// ============================================
// Investigation Fix Approvals
// ============================================
@@ -472,96 +429,6 @@ export interface UnifiedFindingsResponse {
active_count?: number;
}
export interface RemediationPlansResponse {
plans: RemediationPlan[];
}
export interface RemediationPlan {
id: string;
finding_id: string;
resource_id: string;
title: string;
description: string;
steps: RemediationStep[];
risk_level: 'low' | 'medium' | 'high';
status: 'pending' | 'approved' | 'executing' | 'completed' | 'failed' | 'rolled_back';
created_at: string;
// Optional governed action proposal projection. Patrol finding creation
// must not populate this from category/title heuristics.
// RequiresApproval is invariant on every proposal; Allowed=false signals a
// preflight-only proposal (no write capability wired yet).
proposed_action_plan?: ProposedActionPlan;
}
// ProposedActionPlan mirrors pkg/aicontracts.ProposedActionPlan on the
// wire.
export interface ProposedActionPlan {
actionId: string;
capabilityName?: string;
allowed: boolean;
requiresApproval: boolean;
approvalPolicy?: string;
message?: string;
source?: string;
projectedMetric?: ProposedMetricSummary;
preflight?: ProposedActionPreflight;
plannedAt?: string;
expiresAt?: string;
}
// ProposedMetricSummary is the operator-facing metric snapshot for a
// governed action proposal.
export interface ProposedMetricSummary {
metric: string;
currentValue: number;
predictedValue?: number;
thresholdValue?: number;
timeToThresholdSeconds?: number | null;
}
// ProposedActionPreflight mirrors unifiedresources.ActionPreflight on the
// wire, narrowed to the fields the proposal card surfaces.
export interface ProposedActionPreflight {
target?: string;
currentState?: string;
intendedChange?: string;
dryRunAvailable: boolean;
dryRunSummary?: string;
safetyChecks?: string[];
verificationSteps?: string[];
}
export interface RemediationStep {
order: number;
action: string;
command?: string;
rollback_command?: string;
risk_level: 'low' | 'medium' | 'high';
}
export interface StepResult {
step: number;
success: boolean;
output?: string;
error?: string;
duration_ms: number;
run_at: string;
}
// Compatibility response shape for older execution endpoints.
export interface RemediationExecutionResult {
execution_id: string;
plan_id: string;
status: 'success' | 'failed' | 'partial';
steps_completed: number;
error?: string;
// Full execution details from backend
id?: string;
step_results?: StepResult[];
started_at?: string;
completed_at?: string;
}
// ============================================
// Investigation Fix Approval Types
// ============================================
-565
View File
@@ -1,565 +0,0 @@
import { apiFetchJSON, apiFetch } from '@/utils/apiClient';
import { assertAPIResponseOK } from './responseUtils';
import { logger } from '@/utils/logger';
import type { AIChatStreamEvent } from './generated/aiChatEvents';
import type { AgentSurfaceToolContract } from './agentCapabilities';
import { consumeJSONEventStream } from './streaming';
import { maybeRunAIChatDevStreamFixture } from './aiChatDevStreamFixture';
// AI Chat API - Simplified AI interface
export interface ChatSession {
id: string;
title: string;
created_at: string;
updated_at: string;
message_count: number;
can_redo?: boolean;
/** Pulse-owned background run (Patrol detection/eval/investigation), not a resumable user chat. */
system?: boolean;
handoff_summary?: ChatSessionHandoffSummary;
}
export interface ChatSessionUndoResult {
success: boolean;
session_id: string;
restored_prompt?: string;
removed_messages?: number;
can_redo: boolean;
message?: string;
}
export interface ChatSessionRedoResult {
success: boolean;
session_id: string;
restored_messages?: number;
can_redo: boolean;
message?: string;
}
export interface ChatSessionSteerResult {
accepted: boolean;
session_id: string;
reason?: string; // "no_active_run" | "system_session" | "empty_prompt" | "steer_backlog"
}
export interface ChatSessionCompactionResult {
success: boolean;
status: 'compacted' | 'not_needed' | 'empty' | string;
message?: string;
session_id: string;
summary_message_id?: string;
original_message_count?: number;
compacted_message_count?: number;
compacted_messages?: number;
kept_recent_messages?: number;
summary_chars?: number;
}
export interface ListChatSessionsOptions {
search?: string;
limit?: number;
}
export type ChatMentionType = 'vm' | 'system-container' | 'app-container' | 'agent' | 'storage';
export interface ChatMention {
id: string;
name: string;
type: ChatMentionType;
node?: string;
}
export interface ChatHandoffResource {
id: string;
name?: string;
type?: string;
node?: string;
}
export interface ChatSessionHandoffResource {
id?: string;
name?: string;
type?: string;
node?: string;
}
export interface ChatSessionHandoffSummary {
kind?: string;
finding_id?: string;
run_id?: string;
run_type?: string;
run_status?: string;
runtime_failure?: boolean;
has_model_context: boolean;
resource_count?: number;
primary_resource?: ChatSessionHandoffResource;
action_count?: number;
requires_approval?: boolean;
last_known_approval_status?: string;
last_known_action_state?: string;
last_known_action_risk?: string;
updated_at?: string;
}
export interface ChatHandoffMetadata {
kind?: string;
runId?: string;
runType?: string;
runStatus?: string;
runtimeFailure?: boolean;
}
export interface ChatHandoffAction {
findingId?: string;
recordId?: string;
approvalId?: string;
approvalStatus?: string;
approvalRequestedAt?: string;
approvalExpiresAt?: string;
approvalDecidedAt?: string;
approvalConsumed?: boolean;
actionId?: string;
actionState?: string;
actionUpdatedAt?: string;
actionRequestedBy?: string;
actionCapability?: string;
actionApprovalPolicy?: string;
actionRequiresApproval?: boolean;
actionPlanExpiresAt?: string;
actionPlanMessage?: string;
actionPreflight?: string;
actionDryRunSummary?: string;
actionResult?: string;
fixId?: string;
description?: string;
riskLevel?: string;
destructive?: boolean;
targetHost?: string;
targetResourceId?: string;
targetResourceName?: string;
targetResourceType?: string;
targetNode?: string;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
model?: string;
tool_calls?: ToolCall[];
}
export interface ToolCall {
name: string;
input?: string | Record<string, unknown>;
output?: string;
success?: boolean;
}
export type StreamEvent = AIChatStreamEvent;
export interface AIChatStreamLifecycle {
onStreamOpen?: () => void;
}
const AI_CHAT_STREAM_TEXT_PAINT_CHECKPOINT_INTERVAL = 4;
const AI_CHAT_STREAM_TEXT_EVENT_TYPES = new Set(['content', 'thinking']);
// The backend writes an SSE ": heartbeat" comment every 5 seconds while a
// turn is in flight, so a healthy connection never goes quiet for long. If no
// bytes arrive for this window the connection is dead (backend restart,
// half-open socket) and the turn must fail fast instead of leaving the user
// staring at "Generating response" for the old 300s ceiling.
export const AI_CHAT_STREAM_INACTIVITY_TIMEOUT_MS = 30000;
const isAIChatStreamTextEvent = (event: Pick<StreamEvent, 'type'>): boolean =>
AI_CHAT_STREAM_TEXT_EVENT_TYPES.has(event.type);
export const createAIChatStreamPaintCheckpointPredicate = () => {
let textEventsSinceCheckpoint = 0;
return (event: Pick<StreamEvent, 'type'>): boolean => {
if (!isAIChatStreamTextEvent(event)) {
textEventsSinceCheckpoint = 0;
return true;
}
textEventsSinceCheckpoint += 1;
if (
textEventsSinceCheckpoint === 1 ||
textEventsSinceCheckpoint >= AI_CHAT_STREAM_TEXT_PAINT_CHECKPOINT_INTERVAL
) {
if (textEventsSinceCheckpoint >= AI_CHAT_STREAM_TEXT_PAINT_CHECKPOINT_INTERVAL) {
textEventsSinceCheckpoint = 0;
}
return true;
}
return false;
};
};
export interface AIStatus {
running: boolean;
engine: string;
}
export interface AssistantWorkflowPromptRenderRequest {
name: string;
arguments?: Record<string, string>;
}
export const PULSE_OPERATIONS_LOOP_WORKFLOW_PROMPT_NAME = 'pulse_operations_loop';
export const PULSE_PATROL_WORKFLOW_PROMPT_SURFACE = 'pulse_patrol';
export const PULSE_PATROL_CONTROL_WORKFLOW_PROMPT_SURFACE = 'patrol_control';
export const PULSE_PATROL_AUTONOMY_WORKFLOW_PROMPT_SURFACE = 'patrol_autonomy';
export const PULSE_PRO_ACTIVATION_WORKFLOW_PROMPT_SURFACE = 'pulse_pro_activation';
export type AssistantWorkflowPromptActivitySurface =
| 'pulse_assistant'
| 'pulse_patrol'
| 'patrol_control'
| 'patrol_autonomy'
| 'pulse_pro_activation';
export interface AssistantWorkflowPromptActivityRequest {
name: string;
surface?: AssistantWorkflowPromptActivitySurface;
}
export interface AssistantWorkflowPromptRenderResponse {
description: string;
text: string;
}
// AI Agent (build, code, etc.) with specific permissions and model
export interface Agent {
name: string;
description?: string;
mode: 'subagent' | 'primary' | 'all';
native?: boolean;
hidden?: boolean;
color?: string;
model?: {
providerID: string;
modelID: string;
};
}
export class AIChatAPI {
private static baseUrl = '/api/ai';
// Get AI status
static async getStatus(): Promise<AIStatus> {
return apiFetchJSON(`${this.baseUrl}/status`) as Promise<AIStatus>;
}
// Get the live native Assistant surface tool contract for the current runtime mode.
static async getAssistantSurfaceTools(): Promise<AgentSurfaceToolContract> {
return apiFetchJSON(
`${this.baseUrl}/assistant/surface-tools`,
) as Promise<AgentSurfaceToolContract>;
}
// Render a shared Pulse Intelligence workflow prompt for the native Assistant surface.
static async renderWorkflowPrompt(
request: AssistantWorkflowPromptRenderRequest,
): Promise<AssistantWorkflowPromptRenderResponse> {
return apiFetchJSON(`${this.baseUrl}/workflow-prompts/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
}) as Promise<AssistantWorkflowPromptRenderResponse>;
}
// Record that a first-party Pulse surface started a shared workflow prompt.
static async recordWorkflowPromptActivity(
request: AssistantWorkflowPromptActivityRequest,
): Promise<void> {
await apiFetch(`${this.baseUrl}/workflow-prompts/activity`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
}
// List all chat sessions. Normalizes a null/non-array payload (e.g. a server
// error body) to an empty array so callers can rely on array semantics and do
// not crash on .length/.some()/.map() (#1149).
static async listSessions(options: ListChatSessionsOptions = {}): Promise<ChatSession[]> {
const params = new URLSearchParams();
const search = options.search?.trim();
if (search) params.set('search', search);
if (typeof options.limit === 'number' && Number.isFinite(options.limit) && options.limit > 0) {
params.set('limit', String(Math.floor(options.limit)));
}
const suffix = params.toString();
const value = await apiFetchJSON(`${this.baseUrl}/sessions${suffix ? `?${suffix}` : ''}`);
return Array.isArray(value) ? (value as ChatSession[]) : [];
}
// Create a new session
static async createSession(): Promise<ChatSession> {
return apiFetchJSON(`${this.baseUrl}/sessions`, {
method: 'POST',
}) as Promise<ChatSession>;
}
// Delete a session
static async deleteSession(sessionId: string): Promise<void> {
await apiFetch(`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
});
}
// Rename a session
static async renameSession(sessionId: string, title: string): Promise<ChatSession> {
return apiFetchJSON(`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
}) as Promise<ChatSession>;
}
// Get messages for a session
static async getMessages(sessionId: string): Promise<ChatMessage[]> {
return apiFetchJSON(
`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}/messages`,
) as Promise<ChatMessage[]>;
}
// Abort a session
static async abortSession(sessionId: string): Promise<void> {
await apiFetch(`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}/abort`, {
method: 'POST',
});
}
// Approve a pending command
static async approveCommand(approvalId: string): Promise<{ approved: boolean; message: string }> {
return apiFetchJSON(`${this.baseUrl}/approvals/${encodeURIComponent(approvalId)}/approve`, {
method: 'POST',
}) as Promise<{ approved: boolean; message: string }>;
}
// Deny a pending command
static async denyCommand(
approvalId: string,
reason?: string,
): Promise<{ denied: boolean; message: string }> {
return apiFetchJSON(`${this.baseUrl}/approvals/${encodeURIComponent(approvalId)}/deny`, {
method: 'POST',
body: JSON.stringify({ reason: reason || 'User skipped' }),
}) as Promise<{ denied: boolean; message: string }>;
}
// Answer a pending question from the AI chat
static async answerQuestion(
questionId: string,
answers: Array<{ id: string; value: string }>,
): Promise<void> {
await apiFetch(`${this.baseUrl}/question/${encodeURIComponent(questionId)}/answer`, {
method: 'POST',
body: JSON.stringify({ answers }),
});
}
// ============================================
// AI Chat Extended Features
// ============================================
// List available agents (build, code, etc.)
static async listAgents(): Promise<Agent[]> {
return apiFetchJSON(`${this.baseUrl}/agents`) as Promise<Agent[]>;
}
// Summarize a session (compress context when nearing limits)
static async summarizeSession(sessionId: string): Promise<ChatSessionCompactionResult> {
return apiFetchJSON(`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}/summarize`, {
method: 'POST',
}) as Promise<ChatSessionCompactionResult>;
}
// Fork a session (create a branch point)
static async forkSession(sessionId: string): Promise<ChatSession> {
return apiFetchJSON(`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}/fork`, {
method: 'POST',
}) as Promise<ChatSession>;
}
// Undo latest chat turn and return the user prompt so the composer can restore it.
// Retry/regenerate passes expectedPrompt so a stale retry can never remove a
// turn other than the one being re-run.
static async undoLastTurn(
sessionId: string,
options?: { expectedPrompt?: string },
): Promise<ChatSessionUndoResult> {
const expectedPrompt = options?.expectedPrompt?.trim();
return apiFetchJSON(`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}/undo`, {
method: 'POST',
...(expectedPrompt
? {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expected_prompt: expectedPrompt }),
}
: {}),
}) as Promise<ChatSessionUndoResult>;
}
// Redo the latest chat turn removed by undoLastTurn.
static async redoLastTurn(sessionId: string): Promise<ChatSessionRedoResult> {
return apiFetchJSON(`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}/redo`, {
method: 'POST',
}) as Promise<ChatSessionRedoResult>;
}
// Steer the session's running response: the message joins the in-flight
// agentic loop at its next turn boundary. accepted=false (e.g. the run
// already finished) is a normal outcome; the caller keeps the follow-up
// queued and it drains as an ordinary new turn.
static async steerSession(
sessionId: string,
request: { prompt: string; clientMessageId?: string },
): Promise<ChatSessionSteerResult> {
return apiFetchJSON(`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}/steer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: request.prompt,
...(request.clientMessageId ? { client_message_id: request.clientMessageId } : {}),
}),
}) as Promise<ChatSessionSteerResult>;
}
// Stream chat - the main chat interface
static async chat(
prompt: string,
sessionId: string | undefined,
model: string | undefined,
onEvent: (event: StreamEvent) => void,
signal?: AbortSignal,
mentions?: ChatMention[],
findingId?: string,
handoffContext?: string,
handoffResources?: ChatHandoffResource[],
handoffActions?: ChatHandoffAction[],
handoffMetadata?: ChatHandoffMetadata,
lifecycle?: AIChatStreamLifecycle,
): Promise<void> {
logger.debug('[AI Chat] Starting chat stream', { prompt: prompt.substring(0, 50) });
if (await maybeRunAIChatDevStreamFixture({ prompt, model, onEvent, signal })) {
return;
}
const body: Record<string, unknown> = {
prompt,
session_id: sessionId,
model,
};
if (mentions && mentions.length > 0) {
body.mentions = mentions;
}
if (findingId) {
body.finding_id = findingId;
}
if (handoffContext && handoffContext.trim()) {
body.handoff_context = handoffContext;
}
if (handoffResources && handoffResources.length > 0) {
body.handoff_resources = handoffResources;
}
if (handoffActions && handoffActions.length > 0) {
body.handoff_actions = handoffActions.map(serializeChatHandoffAction);
}
if (handoffMetadata) {
body.handoff_metadata = {
kind: handoffMetadata.kind,
run_id: handoffMetadata.runId,
run_type: handoffMetadata.runType,
run_status: handoffMetadata.runStatus,
runtime_failure: handoffMetadata.runtimeFailure,
};
}
const response = await apiFetch(`${this.baseUrl}/chat`, {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
signal,
});
await assertAPIResponseOK(response, `Request failed with status ${response.status}`);
lifecycle?.onStreamOpen?.();
await consumeJSONEventStream<StreamEvent>(response, {
onEvent: (event) => {
logger.debug('[AI Chat] Event', { type: event.type });
onEvent(event);
return event.type === 'done' || event.type === 'error';
},
onParseError: (line) => {
logger.error('[AI Chat] Failed to parse event', { line });
},
onTrailingParseError: () => {
logger.warn('[AI Chat] Could not parse remaining buffer');
},
onTimeout: () => {
logger.warn('[AI Chat] Stream timeout');
throw new Error('Pulse Assistant stream timed out waiting for provider data.');
},
onComplete: () => {
// The backend always terminates a chat stream with an explicit done or
// error event. A clean close without one means the connection was
// severed mid-turn (backend restart, proxy drop) — surface an
// interruption the user can retry instead of pretending the turn
// finished.
onEvent({
type: 'error',
data: { message: 'Connection to Pulse closed before the response finished.' },
} as StreamEvent);
},
timeoutMs: AI_CHAT_STREAM_INACTIVITY_TIMEOUT_MS,
yieldBetweenEvents: createAIChatStreamPaintCheckpointPredicate(),
});
}
}
function serializeChatHandoffAction(action: ChatHandoffAction): Record<string, unknown> {
return {
finding_id: action.findingId,
record_id: action.recordId,
approval_id: action.approvalId,
approval_status: action.approvalStatus,
approval_requested_at: action.approvalRequestedAt,
approval_expires_at: action.approvalExpiresAt,
approval_decided_at: action.approvalDecidedAt,
approval_consumed: action.approvalConsumed,
action_id: action.actionId,
action_state: action.actionState,
action_updated_at: action.actionUpdatedAt,
action_requested_by: action.actionRequestedBy,
action_capability: action.actionCapability,
action_approval_policy: action.actionApprovalPolicy,
action_requires_approval: action.actionRequiresApproval,
action_plan_expires_at: action.actionPlanExpiresAt,
action_plan_message: action.actionPlanMessage,
action_preflight: action.actionPreflight,
action_dry_run_summary: action.actionDryRunSummary,
action_result: action.actionResult,
fix_id: action.fixId,
description: action.description,
risk_level: action.riskLevel,
destructive: action.destructive,
target_host: action.targetHost,
target_resource_id: action.targetResourceId,
target_resource_name: action.targetResourceName,
target_resource_type: action.targetResourceType,
target_node: action.targetNode,
};
}
File diff suppressed because it is too large Load Diff
@@ -1,163 +0,0 @@
// This file is generated from scripts/generate-types.go; DO NOT EDIT.
// Source: internal/ai/chat event payload structs.
/* eslint-disable */
export interface ApprovalContextConfidenceData {
level?: string;
summary?: string;
evidence?: string[];
}
export interface ApprovalNeededData {
approval_id: string;
tool_id: string;
tool_name: string;
command: string;
run_on_host: boolean;
target_host?: string;
target_type?: string;
target_id?: string;
risk?: string;
description?: string;
audit_id?: string;
plan?: ApprovalPlanData;
context_confidence?: ApprovalContextConfidenceData;
preflight?: ApprovalPreflightData;
}
export interface ApprovalPlanData {
action_id?: string;
request_id?: string;
summary?: string;
requires_approval: boolean;
approval_policy?: string;
blast_radius?: string;
rollback_available: boolean;
plan_hash?: string;
expires_at?: string;
}
export interface ApprovalPreflightData {
target?: string;
current_state?: string;
intended_change?: string;
dry_run_available: boolean;
dry_run_summary?: string;
safety_checks?: string[];
verification_steps?: string[];
generated_at?: string;
}
export interface ContentData {
text: string;
}
export interface DoneData {
session_id?: string;
model?: string;
input_tokens?: number;
output_tokens?: number;
context_limit_tokens?: number;
session_cost_usd?: number;
}
export interface ErrorData {
message: string;
}
export interface Question {
id: string;
type?: string;
question: string;
header?: string;
options?: QuestionOption[];
}
export interface QuestionData {
session_id?: string;
question_id: string;
questions: Question[];
}
export interface QuestionOption {
label: string;
value?: string;
description?: string;
}
export interface SessionData {
id: string;
}
export interface SteerAppliedData {
session_id?: string;
message_id?: string;
client_message_id?: string;
prompt?: string;
turn: number;
}
export interface ThinkingData {
text: string;
}
export interface ToolCancelData {
id: string;
name: string;
reason?: string;
}
export interface ToolEndData {
id: string;
name: string;
input?: string;
raw_input?: string;
output?: string;
success: boolean;
}
export interface ToolProgressData {
id: string;
name: string;
input?: string;
raw_input?: string;
phase?: string;
message?: string;
}
export interface ToolStartData {
id: string;
name: string;
input: string;
raw_input?: string;
phase?: string;
message?: string;
}
export interface WorkflowStateData {
phase: string;
message: string;
state?: string;
tool?: string;
provider?: string;
model?: string;
attempt?: number;
max_attempts?: number;
retry_after_ms?: number;
}
export type AIChatStreamEvent =
| { type: 'session'; data: SessionData }
| { type: 'content'; data: ContentData }
| { type: 'thinking'; data: ThinkingData }
| { type: 'workflow_state'; data: WorkflowStateData }
| { type: 'tool_start'; data: ToolStartData }
| { type: 'tool_progress'; data: ToolProgressData }
| { type: 'tool_cancel'; data: ToolCancelData }
| { type: 'tool_end'; data: ToolEndData }
| { type: 'approval_needed'; data: ApprovalNeededData }
| { type: 'question'; data: QuestionData & { session_id?: string } }
| { type: 'steer_applied'; data: SteerAppliedData }
| { type: 'done'; data?: DoneData }
| { type: 'error'; data: ErrorData };
+4 -2
View File
@@ -6,7 +6,6 @@
import { apiFetchJSON } from '@/utils/apiClient';
import { arrayOrEmpty, promoteLegacyAlertIdentifier } from './responseUtils';
import type { InvestigationRecord } from './ai';
import type { ToolCall } from './aiChat';
import type { ResourceCriticality } from './resourceOperatorState';
import type { PatrolActionReference } from '@/types/actionAudit';
import type { PatrolModelReadinessSnapshot } from '@/types/ai';
@@ -307,9 +306,12 @@ export interface ChatMessage {
timestamp: string;
}
export interface ChatToolCall extends ToolCall {
export interface ChatToolCall {
id: string;
name: string;
input: Record<string, unknown>;
output?: string;
success?: boolean;
}
export interface ChatToolResult {
@@ -0,0 +1,23 @@
import { apiFetch } from '@/utils/apiClient';
export const PULSE_OPERATIONS_LOOP_WORKFLOW_PROMPT_NAME = 'pulse_operations_loop';
export const PULSE_PATROL_CONTROL_WORKFLOW_PROMPT_SURFACE = 'patrol_control';
export type WorkflowPromptActivitySurface =
'pulse_patrol' | 'patrol_control' | 'patrol_autonomy' | 'pulse_pro_activation';
export interface WorkflowPromptActivityRequest {
name: string;
surface?: WorkflowPromptActivitySurface;
}
// Record that a first-party Pulse surface started a shared workflow prompt.
export async function recordWorkflowPromptActivity(
request: WorkflowPromptActivityRequest,
): Promise<void> {
await apiFetch('/api/ai/workflow-prompts/activity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
}
@@ -1,324 +0,0 @@
import { Component, For, Show } from 'solid-js';
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
import CheckIcon from 'lucide-solid/icons/check';
import ClipboardCheckIcon from 'lucide-solid/icons/clipboard-check';
import LoaderCircleIcon from 'lucide-solid/icons/loader-circle';
import ShieldCheckIcon from 'lucide-solid/icons/shield-check';
import XIcon from 'lucide-solid/icons/x';
import type { PendingApproval } from './types';
interface ApprovalCardProps {
approval: PendingApproval;
onApprove: () => void;
onSkip: () => void;
}
const riskLabel = (risk?: string) => {
const normalized = risk?.trim();
return normalized ? normalized.toUpperCase() : 'REVIEW';
};
const confidenceLabel = (level?: string) => {
const normalized = level?.trim();
return normalized ? normalized.replace(/_/g, ' ').toUpperCase() : 'UNKNOWN';
};
const confidenceClasses = (level?: string) => {
switch (level) {
case 'verified':
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/25 dark:text-emerald-200';
case 'partial':
return 'bg-sky-100 text-sky-700 dark:bg-sky-900/25 dark:text-sky-200';
case 'inferred':
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/25 dark:text-amber-200';
default:
return 'bg-base-200 text-base-content';
}
};
const formatPlanHash = (hash?: string) => {
const trimmed = hash?.trim();
if (!trimmed) return '';
return trimmed.length > 12 ? trimmed.slice(0, 12) : trimmed;
};
const formatExpiry = (expiresAt?: string) => {
if (!expiresAt) return '';
const date = new Date(expiresAt);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
export const ApprovalCard: Component<ApprovalCardProps> = (props) => {
return (
<div class="rounded-md border border-amber-300 dark:border-amber-700 overflow-hidden shadow-xs">
{/* Header */}
<div class="px-3 py-2 text-xs font-medium flex items-center gap-2 bg-amber-50 dark:bg-amber-900/25 text-amber-800 dark:text-amber-200 border-b border-amber-200 dark:border-amber-800">
<div class="p-1 rounded-sm bg-amber-100 dark:bg-amber-800">
<AlertTriangleIcon class="w-3.5 h-3.5" />
</div>
<span class="font-semibold">Approval Required</span>
<span class="px-1.5 py-0.5 bg-amber-200 dark:bg-amber-800 rounded-sm text-[10px] font-bold uppercase tracking-wider">
{riskLabel(props.approval.risk)}
</span>
<Show when={props.approval.runOnHost}>
<span class="px-1.5 py-0.5 bg-amber-200 dark:bg-amber-800 rounded-sm text-[10px] font-bold uppercase tracking-wider">
Agent
</span>
</Show>
<Show when={props.approval.targetHost}>
<span class="text-[10px] text-amber-600 dark:text-amber-400">
→ {props.approval.targetHost}
</span>
</Show>
</div>
{/* Command */}
<div class="px-3 py-3 bg-amber-50 dark:bg-amber-900/25">
<Show when={props.approval.description}>
<p class="mb-3 text-xs leading-relaxed text-amber-900 dark:text-amber-100">
{props.approval.description}
</p>
</Show>
<div class="mb-3 grid grid-cols-1 sm:grid-cols-3 gap-2 text-[11px]">
<div>
<div class="uppercase font-semibold text-amber-700 dark:text-amber-300">Tool</div>
<div class="text-base-content break-all">{props.approval.toolName}</div>
</div>
<div>
<div class="uppercase font-semibold text-amber-700 dark:text-amber-300">Target</div>
<div class="text-base-content break-all">
{props.approval.targetHost || 'Pulse runtime'}
</div>
</div>
<div>
<div class="uppercase font-semibold text-amber-700 dark:text-amber-300">Execution</div>
<div class="text-base-content">
{props.approval.runOnHost ? 'Agent routed' : 'Pulse API'}
</div>
</div>
</div>
<div class="mb-3 p-2 bg-surface rounded-sm border border-amber-200 dark:border-amber-700">
<code class="text-xs font-mono text-base-content break-all">
{props.approval.command}
</code>
</div>
<Show
when={
props.approval.plan ||
props.approval.contextConfidence ||
props.approval.preflight ||
props.approval.auditId ||
props.approval.targetType ||
props.approval.targetId
}
>
<div class="mb-3 rounded-md border border-amber-200 dark:border-amber-700 bg-white/70 dark:bg-black/10">
<div class="px-2.5 py-2 flex items-center gap-2 border-b border-amber-200 dark:border-amber-700 text-[11px] font-semibold uppercase text-amber-700 dark:text-amber-300">
<ClipboardCheckIcon class="w-3.5 h-3.5" />
<span>Governed Plan</span>
</div>
<div class="p-2.5 space-y-2 text-xs">
<Show when={props.approval.plan?.summary}>
<p class="leading-relaxed text-base-content">{props.approval.plan?.summary}</p>
</Show>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-[11px]">
<div>
<div class="uppercase font-semibold text-amber-700 dark:text-amber-300">
Approval
</div>
<div class="text-base-content wrap-break-word">
{props.approval.plan?.approval_policy || 'admin approval'}
</div>
</div>
<div>
<div class="uppercase font-semibold text-amber-700 dark:text-amber-300">
Blast radius
</div>
<div class="text-base-content wrap-break-word">
{props.approval.plan?.blast_radius || 'target resource state'}
</div>
</div>
<div>
<div class="uppercase font-semibold text-amber-700 dark:text-amber-300">
Rollback
</div>
<div class="text-base-content">
{props.approval.plan?.rollback_available ? 'Available' : 'Not declared'}
</div>
</div>
<Show when={formatExpiry(props.approval.plan?.expires_at)}>
<div>
<div class="uppercase font-semibold text-amber-700 dark:text-amber-300">
Approval window
</div>
<div class="text-base-content">
Expires {formatExpiry(props.approval.plan?.expires_at)}
</div>
</div>
</Show>
</div>
<Show when={props.approval.preflight}>
<div class="pt-2 border-t border-amber-200 dark:border-amber-700">
<div class="flex items-center gap-2 mb-1">
<ShieldCheckIcon class="w-3.5 h-3.5 text-amber-700 dark:text-amber-300" />
<span class="text-[11px] font-semibold uppercase text-amber-700 dark:text-amber-300">
Preflight
</span>
<span class="px-1.5 py-0.5 rounded-sm bg-base-200 text-[10px] font-bold uppercase text-base-content">
{props.approval.preflight?.dry_run_available ? 'Dry run' : 'No dry run'}
</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-[11px]">
<Show when={props.approval.preflight?.target}>
<div>
<div class="uppercase font-semibold text-amber-700 dark:text-amber-300">
Target
</div>
<div class="text-base-content wrap-break-word">
{props.approval.preflight?.target}
</div>
</div>
</Show>
<Show when={props.approval.preflight?.intended_change}>
<div>
<div class="uppercase font-semibold text-amber-700 dark:text-amber-300">
Intended change
</div>
<div class="text-base-content wrap-break-word">
{props.approval.preflight?.intended_change}
</div>
</div>
</Show>
</div>
<Show when={props.approval.preflight?.current_state}>
<p class="mt-1.5 leading-relaxed text-base-content">
{props.approval.preflight?.current_state}
</p>
</Show>
<Show when={props.approval.preflight?.dry_run_summary}>
<p class="mt-1.5 leading-relaxed text-base-content">
{props.approval.preflight?.dry_run_summary}
</p>
</Show>
<Show when={(props.approval.preflight?.safety_checks || []).length > 0}>
<div class="mt-2">
<div class="text-[11px] font-semibold uppercase text-amber-700 dark:text-amber-300">
Safety checks
</div>
<ul class="mt-1 space-y-1 text-[11px]">
<For each={props.approval.preflight?.safety_checks || []}>
{(item) => <li class="wrap-break-word">{item}</li>}
</For>
</ul>
</div>
</Show>
<Show when={(props.approval.preflight?.verification_steps || []).length > 0}>
<div class="mt-2">
<div class="text-[11px] font-semibold uppercase text-amber-700 dark:text-amber-300">
Verification
</div>
<ul class="mt-1 space-y-1 text-[11px]">
<For each={props.approval.preflight?.verification_steps || []}>
{(item) => <li class="wrap-break-word">{item}</li>}
</For>
</ul>
</div>
</Show>
</div>
</Show>
<Show when={props.approval.contextConfidence}>
<div class="pt-2 border-t border-amber-200 dark:border-amber-700">
<div class="flex items-center gap-2 mb-1">
<ShieldCheckIcon class="w-3.5 h-3.5 text-amber-700 dark:text-amber-300" />
<span
class={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase ${confidenceClasses(
props.approval.contextConfidence?.level,
)}`}
>
{confidenceLabel(props.approval.contextConfidence?.level)}
</span>
</div>
<Show when={props.approval.contextConfidence?.summary}>
<p class="leading-relaxed text-base-content">
{props.approval.contextConfidence?.summary}
</p>
</Show>
<Show when={(props.approval.contextConfidence?.evidence || []).length > 0}>
<ul class="mt-1.5 space-y-1 text-[11px]">
<For each={props.approval.contextConfidence?.evidence || []}>
{(item) => <li class="wrap-break-word">{item}</li>}
</For>
</ul>
</Show>
</div>
</Show>
<Show when={props.approval.auditId || props.approval.plan?.plan_hash}>
<div class="pt-2 border-t border-amber-200 dark:border-amber-700 text-[11px] break-all">
<Show when={props.approval.auditId}>
<span>Audit {props.approval.auditId}</span>
</Show>
<Show when={props.approval.plan?.plan_hash}>
<span class="ml-2">Plan {formatPlanHash(props.approval.plan?.plan_hash)}</span>
</Show>
</div>
</Show>
</div>
</div>
</Show>
{/* Actions */}
<div class="flex gap-2">
<button
type="button"
onClick={props.onApprove}
disabled={props.approval.isExecuting}
class={`flex-1 px-3 py-2 text-xs font-semibold rounded-md transition-all ${
props.approval.isExecuting
? 'bg-green-400 text-white cursor-wait'
: 'bg-green-500 hover:bg-green-600 text-white shadow-xs hover:shadow-xs'
}`}
>
<Show
when={!props.approval.isExecuting}
fallback={
<span class="flex items-center justify-center gap-1.5">
<LoaderCircleIcon class="w-3.5 h-3.5 animate-spin" />
Running...
</span>
}
>
<span class="flex items-center justify-center gap-1.5">
<CheckIcon class="w-3.5 h-3.5" />
Approve & Run
</span>
</Show>
</button>
<button
type="button"
onClick={props.onSkip}
disabled={props.approval.isExecuting}
class="flex-1 px-3 py-2 text-xs font-semibold hover:bg-surface-hover text-base-content rounded-md transition-colors disabled:opacity-50"
>
<span class="flex items-center justify-center gap-1.5">
<XIcon class="w-3.5 h-3.5" />
Skip
</span>
</button>
</div>
</div>
</div>
);
};
@@ -1,222 +0,0 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import XIcon from 'lucide-solid/icons/x';
import {
type AssistantSlashCommandAvailability,
filterAssistantSlashCommands,
getAssistantSlashCommandTokens,
groupAssistantSlashCommands,
type AssistantSlashCommand,
} from './assistantSlashCommands';
import { AssistantSlashCommandIcon } from './SlashCommandAutocomplete';
import {
AI_CHAT_COMMAND_HELP_CLOSE_LABEL,
AI_CHAT_COMMAND_HELP_EMPTY_STATE,
AI_CHAT_COMMAND_HELP_SEARCH_LABEL,
AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER,
AI_CHAT_COMMAND_HELP_TITLE,
} from '@/utils/aiChatPresentation';
import { SearchField } from '@/components/shared/SearchField';
import { Dialog } from '@/components/shared/Dialog';
interface AssistantCommandHelpDialogProps {
availability?: AssistantSlashCommandAvailability;
onClose: () => void;
onRunCommand: (command: AssistantSlashCommand) => void;
}
export function AssistantCommandHelpDialog(props: AssistantCommandHelpDialogProps) {
const [commandSearchQuery, setCommandSearchQuery] = createSignal('');
const [selectedCommandIndex, setSelectedCommandIndex] = createSignal(0);
const commands = createMemo(() =>
filterAssistantSlashCommands(commandSearchQuery(), undefined, {
availability: props.availability,
includeDisabled: true,
}),
);
const groupedCommands = createMemo(() => groupAssistantSlashCommands(commands()));
const shouldGroupCommands = createMemo(() => !commandSearchQuery().trim());
const selectedCommand = () => commands()[selectedCommandIndex()];
const consumeDialogCloseKey = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
};
const moveSelectedCommand = (direction: -1 | 1) => {
const count = commands().length;
if (count === 0) {
setSelectedCommandIndex(0);
return;
}
setSelectedCommandIndex((index) => Math.max(0, Math.min(count - 1, index + direction)));
};
const handleKeyDown = (event: KeyboardEvent) => {
const controlOnly = event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
switch (event.key) {
case 'ArrowDown':
consumeDialogCloseKey(event);
moveSelectedCommand(1);
break;
case 'ArrowUp':
consumeDialogCloseKey(event);
moveSelectedCommand(-1);
break;
case 'Home':
consumeDialogCloseKey(event);
setSelectedCommandIndex(0);
break;
case 'End':
consumeDialogCloseKey(event);
setSelectedCommandIndex(Math.max(0, commands().length - 1));
break;
case 'Enter': {
consumeDialogCloseKey(event);
const command = selectedCommand();
if (!command) return;
props.onRunCommand(command);
break;
}
default:
if (controlOnly && event.key.toLowerCase() === 'u') {
consumeDialogCloseKey(event);
setCommandSearchQuery('');
}
break;
}
};
createEffect(() => {
document.addEventListener('keydown', handleKeyDown);
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
});
createEffect(() => {
commandSearchQuery();
setSelectedCommandIndex(0);
});
createEffect(() => {
const count = commands().length;
if (selectedCommandIndex() >= count) {
setSelectedCommandIndex(Math.max(0, count - 1));
}
});
return (
<Dialog
isOpen={true}
onClose={props.onClose}
layout="mobile-sheet"
panelClass="max-w-120 shadow-xl"
ariaLabel={AI_CHAT_COMMAND_HELP_TITLE}
>
<>
<div class="flex min-h-12 items-center justify-between gap-3 border-b border-border px-4 py-3">
<h3 class="text-sm font-semibold text-base-content">{AI_CHAT_COMMAND_HELP_TITLE}</h3>
<button
type="button"
onClick={props.onClose}
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content"
title={AI_CHAT_COMMAND_HELP_CLOSE_LABEL}
aria-label={AI_CHAT_COMMAND_HELP_CLOSE_LABEL}
>
<XIcon class="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div class="border-b border-border px-3 py-2">
<SearchField
autofocus
value={commandSearchQuery()}
onChange={setCommandSearchQuery}
title={AI_CHAT_COMMAND_HELP_SEARCH_LABEL}
placeholder={AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER}
onKeyDown={handleKeyDown}
/>
</div>
<div
class="max-h-100 overflow-y-auto p-2"
role="listbox"
aria-label={AI_CHAT_COMMAND_HELP_TITLE}
>
<Show
when={commands().length > 0}
fallback={
<div class="px-3 py-6 text-center text-xs text-muted">
{AI_CHAT_COMMAND_HELP_EMPTY_STATE}
</div>
}
>
<For each={groupedCommands()}>
{(group) => (
<>
<Show when={shouldGroupCommands()}>
<div
role="presentation"
class="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase text-muted"
>
{group.category}
</div>
</Show>
<For each={group.items}>
{(item) => {
const command = item.command;
const aliases = () => getAssistantSlashCommandTokens(command).slice(1);
return (
<button
type="button"
role="option"
aria-selected={item.index === selectedCommandIndex()}
aria-disabled={command.disabled ? 'true' : undefined}
class={`flex w-full items-start gap-3 rounded-md px-3 py-2.5 text-left transition-colors focus:outline-hidden ${
command.disabled
? 'cursor-help opacity-55'
: 'hover:bg-surface-hover focus:bg-surface-hover'
} ${item.index === selectedCommandIndex() ? 'bg-surface-hover' : ''}`}
onClick={() => {
props.onRunCommand(command);
}}
onMouseEnter={() => setSelectedCommandIndex(item.index)}
>
<span class="mt-0.5 text-muted">
<AssistantSlashCommandIcon action={command.action} />
</span>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 flex-wrap items-center gap-2">
<span class="font-mono text-xs font-semibold text-base-content">
/{command.name}
</span>
<Show when={aliases().length > 0}>
<span class="min-w-0 truncate text-[10px] text-muted">
{aliases()
.map((alias) => `/${alias}`)
.join(', ')}
</span>
</Show>
</span>
<span class="mt-0.5 block text-xs leading-5 text-muted">
{command.description}
</span>
<Show when={command.disabled && command.disabledReason}>
<span class="mt-0.5 block text-[11px] leading-4 text-muted">
{command.disabledReason}
</span>
</Show>
</span>
</button>
);
}}
</For>
</>
)}
</For>
</Show>
</div>
</>
</Dialog>
);
}
@@ -1,414 +0,0 @@
import { Component, Show, For, createEffect, createMemo, createSignal } from 'solid-js';
import ArrowDownIcon from 'lucide-solid/icons/arrow-down';
import { Button } from '@/components/shared/Button';
import { MessageItem } from './MessageItem';
import type { ChatSession } from '@/api/aiChat';
import type { QueuedFollowUp } from './hooks/useChat';
import type {
ChatMessage,
ModelRouteRecoveryOption,
PendingApproval,
PendingTool,
PendingQuestion,
StreamDisplayEvent,
} from './types';
import { humanizeToken } from '@/utils/textPresentation';
const STICKY_SCROLL_BOTTOM_THRESHOLD_PX = 200;
interface ChatMessagesProps {
messages: ChatMessage[];
onApprove: (messageId: string, approval: PendingApproval) => void;
onSkip: (messageId: string, toolId: string) => void;
onAnswerQuestion: (
messageId: string,
question: PendingQuestion,
answers: Array<{ id: string; value: string }>,
) => void;
onSkipQuestion: (messageId: string, questionId: string) => void;
onRetry?: (messageId: string) => void;
onRegenerate?: (messageId: string) => void;
onEditPrompt?: (messageId: string) => void;
onChangeModel?: () => void;
getModelRouteLabel?: (modelId: string) => string;
getModelRouteAlternative?: (message: ChatMessage) => ModelRouteRecoveryOption | null;
onUseModelRoute?: (modelId: string, messageId?: string) => void;
queuedFollowUps?: QueuedFollowUp[];
queuedFollowUpsPaused?: boolean;
onEditQueuedFollowUp?: (id: string) => void;
onCancelQueuedFollowUp?: (id: string) => void;
// Dashboard props
recentSessions?: ChatSession[];
onLoadSession?: (sessionId: string) => void;
// Empty-state affordance: sends the example prompt as a chat turn.
onSuggestedPrompt?: (prompt: string) => void;
}
// Plain-language example prompts for the empty transcript. These teach a
// first-run user what the Assistant is for; each sends as a real turn.
export const ASSISTANT_SUGGESTED_PROMPTS = [
'How is my infrastructure looking right now?',
'Are there any alerts I should look at?',
"What's using the most CPU?",
] as const;
/**
* ChatMessages - Renders the scrollable message list.
*
* Features:
* - Auto-scroll to bottom on new messages
* - Recent-session resume actions
* - Smooth scrolling behavior
*/
export const ChatMessages: Component<ChatMessagesProps> = (props) => {
let messagesEndRef: HTMLDivElement | undefined;
let containerRef: HTMLDivElement | undefined;
const [isPinnedToBottom, setIsPinnedToBottom] = createSignal(true);
// Key only the message rows. Deep store reconciliation mutates objects shared
// by toolCalls and streamEvents when a status row is removed, corrupting the
// source transcript. Read each immutable message through its stable ID instead.
const messagesById = createMemo(
() => new Map(props.messages.map((message) => [message.id, message])),
);
const messageIds = createMemo(() => [...messagesById().keys()]);
// Regenerate re-runs the LAST turn via session undo, so only the final
// assistant answer qualifies, and only once it has settled with nothing
// pending and a user prompt to re-send.
const regenerableMessageId = createMemo(() => {
if (!props.onRegenerate) return null;
const msgs = props.messages;
if (msgs.length === 0) return null;
const last = msgs[msgs.length - 1];
if (last.role !== 'assistant' || last.isStreaming || last.error) return null;
if (last.pendingApprovals?.length || last.pendingQuestions?.length) return null;
const hasPrompt = msgs.some((m) => m.role === 'user' && m.content.trim());
return hasPrompt ? last.id : null;
});
// Edit-and-resend rides the same last-turn undo boundary: the only editable
// prompt is the user message that undo would remove, i.e. the latest
// non-queued user prompt.
const editablePromptMessageId = createMemo(() => {
if (!props.onEditPrompt) return null;
const msgs = props.messages;
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i];
if (msg.role !== 'user' || msg.delivery === 'queued') continue;
return msg.content.trim() ? msg.id : null;
}
return null;
});
const isContainerNearBottom = () => {
if (!containerRef) return true;
const { scrollTop, scrollHeight, clientHeight } = containerRef;
return scrollHeight - scrollTop - clientHeight < STICKY_SCROLL_BOTTOM_THRESHOLD_PX;
};
const updatePinnedToBottom = () => {
setIsPinnedToBottom(isContainerNearBottom());
};
const jumpToLatest = () => {
setIsPinnedToBottom(true);
containerRef?.scrollTo({ top: containerRef.scrollHeight, behavior: 'smooth' });
};
const textActivityFingerprint = (value?: string) =>
value ? `${value.length}:${value.slice(-32)}` : '0:';
const pendingToolActivityFingerprint = (tool?: PendingTool) =>
[
tool?.id,
tool?.name,
tool?.status,
tool?.progress,
textActivityFingerprint(tool?.input),
textActivityFingerprint(tool?.rawInput),
tool?.startedAt,
tool?.updatedAt,
]
.map((value) => String(value ?? ''))
.join(':');
const streamEventActivityFingerprint = (event: StreamDisplayEvent) => {
const base = [event.type, event.toolId, event.startedAt, event.updatedAt];
switch (event.type) {
case 'pending_tool':
return [...base, pendingToolActivityFingerprint(event.pendingTool)].join(':');
case 'tool':
return [
...base,
event.tool?.name,
event.tool?.success,
textActivityFingerprint(event.tool?.input),
textActivityFingerprint(event.tool?.rawInput),
textActivityFingerprint(event.tool?.output),
].join(':');
case 'tool_cancel':
return [
...base,
event.toolCancel?.id,
event.toolCancel?.name,
textActivityFingerprint(event.toolCancel?.input),
textActivityFingerprint(event.toolCancel?.rawInput),
textActivityFingerprint(event.toolCancel?.reason),
].join(':');
case 'content':
return [...base, textActivityFingerprint(event.content)].join(':');
case 'thinking':
return [...base, textActivityFingerprint(event.thinking)].join(':');
case 'model_switch':
return [...base, event.model, event.failedModel, event.modelEvent].join(':');
case 'approval':
return [
...base,
event.approval?.toolId,
event.approval?.toolName,
event.approval?.isExecuting,
event.approval?.approvalId,
].join(':');
case 'question':
return [
...base,
event.question?.questionId,
event.question?.isAnswering,
event.question?.questions.length,
].join(':');
default:
return base.join(':');
}
};
const messageActivityFingerprint = (message: ChatMessage) =>
[
message.id,
message.role,
message.isStreaming ? 'streaming' : 'idle',
message.interruption,
textActivityFingerprint(message.content),
textActivityFingerprint(message.error),
message.workflowStatus?.phase,
message.workflowStatus?.message,
message.workflowStatus?.tool,
message.workflowStatus?.startedAt,
...(message.pendingTools || []).map(pendingToolActivityFingerprint),
...(message.streamEvents || []).map(streamEventActivityFingerprint),
]
.map((value) => String(value ?? ''))
.join('|');
// Track content changes for auto-scroll (not just array length)
// This tracks in-place streamed activity as well as message/event count so
// patched tool progress remains visible while the transcript row stays put.
const scrollTrigger = createMemo(() => {
const msgs = props.messages;
if (msgs.length === 0) return 0;
const lastMsg = msgs[msgs.length - 1];
return `${msgs.length}:${messageActivityFingerprint(lastMsg)}`;
});
const recentSessions = createMemo(() =>
(props.recentSessions || []).filter((session) => session.message_count > 0).slice(0, 3),
);
const queuedFollowUpMetaByMessageId = createMemo(() => {
const entries = props.queuedFollowUps || [];
return new Map(
entries.map((entry, index) => [
entry.messageId,
{
id: entry.id,
position: index + 1,
count: entries.length,
paused: Boolean(props.queuedFollowUpsPaused),
steering: Boolean(entry.steering),
},
]),
);
});
const formatSessionMessageCount = (count: number) =>
`${count} ${count === 1 ? 'message' : 'messages'}`;
const formatSessionHandoffLabel = (session: ChatSession) => {
const summary = session.handoff_summary;
if (!summary) return '';
const kind = summary.kind?.trim();
if (kind) return humanizeToken(kind);
if (summary.finding_id) return 'Patrol finding';
if (summary.run_id) return 'Patrol run';
return summary.has_model_context ? 'Context attached' : '';
};
// Auto-scroll to bottom on new messages or streaming content
createEffect(() => {
// Access the trigger to establish dependency (void suppresses unused var warning)
void scrollTrigger();
if (props.messages.length > 0 && messagesEndRef && containerRef) {
if (isPinnedToBottom() || isContainerNearBottom()) {
// Use instant scroll during active streaming for smoother experience
const lastMsg = props.messages[props.messages.length - 1];
const behavior = lastMsg.isStreaming ? 'instant' : 'smooth';
// Scroll only the transcript. scrollIntoView also moves outer page
// ancestors and can push the docked Assistant outside the viewport.
containerRef.scrollTo({
top: containerRef.scrollHeight,
behavior: behavior as ScrollBehavior,
});
}
}
});
return (
<div class="relative flex-1 min-h-0 bg-surface">
<div
ref={containerRef}
class="h-full overflow-y-auto overscroll-contain px-4 py-3 bg-surface"
data-testid="assistant-message-list"
onScroll={updatePinnedToBottom}
>
<Show when={props.messages.length === 0 && props.onSuggestedPrompt}>
<section
class="mb-4 w-full"
aria-label="Assistant welcome"
data-testid="assistant-welcome"
>
<p class="text-sm text-base-content">
Ask about your infrastructure. The Assistant sees your live inventory, metrics, and
alerts, and can dig into problems with you.
</p>
<div class="mt-2 text-[11px] font-semibold uppercase text-muted">Try one</div>
<div class="mt-1.5 space-y-1.5">
<For each={ASSISTANT_SUGGESTED_PROMPTS}>
{(prompt) => (
<button
type="button"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-left text-sm text-base-content transition-colors hover:border-blue-300 hover:bg-surface-alt focus:outline-hidden focus:ring-2 focus:ring-blue-500/30"
onClick={() => props.onSuggestedPrompt?.(prompt)}
data-testid="assistant-suggested-prompt"
>
{prompt}
</button>
)}
</For>
</div>
</section>
</Show>
<Show
when={props.messages.length === 0 && recentSessions().length > 0 && props.onLoadSession}
>
<section class="mb-3 w-full" aria-label="Recent Assistant sessions">
<div class="mb-2 text-[11px] font-semibold uppercase text-muted">Recent sessions</div>
<div class="space-y-1.5">
<For each={recentSessions()}>
{(session) => {
const handoffLabel = () => formatSessionHandoffLabel(session);
return (
<button
type="button"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-left transition-colors hover:border-blue-300 hover:bg-surface-alt focus:outline-hidden focus:ring-2 focus:ring-blue-500/30"
onClick={() => props.onLoadSession?.(session.id)}
aria-label={`Resume ${session.title || 'Untitled Assistant session'}`}
>
<div class="truncate text-sm font-medium text-base-content">
{session.title || 'Untitled'}
</div>
<div class="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5 text-[11px] text-muted">
<span>{formatSessionMessageCount(session.message_count)}</span>
<Show when={handoffLabel()}>
{(label) => (
<>
<span aria-hidden="true">/</span>
<span class="truncate">{label()}</span>
</>
)}
</Show>
</div>
</button>
);
}}
</For>
</div>
</section>
</Show>
{/* Messages */}
<For each={messageIds()}>
{(messageId) => {
const queuedMeta = createMemo(() => queuedFollowUpMetaByMessageId().get(messageId));
return (
<Show when={messagesById().get(messageId)}>
{(message) => (
<MessageItem
message={message()}
onApprove={(approval) => props.onApprove(messageId, approval)}
onSkip={(toolId) => props.onSkip(messageId, toolId)}
onAnswerQuestion={(question, answers) =>
props.onAnswerQuestion(messageId, question, answers)
}
onSkipQuestion={(questionId) => props.onSkipQuestion(messageId, questionId)}
onRetry={props.onRetry}
onRegenerate={
messageId === regenerableMessageId()
? () => props.onRegenerate?.(messageId)
: undefined
}
onEditPrompt={
messageId === editablePromptMessageId()
? () => props.onEditPrompt?.(messageId)
: undefined
}
onChangeModel={props.onChangeModel}
getModelRouteLabel={props.getModelRouteLabel}
modelRouteAlternative={props.getModelRouteAlternative?.(message())}
onUseModelRoute={props.onUseModelRoute}
queuedPosition={queuedMeta()?.position}
queuedCount={queuedMeta()?.count}
queuedPaused={queuedMeta()?.paused}
queuedSteering={queuedMeta()?.steering}
onEditQueued={
queuedMeta() && !queuedMeta()?.steering && props.onEditQueuedFollowUp
? () => {
const meta = queuedMeta();
if (meta) props.onEditQueuedFollowUp?.(meta.id);
}
: undefined
}
onCancelQueued={
queuedMeta() && !queuedMeta()?.steering && props.onCancelQueuedFollowUp
? () => {
const meta = queuedMeta();
if (meta) props.onCancelQueuedFollowUp?.(meta.id);
}
: undefined
}
/>
)}
</Show>
);
}}
</For>
{/* Scroll anchor */}
<div ref={messagesEndRef} class="h-1" />
</div>
<Show when={props.messages.length > 0 && !isPinnedToBottom()}>
<Button
type="button"
variant="secondary"
size="sm"
class="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 gap-1.5 px-3 shadow-lg hover:bg-surface-alt focus:ring-blue-500/40"
onClick={jumpToLatest}
aria-label="Jump to latest Assistant message"
>
<ArrowDownIcon class="h-3.5 w-3.5" aria-hidden="true" />
<span>Latest</span>
</Button>
</Show>
</div>
);
};
@@ -1,267 +0,0 @@
import { createSignal, createEffect, For, Show, onCleanup } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { getSimpleStatusIndicator } from '@/utils/status';
export interface MentionResource {
id: string;
label: string;
type: 'vm' | 'system-container' | 'app-container' | 'agent' | 'storage';
status?: string;
node?: string;
scope?: string;
}
interface MentionAutocompleteProps {
query: string;
resources: MentionResource[];
position: { top: number; left: number };
onSelect: (resource: MentionResource) => void;
onClose: () => void;
visible: boolean;
onActiveDescendantChange?: (id: string | undefined) => void;
}
export const ASSISTANT_MENTION_LISTBOX_ID = 'assistant-mention-listbox';
export const getAssistantMentionOptionId = (index: number) => `assistant-mention-option-${index}`;
export function MentionAutocomplete(props: MentionAutocompleteProps) {
const [selectedIndex, setSelectedIndex] = createSignal(0);
// Filter resources based on query
const filteredResources = () => {
const q = props.query.toLowerCase();
if (!q) return props.resources.slice(0, 10); // Show first 10 if no query
return props.resources
.filter((r) => [r.label, r.node, r.scope].some((value) => value?.toLowerCase().includes(q)))
.slice(0, 10); // Limit to 10 results
};
// Reset selection when query changes
createEffect(() => {
props.query; // Track query
setSelectedIndex(0);
});
createEffect(() => {
const total = filteredResources().length;
setSelectedIndex((index) => (total > 0 ? Math.min(index, total - 1) : 0));
});
createEffect(() => {
const resources = filteredResources();
const activeId =
props.visible && resources.length > 0
? getAssistantMentionOptionId(selectedIndex())
: undefined;
props.onActiveDescendantChange?.(activeId);
if (activeId) {
queueMicrotask(() => {
document.getElementById(activeId)?.scrollIntoView?.({ block: 'nearest' });
});
}
});
onCleanup(() => props.onActiveDescendantChange?.(undefined));
const consumeMentionKey = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
};
const moveSelection = (direction: -1 | 1, total: number) => {
if (total <= 0) return;
setSelectedIndex((index) => {
const next = index + direction;
if (next < 0) return total - 1;
if (next >= total) return 0;
return next;
});
};
// Handle keyboard navigation
const handleKeyDown = (e: KeyboardEvent) => {
if (!props.visible) return;
const resources = filteredResources();
switch (e.key) {
case 'ArrowDown':
consumeMentionKey(e);
moveSelection(1, resources.length);
break;
case 'ArrowUp':
consumeMentionKey(e);
moveSelection(-1, resources.length);
break;
case 'Enter':
case 'Tab':
consumeMentionKey(e);
if (resources[selectedIndex()]) {
props.onSelect(resources[selectedIndex()]);
}
break;
case 'Escape':
consumeMentionKey(e);
props.onClose();
break;
}
};
// Register keyboard listener when visible
createEffect(() => {
if (props.visible) {
document.addEventListener('keydown', handleKeyDown);
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
}
});
// Get icon for resource type
const getTypeIcon = (type: string) => {
switch (type) {
case 'vm':
return (
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
);
case 'system-container':
return (
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
);
case 'app-container':
return (
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.119a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288z" />
</svg>
);
case 'agent':
return (
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
);
case 'storage':
return (
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6c0-1.657 3.582-3 8-3s8 1.343 8 3-3.582 3-8 3-8-1.343-8-3zm0 0v6c0 1.657 3.582 3 8 3s8-1.343 8-3V6m-16 6v6c0 1.657 3.582 3 8 3s8-1.343 8-3v-6"
/>
</svg>
);
default:
return (
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4"
/>
</svg>
);
}
};
return (
<Show when={props.visible && filteredResources().length > 0}>
<div
class="absolute z-50 w-[calc(100vw-36px)] min-w-[280px] max-w-[400px] overflow-hidden rounded-md border border-border bg-surface shadow-xs"
style={{
bottom: `${props.position.top}px`,
left: `${props.position.left}px`,
}}
data-mention-autocomplete
onClick={(event) => event.stopPropagation()}
>
<div class="px-3 py-2 border-b border-border text-xs font-medium text-muted">Resources</div>
<div
id={ASSISTANT_MENTION_LISTBOX_ID}
class="max-h-[240px] overflow-y-auto"
role="listbox"
aria-label="Assistant resources"
>
<For each={filteredResources()}>
{(resource, index) => (
<button
type="button"
id={getAssistantMentionOptionId(index())}
role="option"
tabIndex={-1}
aria-selected={index() === selectedIndex()}
aria-label={`Mention ${resource.label}: ${resource.type}${
resource.node && resource.node !== resource.scope ? ` on ${resource.node}` : ''
}${resource.scope ? ` in ${resource.scope}` : ''}${resource.status ? `, ${resource.status}` : ''}`}
class={`w-full px-3 py-2 flex items-center gap-3 text-left hover:bg-surface-hover transition-colors ${
index() === selectedIndex() ? 'bg-surface-hover' : ''
}`}
onPointerDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation();
props.onSelect(resource);
}}
onMouseEnter={() => setSelectedIndex(index())}
>
<span class="text-muted" aria-hidden="true">
{getTypeIcon(resource.type)}
</span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-base-content truncate">{resource.label}</span>
<Show when={resource.status}>
<StatusDot
variant={getSimpleStatusIndicator(resource.status).variant}
size="sm"
ariaHidden
/>
</Show>
</div>
<div class="text-xs text-muted break-words">
{resource.type}
<Show when={resource.scope}>
{' · '}
{resource.scope}
</Show>
<Show when={resource.node && resource.node !== resource.scope}>
{' · '}
{resource.node}
</Show>
</div>
</div>
</button>
)}
</For>
</div>
<div class="px-3 py-1.5 border-t border-border text-xs text-muted flex items-center gap-2">
<span class="px-1.5 py-0.5 bg-surface-hover rounded-sm text-[10px]">↑↓</span>
navigate
<span class="px-1.5 py-0.5 bg-surface-hover rounded-sm text-[10px]">↵</span>
select
<span class="px-1.5 py-0.5 bg-surface-hover rounded-sm text-[10px]">esc</span>
close
</div>
</div>
</Show>
);
}
@@ -1,970 +0,0 @@
import {
Component,
Show,
Index,
Switch,
Match,
createEffect,
createMemo,
createSignal,
onCleanup,
} from 'solid-js';
import CircleAlertIcon from 'lucide-solid/icons/circle-alert';
import ClockIcon from 'lucide-solid/icons/clock';
import CpuIcon from 'lucide-solid/icons/cpu';
import PencilIcon from 'lucide-solid/icons/pencil';
import RotateCcwIcon from 'lucide-solid/icons/rotate-ccw';
import SparklesIcon from 'lucide-solid/icons/sparkles';
import XIcon from 'lucide-solid/icons/x';
import { renderMarkdown } from '../aiChatUtils';
import { highlightSettledCodeBlocks } from './aiCodeHighlight';
import { morphMarkdownInto } from './markdownMorph';
import { PendingToolBlock, ToolCancellationBlock, ToolExecutionBlock } from './ToolExecutionBlock';
import { ApprovalCard } from './ApprovalCard';
import { QuestionCard } from './QuestionCard';
import { ThinkingBlock } from './ThinkingBlock';
import { getAssistantAnswerText } from './assistantAnswerText';
import { stripAssistantOutputArtifacts } from './assistantOutputHygiene';
import { formatAssistantTurnDuration } from './assistantTurnSummary';
import { assistantWorkflowStatusKind, formatAssistantWorkflowStatus } from './activeTurnStatus';
import { groupStreamEventsForDisplay } from './streamEventGrouping';
import { createPacedWorkflowStatus, WORKFLOW_STATUS_REFRESH_MS } from './workflowStatusDisplay';
import type {
ChatMessage,
ModelRouteRecoveryOption,
PendingApproval,
PendingQuestion,
StreamDisplayEvent,
WorkflowStatus,
} from './types';
import { AI_CHAT_ASSISTANT_MESSAGE_LABEL } from '@/utils/aiChatPresentation';
import { formatAIModelRouteLabel } from '@/utils/aiProviderPresentation';
import { ActionIconButton, CopyValueButton } from '@/components/shared/Button';
import { copyToClipboard } from '@/utils/clipboard';
interface MessageItemProps {
message: ChatMessage;
onApprove: (approval: PendingApproval) => void;
onSkip: (toolId: string) => void;
onAnswerQuestion: (
question: PendingQuestion,
answers: Array<{ id: string; value: string }>,
) => void;
onSkipQuestion: (questionId: string) => void;
onRetry?: (messageId: string) => void;
// Provided only for the latest settled assistant answer: re-runs the turn in
// place (same prompt, fresh generation).
onRegenerate?: () => void;
// Provided only for the user prompt of the latest turn: undoes the turn and
// restores the prompt into the composer for editing.
onEditPrompt?: () => void;
onChangeModel?: () => void;
getModelRouteLabel?: (modelId: string) => string;
modelRouteAlternative?: ModelRouteRecoveryOption | null;
onUseModelRoute?: (modelId: string, messageId?: string) => void;
queuedPosition?: number;
queuedCount?: number;
queuedPaused?: boolean;
// The follow-up was accepted for mid-turn steering and will join the
// running response at its next step; edit/remove are no longer offered.
queuedSteering?: boolean;
onEditQueued?: () => void;
onCancelQueued?: () => void;
}
const markdownClass =
'text-sm prose prose-slate prose-sm dark:prose-invert max-w-none prose-p:leading-relaxed prose-p:my-2 prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:rounded-md prose-pre:text-xs prose-pre:border prose-pre:border-slate-800 prose-code:wrap-anywhere prose-code:text-blue-700 dark:prose-code:text-blue-300 prose-code:bg-blue-50 dark:prose-code:bg-blue-900/25 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:font-mono prose-code:text-[0.9em] prose-code:border prose-code:border-blue-100 dark:prose-code:border-blue-800 prose-code:before:content-none prose-code:after:content-none prose-headings:font-semibold prose-hr:border-slate-200 dark:prose-hr:border-slate-700 prose-ul:my-2 prose-ol:my-2 prose-li:my-1';
const TEXT_RENDER_PACE_MS = 24;
const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/;
const textRenderStep = (size: number) => {
if (size <= 12) return 2;
if (size <= 48) return 4;
if (size <= 96) return 8;
return Math.min(24, Math.ceil(size / 8));
};
const nextPacedTextIndex = (text: string, start: number) => {
const end = Math.min(text.length, start + textRenderStep(text.length - start));
const max = Math.min(text.length, end + 8);
for (let idx = end; idx < max; idx += 1) {
if (TEXT_RENDER_SNAP.test(text[idx] || '')) return idx + 1;
}
return end;
};
const pacedTextCache = new Map<string, string>();
const pacedTextCleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
const cancelPacedTextCleanup = (key: string) => {
const timer = pacedTextCleanupTimers.get(key);
if (!timer) return;
clearTimeout(timer);
pacedTextCleanupTimers.delete(key);
};
const deletePacedTextCache = (key: string) => {
cancelPacedTextCleanup(key);
pacedTextCache.delete(key);
};
const schedulePacedTextCleanup = (key: string) => {
cancelPacedTextCleanup(key);
pacedTextCleanupTimers.set(
key,
setTimeout(() => {
pacedTextCleanupTimers.delete(key);
pacedTextCache.delete(key);
}, 1000),
);
};
const createPacedText = (getText: () => string, live: () => boolean, cacheKey: () => string) => {
const initialText = getText();
const initialKey = cacheKey();
if (initialKey) {
cancelPacedTextCleanup(initialKey);
}
const cachedText = initialKey ? pacedTextCache.get(initialKey) : undefined;
const initialLiveText = () => {
if (!initialText) return initialText;
if (initialText.length <= 48) return initialText;
const end = nextPacedTextIndex(initialText, 0);
return end < initialText.length ? initialText.slice(0, end) : initialText;
};
const initialValue =
live() &&
cachedText &&
initialText.startsWith(cachedText) &&
cachedText.length <= initialText.length
? cachedText
: live()
? initialLiveText()
: initialText;
const [value, setValue] = createSignal(initialValue);
let shown = initialValue;
let timeout: ReturnType<typeof setTimeout> | undefined;
if (initialKey) {
pacedTextCache.set(initialKey, initialValue);
}
const clear = () => {
if (!timeout) return;
clearTimeout(timeout);
timeout = undefined;
};
const sync = (text: string) => {
shown = text;
setValue(text);
const key = cacheKey();
if (!key) return;
if (live()) {
cancelPacedTextCleanup(key);
pacedTextCache.set(key, text);
} else {
deletePacedTextCache(key);
}
};
const run = () => {
timeout = undefined;
const text = getText();
if (!live()) {
sync(text);
return;
}
if (!text.startsWith(shown) || text.length <= shown.length) {
sync(text);
return;
}
const end = nextPacedTextIndex(text, shown.length);
sync(text.slice(0, end));
if (end < text.length) timeout = setTimeout(run, TEXT_RENDER_PACE_MS);
};
createEffect(() => {
const text = getText();
if (!live()) {
clear();
sync(text);
return;
}
if (!text.startsWith(shown) || text.length < shown.length) {
clear();
sync(text);
return;
}
if (text.length === shown.length || timeout) return;
timeout = setTimeout(run, TEXT_RENDER_PACE_MS);
});
onCleanup(() => {
clear();
const key = cacheKey();
if (key) schedulePacedTextCleanup(key);
});
return value;
};
const AssistantMarkdownBlock: Component<{
text: string;
streaming?: boolean;
paceKey: string;
}> = (props) => {
const visibleText = createPacedText(
() => props.text,
() => props.streaming === true,
() => props.paceKey,
);
let container: HTMLDivElement | undefined;
// Morph the rendered markdown into the container instead of replacing
// innerHTML each tick. renderMarkdown already sanitizes (DOMPurify); the morph
// keeps the stable prefix of blocks so a streaming answer doesn't rebuild and
// reflow every earlier paragraph on every delta. See markdownMorph.ts.
createEffect(() => {
const html = renderMarkdown(visibleText());
if (container) morphMarkdownInto(container, html);
// Highlight only once the turn settles: re-highlighting per streaming
// delta would fight the morph (it diffs against plain markup) and churn
// the DOM. The highlighter runs over the sanitized DOM, so its spans
// never pass through DOMPurify.
if (container && props.streaming !== true) {
void highlightSettledCodeBlocks(container);
}
});
return (
<div ref={container} class={markdownClass} aria-live={props.streaming ? 'polite' : undefined} />
);
};
/**
* MessageItem - Renders a single message in the chat.
*
* User messages: Compact, right-aligned bubble
* Assistant messages: Full-width transcript rows with clear sections
*/
export const MessageItem: Component<MessageItemProps> = (props) => {
const isUser = () => props.message.role === 'user';
const isQueuedUserMessage = () => isUser() && props.message.delivery === 'queued';
const queuedStatusLabel = createMemo(() => {
if (!isQueuedUserMessage()) return '';
if (props.queuedSteering) return 'Steering the running response';
const position = props.queuedPosition;
const count = props.queuedCount;
const state = props.queuedPaused ? 'Paused' : 'Queued';
if (position && count && count > 1) {
return `${state} ${position} of ${count}`;
}
return state;
});
// Group stream events into display blocks. Content collapses into a single
// block even when a reasoning model interleaves hidden thinking deltas, so
// the answer stays a coherent markdown document instead of fragmenting into
// whitespace-trimmed pieces. See groupStreamEventsForDisplay for the rationale.
const groupedEvents = createMemo(() =>
groupStreamEventsForDisplay(props.message.streamEvents || []),
);
const isSelectedModelRouteEvent = (evt: StreamDisplayEvent) =>
evt.type === 'model_switch' && evt.modelEvent === 'selected' && !evt.failedModel?.trim();
// A plain selected-route row repeats the route badge already shown in the
// turn header. Keep it only when it adds information: the header has no
// route, the route reads differently from the turn's final model, or a
// later switch makes the starting route part of the turn's story. Compare
// display labels, not raw ids — the done event can rewrite message.model to
// a provider-reported id that differs in raw form while reading identically
// to the user.
const shouldRenderModelRouteEvent = (evt: StreamDisplayEvent) => {
if (evt.type !== 'model_switch' || !evt.model?.trim()) return false;
if (!isSelectedModelRouteEvent(evt)) return true;
const hasRouteSwitch = (props.message.streamEvents || []).some(
(event) =>
event.type === 'model_switch' && !!event.model?.trim() && !isSelectedModelRouteEvent(event),
);
return (
hasRouteSwitch || !messageModelLabel() || modelRouteLabel(evt.model) !== messageModelLabel()
);
};
const isConcreteStreamActivity = (evt: StreamDisplayEvent) => {
switch (evt.type) {
case 'workflow_status':
return !!formatAssistantWorkflowStatus(evt.workflowStatus);
case 'thinking':
return !!evt.thinking?.trim();
case 'content':
return !!stripAssistantOutputArtifacts(evt.content || '').text;
case 'tool':
return !!evt.tool;
case 'pending_tool':
return !!evt.pendingTool;
case 'tool_cancel':
return !!evt.toolCancel;
case 'model_switch':
return !!evt.model?.trim() && !isSelectedModelRouteEvent(evt);
case 'approval':
return !!evt.approval;
case 'question':
return !!evt.question;
default:
return false;
}
};
// Live workflow status ("Preparing context", "Reading inventory", "Counting
// results", retrying, ...) is transient working-state, not a transcript
// artifact. OpenCode keeps that in a single pinned footer spinner and never
// narrates it into the scrolling timeline; Pulse surfaces the same live status
// in the activity dock above the composer (currentStatus in index.tsx). So the
// transcript itself shows only durable artifacts — reasoning, tool calls, and
// the answer — and never renders workflow_status rows. Keep the predicate so
// the gating/grouping code paths stay intact, but never render the row.
const shouldRenderWorkflowStatusEvent = (_evt: StreamDisplayEvent) => false;
const isRenderableStreamEvent = (evt: StreamDisplayEvent) => {
switch (evt.type) {
case 'thinking':
return !!evt.thinking?.trim();
case 'workflow_status':
return shouldRenderWorkflowStatusEvent(evt);
case 'content':
return !!stripAssistantOutputArtifacts(evt.content || '').text;
case 'tool':
return !!evt.tool;
case 'pending_tool':
return !!evt.pendingTool;
case 'tool_cancel':
return !!evt.toolCancel;
case 'model_switch':
return shouldRenderModelRouteEvent(evt);
case 'approval':
return !!evt.approval;
case 'question':
return !!evt.question;
default:
return false;
}
};
const hasRenderableStreamEvents = () => groupedEvents().some(isRenderableStreamEvent);
const hasLaterConcreteLiveActivity = (eventIndex: number) =>
props.message.isStreaming === true &&
groupedEvents()
.slice(eventIndex + 1)
.some((evt) => evt.type !== 'model_switch' && isConcreteStreamActivity(evt));
const isDurableWorkflowBoundary = (evt: StreamDisplayEvent) => {
switch (evt.type) {
case 'content':
return !!stripAssistantOutputArtifacts(evt.content || '').text;
case 'tool':
return !!evt.tool;
case 'pending_tool':
return !!evt.pendingTool;
case 'tool_cancel':
return !!evt.toolCancel;
case 'approval':
return !!evt.approval;
case 'question':
return !!evt.question;
default:
return false;
}
};
const hasDurableWorkflowBoundaryAround = (eventIndex: number) =>
groupedEvents().some((evt, index) => index !== eventIndex && isDurableWorkflowBoundary(evt));
const liveWorkflowStatusHistory = createMemo(() =>
props.message.isStreaming === true ? props.message.workflowStatusHistory || [] : [],
);
const workflowStatusPaceSequenceKey = createMemo(() => {
const first = liveWorkflowStatusHistory()[0];
return [props.message.id, first?.phase, first?.message, first?.startedAt]
.map((value) => String(value ?? ''))
.join(':');
});
const pacedWorkflowStatus = createPacedWorkflowStatus(
liveWorkflowStatusHistory,
() => props.message.isStreaming === true && liveWorkflowStatusHistory().length > 1,
workflowStatusPaceSequenceKey,
);
const shouldPaceWorkflowStatusEvent = (evt: StreamDisplayEvent, eventIndex: number) =>
props.message.isStreaming === true &&
evt.type === 'workflow_status' &&
liveWorkflowStatusHistory().length > 1 &&
!hasDurableWorkflowBoundaryAround(eventIndex);
const shouldCompactCompletedToolEvent = (evt: StreamDisplayEvent, eventIndex: number) =>
evt.type === 'tool' &&
props.message.isStreaming === true &&
evt.tool?.success !== false &&
hasLaterConcreteLiveActivity(eventIndex);
const isLeadingThinkingEvent = (index: number) =>
groupedEvents()
.slice(0, index)
.every((evt) => evt.type === 'thinking');
const visibleMessageContent = () =>
stripAssistantOutputArtifacts(props.message.content || '').text;
const modelRouteLabel = (route?: string) => {
const model = route?.trim();
if (!model) return '';
return props.getModelRouteLabel?.(model) || formatAIModelRouteLabel(model);
};
const modelRouteRecoveryButtonLabel = () => {
const alternative = props.modelRouteAlternative;
if (!alternative) return '';
return alternative.kind === 'same-model-route'
? `Switch to ${alternative.providerLabel} route and retry`
: `Switch to ${alternative.providerLabel} model route and retry`;
};
const hasPreviousModelRoute = (event: StreamDisplayEvent) => {
const model = event.model?.trim();
const failed = event.failedModel?.trim();
return !!model && !!failed && failed !== model;
};
const isSelectedModelEvent = (event: StreamDisplayEvent) =>
event.modelEvent === 'selected' && !!event.model?.trim();
const modelSwitchTitle = (event: StreamDisplayEvent) => {
if (!hasPreviousModelRoute(event)) return modelRouteLabel(event.model);
return `${modelRouteLabel(event.failedModel)} -> ${modelRouteLabel(event.model)}`;
};
const messageModelLabel = () => modelRouteLabel(props.message.model);
const queuedModelRouteLabel = () =>
isQueuedUserMessage() ? modelRouteLabel(props.message.request?.model) : '';
const messageDurationLabel = () =>
props.message.isStreaming
? ''
: formatAssistantTurnDuration(props.message.timestamp, props.message.completedAt);
// Check if currently streaming content (no tools pending, still streaming)
const isStreamingText = () =>
props.message.isStreaming &&
(!props.message.pendingTools || props.message.pendingTools.length === 0);
const isWaitingForFirstToken = () =>
isStreamingText() &&
!props.message.content.trim() &&
!hasRenderableStreamEvents() &&
!props.message.error;
const currentWorkflowStatus = () => props.message.workflowStatus;
const [statusNow, setStatusNow] = createSignal(Date.now());
createEffect(() => {
const status = currentWorkflowStatus();
if (!props.message.isStreaming || !status?.startedAt) return;
setStatusNow(Date.now());
const interval = window.setInterval(() => setStatusNow(Date.now()), WORKFLOW_STATUS_REFRESH_MS);
onCleanup(() => window.clearInterval(interval));
});
const formatWorkflowStatus = (status?: WorkflowStatus, includeElapsed = false) => {
const message = formatAssistantWorkflowStatus(
status,
props.message.isStreaming ? statusNow() : undefined,
);
if (!message) return '';
let elapsedSuffix = '';
if (includeElapsed && status?.startedAt) {
const elapsedSeconds = Math.max(0, Math.floor((statusNow() - status.startedAt) / 1000));
if (elapsedSeconds >= 2) {
elapsedSuffix = ` (${elapsedSeconds}s)`;
}
}
return `${message}${elapsedSuffix}`;
};
const workflowStatusToneClass = (status?: WorkflowStatus) => {
const kind = assistantWorkflowStatusKind(status);
if (kind === 'retrying') {
return 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-200';
}
if (kind === 'tool') {
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200';
}
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200';
};
const workflowStatusDotClass = (status?: WorkflowStatus) => {
const kind = assistantWorkflowStatusKind(status);
if (kind === 'retrying') return 'bg-amber-500';
if (kind === 'tool') return 'bg-blue-500';
return 'bg-blue-500';
};
const workflowStatusText = createMemo(() => formatWorkflowStatus(currentWorkflowStatus(), true));
// Workflow status is footer-owned (the activity dock in index.tsx), never shown
// in the transcript — not as a per-event row (shouldRenderWorkflowStatusEvent)
// and not as this early-phase header chip. During the empty-content window the
// transcript stays blank while the dock spinner conveys the live status, which
// is the OpenCode model. Keep the predicate so its inputs stay referenced, but
// never render the chip.
const shouldShowHeaderWorkflowStatus = () =>
props.message.isStreaming &&
!isWaitingForFirstToken() &&
!visibleMessageContent().trim() &&
!hasRenderableStreamEvents() &&
!!workflowStatusText() &&
false;
const interruptionLabel = createMemo(() => {
switch (props.message.interruption) {
case 'replaced':
return 'Stopped when you sent the next message';
case 'stopped':
return 'Stopped';
default:
return '';
}
});
// Copy-to-clipboard for completed transcript messages.
const [copied, setCopied] = createSignal(false);
let copiedResetTimer: ReturnType<typeof setTimeout> | undefined;
const copyButtonLabel = () => (copied() ? 'Copied message' : 'Copy message');
const copyableMessageText = () => {
if (isUser()) {
const text = props.message.content || '';
return text.trim() ? text : '';
}
return getAssistantAnswerText(props.message);
};
const canCopy = () => !props.message.isStreaming && !!copyableMessageText();
const copyMessage = async (value: string) => {
const text = value.trim();
if (!text) return;
const ok = await copyToClipboard(text);
if (!ok) return;
setCopied(true);
if (copiedResetTimer) clearTimeout(copiedResetTimer);
copiedResetTimer = setTimeout(() => setCopied(false), 1500);
};
onCleanup(() => {
if (copiedResetTimer) clearTimeout(copiedResetTimer);
});
return (
<div class={`${isUser() ? 'flex justify-end' : ''} mb-4`}>
{/* User message - compact bubble */}
<Show when={isUser()}>
<div class="group flex max-w-[85%] items-start justify-end gap-2">
<Show when={props.onEditPrompt}>
<ActionIconButton
onClick={() => props.onEditPrompt?.()}
label="Edit and resend"
title="Edit and resend"
tone="outline"
size="sm"
class="mt-1 opacity-0 shadow-xs transition-opacity focus:opacity-100 group-hover:opacity-100"
>
<PencilIcon class="h-3.5 w-3.5" aria-hidden="true" />
</ActionIconButton>
</Show>
<Show when={canCopy()}>
<CopyValueButton
value={copyableMessageText()}
copied={copied()}
onCopyValue={copyMessage}
label={copyButtonLabel()}
size="md"
stopPropagation
class="mt-1 opacity-0 shadow-xs transition-opacity focus:opacity-100 group-hover:opacity-100"
/>
</Show>
<div
class={`min-w-0 px-4 py-2.5 rounded-md rounded-br-sm shadow-xs ${
isQueuedUserMessage()
? 'border border-blue-200 bg-blue-50 text-blue-950 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-100'
: 'bg-blue-600 text-white'
}`}
>
<p class="text-sm whitespace-pre-wrap">{props.message.content}</p>
<Show when={isQueuedUserMessage()}>
<div
class="mt-1.5 flex flex-wrap items-center justify-end gap-1.5 text-[11px] font-medium text-blue-700 dark:text-blue-300"
role="status"
>
<ClockIcon class="h-3 w-3" aria-hidden="true" />
<span>{queuedStatusLabel()}</span>
<Show when={queuedModelRouteLabel()}>
{(label) => (
<span
class="inline-flex max-w-48 items-center rounded-sm border border-blue-200 bg-white/80 px-1.5 py-0.5 text-[10px] font-semibold text-blue-800 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-200"
title={props.message.request?.model}
>
<span class="truncate">{label()}</span>
</span>
)}
</Show>
<Show when={props.onEditQueued}>
<ActionIconButton
onClick={() => props.onEditQueued?.()}
label="Edit queued follow-up"
tone="accentGhost"
size="2xs"
>
<PencilIcon class="h-3 w-3" aria-hidden="true" />
</ActionIconButton>
</Show>
<Show when={props.onCancelQueued}>
<ActionIconButton
onClick={() => props.onCancelQueued?.()}
label="Remove queued follow-up"
tone="accentGhost"
size="2xs"
>
<XIcon class="h-3 w-3" aria-hidden="true" />
</ActionIconButton>
</Show>
</div>
</Show>
</div>
</div>
</Show>
{/* Assistant message */}
<Show when={!isUser()}>
<div class="group flex w-full min-w-0 gap-3 px-1 py-2">
<div class="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border-subtle bg-surface-alt text-blue-600 shadow-xs dark:text-blue-400">
<SparklesIcon class="h-3.5 w-3.5" />
</div>
<div class="min-w-0 flex-1">
<div class="mb-2 flex min-h-7 min-w-0 items-center gap-2">
<span class="shrink-0 text-xs font-semibold text-base-content">
{AI_CHAT_ASSISTANT_MESSAGE_LABEL}
</span>
<Show when={messageModelLabel()}>
<span
class="max-w-48 truncate rounded-sm border border-border-subtle bg-surface-alt px-1.5 py-0.5 text-[10px] font-medium text-muted"
title={props.message.model}
>
{messageModelLabel()}
</span>
</Show>
<Show when={messageDurationLabel()}>
<span
class="inline-flex shrink-0 items-center gap-1 rounded-sm border border-border-subtle bg-surface-alt px-1.5 py-0.5 text-[10px] font-medium text-muted"
title="Turn duration"
aria-label={`Turn duration ${messageDurationLabel()}`}
>
<ClockIcon class="h-3 w-3" aria-hidden="true" />
{messageDurationLabel()}
</span>
</Show>
<Show when={shouldShowHeaderWorkflowStatus()}>
<span
class={`inline-flex min-w-0 max-w-[18rem] items-center gap-1.5 rounded border px-1.5 py-0.5 text-[10px] font-medium ${workflowStatusToneClass(
currentWorkflowStatus(),
)}`}
title={workflowStatusText()}
aria-live="polite"
data-status-kind={assistantWorkflowStatusKind(currentWorkflowStatus())}
>
<span
class={`h-1.5 w-1.5 shrink-0 rounded-full animate-pulse ${workflowStatusDotClass(
currentWorkflowStatus(),
)}`}
/>
<span class="truncate">{workflowStatusText()}</span>
</span>
</Show>
<Show when={canCopy()}>
<CopyValueButton
value={copyableMessageText()}
copied={copied()}
onCopyValue={copyMessage}
label={copyButtonLabel()}
size="md"
stopPropagation
class="ml-auto opacity-0 shadow-xs transition-opacity focus:opacity-100 group-hover:opacity-100"
/>
</Show>
<Show when={props.onRegenerate}>
<ActionIconButton
onClick={() => props.onRegenerate?.()}
label="Regenerate response"
title="Regenerate response"
tone="outline"
size="sm"
class={`${canCopy() ? '' : 'ml-auto '}opacity-0 shadow-xs transition-opacity focus:opacity-100 group-hover:opacity-100`}
>
<RotateCcwIcon class="h-3.5 w-3.5" aria-hidden="true" />
</ActionIconButton>
</Show>
</div>
<div>
<Show when={isWaitingForFirstToken()}>
<div class="flex items-center gap-2 py-1 text-sm text-muted">
<span class="flex gap-1" aria-hidden="true">
<span class="h-1.5 w-1.5 rounded-full bg-blue-500 animate-bounce" />
<span class="h-1.5 w-1.5 rounded-full bg-blue-500 animate-bounce [animation-delay:120ms]" />
<span class="h-1.5 w-1.5 rounded-full bg-blue-500 animate-bounce [animation-delay:240ms]" />
</span>
<Show when={workflowStatusText()} fallback={<span>Thinking...</span>}>
<span>{workflowStatusText()}</span>
</Show>
</div>
</Show>
{/* Stream events - chronological display */}
<Show when={hasRenderableStreamEvents()}>
<Index each={groupedEvents()}>
{(evt, index) => {
const contentText = () =>
stripAssistantOutputArtifacts(evt()?.content || '').text;
const visibleWorkflowStatus = () =>
evt()?.type === 'workflow_status'
? shouldPaceWorkflowStatusEvent(evt(), index)
? pacedWorkflowStatus() || evt().workflowStatus
: evt().workflowStatus
: undefined;
return (
<Switch>
<Match
when={
evt()?.type === 'thinking' &&
evt().thinking?.trim() &&
isLeadingThinkingEvent(index)
}
>
<ThinkingBlock
content={evt()?.thinking || ''}
isStreaming={props.message.isStreaming}
startedAt={evt()?.startedAt}
updatedAt={evt()?.updatedAt}
/>
</Match>
<Match
when={
evt()?.type === 'workflow_status' &&
shouldRenderWorkflowStatusEvent(evt())
}
>
<div
class={`my-1 inline-flex max-w-full items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium ${workflowStatusToneClass(
visibleWorkflowStatus(),
)}`}
role="status"
aria-live="polite"
title={formatWorkflowStatus(
visibleWorkflowStatus(),
props.message.isStreaming,
)}
data-status-kind={assistantWorkflowStatusKind(visibleWorkflowStatus())}
>
<span
class={`h-1.5 w-1.5 shrink-0 rounded-full animate-pulse ${workflowStatusDotClass(
visibleWorkflowStatus(),
)}`}
aria-hidden="true"
/>
<span class="min-w-0 truncate">
{formatWorkflowStatus(
visibleWorkflowStatus(),
props.message.isStreaming,
)}
</span>
</div>
</Match>
<Match
when={evt()?.type === 'pending_tool' ? evt().pendingTool : undefined}
>
{(pendingTool) => <PendingToolBlock tool={pendingTool()} />}
</Match>
<Match when={evt()?.type === 'tool_cancel' ? evt().toolCancel : undefined}>
{(toolCancel) => <ToolCancellationBlock tool={toolCancel()} />}
</Match>
<Match when={evt()?.type === 'tool' ? evt().tool : undefined}>
{(tool) => (
<ToolExecutionBlock
startedAt={evt()?.startedAt}
runningAt={evt()?.runningAt}
completedAt={evt()?.updatedAt}
live={props.message.isStreaming}
settleUntil={evt()?.settleUntil}
compact={shouldCompactCompletedToolEvent(evt(), index)}
tool={{
name: tool().name || 'unknown',
input: tool().input || '{}',
rawInput: tool().rawInput,
output: tool().output || '',
success: tool().success ?? true,
}}
/>
)}
</Match>
<Match
when={evt() && shouldRenderModelRouteEvent(evt()) ? evt() : undefined}
>
{(modelEvent) => {
const event = modelEvent();
return (
<div
class="my-2 inline-flex max-w-full items-center gap-2 rounded-md border border-border-subtle bg-surface-alt px-2.5 py-1.5 text-xs text-muted"
role="status"
aria-label={
isSelectedModelEvent(event)
? 'Assistant model route selected'
: 'Assistant model route changed'
}
title={modelSwitchTitle(event)}
>
<CpuIcon
class="h-3.5 w-3.5 shrink-0 text-blue-500"
aria-hidden="true"
/>
<Show
when={hasPreviousModelRoute(event)}
fallback={
<>
<Show when={isSelectedModelEvent(event)}>
<span class="shrink-0">Using</span>{' '}
</Show>
<Show when={!isSelectedModelEvent(event)}>
<span class="shrink-0">Switched to</span>{' '}
</Show>
<span class="truncate font-medium text-base-content">
{modelRouteLabel(event.model)}
</span>
</>
}
>
<span class="shrink-0">Switched from</span>
<span class="min-w-0 truncate font-medium text-base-content">
{modelRouteLabel(event.failedModel)}
</span>
<span class="shrink-0 text-muted" aria-hidden="true">
-&gt;
</span>
<span class="min-w-0 truncate font-medium text-base-content">
{modelRouteLabel(event.model)}
</span>
</Show>
</div>
);
}}
</Match>
{/* Content/text block */}
<Match when={evt()?.type === 'content' && contentText()}>
<AssistantMarkdownBlock
text={contentText()}
streaming={props.message.isStreaming}
paceKey={`${props.message.id}:stream:${index}`}
/>
</Match>
<Match when={evt()?.type === 'approval' ? evt().approval : undefined}>
{(approval) => (
<div class="my-4">
<ApprovalCard
approval={approval()}
onApprove={() => props.onApprove(approval())}
onSkip={() => props.onSkip(approval().toolId)}
/>
</div>
)}
</Match>
<Match when={evt()?.type === 'question' ? evt().question : undefined}>
{(question) => (
<div class="my-4">
<QuestionCard
question={question()}
onAnswer={(answers) => props.onAnswerQuestion(question(), answers)}
onSkip={() => props.onSkipQuestion(question().questionId)}
/>
</div>
)}
</Match>
</Switch>
);
}}
</Index>
</Show>
{/* Fallback */}
<Show when={visibleMessageContent() && !hasRenderableStreamEvents()}>
<AssistantMarkdownBlock
text={visibleMessageContent()}
streaming={props.message.isStreaming}
paceKey={`${props.message.id}:fallback`}
/>
</Show>
<Show when={interruptionLabel()}>
<div
class="mt-2 inline-flex items-center gap-1.5 rounded-md border border-border-subtle bg-surface-alt px-2 py-1 text-[11px] font-medium text-muted"
role="status"
>
<span>{interruptionLabel()}</span>
</div>
</Show>
{/* Error block - distinct, recoverable */}
<Show when={props.message.error}>
<div
class="mt-2 flex items-start gap-2.5 rounded-md border border-red-200 dark:border-red-900/60 bg-red-50 dark:bg-red-950/30 px-3 py-2.5"
role="alert"
>
<CircleAlertIcon class="mt-0.5 h-4 w-4 shrink-0 text-red-500 dark:text-red-400" />
<div class="flex-1 min-w-0">
<p class="text-sm text-red-700 dark:text-red-300">{props.message.error}</p>
<div class="mt-2 flex flex-wrap gap-1.5">
<Show
when={
props.modelRouteAlternative && props.onUseModelRoute
? props.modelRouteAlternative
: null
}
>
{(alternative) => (
<button
type="button"
onClick={() =>
props.onUseModelRoute?.(alternative().id, props.message.id)
}
aria-label={modelRouteRecoveryButtonLabel()}
title={alternative().label}
class="inline-flex max-w-56 items-center gap-1.5 rounded-md border border-red-300 bg-white/80 px-2 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-950/20 dark:text-red-300 dark:hover:bg-red-900/40"
>
<CpuIcon class="h-3.5 w-3.5" />
<span class="truncate">{modelRouteRecoveryButtonLabel()}</span>
</button>
)}
</Show>
<Show when={props.onChangeModel}>
<button
type="button"
onClick={() => props.onChangeModel?.()}
class="inline-flex items-center gap-1.5 rounded-md border border-red-300 bg-white/80 px-2 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-950/20 dark:text-red-300 dark:hover:bg-red-900/40"
>
<CpuIcon class="h-3.5 w-3.5" />
Change model
</button>
</Show>
<Show when={props.onRetry}>
<button
type="button"
onClick={() => props.onRetry?.(props.message.id)}
class="inline-flex items-center gap-1.5 rounded-md border border-red-300 px-2 py-1 text-xs font-medium text-red-700 transition-colors hover:bg-red-100 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-900/40"
>
<RotateCcwIcon class="h-3.5 w-3.5" />
Try again
</button>
</Show>
</div>
</div>
</div>
</Show>
{/* Streaming cursor */}
<Show when={isStreamingText() && !isWaitingForFirstToken()}>
<span class="inline-block w-1.5 h-4 ml-0.5 align-middle bg-blue-500 dark:bg-blue-400 animate-pulse rounded-full" />
</Show>
</div>
</div>
</div>
</Show>
</div>
);
};
@@ -1,106 +0,0 @@
import { Component, createMemo } from 'solid-js';
import {
AIModelPicker,
type AIModelPickerExtraOption,
type AIModelPickerModelSection,
} from '@/components/shared/AIModelPicker';
import { formatAIModelRouteLabel } from '@/utils/aiProviderPresentation';
import type { ModelInfo } from './types';
export interface ModelSelectorProps {
models: ModelInfo[];
selectedModel: string;
defaultModel?: string;
defaultModelLabel?: string;
chatOverrideModel?: string;
chatOverrideLabel?: string;
recentModelIds?: string[];
isLoading?: boolean;
error?: string;
openRequest?: number;
initialSearchQuery?: string;
onModelSelect: (modelId: string) => void;
onRefresh?: () => void;
onManageProviders?: () => void;
}
/**
* Chat-specific model selector wrapper.
*
* OpenCode's DialogModel keeps recent models above the provider catalog while
* the shared picker owns filtering, route labels, current-selection visibility,
* and dropdown mechanics.
*/
export const ModelSelector: Component<ModelSelectorProps> = (props) => {
const normalizedChatOverrideModel = createMemo(() => props.chatOverrideModel?.trim() || '');
const shouldShowChatOverride = createMemo(() => {
const override = normalizedChatOverrideModel();
if (!override) return false;
const selected = props.selectedModel?.trim() || '';
if (selected && selected === override) return false;
const defaultModel = props.defaultModel?.trim() || '';
if (defaultModel && defaultModel === override) return false;
return true;
});
const defaultOption = createMemo(() => ({
label: 'Default',
description: props.defaultModelLabel
? `Use configured default model (${props.defaultModelLabel})`
: 'Use configured default model',
}));
const extraOptions = createMemo<AIModelPickerExtraOption[]>(() => {
if (!shouldShowChatOverride()) return [];
const override = normalizedChatOverrideModel();
return [
{
id: override,
label: 'Chat override',
description: props.chatOverrideLabel || formatAIModelRouteLabel(override),
},
];
});
const modelSections = createMemo<AIModelPickerModelSection[]>(() => {
const recentModelIds = props.recentModelIds || [];
if (recentModelIds.length === 0) return [];
return [
{
title: 'Recent',
modelIds: recentModelIds,
},
];
});
const selectionBadge = createMemo(() => {
if ((props.selectedModel || '').trim()) return '';
return props.defaultModelLabel ? 'default' : '';
});
return (
<AIModelPicker
models={props.models}
selectedModel={props.selectedModel}
onModelSelect={props.onModelSelect}
defaultOption={defaultOption()}
extraOptions={extraOptions()}
modelSections={modelSections()}
emptySelectionLabel={props.defaultModelLabel || 'Default'}
selectionBadge={selectionBadge()}
title="Select model for this chat"
customModelDescription="Custom provider:model route"
isLoading={props.isLoading}
error={props.error}
openRequest={props.openRequest}
initialSearchQuery={props.initialSearchQuery}
onRefresh={props.onRefresh}
onManageProviders={props.onManageProviders}
manageProvidersLabel="Open Assistant provider settings"
align="left"
buttonClass="flex shrink-0 items-center gap-1.5 rounded-md border border-border bg-surface px-2.5 py-1.5 text-[11px] text-muted transition-colors hover:border-border hover:text-base-content"
buttonLabelClass="max-w-[90px] truncate font-medium sm:max-w-[180px]"
dropdownClass="w-80 max-w-[calc(100vw-2rem)]"
/>
);
};
@@ -1,181 +0,0 @@
import { Component, Show, For, createSignal } from 'solid-js';
import type { PendingQuestion } from './types';
import {
AI_CHAT_QUESTION_CARD_PLACEHOLDER,
AI_CHAT_QUESTION_CARD_TITLE,
} from '@/utils/aiChatPresentation';
interface QuestionCardProps {
question: PendingQuestion;
onAnswer: (answers: Array<{ id: string; value: string }>) => void;
onSkip: () => void;
}
export const QuestionCard: Component<QuestionCardProps> = (props) => {
// Store answers for each question
const [answers, setAnswers] = createSignal<Record<string, string>>({});
const handleInputChange = (questionId: string, value: string) => {
setAnswers((prev) => ({ ...prev, [questionId]: value }));
};
const handleSelectOption = (questionId: string, value: string) => {
setAnswers((prev) => ({ ...prev, [questionId]: value }));
};
const handleSubmit = () => {
const answerArray = props.question.questions.map((q) => ({
id: q.id,
value: answers()[q.id] || '',
}));
props.onAnswer(answerArray);
};
const isValid = () => {
// Check all questions have answers
return props.question.questions.every((q) => {
const answer = answers()[q.id];
return answer && answer.trim() !== '';
});
};
return (
<div class="rounded-md border border-blue-300 dark:border-blue-700 overflow-hidden shadow-xs">
{/* Header */}
<div class="px-3 py-2 text-xs font-medium flex items-center gap-2 bg-blue-50 dark:bg-blue-900/25 text-blue-800 dark:text-blue-200 border-b border-blue-200 dark:border-blue-800">
<div class="p-1 rounded-sm bg-blue-100 dark:bg-blue-800">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<span class="font-semibold">{AI_CHAT_QUESTION_CARD_TITLE}</span>
</div>
{/* Questions */}
<div class="px-3 py-3 bg-blue-50 dark:bg-blue-900/25 space-y-4">
<For each={props.question.questions}>
{(q) => (
<div class="space-y-2">
<Show when={q.header}>
<p class="text-[11px] text-muted">{q.header}</p>
</Show>
<p class="text-sm font-medium text-base-content">{q.question}</p>
<Show when={q.type === 'text'}>
<input
type="text"
value={answers()[q.id] || ''}
onInput={(e) => handleInputChange(q.id, e.currentTarget.value)}
class="w-full px-3 py-2 text-sm border border-blue-200 dark:border-blue-700 rounded-md bg-surface text-base-content focus:outline-hidden focus:ring-2 focus:ring-blue-500"
placeholder={AI_CHAT_QUESTION_CARD_PLACEHOLDER}
disabled={props.question.isAnswering}
/>
</Show>
<Show when={q.type === 'select' && q.options}>
<div class="space-y-1">
<For each={q.options}>
{(option) => (
<button
type="button"
onClick={() => handleSelectOption(q.id, option.value)}
disabled={props.question.isAnswering}
class={`w-full px-3 py-2 text-sm text-left rounded-md border transition-colors ${
answers()[q.id] === option.value
? 'bg-blue-100 dark:bg-blue-800 border-blue-400 dark:border-blue-600 text-blue-800 dark:text-blue-200'
: 'bg-surface border-blue-200 dark:border-blue-700 text-base-content hover:bg-blue-50 dark:hover:bg-blue-900/25'
} ${props.question.isAnswering ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div class="flex flex-col gap-0.5">
<span>{option.label}</span>
<Show when={option.description}>
<span class="text-[11px] text-muted">{option.description}</span>
</Show>
</div>
</button>
)}
</For>
</div>
</Show>
</div>
)}
</For>
{/* Actions */}
<div class="flex gap-2 pt-2">
<button
type="button"
onClick={handleSubmit}
disabled={props.question.isAnswering || !isValid()}
class={`flex-1 px-3 py-2 text-xs font-semibold rounded-md transition-all ${
props.question.isAnswering
? 'bg-blue-400 text-white cursor-wait'
: !isValid()
? 'bg-surface-hover text-muted cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600 text-white shadow-xs hover:shadow-xs'
}`}
>
<Show
when={!props.question.isAnswering}
fallback={
<span class="flex items-center justify-center gap-1.5">
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Answering...
</span>
}
>
<span class="flex items-center justify-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
Submit Answer
</span>
</Show>
</button>
<button
type="button"
onClick={props.onSkip}
disabled={props.question.isAnswering}
class="flex-1 px-3 py-2 text-xs font-semibold hover:bg-surface-hover text-base-content rounded-md transition-colors disabled:opacity-50"
>
<span class="flex items-center justify-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
Skip
</span>
</button>
</div>
</div>
</div>
);
};
@@ -1,269 +0,0 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import ActivityIcon from 'lucide-solid/icons/activity';
import ClockIcon from 'lucide-solid/icons/clock';
import CopyIcon from 'lucide-solid/icons/copy';
import DownloadIcon from 'lucide-solid/icons/download';
import FlaskConicalIcon from 'lucide-solid/icons/flask-conical';
import GitForkIcon from 'lucide-solid/icons/git-fork';
import KeyRoundIcon from 'lucide-solid/icons/key-round';
import Minimize2Icon from 'lucide-solid/icons/minimize-2';
import PlusIcon from 'lucide-solid/icons/plus';
import Redo2Icon from 'lucide-solid/icons/redo-2';
import SettingsIcon from 'lucide-solid/icons/settings';
import Undo2Icon from 'lucide-solid/icons/undo-2';
import CircleHelpIcon from 'lucide-solid/icons/circle-help';
import type { AssistantSlashCommand, AssistantSlashCommandAction } from './assistantSlashCommands';
import {
type AssistantSlashCommandAvailability,
filterAssistantSlashCommands,
getAssistantSlashCommandTokens,
groupAssistantSlashCommands,
} from './assistantSlashCommands';
interface SlashCommandAutocompleteProps {
availability?: AssistantSlashCommandAvailability;
query: string;
visible: boolean;
position: { top: number; left: number };
onClose: () => void;
onSelect: (command: AssistantSlashCommand) => void;
onActiveDescendantChange?: (id: string | undefined) => void;
}
export const ASSISTANT_SLASH_COMMAND_LISTBOX_ID = 'assistant-slash-command-listbox';
export const getAssistantSlashCommandOptionId = (index: number) =>
`assistant-slash-command-option-${index}`;
export const AssistantSlashCommandIcon = (props: { action: AssistantSlashCommandAction }) => {
switch (props.action) {
case 'help':
return <CircleHelpIcon class="h-4 w-4" aria-hidden="true" />;
case 'new':
return <PlusIcon class="h-4 w-4" aria-hidden="true" />;
case 'sessions':
return <ClockIcon class="h-4 w-4" aria-hidden="true" />;
case 'queue':
return <ClockIcon class="h-4 w-4" aria-hidden="true" />;
case 'compact':
return <Minimize2Icon class="h-4 w-4" aria-hidden="true" />;
case 'models':
return <SettingsIcon class="h-4 w-4" aria-hidden="true" />;
case 'providers':
return <KeyRoundIcon class="h-4 w-4" aria-hidden="true" />;
case 'status':
return <ActivityIcon class="h-4 w-4" aria-hidden="true" />;
case 'copy':
return <CopyIcon class="h-4 w-4" aria-hidden="true" />;
case 'export':
return <DownloadIcon class="h-4 w-4" aria-hidden="true" />;
case 'fixture':
return <FlaskConicalIcon class="h-4 w-4" aria-hidden="true" />;
case 'fork':
return <GitForkIcon class="h-4 w-4" aria-hidden="true" />;
case 'undo':
return <Undo2Icon class="h-4 w-4" aria-hidden="true" />;
case 'redo':
return <Redo2Icon class="h-4 w-4" aria-hidden="true" />;
}
};
export function SlashCommandAutocomplete(props: SlashCommandAutocompleteProps) {
const [selectedIndex, setSelectedIndex] = createSignal(0);
const commands = createMemo(() =>
filterAssistantSlashCommands(props.query, undefined, {
availability: props.availability,
includeDisabled: true,
}),
);
const groupedCommands = createMemo(() => groupAssistantSlashCommands(commands()));
const shouldGroupCommands = createMemo(() => !props.query.trim());
const emptyMessage = createMemo(() => {
const query = props.query.trim();
return query ? `No Assistant commands match /${query}` : 'No Assistant commands available';
});
createEffect(() => {
props.query;
setSelectedIndex(0);
});
createEffect(() => {
const total = commands().length;
setSelectedIndex((index) => (total > 0 ? Math.min(index, total - 1) : 0));
});
createEffect(() => {
const options = commands();
const activeId =
props.visible && options.length > 0
? getAssistantSlashCommandOptionId(selectedIndex())
: undefined;
props.onActiveDescendantChange?.(activeId);
if (activeId) {
queueMicrotask(() => {
document.getElementById(activeId)?.scrollIntoView?.({ block: 'nearest' });
});
}
});
onCleanup(() => props.onActiveDescendantChange?.(undefined));
const selectCommand = (command?: AssistantSlashCommand) => {
if (!command) return;
props.onSelect(command);
};
const consumeCommandKey = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
};
const moveSelection = (direction: -1 | 1, total: number) => {
if (total <= 0) return;
setSelectedIndex((index) => {
const next = index + direction;
if (next < 0) return total - 1;
if (next >= total) return 0;
return next;
});
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!props.visible) return;
const options = commands();
switch (event.key) {
case 'ArrowDown':
consumeCommandKey(event);
moveSelection(1, options.length);
break;
case 'ArrowUp':
consumeCommandKey(event);
moveSelection(-1, options.length);
break;
case 'Enter':
consumeCommandKey(event);
selectCommand(options[selectedIndex()]);
break;
case 'Tab':
consumeCommandKey(event);
selectCommand(options[selectedIndex()]);
break;
case 'Escape':
consumeCommandKey(event);
props.onClose();
break;
}
};
createEffect(() => {
if (!props.visible) return;
document.addEventListener('keydown', handleKeyDown);
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
});
return (
<Show when={props.visible}>
<div
class="absolute z-50 w-[calc(100vw-36px)] min-w-[280px] max-w-[420px] overflow-hidden rounded-md border border-border bg-surface shadow-xs"
style={{
bottom: `${props.position.top}px`,
left: `${props.position.left}px`,
}}
data-slash-command-autocomplete
onClick={(event) => event.stopPropagation()}
>
<div class="border-b border-border px-3 py-2 text-xs font-medium text-muted">Commands</div>
<div
id={ASSISTANT_SLASH_COMMAND_LISTBOX_ID}
class="max-h-[260px] overflow-y-auto"
role="listbox"
aria-label="Assistant commands"
>
<Show
when={commands().length > 0}
fallback={
<div class="px-3 py-3 text-xs text-muted" role="status">
{emptyMessage()}
</div>
}
>
<For each={groupedCommands()}>
{(group) => (
<>
<Show when={shouldGroupCommands()}>
<div
role="presentation"
class="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase text-muted"
>
{group.category}
</div>
</Show>
<For each={group.items}>
{(item) => {
const command = item.command;
return (
<button
type="button"
id={getAssistantSlashCommandOptionId(item.index)}
role="option"
tabIndex={-1}
aria-selected={item.index === selectedIndex()}
aria-disabled={command.disabled ? 'true' : undefined}
aria-label={`${
command.disabled ? 'Unavailable' : command.insertText ? 'Insert' : 'Run'
} /${command.name}: ${command.description}${
command.disabledReason ? `. ${command.disabledReason}` : ''
}`}
class={`flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors ${
command.disabled
? 'cursor-not-allowed opacity-60'
: 'hover:bg-surface-hover'
} ${item.index === selectedIndex() ? 'bg-surface-hover' : ''}`}
onPointerDown={(event) => event.preventDefault()}
onClick={(event) => {
event.stopPropagation();
selectCommand(command);
}}
onMouseEnter={() => setSelectedIndex(item.index)}
>
<span class="mt-0.5 text-muted">
<AssistantSlashCommandIcon action={command.action} />
</span>
<span class="min-w-0 flex-1">
<span class="flex min-w-0 items-center gap-2">
<span class="font-mono text-xs font-semibold text-base-content">
/{command.name}
</span>
<Show when={command.aliases?.length}>
<span class="truncate text-[10px] text-muted">
{getAssistantSlashCommandTokens(command)
.slice(1)
.map((alias) => `/${alias}`)
.join(', ')}
</span>
</Show>
</span>
<span class="mt-0.5 block truncate text-xs text-muted">
{command.description}
</span>
<Show when={command.disabledReason}>
<span class="mt-1 block truncate text-[11px] text-warning">
{command.disabledReason}
</span>
</Show>
</span>
</button>
);
}}
</For>
</>
)}
</For>
</Show>
</div>
</div>
</Show>
);
}
@@ -1,76 +0,0 @@
import { Component, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import BrainIcon from 'lucide-solid/icons/brain';
import { extractReasoningSummaryTitle } from './reasoningSummary';
interface ThinkingBlockProps {
content?: string;
isStreaming?: boolean;
startedAt?: number;
updatedAt?: number;
}
const formatThinkingDuration = (durationMs: number): string => {
if (!Number.isFinite(durationMs) || durationMs < 0) return '';
if (durationMs < 1000) return '<1s';
const totalSeconds = Math.max(1, Math.floor(durationMs / 1000));
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
};
export const ThinkingBlock: Component<ThinkingBlockProps> = (props) => {
const [now, setNow] = createSignal(Date.now());
createEffect(() => {
if (!props.isStreaming || !props.startedAt) return;
setNow(Date.now());
const interval = window.setInterval(() => setNow(Date.now()), 1000);
onCleanup(() => window.clearInterval(interval));
});
const summaryTitle = createMemo(() => extractReasoningSummaryTitle(props.content));
const durationLabel = createMemo(() => {
if (!props.startedAt) return '';
const end = props.isStreaming ? now() : props.updatedAt;
if (!end) return '';
const duration = formatThinkingDuration(end - props.startedAt);
if (props.isStreaming && duration === '<1s') return '';
return duration;
});
const statusText = createMemo(() => {
const title = summaryTitle();
if (props.isStreaming) {
return title ? `Thinking: ${title}` : 'Thinking...';
}
return title ? `Thought: ${title}` : 'Thinking complete';
});
return (
<div
class="my-2 inline-flex max-w-full items-center gap-2 rounded-md border border-border-subtle bg-surface-alt px-2.5 py-1.5 text-xs text-muted"
role="status"
>
<BrainIcon
class={`h-3.5 w-3.5 shrink-0 text-blue-500 ${props.isStreaming ? 'animate-pulse' : ''}`}
aria-hidden="true"
/>
<span class="min-w-0 truncate">
{statusText()}
<span class="">
{durationLabel()
? props.isStreaming
? ` (${durationLabel()})`
: ` · ${durationLabel()}`
: ''}
</span>
</span>
</div>
);
};
@@ -1,930 +0,0 @@
import {
Component,
Show,
createSignal,
createMemo,
createEffect,
onCleanup,
For,
createUniqueId,
} from 'solid-js';
import CheckCircleIcon from 'lucide-solid/icons/check-circle';
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
import ClockIcon from 'lucide-solid/icons/clock';
import LoaderCircleIcon from 'lucide-solid/icons/loader-circle';
import XCircleIcon from 'lucide-solid/icons/x-circle';
import type { ToolExecution, PendingTool, ToolCancellation } from './types';
import { copyToClipboard } from '@/utils/clipboard';
import { ButtonLink, CopyValueButton } from '@/components/shared/Button';
import { aiChatStore } from '@/stores/aiChat';
import { getToolCallResultTextClass } from '@/utils/patrolRunPresentation';
import {
getToolLabel,
canonicalToolActionURL,
isPlaceholderToolInputSummary,
parseToolCommandPreview,
parseToolInputSummary,
pendingToolActionLabel,
pendingToolActionState,
toolValueText,
} from './toolPresentation';
import { getAssistantFastToolCompletionSettleUntil } from './streamActivityTiming';
interface ToolExecutionBlockProps {
tool: ToolExecution;
startedAt?: number;
// When execution actually began. startedAt marks the model starting to
// stream tool arguments; on slow routes that can run minutes before the
// tool runs, and the duration stamp must not bill it to the tool.
runningAt?: number;
completedAt?: number;
live?: boolean;
settleUntil?: number;
compact?: boolean;
}
interface ToolInputSummaryProps {
summary: string;
}
interface ToolCommandPreviewProps {
preview: string;
}
interface ToolCancellationBlockProps {
tool: ToolCancellation;
}
type ToolDetailKind = 'input' | 'output' | 'progress' | 'reason';
interface ToolDetailsPanelProps {
id: string;
input?: string;
output?: string;
progress?: string;
reason?: string;
}
const hasReadableToolOutput = (output: string) => {
const trimmed = output.trim();
return trimmed.length > 0 && !trimmed.toLowerCase().includes('not available');
};
const ansiControlCodePattern = new RegExp(String.raw`\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])`, 'g');
const stripAnsiControlCodes = (value: string) => value.replace(ansiControlCodePattern, '');
const looksLikeStructuredOutput = (value: string) => {
const trimmed = value.trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return false;
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
};
const hasBinaryControlCharacters = (value: string) => {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31)) {
return true;
}
}
return false;
};
const trimPreviewLine = (line: string, maxLength: number) => {
const trimmed = line.trimEnd();
if (trimmed.length <= maxLength) return trimmed;
return `${trimmed.slice(0, maxLength).trimEnd()}...`;
};
const formatOutputPreview = (output: string) => {
const normalized = stripAnsiControlCodes(output).replace(/\r\n/g, '\n').trim();
if (!hasReadableToolOutput(normalized)) return '';
if (looksLikeStructuredOutput(normalized)) return '';
if (hasBinaryControlCharacters(normalized)) return '';
const maxLines = 4;
const maxLineLength = 120;
const lines = normalized.split('\n');
const previewLines = lines.slice(0, maxLines).map((line) => trimPreviewLine(line, maxLineLength));
const fullPreview = previewLines.join('\n').trim();
if (!fullPreview) return '';
const linesTruncated = lines.length > maxLines;
const charsTruncated = lines
.slice(0, maxLines)
.some((line) => line.trimEnd().length > maxLineLength);
return linesTruncated || charsTruncated ? `${fullPreview}\n...` : fullPreview;
};
const formatHiddenOutputSummary = (output: string) => {
const normalized = stripAnsiControlCodes(output).replace(/\r\n/g, '\n').trim();
if (!hasReadableToolOutput(normalized)) return '';
if (looksLikeStructuredOutput(normalized)) return 'structured output';
if (hasBinaryControlCharacters(normalized)) return 'binary output';
const lines = normalized.split('\n');
if (lines.length > 1) return `${lines.length} lines output`;
const characterCount = normalized.length;
return `${characterCount} ${characterCount === 1 ? 'char' : 'chars'} output`;
};
const formatCompletedToolDuration = (startedAt?: number, completedAt?: number): string => {
if (!startedAt || !completedAt) return '';
const durationMs = completedAt - startedAt;
if (!Number.isFinite(durationMs) || durationMs < 0) return '';
if (durationMs < 1000) return '<1s';
const totalSeconds = Math.max(1, Math.round(durationMs / 1000));
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes < 60) return seconds ? `${minutes}m ${seconds}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
};
const ToolInputSummary: Component<ToolInputSummaryProps> = (props) => {
const isShellSummary = createMemo(() => props.summary.trim().startsWith('$ '));
const className = createMemo(
() =>
`mt-1 block whitespace-pre-wrap wrap-break-word leading-5 text-base-content ${
isShellSummary() ? 'font-mono text-[11px]' : 'text-[12px] font-medium'
}`,
);
return (
<Show
when={isShellSummary()}
fallback={
<span data-testid="tool-input-summary" class={className()}>
{props.summary}
</span>
}
>
<code data-testid="tool-input-summary" class={className()}>
{props.summary}
</code>
</Show>
);
};
const ToolCommandPreview: Component<ToolCommandPreviewProps> = (props) => (
<VisibleToolCommandPreview preview={props.preview} />
);
const VisibleToolCommandPreview: Component<ToolCommandPreviewProps> = (props) => {
const [copied, setCopied] = createSignal(false);
let copiedResetTimer: number | undefined;
const copyLabel = () => (copied() ? 'Copied tool command' : 'Copy tool command');
const commandText = () => props.preview.replace(/^\$\s*/, '').trim() || props.preview.trim();
const copyCommand = async (value: string) => {
const text = value.trim();
if (!text) return;
const ok = await copyToClipboard(text);
if (!ok) return;
setCopied(true);
if (copiedResetTimer) window.clearTimeout(copiedResetTimer);
copiedResetTimer = window.setTimeout(() => setCopied(false), 1500);
};
const handleKeyDown = (event: KeyboardEvent) => {
event.stopPropagation();
};
onCleanup(() => {
if (copiedResetTimer) window.clearTimeout(copiedResetTimer);
});
return (
<div class="mt-1 flex min-w-0 items-start gap-1.5">
<code
data-testid="tool-command-preview"
aria-label="Tool command"
class="min-w-0 flex-1 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-5 text-muted"
>
{props.preview}
</code>
<CopyValueButton
value={commandText()}
copied={copied()}
onCopyValue={copyCommand}
label={copyLabel()}
variant="neutral"
size="sm"
stopPropagation
class="mt-0.5"
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={handleKeyDown}
/>
</div>
);
};
const normalizeDetailText = (value?: string) => value?.trim() ?? '';
const toolDetailInputText = (input: string, rawInput?: string) => {
const text = input.trim();
const raw = rawInput?.trim() ?? '';
if (raw && (!text || text === '{}' || text === '[]')) return raw;
return text || raw;
};
const detailTitle = (kind: ToolDetailKind) => {
if (kind === 'input') return 'Input';
if (kind === 'output') return 'Output';
if (kind === 'progress') return 'Progress';
return 'Reason';
};
const detailCopyTarget = (kind: ToolDetailKind) => {
if (kind === 'input') return 'tool input';
if (kind === 'output') return 'tool output';
if (kind === 'progress') return 'tool progress';
return 'skip reason';
};
const detailPreClass = (kind: ToolDetailKind) =>
`${kind === 'output' ? 'max-h-72' : 'max-h-36'} overflow-auto rounded-sm bg-surface-alt p-2 font-mono text-[10px] leading-5 text-muted whitespace-pre-wrap wrap-break-word`;
const ToolDetailsPanel: Component<ToolDetailsPanelProps> = (props) => {
const [copiedDetail, setCopiedDetail] = createSignal<ToolDetailKind | null>(null);
let copiedResetTimer: number | undefined;
const details = createMemo(() => {
const items: Array<{ kind: ToolDetailKind; text: string }> = [];
const input = normalizeDetailText(props.input);
const output = normalizeDetailText(props.output);
const progress = normalizeDetailText(props.progress);
const reason = normalizeDetailText(props.reason);
if (input) items.push({ kind: 'input', text: input });
if (output) items.push({ kind: 'output', text: output });
if (progress) items.push({ kind: 'progress', text: progress });
if (reason) items.push({ kind: 'reason', text: reason });
return items;
});
const detailCopyLabel = (kind: ToolDetailKind) => {
const target = detailCopyTarget(kind);
return copiedDetail() === kind ? `Copied ${target}` : `Copy ${target}`;
};
const copyDetail = async (kind: ToolDetailKind, value: string) => {
const text = value.trim();
if (!text) return;
const copied = await copyToClipboard(text);
if (!copied) return;
setCopiedDetail(kind);
if (copiedResetTimer) window.clearTimeout(copiedResetTimer);
copiedResetTimer = window.setTimeout(() => setCopiedDetail(null), 1500);
};
onCleanup(() => {
if (copiedResetTimer) window.clearTimeout(copiedResetTimer);
});
return (
<div id={props.id} class="border-t border-border-subtle px-3 py-2">
<For each={details()}>
{(detail) => (
<div class="mb-2 last:mb-0">
<div class="mb-1 flex items-center justify-between gap-2">
<div class="text-[9px] font-semibold uppercase tracking-wide text-muted">
{detailTitle(detail.kind)}
</div>
<CopyValueButton
value={detail.text}
copied={copiedDetail() === detail.kind}
onCopyValue={(value) => copyDetail(detail.kind, value)}
label={detailCopyLabel(detail.kind)}
variant="neutral"
size="sm"
stopPropagation
/>
</div>
<pre class={detailPreClass(detail.kind)}>{detail.text}</pre>
</div>
)}
</For>
</div>
);
};
/**
* ToolExecutionBlock - Displays completed tool executions in a compact terminal-like style.
*/
export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) => {
const [showDetails, setShowDetails] = createSignal(false);
const [settlingFastCompletion, setSettlingFastCompletion] = createSignal(false);
const detailsId = createUniqueId();
const toolLabel = createMemo(() => getToolLabel(props.tool.name));
const inputText = createMemo(() => toolValueText(props.tool.input));
const detailInputText = createMemo(() => toolDetailInputText(inputText(), props.tool.rawInput));
const outputText = createMemo(() => toolValueText(props.tool.output));
const actionURL = createMemo(() =>
canonicalToolActionURL(props.tool.name, props.tool.success, outputText()),
);
const inputSummary = createMemo(() =>
parseToolInputSummary(inputText(), props.tool.name, props.tool.rawInput),
);
const commandPreview = createMemo(() => {
const preview = parseToolCommandPreview(inputText(), props.tool.name, props.tool.rawInput);
const summary = inputSummary().trim();
if (!preview || summary.startsWith('$ ') || preview === summary) return '';
return preview;
});
const outputPreview = createMemo(() => formatOutputPreview(outputText()));
const showInlineOutputPreview = createMemo(
() => !settlingFastCompletion() && outputPreview().length > 0,
);
const hiddenOutputSummary = createMemo(() =>
!props.compact && showInlineOutputPreview() ? '' : formatHiddenOutputSummary(outputText()),
);
const hiddenOutputBadgeSummary = createMemo(() =>
settlingFastCompletion() ? '' : hiddenOutputSummary(),
);
const hiddenOutputBadgeLabel = createMemo(() => hiddenOutputBadgeSummary());
const hasInput = createMemo(() => detailInputText().trim().length > 0);
const hasOutput = createMemo(() => hasReadableToolOutput(outputText()));
const hasDetails = createMemo(() => hasInput() || hasOutput());
createEffect(() => {
if (!props.tool.success) {
setSettlingFastCompletion(false);
return;
}
const now = Date.now();
const explicitSettleUntil =
Number.isFinite(props.settleUntil) && (props.settleUntil || 0) > now
? props.settleUntil
: undefined;
const liveSettleUntil =
props.live === true
? getAssistantFastToolCompletionSettleUntil(props.startedAt, props.completedAt, now)
: undefined;
const settleUntil = explicitSettleUntil || liveSettleUntil;
if (!settleUntil) {
setSettlingFastCompletion(false);
return;
}
setSettlingFastCompletion(true);
const timeout = window.setTimeout(() => setSettlingFastCompletion(false), settleUntil - now);
onCleanup(() => window.clearTimeout(timeout));
});
const durationLabel = createMemo(() =>
settlingFastCompletion()
? ''
: formatCompletedToolDuration(props.runningAt ?? props.startedAt, props.completedAt),
);
const statusLabel = () =>
settlingFastCompletion() ? 'running' : props.tool.success ? 'completed' : 'failed';
const statusPillClass = () =>
settlingFastCompletion()
? 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-300'
: props.tool.success
? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300'
: 'border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300';
const summaryControlTitle = () => (showDetails() ? 'Hide tool details' : 'Show tool details');
const toggleDetails = () => {
if (!hasDetails()) return;
setShowDetails(!showDetails());
};
const handleSummaryKeyDown = (event: KeyboardEvent) => {
if (!hasDetails()) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleDetails();
};
return (
<Show
when={!props.compact}
fallback={
<div
class="my-1 inline-flex max-w-full items-start gap-2 rounded-md border border-border-subtle bg-surface-alt px-2.5 py-1.5 text-[11px] text-muted"
role="status"
aria-label={
settlingFastCompletion()
? 'Assistant tool running'
: 'Assistant completed tool activity'
}
title={inputSummary()}
>
<Show
when={!settlingFastCompletion()}
fallback={
<LoaderCircleIcon
class="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-blue-500 dark:text-blue-400"
aria-label="running"
/>
}
>
<CheckCircleIcon
class="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-300"
aria-label="completed"
/>
</Show>
<span class="shrink-0 font-mono text-[9px] font-semibold uppercase tracking-wider">
{toolLabel()}
</span>
<span class="shrink-0 text-[10px] font-medium">{statusLabel()}</span>
<span class="min-w-0 truncate text-base-content">{inputSummary()}</span>
<Show when={durationLabel()}>
<span class="shrink-0 text-[10px]" aria-label={`Tool duration ${durationLabel()}`}>
{durationLabel()}
</span>
</Show>
<Show when={hiddenOutputBadgeSummary()}>
<span
class="shrink-0 rounded-sm border border-border-subtle bg-surface px-1.5 py-0.5 text-[9px] font-medium"
title="Open the completed turn to inspect tool output"
aria-label={`Tool output available: ${hiddenOutputBadgeSummary()}`}
>
{hiddenOutputBadgeLabel()}
</span>
</Show>
</div>
}
>
<div
class="my-2 overflow-hidden rounded-md border border-border-subtle bg-surface text-[11px] shadow-xs"
role={settlingFastCompletion() ? 'status' : undefined}
aria-label={settlingFastCompletion() ? 'Assistant tool running' : undefined}
>
<div
class={`flex min-w-0 items-start gap-2 px-2.5 py-2 ${
hasDetails()
? 'cursor-pointer transition-colors hover:bg-surface-hover focus:outline-hidden focus:ring-2 focus:ring-blue-500/30 focus:ring-inset'
: ''
}`}
role={hasDetails() ? 'button' : undefined}
tabIndex={hasDetails() ? 0 : undefined}
aria-expanded={hasDetails() ? showDetails() : undefined}
aria-controls={hasDetails() ? detailsId : undefined}
title={hasDetails() ? summaryControlTitle() : undefined}
onClick={toggleDetails}
onKeyDown={handleSummaryKeyDown}
>
<div class="pt-0.5">
<Show
when={!settlingFastCompletion()}
fallback={
<LoaderCircleIcon
class="h-3.5 w-3.5 shrink-0 animate-spin text-blue-500 dark:text-blue-400"
aria-label="running"
/>
}
>
<Show
when={props.tool.success}
fallback={
<XCircleIcon
class={`${getToolCallResultTextClass(props.tool.success)} h-3.5 w-3.5 shrink-0`}
aria-label={statusLabel()}
/>
}
>
<CheckCircleIcon
class={`${getToolCallResultTextClass(props.tool.success)} h-3.5 w-3.5 shrink-0`}
aria-label={statusLabel()}
/>
</Show>
</Show>
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span class="shrink-0 font-mono text-[9px] font-semibold uppercase tracking-wider text-muted">
{toolLabel()}
</span>
<span
class={`shrink-0 rounded-sm border px-1.5 py-0.5 text-[9px] font-medium ${statusPillClass()}`}
>
{statusLabel()}
</span>
<Show when={durationLabel()}>
<span
class="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium text-muted"
title="Tool duration"
aria-label={`Tool duration ${durationLabel()}`}
>
<ClockIcon class="h-3 w-3" aria-hidden="true" />
{durationLabel()}
</span>
</Show>
<Show when={hasDetails()}>
<ChevronRightIcon
class={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${
showDetails() ? 'rotate-90' : ''
}`}
aria-hidden="true"
/>
</Show>
<Show when={hiddenOutputBadgeSummary()}>
<span
class="shrink-0 rounded-sm border border-border-subtle bg-surface-alt px-1.5 py-0.5 text-[9px] font-medium text-muted"
title="Open tool details to inspect output"
aria-label={`Tool output available: ${hiddenOutputBadgeSummary()}`}
>
{hiddenOutputBadgeLabel()}
</span>
</Show>
</div>
<ToolInputSummary summary={inputSummary()} />
<Show when={commandPreview()}>
<ToolCommandPreview preview={commandPreview()} />
</Show>
</div>
</div>
<Show when={actionURL()}>
<div class="border-t border-border-subtle px-3 py-2">
<ButtonLink
href={actionURL()}
size="sm"
variant="secondary"
onClick={() => aiChatStore.close()}
>
Review action
</ButtonLink>
</div>
</Show>
<Show when={showInlineOutputPreview()}>
<div class="border-t border-border-subtle bg-surface-alt">
<div class="px-3 pt-2 text-[9px] font-semibold uppercase tracking-wide text-muted">
Output preview
</div>
<pre
class="px-3 pb-2 pt-1 font-mono text-[11px] leading-5 text-base-content whitespace-pre-wrap wrap-break-word"
aria-label="Tool output preview"
>
{outputPreview()}
</pre>
</div>
</Show>
<Show when={showDetails() && hasDetails()}>
<ToolDetailsPanel
id={detailsId}
input={hasInput() ? detailInputText() : ''}
output={hasOutput() ? outputText() : ''}
/>
</Show>
</div>
</Show>
);
};
/**
* PendingToolBlock - Compact single-line display for running tools
*/
interface PendingToolBlockProps {
tool: PendingTool;
}
export const PendingToolBlock: Component<PendingToolBlockProps> = (props) => {
const [showDetails, setShowDetails] = createSignal(false);
const detailsId = createUniqueId();
const toolLabel = createMemo(() => getToolLabel(props.tool.name));
const inputText = createMemo(() => toolValueText(props.tool.input));
const detailInputText = createMemo(() => toolDetailInputText(inputText(), props.tool.rawInput));
const parsedInputSummary = createMemo(() =>
parseToolInputSummary(inputText(), props.tool.name, props.tool.rawInput),
);
const inputSummary = createMemo(() => {
const summary = parsedInputSummary();
return isPlaceholderToolInputSummary(summary)
? pendingToolActionLabel(props.tool.name)
: summary;
});
const commandPreview = createMemo(() => {
const preview = parseToolCommandPreview(inputText(), props.tool.name, props.tool.rawInput);
const summary = inputSummary().trim();
if (!preview || summary.startsWith('$ ') || preview === summary) return '';
return preview;
});
// While the model is still streaming the tool arguments the raw input is
// partial JSON that no parser can summarize yet. Show its live tail (the
// OpenCode mutable tool-part pattern: you watch the call being written)
// instead of leaving only the generic placeholder.
const formingArgsPreview = createMemo(() => {
if (commandPreview()) return '';
if (!isPlaceholderToolInputSummary(parsedInputSummary())) return '';
const raw = (props.tool.rawInput || '').replace(/\s+/g, ' ').trim();
if (!raw) return '';
const maxChars = 120;
if (raw.length <= maxChars) return raw;
return '…' + raw.slice(raw.length - maxChars);
});
const status = createMemo(() => props.tool.status || 'pending');
const [now, setNow] = createSignal(Date.now());
const statusLabel = createMemo(() => {
if (status() === 'waiting') return 'waiting';
if (status() === 'running') return 'running';
if (isPlaceholderToolInputSummary(parsedInputSummary()))
return pendingToolActionState(props.tool.name);
return 'pending';
});
const progressText = createMemo(() => (props.tool.progress || '').trim());
const hasInput = createMemo(() => detailInputText().trim().length > 0);
const hasProgress = createMemo(() => progressText().length > 0);
const hasDetails = createMemo(() => hasInput() || hasProgress());
const summaryControlTitle = () => (showDetails() ? 'Hide tool details' : 'Show tool details');
const toggleDetails = () => {
if (!hasDetails()) return;
setShowDetails(!showDetails());
};
const handleSummaryKeyDown = (event: KeyboardEvent) => {
if (!hasDetails()) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleDetails();
};
const activityIconClass = createMemo(() => {
if (status() === 'waiting') return 'h-3 w-3 shrink-0 text-amber-500 dark:text-amber-300';
return 'h-3 w-3 shrink-0 animate-spin text-blue-500 dark:text-blue-400';
});
createEffect(() => {
if (!props.tool.startedAt || status() === 'waiting') return;
setNow(Date.now());
const interval = window.setInterval(() => setNow(Date.now()), 1000);
onCleanup(() => window.clearInterval(interval));
});
const elapsedLabel = createMemo(() => {
if (!props.tool.startedAt || status() === 'waiting') return '';
const elapsedSeconds = Math.max(0, Math.floor((now() - props.tool.startedAt) / 1000));
return elapsedSeconds >= 2 ? `${elapsedSeconds}s` : '';
});
return (
<div class="my-1 overflow-hidden rounded-md border border-blue-200 bg-blue-50/70 text-[11px] dark:border-blue-900/60 dark:bg-blue-950/20">
<div
class={`px-2.5 py-2 ${
hasDetails()
? 'cursor-pointer transition-colors hover:bg-blue-100/60 focus:outline-hidden focus:ring-2 focus:ring-blue-500/30 focus:ring-inset dark:hover:bg-blue-950/30'
: ''
}`}
role={hasDetails() ? 'button' : undefined}
tabIndex={hasDetails() ? 0 : undefined}
aria-expanded={hasDetails() ? showDetails() : undefined}
aria-controls={hasDetails() ? detailsId : undefined}
title={hasDetails() ? summaryControlTitle() : undefined}
onClick={toggleDetails}
onKeyDown={handleSummaryKeyDown}
>
<div class="flex min-w-0 items-start gap-2">
<div class="pt-0.5">
<Show
when={status() === 'waiting'}
fallback={<LoaderCircleIcon class={activityIconClass()} aria-label={statusLabel()} />}
>
<ClockIcon class={activityIconClass()} aria-label={statusLabel()} />
</Show>
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span class="shrink-0 font-mono text-[9px] font-semibold uppercase tracking-wider text-muted">
{toolLabel()}
</span>
<span class="shrink-0 text-[10px] font-medium text-muted">{statusLabel()}</span>
<Show when={elapsedLabel()}>
<span class="shrink-0 text-[10px] text-muted">{elapsedLabel()}</span>
</Show>
<Show when={hasDetails()}>
<ChevronRightIcon
class={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${
showDetails() ? 'rotate-90' : ''
}`}
aria-hidden="true"
/>
</Show>
</div>
<ToolInputSummary summary={inputSummary()} />
<Show when={commandPreview()}>
<ToolCommandPreview preview={commandPreview()} />
</Show>
<Show when={formingArgsPreview()}>
<div
class="mt-1 min-w-0 font-mono text-[10px] leading-snug text-muted"
aria-label="Tool input streaming"
>
<span class="block truncate">{formingArgsPreview()}</span>
</div>
</Show>
</div>
</div>
<Show when={progressText()}>
<div class="mt-1 min-w-0 pl-5.5 text-[10px] leading-snug text-muted">
<span class="block whitespace-pre-wrap wrap-break-word" title={progressText()}>
{progressText()}
</span>
</div>
</Show>
</div>
<Show when={showDetails() && hasDetails()}>
<ToolDetailsPanel
id={detailsId}
input={hasInput() ? detailInputText() : ''}
progress={hasProgress() ? progressText() : ''}
/>
</Show>
</div>
);
};
/**
* ToolCancellationBlock - Durable row for tool calls skipped by policy/runtime boundaries.
*/
export const ToolCancellationBlock: Component<ToolCancellationBlockProps> = (props) => {
const [showDetails, setShowDetails] = createSignal(false);
const detailsId = createUniqueId();
const toolLabel = createMemo(() => getToolLabel(props.tool.name));
const inputText = createMemo(() => toolValueText(props.tool.input));
const detailInputText = createMemo(() => toolDetailInputText(inputText(), props.tool.rawInput));
const parsedInputSummary = createMemo(() =>
parseToolInputSummary(inputText(), props.tool.name, props.tool.rawInput),
);
const inputSummary = createMemo(() => {
const summary = parsedInputSummary();
return isPlaceholderToolInputSummary(summary)
? pendingToolActionLabel(props.tool.name)
: summary;
});
const commandPreview = createMemo(() => {
const preview = parseToolCommandPreview(inputText(), props.tool.name, props.tool.rawInput);
const summary = inputSummary().trim();
if (!preview || summary.startsWith('$ ') || preview === summary) return '';
return preview;
});
const reason = createMemo(() => props.tool.reason?.trim() || 'Skipped before execution');
const hasInput = createMemo(() => detailInputText().trim().length > 0);
const hasReason = createMemo(() => reason().trim().length > 0);
const hasDetails = createMemo(() => hasInput() || hasReason());
const summaryControlTitle = () => (showDetails() ? 'Hide tool details' : 'Show tool details');
const toggleDetails = () => {
if (!hasDetails()) return;
setShowDetails(!showDetails());
};
const handleSummaryKeyDown = (event: KeyboardEvent) => {
if (!hasDetails()) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleDetails();
};
return (
<div
class="my-1 overflow-hidden rounded-md border border-amber-200 bg-amber-50/70 text-[11px] dark:border-amber-900/60 dark:bg-amber-950/20"
role="status"
aria-label="Assistant tool canceled"
title={reason()}
>
<div
class={`flex min-w-0 items-start gap-2 px-2.5 py-2 ${
hasDetails()
? 'cursor-pointer transition-colors hover:bg-amber-100/60 focus:outline-hidden focus:ring-2 focus:ring-amber-500/30 focus:ring-inset dark:hover:bg-amber-950/30'
: ''
}`}
role={hasDetails() ? 'button' : undefined}
tabIndex={hasDetails() ? 0 : undefined}
aria-expanded={hasDetails() ? showDetails() : undefined}
aria-controls={hasDetails() ? detailsId : undefined}
title={hasDetails() ? summaryControlTitle() : undefined}
onClick={toggleDetails}
onKeyDown={handleSummaryKeyDown}
>
<div class="pt-0.5">
<XCircleIcon
class="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-300"
aria-label="skipped"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span class="shrink-0 font-mono text-[9px] font-semibold uppercase tracking-wider text-muted">
{toolLabel()}
</span>
<span class="shrink-0 rounded-sm border border-amber-200 bg-amber-100 px-1.5 py-0.5 text-[9px] font-medium text-amber-800 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-200">
skipped
</span>
<Show when={hasDetails()}>
<ChevronRightIcon
class={`h-3.5 w-3.5 shrink-0 text-muted transition-transform ${
showDetails() ? 'rotate-90' : ''
}`}
aria-hidden="true"
/>
</Show>
</div>
<ToolInputSummary summary={inputSummary()} />
<Show when={commandPreview()}>
<ToolCommandPreview preview={commandPreview()} />
</Show>
<div class="mt-1 min-w-0 text-[10px] leading-snug text-muted">
<span class="block whitespace-pre-wrap wrap-break-word">{reason()}</span>
</div>
</div>
</div>
<Show when={showDetails() && hasDetails()}>
<ToolDetailsPanel
id={detailsId}
input={hasInput() ? detailInputText() : ''}
reason={hasReason() ? reason() : ''}
/>
</Show>
</div>
);
};
/**
* PendingToolsList - Groups multiple pending tools into a compact list
*/
interface PendingToolsListProps {
tools: PendingTool[];
}
export const PendingToolsList: Component<PendingToolsListProps> = (props) => {
const [expanded, setExpanded] = createSignal(false);
// If 3 or fewer, show all. Otherwise show collapsed.
const shouldCollapse = () => props.tools.length > 3;
const visibleTools = () => {
if (!shouldCollapse() || expanded()) return props.tools;
return props.tools.slice(0, 2);
};
const hiddenCount = () => props.tools.length - 2;
return (
<div class="my-1">
<For each={visibleTools()}>{(tool) => <PendingToolBlock tool={tool} />}</For>
<Show when={shouldCollapse() && !expanded()}>
<button
onClick={() => setExpanded(true)}
class="w-full mt-0.5 py-1 text-[10px] text-muted hover:text-base-content hover:bg-surface-hover rounded-sm text-center font-medium"
>
+ {hiddenCount()} more tools running...
</button>
</Show>
</div>
);
};
/**
* ToolExecutionsList - Compact list for multiple completed tools
*/
interface ToolExecutionsListProps {
tools: ToolExecution[];
}
export const ToolExecutionsList: Component<ToolExecutionsListProps> = (props) => {
const [showAll, setShowAll] = createSignal(false);
// If more than 5 tools, collapse them
const shouldCollapse = () => props.tools.length > 5;
const visibleTools = () => {
if (!shouldCollapse() || showAll()) return props.tools;
return props.tools.slice(0, 3);
};
const hiddenCount = () => props.tools.length - 3;
// Count successes/failures
const stats = createMemo(() => {
const success = props.tools.filter((t) => t.success).length;
const failed = props.tools.length - success;
return { success, failed };
});
return (
<div class="my-1">
<For each={visibleTools()}>{(tool) => <ToolExecutionBlock tool={tool} />}</For>
<Show when={shouldCollapse() && !showAll()}>
<button
onClick={() => setShowAll(true)}
class="w-full mt-0.5 py-1 text-[10px] text-muted hover:bg-surface-hover rounded-sm text-center font-medium"
>
+ {hiddenCount()} more tools ({stats().success} ✓ / {stats().failed} ✗)
</button>
</Show>
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -1,271 +0,0 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { ApprovalCard } from '../ApprovalCard';
import type { PendingApproval } from '../types';
afterEach(cleanup);
function makeApproval(overrides?: Partial<PendingApproval>): PendingApproval {
return {
command: 'systemctl restart nginx',
toolId: 'tool-1',
toolName: 'run_command',
runOnHost: false,
...overrides,
};
}
describe('ApprovalCard', () => {
it('renders the command text', () => {
render(() => <ApprovalCard approval={makeApproval()} onApprove={vi.fn()} onSkip={vi.fn()} />);
expect(screen.getByText('systemctl restart nginx')).toBeInTheDocument();
});
it('renders the "Approval Required" header', () => {
render(() => <ApprovalCard approval={makeApproval()} onApprove={vi.fn()} onSkip={vi.fn()} />);
expect(screen.getByText('Approval Required')).toBeInTheDocument();
});
it('renders Approve & Run and Skip buttons', () => {
render(() => <ApprovalCard approval={makeApproval()} onApprove={vi.fn()} onSkip={vi.fn()} />);
expect(screen.getByText('Approve & Run')).toBeInTheDocument();
expect(screen.getByText('Skip')).toBeInTheDocument();
});
it('calls onApprove when Approve & Run is clicked', () => {
const onApprove = vi.fn();
render(() => <ApprovalCard approval={makeApproval()} onApprove={onApprove} onSkip={vi.fn()} />);
fireEvent.click(screen.getByText('Approve & Run'));
expect(onApprove).toHaveBeenCalledOnce();
});
it('calls onSkip when Skip is clicked', () => {
const onSkip = vi.fn();
render(() => <ApprovalCard approval={makeApproval()} onApprove={vi.fn()} onSkip={onSkip} />);
fireEvent.click(screen.getByText('Skip'));
expect(onSkip).toHaveBeenCalledOnce();
});
it('shows "Agent" badge when runOnHost is true', () => {
render(() => (
<ApprovalCard
approval={makeApproval({ runOnHost: true })}
onApprove={vi.fn()}
onSkip={vi.fn()}
/>
));
expect(screen.getByText('Agent')).toBeInTheDocument();
});
it('does not show "Agent" badge when runOnHost is false', () => {
render(() => (
<ApprovalCard
approval={makeApproval({ runOnHost: false })}
onApprove={vi.fn()}
onSkip={vi.fn()}
/>
));
expect(screen.queryByText('Agent')).not.toBeInTheDocument();
});
it('shows target host when targetHost is set', () => {
render(() => (
<ApprovalCard
approval={makeApproval({ targetHost: 'web-server-01' })}
onApprove={vi.fn()}
onSkip={vi.fn()}
/>
));
expect(screen.getByText('→ web-server-01')).toBeInTheDocument();
});
it('does not show target host when targetHost is not set', () => {
render(() => (
<ApprovalCard
approval={makeApproval({ targetHost: undefined })}
onApprove={vi.fn()}
onSkip={vi.fn()}
/>
));
expect(screen.queryByText('→ web-server-01')).not.toBeInTheDocument();
});
it('does not show target host when targetHost is empty string', () => {
render(() => (
<ApprovalCard
approval={makeApproval({ targetHost: '' })}
onApprove={vi.fn()}
onSkip={vi.fn()}
/>
));
// Empty string is falsy, so Show should not render
expect(screen.queryByText('→')).not.toBeInTheDocument();
});
describe('executing state', () => {
it('shows "Running..." instead of "Run Command" when isExecuting is true', () => {
render(() => (
<ApprovalCard
approval={makeApproval({ isExecuting: true })}
onApprove={vi.fn()}
onSkip={vi.fn()}
/>
));
expect(screen.getByText('Running...')).toBeInTheDocument();
expect(screen.queryByText('Approve & Run')).not.toBeInTheDocument();
});
it('disables both buttons when isExecuting is true', () => {
render(() => (
<ApprovalCard
approval={makeApproval({ isExecuting: true })}
onApprove={vi.fn()}
onSkip={vi.fn()}
/>
));
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2);
for (const button of buttons) {
expect(button).toBeDisabled();
}
});
it('does not call onApprove when approve button is clicked while executing', () => {
const onApprove = vi.fn();
render(() => (
<ApprovalCard
approval={makeApproval({ isExecuting: true })}
onApprove={onApprove}
onSkip={vi.fn()}
/>
));
fireEvent.click(screen.getByRole('button', { name: /running/i }));
expect(onApprove).not.toHaveBeenCalled();
});
it('does not call onSkip when skip button is clicked while executing', () => {
const onSkip = vi.fn();
render(() => (
<ApprovalCard
approval={makeApproval({ isExecuting: true })}
onApprove={vi.fn()}
onSkip={onSkip}
/>
));
fireEvent.click(screen.getByRole('button', { name: /skip/i }));
expect(onSkip).not.toHaveBeenCalled();
});
});
it('shows both host badge and target host together', () => {
render(() => (
<ApprovalCard
approval={makeApproval({ runOnHost: true, targetHost: 'db-primary' })}
onApprove={vi.fn()}
onSkip={vi.fn()}
/>
));
expect(screen.getByText('Agent')).toBeInTheDocument();
expect(screen.getByText('→ db-primary')).toBeInTheDocument();
});
it('renders long commands without truncation', () => {
const longCommand = 'apt-get update && apt-get install -y nginx certbot python3-certbot-nginx';
render(() => (
<ApprovalCard
approval={makeApproval({ command: longCommand })}
onApprove={vi.fn()}
onSkip={vi.fn()}
/>
));
expect(screen.getByText(longCommand)).toBeInTheDocument();
});
it('renders governed action context when provided', () => {
render(() => (
<ApprovalCard
approval={makeApproval({
risk: 'high',
description: 'Restart the web service after applying the new config.',
targetHost: 'web1',
toolName: 'pulse_control',
})}
onApprove={vi.fn()}
onSkip={vi.fn()}
/>
));
expect(screen.getByText('HIGH')).toBeInTheDocument();
expect(
screen.getByText('Restart the web service after applying the new config.'),
).toBeInTheDocument();
expect(screen.getByText('pulse_control')).toBeInTheDocument();
expect(screen.getByText('web1')).toBeInTheDocument();
});
it('renders planned action and confidence details when provided', () => {
render(() => (
<ApprovalCard
approval={makeApproval({
auditId: 'action-123',
plan: {
action_id: 'action-123',
request_id: 'approval-123',
summary: 'Restart the nginx service.',
requires_approval: true,
approval_policy: 'admin',
blast_radius: 'service interruption on target',
rollback_available: true,
plan_hash: 'abcdef1234567890',
expires_at: '2026-04-23T12:30:00Z',
},
contextConfidence: {
level: 'verified',
summary: 'Target was resolved to a concrete resource before approval.',
evidence: ['Target identifier bound to agent-1.'],
},
preflight: {
target: 'agent:web1 (agent-1)',
current_state: 'Resolved approval target: agent:web1 (agent-1).',
intended_change: 'Restart nginx',
dry_run_available: false,
dry_run_summary: 'No provider-supported dry run is available for this action.',
safety_checks: ['Approval is scoped to this organization.'],
verification_steps: ['Read back the target state after execution.'],
generated_at: '2026-04-23T12:29:00Z',
},
})}
onApprove={vi.fn()}
onSkip={vi.fn()}
/>
));
expect(screen.getByText('Governed Plan')).toBeInTheDocument();
expect(screen.getByText('Restart the nginx service.')).toBeInTheDocument();
expect(screen.getByText('service interruption on target')).toBeInTheDocument();
expect(screen.getByText('VERIFIED')).toBeInTheDocument();
expect(screen.getByText('Target identifier bound to agent-1.')).toBeInTheDocument();
expect(screen.getByText('Preflight')).toBeInTheDocument();
expect(screen.getByText('No dry run')).toBeInTheDocument();
expect(screen.getByText('Restart nginx')).toBeInTheDocument();
expect(screen.getByText('Approval is scoped to this organization.')).toBeInTheDocument();
expect(screen.getByText('Read back the target state after execution.')).toBeInTheDocument();
expect(screen.getByText(/Audit action-123/)).toBeInTheDocument();
});
});
@@ -1,183 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { Show, createSignal } from 'solid-js';
import { AssistantCommandHelpDialog } from '../AssistantCommandHelpDialog';
afterEach(cleanup);
describe('AssistantCommandHelpDialog', () => {
it('focuses command search and filters commands through the shared slash matcher', async () => {
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={vi.fn()} />);
const search = screen.getByLabelText('Search Assistant commands');
await waitFor(() => expect(document.activeElement).toBe(search));
expect(search).toHaveAttribute('type', 'text');
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
fireEvent.input(search, { target: { value: 'runtime' } });
expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument();
expect(screen.getByRole('listbox', { name: 'Assistant commands' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /\/status/ })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: /\/models/ })).not.toBeInTheDocument();
});
it('runs the selected filtered command with Enter', () => {
const onRunCommand = vi.fn();
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={onRunCommand} />);
fireEvent.input(screen.getByLabelText('Search Assistant commands'), {
target: { value: 'runtime' },
});
fireEvent.keyDown(document, { key: 'Enter' });
expect(onRunCommand).toHaveBeenCalledWith(
expect.objectContaining({ action: 'status', name: 'status' }),
);
});
it('finds local stream fixtures from command help search', () => {
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={vi.fn()} />);
fireEvent.input(screen.getByLabelText('Search Assistant commands'), {
target: { value: 'provider-retry' },
});
expect(screen.getByRole('option', { name: /\/fixture/ })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /\/fixture/ })).toHaveTextContent('/fixture');
});
it('finds queued follow-up management from command help search', () => {
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={vi.fn()} />);
fireEvent.input(screen.getByLabelText('Search Assistant commands'), {
target: { value: 'queued' },
});
expect(screen.getByRole('option', { name: /\/queue/ })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /\/queue/ })).toHaveTextContent(
'Manage queued follow-ups',
);
});
it('moves command selection with arrow keys before running', () => {
const onRunCommand = vi.fn();
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={onRunCommand} />);
const sessionsCommand = screen.getByRole('option', { name: /\/sessions/ });
expect(sessionsCommand).toHaveAttribute('aria-selected', 'false');
fireEvent.keyDown(document, { key: 'ArrowDown' });
expect(sessionsCommand).toHaveAttribute('aria-selected', 'true');
fireEvent.keyDown(document, { key: 'Enter' });
expect(onRunCommand).toHaveBeenCalledWith(
expect.objectContaining({ action: 'sessions', name: 'sessions' }),
);
});
it('groups unfiltered commands by purpose without making headers selectable', () => {
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={vi.fn()} />);
expect(screen.getByText('Session')).toBeInTheDocument();
expect(screen.getByText('Model')).toBeInTheDocument();
expect(screen.getByText('Transcript')).toBeInTheDocument();
expect(screen.getByText('Help')).toBeInTheDocument();
expect(screen.getAllByRole('option')).toHaveLength(14);
});
it('hides command group headers once help search is filtered', () => {
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={vi.fn()} />);
fireEvent.input(screen.getByLabelText('Search Assistant commands'), {
target: { value: 'runtime' },
});
expect(screen.getByRole('option', { name: /\/status/ })).toBeInTheDocument();
expect(screen.queryByText('Model')).not.toBeInTheDocument();
});
it('keeps disabled commands selectable so the caller can explain them', () => {
const onRunCommand = vi.fn();
render(() => (
<AssistantCommandHelpDialog
availability={{
compact: {
disabled: true,
reason: 'Requires transcript content.',
},
}}
onClose={vi.fn()}
onRunCommand={onRunCommand}
/>
));
fireEvent.input(screen.getByLabelText('Search Assistant commands'), {
target: { value: 'compact' },
});
const compactCommand = screen.getByRole('option', { name: /\/compact/ });
expect(compactCommand).toHaveAttribute('aria-disabled', 'true');
expect(compactCommand).not.toBeDisabled();
expect(screen.getByText('Requires transcript content.')).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Enter' });
expect(onRunCommand).toHaveBeenCalledWith(
expect.objectContaining({ action: 'compact', name: 'compact' }),
);
});
it('consumes Escape as a local dialog close command', () => {
const onClose = vi.fn();
render(() => <AssistantCommandHelpDialog onClose={onClose} onRunCommand={vi.fn()} />);
expect(screen.getByRole('dialog', { name: 'Assistant commands' })).toBeInTheDocument();
const laterDocumentHandler = vi.fn();
document.addEventListener('keydown', laterDocumentHandler);
const escapeEvent = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Escape',
});
document.dispatchEvent(escapeEvent);
document.removeEventListener('keydown', laterDocumentHandler);
expect(escapeEvent.defaultPrevented).toBe(true);
expect(laterDocumentHandler).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledOnce();
});
it('contains focus, isolates the background, and restores the invoking control', async () => {
const [isOpen, setIsOpen] = createSignal(false);
render(() => (
<>
<button type="button" onClick={() => setIsOpen(true)}>
Open command help
</button>
<Show when={isOpen()}>
<AssistantCommandHelpDialog onClose={() => setIsOpen(false)} onRunCommand={vi.fn()} />
</Show>
</>
));
const trigger = screen.getByRole('button', { name: 'Open command help' });
trigger.focus();
fireEvent.click(trigger);
const search = await screen.findByLabelText('Search Assistant commands');
await waitFor(() => expect(search).toHaveFocus());
const dialog = screen.getByRole('dialog', { name: 'Assistant commands' });
expect(dialog.closest('[data-dialog-layer]')).not.toHaveAttribute('inert');
expect(trigger.closest('div')).toHaveAttribute('inert');
const options = screen.getAllByRole('option');
const lastOption = options[options.length - 1];
lastOption.focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(screen.getByRole('button', { name: 'Close Assistant commands' })).toHaveFocus();
fireEvent.click(screen.getByRole('button', { name: 'Close Assistant commands' }));
expect(screen.queryByRole('dialog', { name: 'Assistant commands' })).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
});
@@ -1,917 +0,0 @@
import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { ChatMessages } from '../ChatMessages';
import type { QueuedFollowUp } from '../hooks/useChat';
import type {
ChatMessage,
ModelRouteRecoveryOption,
PendingApproval,
PendingQuestion,
} from '../types';
// Capture callback props passed to MessageItem so we can invoke them in tests
let capturedMessageItemProps: Array<{
message: ChatMessage;
onApprove: (approval: PendingApproval) => void;
onSkip: (toolId: string) => void;
onAnswerQuestion: (
question: PendingQuestion,
answers: Array<{ id: string; value: string }>,
) => void;
onSkipQuestion: (questionId: string) => void;
onRegenerate?: () => void;
onEditPrompt?: () => void;
onChangeModel?: () => void;
getModelRouteLabel?: (modelId: string) => string;
modelRouteAlternative?: ModelRouteRecoveryOption | null;
onUseModelRoute?: (modelId: string, messageId?: string) => void;
queuedPosition?: number;
queuedCount?: number;
queuedPaused?: boolean;
onEditQueued?: () => void;
onCancelQueued?: () => void;
}> = [];
vi.mock('../MessageItem', () => ({
MessageItem: (props: {
message: ChatMessage;
onApprove: (approval: PendingApproval) => void;
onSkip: (toolId: string) => void;
onAnswerQuestion: (
question: PendingQuestion,
answers: Array<{ id: string; value: string }>,
) => void;
onSkipQuestion: (questionId: string) => void;
onRegenerate?: () => void;
onEditPrompt?: () => void;
onChangeModel?: () => void;
getModelRouteLabel?: (modelId: string) => string;
modelRouteAlternative?: ModelRouteRecoveryOption | null;
onUseModelRoute?: (modelId: string, messageId?: string) => void;
queuedPosition?: number;
queuedCount?: number;
queuedPaused?: boolean;
onEditQueued?: () => void;
onCancelQueued?: () => void;
}) => {
capturedMessageItemProps.push(props);
return (
<div data-testid={`message-item-${props.message.id}`} data-role={props.message.role}>
{props.message.content}
</div>
);
},
}));
// jsdom does not implement scrollIntoView
beforeEach(() => {
capturedMessageItemProps = [];
Element.prototype.scrollIntoView = vi.fn();
Element.prototype.scrollTo = vi.fn();
});
afterEach(cleanup);
function makeMessage(overrides?: Partial<ChatMessage>): ChatMessage {
return {
id: 'msg-1',
role: 'user',
content: 'Hello, AI!',
timestamp: new Date('2026-03-01T12:00:00Z'),
...overrides,
};
}
function makeHandlers() {
return {
onApprove: vi.fn(),
onSkip: vi.fn(),
onAnswerQuestion: vi.fn(),
onSkipQuestion: vi.fn(),
};
}
function setScrollMetrics(
element: Element,
metrics: {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
},
) {
Object.defineProperty(element, 'scrollTop', {
configurable: true,
value: metrics.scrollTop,
writable: true,
});
Object.defineProperty(element, 'scrollHeight', {
configurable: true,
value: metrics.scrollHeight,
});
Object.defineProperty(element, 'clientHeight', {
configurable: true,
value: metrics.clientHeight,
});
}
describe('ChatMessages', () => {
it('does not mutate shared tool evidence when completed status rows are removed', () => {
const a = { name: 'pulse_query', input: 'client', output: 'client evidence', success: true };
const b = { name: 'pulse_alerts', input: 'alerts', output: 'alert evidence', success: true };
const pendingA = { id: 'a', name: a.name, input: a.input };
const pendingB = { id: 'b', name: b.name, input: b.input };
const [messages, setMessages] = createSignal<ChatMessage[]>([
makeMessage({
role: 'assistant',
toolCalls: [],
streamEvents: [
{
type: 'workflow_status',
workflowStatus: { phase: 'provider_start', message: 'Starting' },
},
{ type: 'pending_tool', toolId: 'a', pendingTool: pendingA },
{ type: 'pending_tool', toolId: 'b', pendingTool: pendingB },
],
pendingTools: [pendingA, pendingB],
}),
]);
render(() => <ChatMessages messages={messages()} {...makeHandlers()} />);
setMessages([
makeMessage({
role: 'assistant',
toolCalls: [a],
streamEvents: [
{
type: 'workflow_status',
workflowStatus: { phase: 'provider_start', message: 'Starting' },
},
{ type: 'tool', toolId: 'a', tool: a },
{ type: 'pending_tool', toolId: 'b', pendingTool: pendingB },
],
pendingTools: [pendingB],
}),
]);
setMessages([
makeMessage({
role: 'assistant',
toolCalls: [a, b],
streamEvents: [
{
type: 'workflow_status',
workflowStatus: { phase: 'provider_start', message: 'Starting' },
},
{ type: 'tool', toolId: 'a', tool: a },
{ type: 'tool', toolId: 'b', tool: b },
],
pendingTools: [],
}),
]);
setMessages([
makeMessage({
role: 'assistant',
toolCalls: [a, b],
streamEvents: [
{ type: 'tool', toolId: 'a', tool: a },
{ type: 'tool', toolId: 'b', tool: b },
],
pendingTools: [],
}),
]);
expect(a).toEqual({
name: 'pulse_query',
input: 'client',
output: 'client evidence',
success: true,
});
expect(
capturedMessageItemProps[0].message.streamEvents?.map((event) => event.tool?.output),
).toEqual(['client evidence', 'alert evidence']);
});
describe('empty transcript', () => {
it('keeps the transcript blank when there are no messages or resume actions', () => {
const { container } = render(() => <ChatMessages messages={[]} {...makeHandlers()} />);
expect(screen.queryByText('Ask about your infrastructure')).not.toBeInTheDocument();
expect(screen.queryByText('Chat with your configured model')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Recent Assistant sessions')).not.toBeInTheDocument();
expect(container.querySelectorAll('[data-testid^="message-item-"]')).toHaveLength(0);
});
it('does not show empty transcript copy when there are messages', () => {
render(() => <ChatMessages messages={[makeMessage()]} {...makeHandlers()} />);
expect(screen.queryByText('Ask about your infrastructure')).not.toBeInTheDocument();
});
it('shows the welcome and suggested prompts in an empty transcript when wired', () => {
const onSuggestedPrompt = vi.fn();
render(() => (
<ChatMessages messages={[]} {...makeHandlers()} onSuggestedPrompt={onSuggestedPrompt} />
));
expect(screen.getByTestId('assistant-welcome')).toBeInTheDocument();
const prompts = screen.getAllByTestId('assistant-suggested-prompt');
expect(prompts).toHaveLength(3);
fireEvent.click(prompts[0]);
expect(onSuggestedPrompt).toHaveBeenCalledWith('How is my infrastructure looking right now?');
});
it('hides the welcome once messages exist', () => {
render(() => (
<ChatMessages messages={[makeMessage()]} {...makeHandlers()} onSuggestedPrompt={vi.fn()} />
));
expect(screen.queryByTestId('assistant-welcome')).not.toBeInTheDocument();
});
it('shows recent sessions as resume actions in an empty transcript', () => {
const onLoadSession = vi.fn();
render(() => (
<ChatMessages
messages={[]}
{...makeHandlers()}
recentSessions={[
{
id: 'session-1',
title: 'Storage follow-up',
created_at: '',
updated_at: '',
message_count: 4,
handoff_summary: {
kind: 'patrol_finding',
finding_id: 'finding-1',
has_model_context: true,
},
},
{
id: 'session-2',
title: 'Router question',
created_at: '',
updated_at: '',
message_count: 1,
},
]}
onLoadSession={onLoadSession}
/>
));
expect(screen.getByLabelText('Recent Assistant sessions')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Resume Storage follow-up' })).toHaveTextContent(
'4 messages',
);
expect(screen.getByRole('button', { name: 'Resume Storage follow-up' })).toHaveTextContent(
'Patrol Finding',
);
expect(screen.getByRole('button', { name: 'Resume Router question' })).toHaveTextContent(
'1 message',
);
fireEvent.click(screen.getByRole('button', { name: 'Resume Storage follow-up' }));
expect(onLoadSession).toHaveBeenCalledWith('session-1');
});
it('does not show recent session resume actions when messages are present', () => {
render(() => (
<ChatMessages
messages={[makeMessage()]}
{...makeHandlers()}
recentSessions={[
{
id: 'session-1',
title: 'Storage follow-up',
created_at: '',
updated_at: '',
message_count: 4,
},
]}
onLoadSession={vi.fn()}
/>
));
expect(screen.queryByLabelText('Recent Assistant sessions')).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Resume Storage follow-up' }),
).not.toBeInTheDocument();
});
});
describe('message rendering', () => {
it('renders a single message via MessageItem', () => {
render(() => (
<ChatMessages
messages={[makeMessage({ id: 'msg-1', content: 'Hello world' })]}
{...makeHandlers()}
/>
));
expect(screen.getByTestId('message-item-msg-1')).toBeInTheDocument();
expect(screen.getByText('Hello world')).toBeInTheDocument();
});
it('renders multiple messages in DOM order', () => {
const messages = [
makeMessage({ id: 'msg-1', role: 'user', content: 'Question one' }),
makeMessage({ id: 'msg-2', role: 'assistant', content: 'Answer one' }),
makeMessage({ id: 'msg-3', role: 'user', content: 'Question two' }),
];
const { container } = render(() => <ChatMessages messages={messages} {...makeHandlers()} />);
const items = container.querySelectorAll('[data-testid^="message-item-"]');
expect(items).toHaveLength(3);
// Verify DOM order matches message order
expect(items[0]).toHaveAttribute('data-testid', 'message-item-msg-1');
expect(items[1]).toHaveAttribute('data-testid', 'message-item-msg-2');
expect(items[2]).toHaveAttribute('data-testid', 'message-item-msg-3');
});
it('renders both user and assistant messages', () => {
const messages = [
makeMessage({ id: 'user-1', role: 'user', content: 'User message' }),
makeMessage({ id: 'asst-1', role: 'assistant', content: 'Assistant message' }),
];
render(() => <ChatMessages messages={messages} {...makeHandlers()} />);
const userMsg = screen.getByTestId('message-item-user-1');
const asstMsg = screen.getByTestId('message-item-asst-1');
expect(userMsg).toHaveAttribute('data-role', 'user');
expect(asstMsg).toHaveAttribute('data-role', 'assistant');
});
it('passes queue position and row actions to queued message items', () => {
const onEditQueuedFollowUp = vi.fn();
const onCancelQueuedFollowUp = vi.fn();
const queuedFollowUps: QueuedFollowUp[] = [
{
id: 'queue-1',
messageId: 'queued-user-1',
prompt: 'first queued turn',
timestamp: new Date('2026-03-01T12:01:00Z'),
},
{
id: 'queue-2',
messageId: 'queued-user-2',
prompt: 'second queued turn',
timestamp: new Date('2026-03-01T12:02:00Z'),
},
];
render(() => (
<ChatMessages
messages={[
makeMessage({
id: 'queued-user-1',
role: 'user',
content: 'first queued turn',
delivery: 'queued',
}),
makeMessage({
id: 'queued-user-2',
role: 'user',
content: 'second queued turn',
delivery: 'queued',
}),
]}
{...makeHandlers()}
queuedFollowUps={queuedFollowUps}
onEditQueuedFollowUp={onEditQueuedFollowUp}
onCancelQueuedFollowUp={onCancelQueuedFollowUp}
/>
));
const firstQueued = capturedMessageItemProps.find((p) => p.message.id === 'queued-user-1');
const secondQueued = capturedMessageItemProps.find((p) => p.message.id === 'queued-user-2');
expect(firstQueued).toMatchObject({ queuedPosition: 1, queuedCount: 2 });
expect(secondQueued).toMatchObject({ queuedPosition: 2, queuedCount: 2 });
expect(firstQueued?.queuedPaused).toBe(false);
expect(secondQueued?.queuedPaused).toBe(false);
firstQueued?.onEditQueued?.();
secondQueued?.onCancelQueued?.();
expect(onEditQueuedFollowUp).toHaveBeenCalledWith('queue-1');
expect(onCancelQueuedFollowUp).toHaveBeenCalledWith('queue-2');
});
it('passes paused queue state to queued message items', () => {
const queuedFollowUps: QueuedFollowUp[] = [
{
id: 'queue-1',
messageId: 'queued-user-1',
prompt: 'paused queued turn',
timestamp: new Date('2026-03-01T12:01:00Z'),
},
];
render(() => (
<ChatMessages
messages={[
makeMessage({
id: 'queued-user-1',
role: 'user',
content: 'paused queued turn',
delivery: 'queued',
}),
]}
{...makeHandlers()}
queuedFollowUps={queuedFollowUps}
queuedFollowUpsPaused
/>
));
const queuedItem = capturedMessageItemProps.find((p) => p.message.id === 'queued-user-1');
expect(queuedItem).toMatchObject({
queuedPosition: 1,
queuedCount: 1,
queuedPaused: true,
});
});
it('renders the scroll anchor element', () => {
const { container } = render(() => (
<ChatMessages messages={[makeMessage()]} {...makeHandlers()} />
));
const anchor = container.querySelector('.h-1');
expect(anchor).toBeInTheDocument();
});
it('renders no message items when messages is empty', () => {
const { container } = render(() => <ChatMessages messages={[]} {...makeHandlers()} />);
expect(container.querySelectorAll('[data-testid^="message-item-"]')).toHaveLength(0);
});
it('renders a large number of messages', () => {
const messages = Array.from({ length: 50 }, (_, i) =>
makeMessage({ id: `msg-${i}`, content: `Message ${i}` }),
);
const { container } = render(() => <ChatMessages messages={messages} {...makeHandlers()} />);
expect(container.querySelectorAll('[data-testid^="message-item-"]')).toHaveLength(50);
});
});
describe('callback forwarding', () => {
it('forwards onApprove with message.id prepended', () => {
const handlers = makeHandlers();
render(() => <ChatMessages messages={[makeMessage({ id: 'msg-42' })]} {...handlers} />);
const propsForMsg = capturedMessageItemProps.find((p) => p.message.id === 'msg-42');
expect(propsForMsg).toBeDefined();
const fakeApproval: PendingApproval = {
command: 'systemctl restart nginx',
toolId: 'tool-abc',
toolName: 'run_command',
runOnHost: false,
};
propsForMsg!.onApprove(fakeApproval);
expect(handlers.onApprove).toHaveBeenCalledWith('msg-42', fakeApproval);
});
it('provides onRegenerate only for the last settled assistant answer', () => {
const handlers = makeHandlers();
const onRegenerate = vi.fn();
const messages = [
makeMessage({ id: 'u1', role: 'user', content: 'first prompt' }),
makeMessage({ id: 'a1', role: 'assistant', content: 'first answer' }),
makeMessage({ id: 'u2', role: 'user', content: 'second prompt' }),
makeMessage({ id: 'a2', role: 'assistant', content: 'second answer' }),
];
render(() => <ChatMessages messages={messages} {...handlers} onRegenerate={onRegenerate} />);
const earlier = capturedMessageItemProps.find((p) => p.message.id === 'a1');
const last = capturedMessageItemProps.find((p) => p.message.id === 'a2');
expect(earlier!.onRegenerate).toBeUndefined();
expect(last!.onRegenerate).toBeDefined();
last!.onRegenerate!();
expect(onRegenerate).toHaveBeenCalledWith('a2');
});
it('withholds onRegenerate while the last answer is streaming, failed, or awaiting input', () => {
const handlers = makeHandlers();
const onRegenerate = vi.fn();
const variants: Array<Partial<ChatMessage>> = [
{ isStreaming: true },
{ error: 'provider unreachable' },
{
pendingApprovals: [
{ command: 'reboot', toolId: 't1', toolName: 'pulse_control', runOnHost: false },
],
},
];
for (const overrides of variants) {
capturedMessageItemProps = [];
const { unmount } = render(() => (
<ChatMessages
messages={[
makeMessage({ id: 'u1', role: 'user', content: 'prompt' }),
makeMessage({ id: 'a1', role: 'assistant', content: 'answer', ...overrides }),
]}
{...handlers}
onRegenerate={onRegenerate}
/>
));
const last = capturedMessageItemProps.find((p) => p.message.id === 'a1');
expect(last!.onRegenerate).toBeUndefined();
unmount();
}
});
it('provides onEditPrompt only for the latest non-queued user prompt', () => {
const handlers = makeHandlers();
const onEditPrompt = vi.fn();
const messages = [
makeMessage({ id: 'u1', role: 'user', content: 'first prompt' }),
makeMessage({ id: 'a1', role: 'assistant', content: 'first answer' }),
makeMessage({ id: 'u2', role: 'user', content: 'second prompt' }),
makeMessage({ id: 'a2', role: 'assistant', content: 'second answer' }),
makeMessage({ id: 'q1', role: 'user', content: 'queued follow-up', delivery: 'queued' }),
];
render(() => <ChatMessages messages={messages} {...handlers} onEditPrompt={onEditPrompt} />);
const earlier = capturedMessageItemProps.find((p) => p.message.id === 'u1');
const latest = capturedMessageItemProps.find((p) => p.message.id === 'u2');
const queued = capturedMessageItemProps.find((p) => p.message.id === 'q1');
expect(earlier!.onEditPrompt).toBeUndefined();
expect(queued!.onEditPrompt).toBeUndefined();
expect(latest!.onEditPrompt).toBeDefined();
latest!.onEditPrompt!();
expect(onEditPrompt).toHaveBeenCalledWith('u2');
});
it('forwards onSkip with message.id prepended', () => {
const handlers = makeHandlers();
render(() => <ChatMessages messages={[makeMessage({ id: 'msg-99' })]} {...handlers} />);
const propsForMsg = capturedMessageItemProps.find((p) => p.message.id === 'msg-99');
propsForMsg!.onSkip('tool-xyz');
expect(handlers.onSkip).toHaveBeenCalledWith('msg-99', 'tool-xyz');
});
it('forwards onAnswerQuestion with message.id prepended', () => {
const handlers = makeHandlers();
render(() => <ChatMessages messages={[makeMessage({ id: 'msg-7' })]} {...handlers} />);
const propsForMsg = capturedMessageItemProps.find((p) => p.message.id === 'msg-7');
const fakeQuestion: PendingQuestion = {
questionId: 'q-1',
questions: [{ id: 'q-1', type: 'text', question: 'What is the host?' }],
};
const fakeAnswers = [{ id: 'q-1', value: 'web-01' }];
propsForMsg!.onAnswerQuestion(fakeQuestion, fakeAnswers);
expect(handlers.onAnswerQuestion).toHaveBeenCalledWith('msg-7', fakeQuestion, fakeAnswers);
});
it('forwards onSkipQuestion with message.id prepended', () => {
const handlers = makeHandlers();
render(() => <ChatMessages messages={[makeMessage({ id: 'msg-5' })]} {...handlers} />);
const propsForMsg = capturedMessageItemProps.find((p) => p.message.id === 'msg-5');
propsForMsg!.onSkipQuestion('q-77');
expect(handlers.onSkipQuestion).toHaveBeenCalledWith('msg-5', 'q-77');
});
it('forwards onChangeModel unchanged for failed-turn recovery', () => {
const onChangeModel = vi.fn();
render(() => (
<ChatMessages
messages={[makeMessage({ id: 'msg-model', role: 'assistant', error: 'failed' })]}
{...makeHandlers()}
onChangeModel={onChangeModel}
/>
));
const propsForMsg = capturedMessageItemProps.find((p) => p.message.id === 'msg-model');
expect(propsForMsg?.onChangeModel).toBe(onChangeModel);
});
it('forwards message-specific route recovery options for failed turns', () => {
const routeAlternative: ModelRouteRecoveryOption = {
id: 'openrouter:deepseek/deepseek-v4-pro',
kind: 'same-model-route',
label: 'DeepSeek: DeepSeek V4 Pro via OpenRouter',
provider: 'openrouter',
providerLabel: 'OpenRouter',
};
const getModelRouteAlternative = vi.fn(() => routeAlternative);
const onUseModelRoute = vi.fn();
const failedMessage = makeMessage({
id: 'msg-route',
role: 'assistant',
error: 'failed',
});
render(() => (
<ChatMessages
messages={[failedMessage]}
{...makeHandlers()}
getModelRouteAlternative={getModelRouteAlternative}
onUseModelRoute={onUseModelRoute}
/>
));
const propsForMsg = capturedMessageItemProps.find((p) => p.message.id === 'msg-route');
const forwardedAlternative = propsForMsg?.modelRouteAlternative;
expect(getModelRouteAlternative).toHaveBeenCalledWith(failedMessage);
expect(forwardedAlternative).toBe(routeAlternative);
expect(propsForMsg?.onUseModelRoute).toBe(onUseModelRoute);
});
it('forwards correct message.id when multiple messages exist', () => {
const handlers = makeHandlers();
render(() => (
<ChatMessages
messages={[makeMessage({ id: 'msg-a' }), makeMessage({ id: 'msg-b' })]}
{...handlers}
/>
));
const propsA = capturedMessageItemProps.find((p) => p.message.id === 'msg-a');
const propsB = capturedMessageItemProps.find((p) => p.message.id === 'msg-b');
propsA!.onSkip('tool-1');
propsB!.onSkip('tool-2');
expect(handlers.onSkip).toHaveBeenNthCalledWith(1, 'msg-a', 'tool-1');
expect(handlers.onSkip).toHaveBeenNthCalledWith(2, 'msg-b', 'tool-2');
});
});
describe('auto-scroll behavior', () => {
it('scrolls the conversation without scrolling outer page ancestors', () => {
render(() => <ChatMessages messages={[makeMessage({ id: 'msg-1' })]} {...makeHandlers()} />);
// The owned conversation scrolls, never its ancestors.
expect(Element.prototype.scrollTo).toHaveBeenCalled();
expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled();
});
it('reacts to in-place pending tool progress without a new stream event', async () => {
const [messages, setMessages] = createSignal<ChatMessage[]>([
makeMessage({
id: 'assistant-1',
role: 'assistant',
content: '',
isStreaming: true,
pendingTools: [
{
id: 'tool-1',
name: 'pulse_read',
input: '{"command":"ls /dev | wc -l"}',
status: 'running',
progress: 'Starting command',
startedAt: 1_000,
updatedAt: 1_000,
},
],
streamEvents: [
{
type: 'pending_tool',
toolId: 'tool-1',
pendingTool: {
id: 'tool-1',
name: 'pulse_read',
input: '{"command":"ls /dev | wc -l"}',
status: 'running',
progress: 'Starting command',
startedAt: 1_000,
updatedAt: 1_000,
},
},
],
}),
]);
render(() => <ChatMessages messages={messages()} {...makeHandlers()} />);
const scrollTo = Element.prototype.scrollTo as ReturnType<typeof vi.fn>;
scrollTo.mockClear();
setMessages([
makeMessage({
id: 'assistant-1',
role: 'assistant',
content: '',
isStreaming: true,
pendingTools: [
{
id: 'tool-1',
name: 'pulse_read',
input: '{"command":"ls /dev | wc -l"}',
status: 'running',
progress: 'Reading device nodes',
startedAt: 1_000,
updatedAt: 2_000,
},
],
streamEvents: [
{
type: 'pending_tool',
toolId: 'tool-1',
pendingTool: {
id: 'tool-1',
name: 'pulse_read',
input: '{"command":"ls /dev | wc -l"}',
status: 'running',
progress: 'Reading device nodes',
startedAt: 1_000,
updatedAt: 2_000,
},
},
],
}),
]);
await Promise.resolve();
expect(scrollTo).toHaveBeenCalled();
});
it('keeps following live output when a large streaming update grows from the bottom', async () => {
const [messages, setMessages] = createSignal<ChatMessage[]>([
makeMessage({
id: 'assistant-1',
role: 'assistant',
content: 'Starting',
isStreaming: true,
}),
]);
render(() => <ChatMessages messages={messages()} {...makeHandlers()} />);
const scrollContainer = screen.getByTestId('assistant-message-list');
const scrollTo = Element.prototype.scrollTo as ReturnType<typeof vi.fn>;
setScrollMetrics(scrollContainer, {
scrollTop: 800,
scrollHeight: 1000,
clientHeight: 200,
});
fireEvent.scroll(scrollContainer);
scrollTo.mockClear();
setScrollMetrics(scrollContainer, {
scrollTop: 800,
scrollHeight: 1400,
clientHeight: 200,
});
setMessages([
makeMessage({
id: 'assistant-1',
role: 'assistant',
content: `${'Streaming update. '.repeat(80)}`,
isStreaming: true,
}),
]);
await Promise.resolve();
expect(scrollTo).toHaveBeenCalledWith({ top: 1400, behavior: 'instant' });
});
it('does not pull the transcript back down after the user scrolls away from live output', async () => {
const [messages, setMessages] = createSignal<ChatMessage[]>([
makeMessage({
id: 'assistant-1',
role: 'assistant',
content: 'Starting',
isStreaming: true,
}),
]);
render(() => <ChatMessages messages={messages()} {...makeHandlers()} />);
const scrollContainer = screen.getByTestId('assistant-message-list');
const scrollTo = Element.prototype.scrollTo as ReturnType<typeof vi.fn>;
setScrollMetrics(scrollContainer, {
scrollTop: 100,
scrollHeight: 1000,
clientHeight: 200,
});
fireEvent.scroll(scrollContainer);
scrollTo.mockClear();
setMessages([
makeMessage({
id: 'assistant-1',
role: 'assistant',
content: `${'Streaming update. '.repeat(80)}`,
isStreaming: true,
}),
]);
await Promise.resolve();
expect(scrollTo).not.toHaveBeenCalled();
});
it('shows a jump to latest control when the user scrolls away from messages', () => {
render(() => <ChatMessages messages={[makeMessage({ id: 'msg-1' })]} {...makeHandlers()} />);
const scrollContainer = screen.getByTestId('assistant-message-list');
expect(
screen.queryByRole('button', { name: 'Jump to latest Assistant message' }),
).not.toBeInTheDocument();
setScrollMetrics(scrollContainer, {
scrollTop: 100,
scrollHeight: 1000,
clientHeight: 200,
});
fireEvent.scroll(scrollContainer);
expect(
screen.getByRole('button', { name: 'Jump to latest Assistant message' }),
).toBeInTheDocument();
});
it('jumps back to live output and hides the control when selected', () => {
render(() => <ChatMessages messages={[makeMessage({ id: 'msg-1' })]} {...makeHandlers()} />);
const scrollContainer = screen.getByTestId('assistant-message-list');
const scrollTo = Element.prototype.scrollTo as ReturnType<typeof vi.fn>;
setScrollMetrics(scrollContainer, {
scrollTop: 100,
scrollHeight: 1000,
clientHeight: 200,
});
fireEvent.scroll(scrollContainer);
scrollTo.mockClear();
fireEvent.click(screen.getByRole('button', { name: 'Jump to latest Assistant message' }));
expect(scrollTo).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' });
expect(
screen.queryByRole('button', { name: 'Jump to latest Assistant message' }),
).not.toBeInTheDocument();
});
it('does not show the jump to latest control in an empty transcript', () => {
render(() => <ChatMessages messages={[]} {...makeHandlers()} />);
expect(
screen.queryByRole('button', { name: 'Jump to latest Assistant message' }),
).not.toBeInTheDocument();
});
it('does not call scrollIntoView when messages list is empty', () => {
// Reset the mock to clear any prior calls
(Element.prototype.scrollTo as ReturnType<typeof vi.fn>).mockClear();
render(() => <ChatMessages messages={[]} {...makeHandlers()} />);
expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled();
});
});
describe('container structure', () => {
it('renders the scrollable container with correct classes', () => {
const { container } = render(() => <ChatMessages messages={[]} {...makeHandlers()} />);
const viewport = container.firstElementChild;
const scrollContainer = screen.getByTestId('assistant-message-list');
expect(viewport).toHaveClass('relative', 'flex-1', 'min-h-0');
expect(scrollContainer).toHaveClass('h-full', 'overflow-y-auto');
expect(scrollContainer).toHaveAttribute('data-testid', 'assistant-message-list');
});
});
describe('streaming stability', () => {
// useChat hands ChatMessages a freshly-rebuilt array on every stream event,
// with a brand-new message object (same id) each time. The list must
// reconcile by id so a streaming message keeps its DOM node instead of being
// torn down and recreated on every tick — that re-mount churn is the visible
// flashing / rows popping in and out this guards against.
it('keeps a streaming message mounted in place across rebuilt arrays', () => {
const [msgs, setMsgs] = createSignal<ChatMessage[]>([
makeMessage({ id: 'm1', role: 'assistant', content: 'Hel', isStreaming: true }),
]);
render(() => <ChatMessages messages={msgs()} {...makeHandlers()} />);
const nodeBefore = screen.getByTestId('message-item-m1');
expect(nodeBefore).toHaveTextContent('Hel');
const instancesForM1 = () =>
capturedMessageItemProps.filter((p) => p.message.id === 'm1').length;
expect(instancesForM1()).toBe(1);
// Next stream tick: a brand-new message object, same id, more content.
setMsgs([
makeMessage({ id: 'm1', role: 'assistant', content: 'Hello world', isStreaming: true }),
]);
const nodeAfter = screen.getByTestId('message-item-m1');
// Same DOM element instance: reconciled in place, not recreated.
expect(nodeAfter).toBe(nodeBefore);
expect(nodeAfter).toHaveTextContent('Hello world');
// MessageItem was instantiated exactly once for this id across both renders.
expect(instancesForM1()).toBe(1);
});
});
});
@@ -1,453 +0,0 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { MentionAutocomplete } from '../MentionAutocomplete';
import type { MentionResource } from '../MentionAutocomplete';
afterEach(cleanup);
function makeResource(overrides?: Partial<MentionResource>): MentionResource {
return {
id: 'vm-100',
label: 'web-server',
type: 'vm',
status: 'running',
node: 'pve1',
...overrides,
};
}
const defaultResources: MentionResource[] = [
makeResource({
id: 'vm-100',
label: 'web-server',
type: 'vm',
status: 'running',
node: 'pve1',
}),
makeResource({
id: 'ct-200',
label: 'db-container',
type: 'system-container',
status: 'running',
node: 'pve2',
}),
makeResource({ id: 'node-1', label: 'pve1', type: 'agent', status: 'running' }),
makeResource({
id: 'docker-1',
label: 'nginx-proxy',
type: 'app-container',
status: 'running',
node: 'docker-host',
}),
makeResource({
id: 'host-1',
label: 'bare-metal-01',
type: 'agent',
status: 'stopped',
node: undefined,
}),
makeResource({
id: 'storage-1',
label: 'nvme-primary',
type: 'storage',
status: 'online',
node: 'Lab VC',
}),
];
const defaultPosition = { top: 100, left: 200 };
function renderAutocomplete(overrides?: Record<string, unknown>) {
const props = {
query: '',
resources: defaultResources,
position: defaultPosition,
onSelect: vi.fn(),
onClose: vi.fn(),
visible: true,
...overrides,
};
return { ...render(() => <MentionAutocomplete {...props} />), props };
}
describe('MentionAutocomplete', () => {
describe('visibility', () => {
it('renders the dropdown when visible is true and resources exist', () => {
renderAutocomplete();
expect(screen.getByText('Resources')).toBeInTheDocument();
});
it('does not render when visible is false', () => {
renderAutocomplete({ visible: false });
expect(screen.queryByText('Resources')).not.toBeInTheDocument();
});
it('does not render when no resources match the query', () => {
renderAutocomplete({ query: 'zzz-nonexistent-resource' });
expect(screen.queryByText('Resources')).not.toBeInTheDocument();
});
});
describe('filtering', () => {
it('shows all resources (up to 10) when query is empty', () => {
renderAutocomplete({ query: '' });
for (const r of defaultResources) {
expect(screen.getByText(r.label)).toBeInTheDocument();
}
});
it('filters resources by name case-insensitively', () => {
renderAutocomplete({ query: 'WEB' });
expect(screen.getByText('web-server')).toBeInTheDocument();
expect(screen.queryByText('db-container')).not.toBeInTheDocument();
});
it('filters by partial match', () => {
renderAutocomplete({ query: 'container' });
expect(screen.getByText('db-container')).toBeInTheDocument();
expect(screen.queryByText('web-server')).not.toBeInTheDocument();
});
it('limits results to 10 items', () => {
const manyResources = Array.from({ length: 15 }, (_, i) =>
makeResource({ id: `vm-${i}`, label: `server-${i}` }),
);
renderAutocomplete({ resources: manyResources, query: '' });
// First 10 should be present
for (let i = 0; i < 10; i++) {
expect(screen.getByText(`server-${i}`)).toBeInTheDocument();
}
// 11th and beyond should not
expect(screen.queryByText('server-10')).not.toBeInTheDocument();
});
});
describe('resource display', () => {
it('shows the resource type label', () => {
renderAutocomplete({ query: 'web' });
// "vm" text is inside a div alongside " · " and node name, so use a substring match
const resourceBtn = screen.getByText('web-server').closest('button')!;
expect(resourceBtn.textContent).toContain('vm');
});
it('shows agent type label for agent resources', () => {
renderAutocomplete({ query: 'bare-metal' });
const resourceBtn = screen.getByText('bare-metal-01').closest('button')!;
expect(resourceBtn.textContent).toContain('agent');
expect(resourceBtn.textContent).not.toContain('host');
});
it('shows the node name when present', () => {
renderAutocomplete({ query: 'web' });
expect(screen.getByText('pve1', { exact: false })).toBeInTheDocument();
});
it('shows the storage type label for storage resources', () => {
renderAutocomplete({ query: 'nvme' });
const resourceBtn = screen.getByText('nvme-primary').closest('button')!;
expect(resourceBtn.textContent).toContain('storage');
expect(resourceBtn.textContent).toContain('Lab VC');
});
it('does not show node separator when node is absent', () => {
renderAutocomplete({ query: 'bare-metal' });
const resourceBtn = screen.getByText('bare-metal-01').closest('button')!;
// Should contain "agent" but NOT " · "
const typeDiv = resourceBtn.querySelector('.text-xs.text-muted')!;
expect(typeDiv.textContent).toContain('agent');
expect(typeDiv.textContent).not.toContain(' · ');
});
it('shows status indicator dot for resources with status', () => {
renderAutocomplete({ query: 'web' });
// The status dot uses the shared success variant.
const resourceBtn = screen.getByText('web-server').closest('button')!;
const statusDot = resourceBtn.querySelector('.bg-emerald-500');
expect(statusDot).not.toBeNull();
});
it('shows red status dot for stopped resources', () => {
renderAutocomplete({ query: 'bare-metal' });
const resourceBtn = screen.getByText('bare-metal-01').closest('button')!;
const statusDot = resourceBtn.querySelector('.bg-red-500');
expect(statusDot).not.toBeNull();
});
it('exposes resources as a named listbox with selected option state', () => {
renderAutocomplete();
const listbox = screen.getByRole('listbox', { name: 'Assistant resources' });
expect(listbox).toBeInTheDocument();
expect(listbox.parentElement).toHaveClass('w-[calc(100vw-36px)]');
const firstOption = screen.getByRole('option', {
name: 'Mention web-server: vm on pve1, running',
});
expect(firstOption).toHaveAttribute('aria-selected', 'true');
expect(firstOption).toHaveAttribute('id', 'assistant-mention-option-0');
expect(firstOption).toHaveAttribute('tabindex', '-1');
fireEvent.keyDown(document, { key: 'ArrowDown' });
expect(firstOption).toHaveAttribute('aria-selected', 'false');
expect(
screen.getByRole('option', {
name: 'Mention db-container: system-container on pve2, running',
}),
).toHaveAttribute('aria-selected', 'true');
});
it('reports the active descendant and keeps it visible while focus remains in the composer', async () => {
const onActiveDescendantChange = vi.fn();
renderAutocomplete({ onActiveDescendantChange });
expect(screen.getByRole('listbox', { name: 'Assistant resources' })).toHaveAttribute(
'id',
'assistant-mention-listbox',
);
expect(onActiveDescendantChange).toHaveBeenLastCalledWith('assistant-mention-option-0');
const secondOption = screen.getByRole('option', {
name: 'Mention db-container: system-container on pve2, running',
});
const scrollIntoView = vi.fn();
Object.defineProperty(secondOption, 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});
fireEvent.keyDown(document, { key: 'ArrowDown' });
expect(onActiveDescendantChange).toHaveBeenLastCalledWith('assistant-mention-option-1');
await vi.waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }));
});
});
describe('click interaction', () => {
it('calls onSelect when a resource is clicked', () => {
const onSelect = vi.fn();
renderAutocomplete({ onSelect });
const option = screen.getByText('web-server').closest('button')!;
const pointerDown = new Event('pointerdown', { bubbles: true, cancelable: true });
option.dispatchEvent(pointerDown);
expect(pointerDown.defaultPrevented).toBe(true);
fireEvent.click(option);
expect(onSelect).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith(defaultResources[0]);
});
it('calls onSelect with the correct resource for non-first items', () => {
const onSelect = vi.fn();
renderAutocomplete({ onSelect });
fireEvent.click(screen.getByText('db-container'));
expect(onSelect).toHaveBeenCalledWith(defaultResources[1]);
});
});
describe('keyboard navigation', () => {
it('selects the next item on ArrowDown', () => {
const onSelect = vi.fn();
renderAutocomplete({ onSelect });
// ArrowDown to move to index 1
fireEvent.keyDown(document, { key: 'ArrowDown' });
// Enter to select
fireEvent.keyDown(document, { key: 'Enter' });
expect(onSelect).toHaveBeenCalledWith(defaultResources[1]);
});
it('selects the previous item on ArrowUp', () => {
const onSelect = vi.fn();
renderAutocomplete({ onSelect });
// Move down twice, then up once → index 1
fireEvent.keyDown(document, { key: 'ArrowDown' });
fireEvent.keyDown(document, { key: 'ArrowDown' });
fireEvent.keyDown(document, { key: 'ArrowUp' });
fireEvent.keyDown(document, { key: 'Enter' });
expect(onSelect).toHaveBeenCalledWith(defaultResources[1]);
});
it('wraps from the first item to the last item on ArrowUp', () => {
const onSelect = vi.fn();
renderAutocomplete({ onSelect });
fireEvent.keyDown(document, { key: 'ArrowUp' });
fireEvent.keyDown(document, { key: 'Enter' });
expect(onSelect).toHaveBeenCalledWith(defaultResources[defaultResources.length - 1]);
});
it('wraps from the last item to the first item on ArrowDown', () => {
const onSelect = vi.fn();
const twoResources = defaultResources.slice(0, 2);
renderAutocomplete({ onSelect, resources: twoResources });
fireEvent.keyDown(document, { key: 'ArrowUp' });
fireEvent.keyDown(document, { key: 'ArrowDown' });
fireEvent.keyDown(document, { key: 'Enter' });
expect(onSelect).toHaveBeenCalledWith(twoResources[0]);
});
it('selects on Tab key', () => {
const onSelect = vi.fn();
renderAutocomplete({ onSelect });
fireEvent.keyDown(document, { key: 'Tab' });
expect(onSelect).toHaveBeenCalledWith(defaultResources[0]);
});
it('calls onClose on Escape', () => {
const onClose = vi.fn();
renderAutocomplete({ onClose });
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledOnce();
});
it('consumes owned keyboard events before later document handlers see them', () => {
const onClose = vi.fn();
renderAutocomplete({ onClose });
const laterDocumentHandler = vi.fn();
document.addEventListener('keydown', laterDocumentHandler);
const escapeEvent = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Escape',
});
document.dispatchEvent(escapeEvent);
document.removeEventListener('keydown', laterDocumentHandler);
expect(escapeEvent.defaultPrevented).toBe(true);
expect(laterDocumentHandler).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledOnce();
});
it('does not respond to keyboard events when not visible', () => {
const onSelect = vi.fn();
const onClose = vi.fn();
// Render as visible first to register the listener, then check behavior
// The component guards with `if (!props.visible) return;` inside handleKeyDown
renderAutocomplete({ visible: false, onSelect, onClose });
// Since the component is not visible, the keydown listener is not registered,
// so dispatching events should have no effect
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(onSelect).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
});
});
describe('mouse hover', () => {
it('updates selected index on mouse enter', () => {
const onSelect = vi.fn();
renderAutocomplete({ onSelect });
// Hover over the second item
fireEvent.mouseEnter(screen.getByText('db-container').closest('button')!);
// Then press Enter to confirm it's selected
fireEvent.keyDown(document, { key: 'Enter' });
expect(onSelect).toHaveBeenCalledWith(defaultResources[1]);
});
});
describe('positioning', () => {
it('applies position styles to the dropdown container', () => {
renderAutocomplete({ position: { top: 50, left: 120 } });
const container = screen.getByText('Resources').closest('div[style]')!;
expect(container).toHaveStyle({ bottom: '50px', left: '120px' });
});
});
describe('footer hints', () => {
it('shows keyboard navigation hints', () => {
renderAutocomplete();
// Footer text is split across span + text nodes; check the footer container text
const footer = document.querySelector('.border-t.border-border.text-xs.text-muted')!;
expect(footer).not.toBeNull();
expect(footer.textContent).toContain('navigate');
expect(footer.textContent).toContain('select');
expect(footer.textContent).toContain('close');
});
});
describe('type icons', () => {
it('renders an SVG icon for each resource type', () => {
renderAutocomplete();
// Scope to the scrollable resource list container only
const listContainer = document.querySelector('.max-h-\\[240px\\].overflow-y-auto')!;
expect(listContainer).not.toBeNull();
const resourceButtons = listContainer.querySelectorAll('button');
expect(resourceButtons.length).toBe(defaultResources.length);
resourceButtons.forEach((btn) => {
const svg = btn.querySelector('svg');
expect(svg).not.toBeNull();
});
});
});
describe('edge cases', () => {
it('handles empty resources list', () => {
renderAutocomplete({ resources: [] });
expect(screen.queryByText('Resources')).not.toBeInTheDocument();
});
it('handles undefined status gracefully — no status dot rendered', () => {
const resources = [makeResource({ status: undefined })];
renderAutocomplete({ resources });
expect(screen.getByText('web-server')).toBeInTheDocument();
// When status is undefined, <Show when={resource.status}> hides the dot
const resourceBtn = screen.getByText('web-server').closest('button')!;
const statusDot = resourceBtn.querySelector('.rounded-full');
expect(statusDot).toBeNull();
});
it('handles undefined node gracefully', () => {
const resources = [makeResource({ node: undefined })];
renderAutocomplete({ resources });
expect(screen.getByText('web-server')).toBeInTheDocument();
});
it('handles paused status', () => {
const resources = [makeResource({ status: 'paused' })];
renderAutocomplete({ resources });
const resourceBtn = screen.getByText('web-server').closest('button')!;
const statusDot = resourceBtn.querySelector('.rounded-full');
expect(statusDot).not.toBeNull();
});
it('handles unknown status with default color', () => {
const resources = [makeResource({ status: 'migrating' })];
renderAutocomplete({ resources });
const resourceBtn = screen.getByText('web-server').closest('button')!;
const statusDot = resourceBtn.querySelector('.bg-slate-400');
expect(statusDot).not.toBeNull();
});
});
});
describe('provider-scoped mentions', () => {
it('shows and searches site names without collapsing equal labels', () => {
const resources = [
makeResource({ id: 'vm-site-a', label: 'duplicate', scope: 'Site A' }),
makeResource({ id: 'vm-site-b', label: 'duplicate', scope: 'Site B' }),
];
const selected = vi.fn();
renderAutocomplete({ resources, query: 'site b', onSelect: selected });
expect(screen.queryByRole('option', { name: /in Site A/ })).not.toBeInTheDocument();
const option = screen.getByRole('option', { name: /in Site B/ });
expect(option).toHaveTextContent('Site B');
fireEvent.click(option);
expect(selected).toHaveBeenCalledWith(resources[1]);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,864 +0,0 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { ModelSelector } from '../ModelSelector';
import type { ModelInfo } from '../types';
afterEach(cleanup);
function makeModel(overrides?: Partial<ModelInfo>): ModelInfo {
return {
id: 'anthropic:claude-sonnet-4',
name: 'Claude Sonnet 4',
description: 'Fast and capable',
notable: true,
...overrides,
};
}
const SAMPLE_MODELS: ModelInfo[] = [
makeModel({ id: 'anthropic:claude-sonnet-4', name: 'Claude Sonnet 4', notable: true }),
makeModel({ id: 'anthropic:claude-opus-4', name: 'Claude Opus 4', notable: true }),
makeModel({
id: 'openai:gpt-4o',
name: 'GPT-4o',
description: 'OpenAI flagship',
notable: true,
}),
makeModel({
id: 'anthropic:claude-3-haiku',
name: 'Claude 3 Haiku',
description: 'Legacy model',
notable: false,
}),
makeModel({
id: 'ollama:llama3',
name: 'Llama 3',
description: 'Local model',
notable: false,
}),
];
describe('ModelSelector', () => {
// --- Rendering & Selected Label ---
it('renders the selected model label', () => {
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel="anthropic:claude-sonnet-4"
onModelSelect={vi.fn()}
/>
));
expect(screen.getByText('Claude Sonnet 4')).toBeInTheDocument();
});
it('keeps gateway-routed selected model labels distinct', () => {
render(() => (
<ModelSelector
models={[
...SAMPLE_MODELS,
makeModel({
id: 'openrouter:deepseek/deepseek-v4-pro',
name: 'DeepSeek: DeepSeek V4 Pro',
provider: 'openrouter',
notable: true,
}),
]}
selectedModel="openrouter:deepseek/deepseek-v4-pro"
onModelSelect={vi.fn()}
/>
));
expect(screen.getByText('DeepSeek: DeepSeek V4 Pro via OpenRouter')).toBeInTheDocument();
});
it('shows "Default" when no model is selected', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
expect(screen.getByText('Default')).toBeInTheDocument();
});
it('shows the default model label with a "default" indicator when no selection', () => {
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel=""
defaultModelLabel="Claude Sonnet 4"
onModelSelect={vi.fn()}
/>
));
expect(screen.getByText('Claude Sonnet 4')).toBeInTheDocument();
expect(screen.getByText('default')).toBeInTheDocument();
});
it('formats gateway route IDs when selectedModel is not in the models list', () => {
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel="openrouter:qwen/qwen3.7-plus"
onModelSelect={vi.fn()}
/>
));
expect(screen.getByText('Qwen: Qwen3.7 Plus via OpenRouter')).toBeInTheDocument();
});
it('shows the loading spinner when isLoading is true', () => {
const { container } = render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel=""
isLoading={true}
onModelSelect={vi.fn()}
/>
));
// The spinner is an animate-spin SVG
const spinners = container.querySelectorAll('.animate-spin');
expect(spinners.length).toBeGreaterThan(0);
});
// --- Dropdown Open/Close ---
it('opens dropdown when button is clicked', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
// Dropdown not open initially
expect(screen.queryByPlaceholderText('Search or enter model ID')).not.toBeInTheDocument();
fireEvent.click(screen.getByTitle('Select model for this chat'));
// Dropdown is now open — search input visible
expect(screen.getByPlaceholderText('Search or enter model ID')).toBeInTheDocument();
});
it('left-aligns the dropdown for composer chrome placement', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
const dropdown = document.querySelector('[data-ai-model-picker] .fixed') as HTMLElement;
expect(dropdown.style.left).toBe('0px');
expect(dropdown.style.right).toBe('');
expect(dropdown.className).toContain('max-w-[calc(100vw-2rem)]');
});
it('opens dropdown and focuses search when an external recovery action requests it', async () => {
const [openRequest, setOpenRequest] = createSignal(0);
render(() => (
<>
<button type="button" onClick={() => setOpenRequest((value) => value + 1)}>
Recovery action
</button>
<ModelSelector
models={SAMPLE_MODELS}
selectedModel=""
openRequest={openRequest()}
onModelSelect={vi.fn()}
/>
</>
));
expect(screen.queryByPlaceholderText('Search or enter model ID')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Recovery action' }));
const searchInput = await screen.findByPlaceholderText('Search or enter model ID');
await waitFor(() => {
expect(document.activeElement).toBe(searchInput);
});
});
it('closes dropdown when toggled again', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
const button = screen.getByTitle('Select model for this chat');
fireEvent.click(button);
expect(screen.getByPlaceholderText('Search or enter model ID')).toBeInTheDocument();
fireEvent.click(button);
expect(screen.queryByPlaceholderText('Search or enter model ID')).not.toBeInTheDocument();
});
// --- Default Option ---
it('renders the Default option in dropdown', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.getByText('Use configured default model')).toBeInTheDocument();
});
it('renders default label with model name when defaultModelLabel provided', () => {
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel=""
defaultModelLabel="GPT-4o"
onModelSelect={vi.fn()}
/>
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.getByText('Use configured default model (GPT-4o)')).toBeInTheDocument();
});
// --- Chat Override Option ---
it('shows chat override option when chatOverrideModel is provided', () => {
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel=""
chatOverrideModel="openai:gpt-4o"
chatOverrideLabel="GPT-4o (override)"
onModelSelect={vi.fn()}
/>
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.getByText('Chat override')).toBeInTheDocument();
expect(screen.getByText('GPT-4o (override)')).toBeInTheDocument();
});
it('formats chatOverrideModel when chatOverrideLabel is not provided', () => {
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel=""
chatOverrideModel="openrouter:qwen/qwen3.7-plus"
onModelSelect={vi.fn()}
/>
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
const overrideButton = screen.getByText('Chat override').closest('button')!;
expect(overrideButton.textContent).toContain('Qwen: Qwen3.7 Plus via OpenRouter');
});
it('hides chat override option when it matches the effective default model', () => {
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel=""
defaultModel="openrouter:qwen/qwen3.7-plus"
defaultModelLabel="Qwen: Qwen3.7 Plus via OpenRouter"
chatOverrideModel="openrouter:qwen/qwen3.7-plus"
chatOverrideLabel="Qwen: Qwen3.7 Plus via OpenRouter"
onModelSelect={vi.fn()}
/>
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.queryByText('Chat override')).not.toBeInTheDocument();
expect(
screen.getByText('Use configured default model (Qwen: Qwen3.7 Plus via OpenRouter)'),
).toBeInTheDocument();
});
it('hides chat override option when it is already selected', () => {
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel="openai:gpt-4o"
chatOverrideModel="openai:gpt-4o"
chatOverrideLabel="GPT-4o"
onModelSelect={vi.fn()}
/>
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.queryByText('Chat override')).not.toBeInTheDocument();
});
it('does not show chat override option when chatOverrideModel is not provided', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.queryByText('Chat override')).not.toBeInTheDocument();
});
// --- Model Selection ---
it('calls onModelSelect with empty string when Default is clicked', () => {
const onModelSelect = vi.fn();
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel="anthropic:claude-sonnet-4"
onModelSelect={onModelSelect}
/>
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
const defaultOption = screen.getByRole('option', {
name: /Default.*Use configured default model/,
});
fireEvent.click(defaultOption);
expect(onModelSelect).toHaveBeenCalledWith('');
});
it('calls onModelSelect with model ID when a model is clicked', () => {
const onModelSelect = vi.fn();
render(() => (
<ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={onModelSelect} />
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
fireEvent.click(screen.getByText('GPT-4o'));
expect(onModelSelect).toHaveBeenCalledWith('openai:gpt-4o');
});
it('closes dropdown after selection', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.getByPlaceholderText('Search or enter model ID')).toBeInTheDocument();
fireEvent.click(screen.getByText('GPT-4o'));
expect(screen.queryByPlaceholderText('Search or enter model ID')).not.toBeInTheDocument();
});
it('calls onModelSelect with chatOverrideModel when chat override is clicked', () => {
const onModelSelect = vi.fn();
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel=""
chatOverrideModel="openai:gpt-4o"
chatOverrideLabel="Override"
onModelSelect={onModelSelect}
/>
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
fireEvent.click(screen.getByText('Chat override'));
expect(onModelSelect).toHaveBeenCalledWith('openai:gpt-4o');
});
it('shows recent model routes before the provider catalog', () => {
render(() => (
<ModelSelector
models={[
...SAMPLE_MODELS,
makeModel({
id: 'openrouter:deepseek/deepseek-v4-pro',
name: 'DeepSeek: DeepSeek V4 Pro',
provider: 'openrouter',
notable: true,
}),
]}
selectedModel=""
recentModelIds={['openrouter:deepseek/deepseek-v4-pro', 'openai:gpt-4o']}
onModelSelect={vi.fn()}
/>
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
const recentHeader = screen.getByText('Recent');
const deepSeekRecent = screen.getByText('DeepSeek: DeepSeek V4 Pro via OpenRouter');
const anthropicHeader = screen.getByText('Anthropic');
expect(
recentHeader.compareDocumentPosition(deepSeekRecent) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
deepSeekRecent.compareDocumentPosition(anthropicHeader) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it('keeps explicit recent custom routes available when they are not in the catalog', () => {
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel=""
recentModelIds={['openrouter:custom/model']}
onModelSelect={vi.fn()}
/>
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.getByText('Custom: Model via OpenRouter')).toBeInTheDocument();
expect(screen.getByText('Recent custom model route')).toBeInTheDocument();
});
// --- Notable Model Filtering ---
it('shows only notable models by default', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
// Notable models should be visible
expect(screen.getByText('Claude Sonnet 4')).toBeInTheDocument();
expect(screen.getByText('Claude Opus 4')).toBeInTheDocument();
expect(screen.getByText('GPT-4o')).toBeInTheDocument();
// Non-notable models should be hidden
expect(screen.queryByText('Claude 3 Haiku')).not.toBeInTheDocument();
expect(screen.queryByText('Llama 3')).not.toBeInTheDocument();
});
it('shows toggle with hidden model count', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
// 2 non-notable models
expect(screen.getByText('Show 2 older models')).toBeInTheDocument();
});
it('shows all models after clicking the toggle', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
fireEvent.click(screen.getByText('Show 2 older models'));
// All models should now be visible
expect(screen.getByText('Claude 3 Haiku')).toBeInTheDocument();
expect(screen.getByText('Llama 3')).toBeInTheDocument();
// Toggle should now say "Hide"
expect(screen.getByText('Hide older models')).toBeInTheDocument();
});
it('falls back to showing all models when no models are notable', () => {
const allNonNotable = SAMPLE_MODELS.map((m) => ({ ...m, notable: false }));
render(() => <ModelSelector models={allNonNotable} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
// All models should be visible since none are notable
expect(screen.getByText('Claude Sonnet 4')).toBeInTheDocument();
expect(screen.getByText('Llama 3')).toBeInTheDocument();
});
// --- Search / Filtering ---
it('filters models by name when searching', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.input(searchInput, { target: { value: 'opus' } });
expect(screen.getByText('Claude Opus 4')).toBeInTheDocument();
// Other models filtered out
expect(screen.queryByText('GPT-4o')).not.toBeInTheDocument();
});
it('searches all models including non-notable when query is present', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.input(searchInput, { target: { value: 'llama' } });
// Non-notable model should appear in search results
expect(screen.getByText('Llama 3')).toBeInTheDocument();
});
it('searches by model ID', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.input(searchInput, { target: { value: 'openai:gpt' } });
expect(screen.getByText('GPT-4o')).toBeInTheDocument();
expect(screen.queryByText('Claude Sonnet 4')).not.toBeInTheDocument();
});
it('searches by description', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.input(searchInput, { target: { value: 'flagship' } });
expect(screen.getByText('GPT-4o')).toBeInTheDocument();
});
it('searches by provider display name', () => {
// Use "Google Gemini" as query — this only matches the PROVIDER_DISPLAY_NAMES mapping,
// not the model ID or name, ensuring we're testing provider-name search specifically.
const modelsWithGemini = [
...SAMPLE_MODELS,
makeModel({
id: 'gemini:gemini-2.0-flash',
name: 'Gemini 2.0 Flash',
description: 'Fast multimodal',
notable: true,
}),
];
render(() => (
<ModelSelector models={modelsWithGemini} selectedModel="" onModelSelect={vi.fn()} />
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
// "Google" only matches via PROVIDER_DISPLAY_NAMES["gemini"] = "Google Gemini"
fireEvent.input(searchInput, { target: { value: 'Google' } });
expect(screen.getByText('Gemini 2.0 Flash')).toBeInTheDocument();
expect(screen.queryByText('Claude Sonnet 4')).not.toBeInTheDocument();
expect(screen.queryByText('GPT-4o')).not.toBeInTheDocument();
});
it('shows "No matching models" and no model rows when search has no results', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.input(searchInput, { target: { value: 'zzz-nonexistent-zzz' } });
expect(screen.getByText('No matching models.')).toBeInTheDocument();
// Verify no model entries are rendered alongside the empty message
expect(screen.queryByText('Claude Sonnet 4')).not.toBeInTheDocument();
expect(screen.queryByText('GPT-4o')).not.toBeInTheDocument();
expect(screen.queryByText('Llama 3')).not.toBeInTheDocument();
});
it('hides the show older models toggle when searching', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.getByText('Show 2 older models')).toBeInTheDocument();
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.input(searchInput, { target: { value: 'claude' } });
expect(screen.queryByText(/older models/)).not.toBeInTheDocument();
});
// --- Custom Model Route ---
it('shows custom model option when an explicit route does not match any model ID', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.input(searchInput, { target: { value: 'openrouter:custom/model' } });
expect(screen.getByText('Use "openrouter:custom/model"')).toBeInTheDocument();
expect(screen.getByText('Custom provider:model route')).toBeInTheDocument();
});
it('does not offer a custom model option for plain search terms', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.input(searchInput, { target: { value: 'my-custom-model' } });
expect(screen.queryByText('Use "my-custom-model"')).not.toBeInTheDocument();
expect(screen.getByText('No matching models.')).toBeInTheDocument();
});
it('does not offer malformed custom model routes', () => {
const onModelSelect = vi.fn();
render(() => (
<ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={onModelSelect} />
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
for (const value of ['openrouter:', ':custom/model', 'https://openrouter.ai/models/foo']) {
fireEvent.input(searchInput, { target: { value } });
expect(screen.queryByText(`Use "${value}"`)).not.toBeInTheDocument();
fireEvent.keyDown(searchInput, { key: 'Enter' });
expect(onModelSelect).not.toHaveBeenCalled();
}
});
it('does not show custom model option when query exactly matches an existing model ID', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.input(searchInput, { target: { value: 'anthropic:claude-sonnet-4' } });
expect(screen.queryByText('Custom provider:model route')).not.toBeInTheDocument();
});
it('selects custom model when clicked', () => {
const onModelSelect = vi.fn();
render(() => (
<ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={onModelSelect} />
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.input(searchInput, { target: { value: 'openrouter:custom/model' } });
fireEvent.click(screen.getByText('Use "openrouter:custom/model"'));
expect(onModelSelect).toHaveBeenCalledWith('openrouter:custom/model');
});
it('selects custom model on Enter key', () => {
const onModelSelect = vi.fn();
render(() => (
<ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={onModelSelect} />
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.input(searchInput, { target: { value: 'openrouter:custom/model' } });
fireEvent.keyDown(searchInput, { key: 'Enter' });
expect(onModelSelect).toHaveBeenCalledWith('openrouter:custom/model');
});
it('does not submit on non-Enter keys', () => {
const onModelSelect = vi.fn();
render(() => (
<ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={onModelSelect} />
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.input(searchInput, { target: { value: 'my-custom-model' } });
fireEvent.keyDown(searchInput, { key: 'Escape' });
expect(onModelSelect).not.toHaveBeenCalled();
});
// --- Error Display ---
it('shows error message when error prop is provided', () => {
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel=""
error="Failed to fetch models"
onModelSelect={vi.fn()}
/>
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.getByText('Failed to fetch models')).toBeInTheDocument();
});
it('does not show error section when no error', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.queryByText('Failed to fetch models')).not.toBeInTheDocument();
});
// --- Refresh Button ---
it('shows refresh button when onRefresh is provided', () => {
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel=""
onRefresh={vi.fn()}
onModelSelect={vi.fn()}
/>
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.getByTitle('Refresh models')).toBeInTheDocument();
});
it('does not show refresh button when onRefresh is not provided', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.queryByTitle('Refresh models')).not.toBeInTheDocument();
});
it('calls onRefresh when refresh button is clicked', () => {
const onRefresh = vi.fn();
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel=""
onRefresh={onRefresh}
onModelSelect={vi.fn()}
/>
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
fireEvent.click(screen.getByTitle('Refresh models'));
expect(onRefresh).toHaveBeenCalledOnce();
});
it('disables refresh button when loading', () => {
render(() => (
<ModelSelector
models={SAMPLE_MODELS}
selectedModel=""
isLoading={true}
onRefresh={vi.fn()}
onModelSelect={vi.fn()}
/>
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.getByTitle('Refresh models')).toBeDisabled();
});
// --- Provider Grouping ---
it('groups models by provider with headers', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
// Should see provider headers (only notable models shown by default)
expect(screen.getByText('Anthropic')).toBeInTheDocument();
expect(screen.getByText('OpenAI')).toBeInTheDocument();
});
// --- Selected Model Highlighting ---
it('highlights the currently selected model', () => {
render(() => (
<ModelSelector models={SAMPLE_MODELS} selectedModel="openai:gpt-4o" onModelSelect={vi.fn()} />
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
// GPT-4o appears twice: in the top button label and in the dropdown list.
const allGpt4o = screen.getAllByText('GPT-4o');
const dropdownEntry = allGpt4o.find((el) => el.closest('[role="listbox"]') !== null);
expect(dropdownEntry).toBeDefined();
const modelButton = dropdownEntry!.closest('button');
expect(modelButton?.className).toContain('bg-blue-50');
});
// --- Model name display ---
it('falls back to a humanized provider route label when name is not provided', () => {
const models = [makeModel({ id: 'anthropic:claude-test', name: '', notable: true })];
render(() => (
<ModelSelector
models={models}
selectedModel="anthropic:claude-test"
onModelSelect={vi.fn()}
/>
));
expect(screen.getByText('Anthropic: Claude Test')).toBeInTheDocument();
});
// --- Empty models list ---
it('handles empty models list gracefully', () => {
render(() => <ModelSelector models={[]} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
// Should still show Default option and no crash
expect(screen.getByText('Use configured default model')).toBeInTheDocument();
});
// --- Search resets on close ---
it('resets search query after selection', () => {
render(() => <ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />);
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.input(searchInput, { target: { value: 'opus' } });
// Select a model
fireEvent.click(screen.getByText('Claude Opus 4'));
// Re-open dropdown
fireEvent.click(screen.getByTitle('Select model for this chat'));
const newSearchInput = screen.getByPlaceholderText(
'Search or enter model ID',
) as HTMLInputElement;
expect(newSearchInput.value).toBe('');
});
// --- Click outside ---
it('closes dropdown when clicking outside the dropdown container', () => {
render(() => (
<div>
<span data-testid="outside">Outside</span>
<ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={vi.fn()} />
</div>
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
expect(screen.getByPlaceholderText('Search or enter model ID')).toBeInTheDocument();
// Simulate click outside the dropdown container
fireEvent.mouseDown(screen.getByTestId('outside'));
expect(screen.queryByPlaceholderText('Search or enter model ID')).not.toBeInTheDocument();
});
// --- Enter key with whitespace trimming ---
it('trims whitespace when submitting custom model via Enter', () => {
const onModelSelect = vi.fn();
render(() => (
<ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={onModelSelect} />
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.input(searchInput, { target: { value: ' openrouter:custom/model ' } });
fireEvent.keyDown(searchInput, { key: 'Enter' });
// customModelCandidate trims the search query
expect(onModelSelect).toHaveBeenCalledWith('openrouter:custom/model');
});
it('does not submit on Enter when search query is empty', () => {
const onModelSelect = vi.fn();
render(() => (
<ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={onModelSelect} />
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
fireEvent.keyDown(searchInput, { key: 'Enter' });
expect(onModelSelect).not.toHaveBeenCalled();
});
it('submits existing model ID on Enter (selects by candidate, not custom)', () => {
const onModelSelect = vi.fn();
render(() => (
<ModelSelector models={SAMPLE_MODELS} selectedModel="" onModelSelect={onModelSelect} />
));
fireEvent.click(screen.getByTitle('Select model for this chat'));
const searchInput = screen.getByPlaceholderText('Search or enter model ID');
// Type an exact existing model ID — custom option hidden, but Enter still submits
fireEvent.input(searchInput, { target: { value: 'anthropic:claude-sonnet-4' } });
fireEvent.keyDown(searchInput, { key: 'Enter' });
expect(onModelSelect).toHaveBeenCalledWith('anthropic:claude-sonnet-4');
});
});
@@ -1,410 +0,0 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { QuestionCard } from '../QuestionCard';
import type { PendingQuestion, Question } from '../types';
afterEach(cleanup);
function makeQuestion(overrides?: Partial<Question>): Question {
return {
id: 'q1',
type: 'text',
question: 'What is the hostname?',
...overrides,
};
}
function makePendingQuestion(overrides?: Partial<PendingQuestion>): PendingQuestion {
return {
questionId: 'pq-1',
questions: [makeQuestion()],
...overrides,
};
}
describe('QuestionCard', () => {
it('renders the header "Pulse Assistant needs your input"', () => {
render(() => (
<QuestionCard question={makePendingQuestion()} onAnswer={vi.fn()} onSkip={vi.fn()} />
));
expect(screen.getByText('Pulse Assistant needs your input')).toBeInTheDocument();
});
it('renders the question text', () => {
render(() => (
<QuestionCard question={makePendingQuestion()} onAnswer={vi.fn()} onSkip={vi.fn()} />
));
expect(screen.getByText('What is the hostname?')).toBeInTheDocument();
});
it('renders Submit Answer and Skip buttons', () => {
render(() => (
<QuestionCard question={makePendingQuestion()} onAnswer={vi.fn()} onSkip={vi.fn()} />
));
expect(screen.getByText('Submit Answer')).toBeInTheDocument();
expect(screen.getByText('Skip')).toBeInTheDocument();
});
it('renders a text input for type "text" questions', () => {
render(() => (
<QuestionCard question={makePendingQuestion()} onAnswer={vi.fn()} onSkip={vi.fn()} />
));
expect(screen.getByPlaceholderText('Type your answer...')).toBeInTheDocument();
});
it('renders the question header when provided', () => {
render(() => (
<QuestionCard
question={makePendingQuestion({
questions: [makeQuestion({ header: 'Network Configuration' })],
})}
onAnswer={vi.fn()}
onSkip={vi.fn()}
/>
));
expect(screen.getByText('Network Configuration')).toBeInTheDocument();
});
it('does not render a header when not provided', () => {
render(() => (
<QuestionCard
question={makePendingQuestion({
questions: [makeQuestion({ header: undefined })],
})}
onAnswer={vi.fn()}
onSkip={vi.fn()}
/>
));
expect(screen.queryByText('Network Configuration')).not.toBeInTheDocument();
});
describe('text input', () => {
it('submit button is disabled when text input is empty', () => {
render(() => (
<QuestionCard question={makePendingQuestion()} onAnswer={vi.fn()} onSkip={vi.fn()} />
));
const submitBtn = screen.getByText('Submit Answer').closest('button')!;
expect(submitBtn).toBeDisabled();
});
it('submit button is enabled after typing an answer', () => {
render(() => (
<QuestionCard question={makePendingQuestion()} onAnswer={vi.fn()} onSkip={vi.fn()} />
));
const input = screen.getByPlaceholderText('Type your answer...');
fireEvent.input(input, { target: { value: 'my-host' } });
const submitBtn = screen.getByText('Submit Answer').closest('button')!;
expect(submitBtn).not.toBeDisabled();
});
it('calls onAnswer with the typed value when submitted', () => {
const onAnswer = vi.fn();
render(() => (
<QuestionCard question={makePendingQuestion()} onAnswer={onAnswer} onSkip={vi.fn()} />
));
const input = screen.getByPlaceholderText('Type your answer...');
fireEvent.input(input, { target: { value: 'web-server-01' } });
fireEvent.click(screen.getByText('Submit Answer'));
expect(onAnswer).toHaveBeenCalledOnce();
expect(onAnswer).toHaveBeenCalledWith([{ id: 'q1', value: 'web-server-01' }]);
});
it('submit button stays disabled for whitespace-only input', () => {
render(() => (
<QuestionCard question={makePendingQuestion()} onAnswer={vi.fn()} onSkip={vi.fn()} />
));
const input = screen.getByPlaceholderText('Type your answer...');
fireEvent.input(input, { target: { value: ' ' } });
const submitBtn = screen.getByText('Submit Answer').closest('button')!;
expect(submitBtn).toBeDisabled();
});
});
describe('select input', () => {
const selectQuestion = makePendingQuestion({
questions: [
makeQuestion({
id: 'q-select',
type: 'select',
question: 'Choose an action',
options: [
{ label: 'Restart', value: 'restart', description: 'Restart the service' },
{ label: 'Stop', value: 'stop' },
],
}),
],
});
it('renders option labels', () => {
render(() => <QuestionCard question={selectQuestion} onAnswer={vi.fn()} onSkip={vi.fn()} />);
expect(screen.getByText('Restart')).toBeInTheDocument();
expect(screen.getByText('Stop')).toBeInTheDocument();
});
it('renders option descriptions when provided', () => {
render(() => <QuestionCard question={selectQuestion} onAnswer={vi.fn()} onSkip={vi.fn()} />);
expect(screen.getByText('Restart the service')).toBeInTheDocument();
});
it('submit is disabled before selecting an option', () => {
render(() => <QuestionCard question={selectQuestion} onAnswer={vi.fn()} onSkip={vi.fn()} />);
const submitBtn = screen.getByText('Submit Answer').closest('button')!;
expect(submitBtn).toBeDisabled();
});
it('submit is enabled after selecting an option', () => {
render(() => <QuestionCard question={selectQuestion} onAnswer={vi.fn()} onSkip={vi.fn()} />);
fireEvent.click(screen.getByText('Restart'));
const submitBtn = screen.getByText('Submit Answer').closest('button')!;
expect(submitBtn).not.toBeDisabled();
});
it('calls onAnswer with the selected option value', () => {
const onAnswer = vi.fn();
render(() => <QuestionCard question={selectQuestion} onAnswer={onAnswer} onSkip={vi.fn()} />);
fireEvent.click(screen.getByText('Stop'));
fireEvent.click(screen.getByText('Submit Answer'));
expect(onAnswer).toHaveBeenCalledOnce();
expect(onAnswer).toHaveBeenCalledWith([{ id: 'q-select', value: 'stop' }]);
});
it('selecting a different option changes the answer', () => {
const onAnswer = vi.fn();
render(() => <QuestionCard question={selectQuestion} onAnswer={onAnswer} onSkip={vi.fn()} />);
fireEvent.click(screen.getByText('Restart'));
fireEvent.click(screen.getByText('Stop'));
fireEvent.click(screen.getByText('Submit Answer'));
expect(onAnswer).toHaveBeenCalledWith([{ id: 'q-select', value: 'stop' }]);
});
});
describe('multiple questions', () => {
const multiQuestion = makePendingQuestion({
questions: [
makeQuestion({ id: 'q-name', question: 'Enter the name' }),
makeQuestion({
id: 'q-action',
type: 'select',
question: 'Choose action',
options: [
{ label: 'Create', value: 'create' },
{ label: 'Update', value: 'update' },
],
}),
],
});
it('renders all questions', () => {
render(() => <QuestionCard question={multiQuestion} onAnswer={vi.fn()} onSkip={vi.fn()} />);
expect(screen.getByText('Enter the name')).toBeInTheDocument();
expect(screen.getByText('Choose action')).toBeInTheDocument();
});
it('submit is disabled until all questions are answered', () => {
render(() => <QuestionCard question={multiQuestion} onAnswer={vi.fn()} onSkip={vi.fn()} />);
// Answer only the text question
const input = screen.getByPlaceholderText('Type your answer...');
fireEvent.input(input, { target: { value: 'test-name' } });
const submitBtn = screen.getByText('Submit Answer').closest('button')!;
expect(submitBtn).toBeDisabled();
});
it('submit is enabled when all questions are answered', () => {
render(() => <QuestionCard question={multiQuestion} onAnswer={vi.fn()} onSkip={vi.fn()} />);
const input = screen.getByPlaceholderText('Type your answer...');
fireEvent.input(input, { target: { value: 'test-name' } });
fireEvent.click(screen.getByText('Create'));
const submitBtn = screen.getByText('Submit Answer').closest('button')!;
expect(submitBtn).not.toBeDisabled();
});
it('submits all answers in order', () => {
const onAnswer = vi.fn();
render(() => <QuestionCard question={multiQuestion} onAnswer={onAnswer} onSkip={vi.fn()} />);
const input = screen.getByPlaceholderText('Type your answer...');
fireEvent.input(input, { target: { value: 'my-resource' } });
fireEvent.click(screen.getByText('Update'));
fireEvent.click(screen.getByText('Submit Answer'));
expect(onAnswer).toHaveBeenCalledWith([
{ id: 'q-name', value: 'my-resource' },
{ id: 'q-action', value: 'update' },
]);
});
});
describe('skip', () => {
it('calls onSkip when Skip is clicked', () => {
const onSkip = vi.fn();
render(() => (
<QuestionCard question={makePendingQuestion()} onAnswer={vi.fn()} onSkip={onSkip} />
));
fireEvent.click(screen.getByText('Skip'));
expect(onSkip).toHaveBeenCalledOnce();
});
});
describe('isAnswering state', () => {
it('shows "Answering..." instead of "Submit Answer"', () => {
render(() => (
<QuestionCard
question={makePendingQuestion({ isAnswering: true })}
onAnswer={vi.fn()}
onSkip={vi.fn()}
/>
));
expect(screen.getByText('Answering...')).toBeInTheDocument();
expect(screen.queryByText('Submit Answer')).not.toBeInTheDocument();
});
it('disables both buttons when isAnswering is true', () => {
render(() => (
<QuestionCard
question={makePendingQuestion({ isAnswering: true })}
onAnswer={vi.fn()}
onSkip={vi.fn()}
/>
));
const buttons = screen.getAllByRole('button');
for (const button of buttons) {
expect(button).toBeDisabled();
}
});
it('disables the text input when isAnswering is true', () => {
render(() => (
<QuestionCard
question={makePendingQuestion({ isAnswering: true })}
onAnswer={vi.fn()}
onSkip={vi.fn()}
/>
));
expect(screen.getByPlaceholderText('Type your answer...')).toBeDisabled();
});
it('disables select option buttons when isAnswering is true', () => {
const selectQ = makePendingQuestion({
isAnswering: true,
questions: [
makeQuestion({
type: 'select',
question: 'Pick one',
options: [
{ label: 'A', value: 'a' },
{ label: 'B', value: 'b' },
],
}),
],
});
render(() => <QuestionCard question={selectQ} onAnswer={vi.fn()} onSkip={vi.fn()} />);
// All buttons (option A, option B, submit, skip) should be disabled
const buttons = screen.getAllByRole('button');
for (const button of buttons) {
expect(button).toBeDisabled();
}
});
it('does not call onAnswer when submit is clicked while answering', () => {
const onAnswer = vi.fn();
render(() => (
<QuestionCard
question={makePendingQuestion({ isAnswering: true })}
onAnswer={onAnswer}
onSkip={vi.fn()}
/>
));
fireEvent.click(screen.getByRole('button', { name: /answering/i }));
expect(onAnswer).not.toHaveBeenCalled();
});
it('does not call onSkip when skip is clicked while answering', () => {
const onSkip = vi.fn();
render(() => (
<QuestionCard
question={makePendingQuestion({ isAnswering: true })}
onAnswer={vi.fn()}
onSkip={onSkip}
/>
));
fireEvent.click(screen.getByRole('button', { name: /skip/i }));
expect(onSkip).not.toHaveBeenCalled();
});
});
describe('edge cases', () => {
it('blocks submission when only some text questions are answered', () => {
const multiQ = makePendingQuestion({
questions: [
makeQuestion({ id: 'q1', question: 'First' }),
makeQuestion({ id: 'q2', question: 'Second' }),
],
});
render(() => <QuestionCard question={multiQ} onAnswer={vi.fn()} onSkip={vi.fn()} />);
// Answer only the first of two text questions
const inputs = screen.getAllByPlaceholderText('Type your answer...');
expect(inputs).toHaveLength(2);
fireEvent.input(inputs[0], { target: { value: 'answer-1' } });
const submitBtn = screen.getByText('Submit Answer').closest('button')!;
expect(submitBtn).toBeDisabled();
});
it('renders select question with no options without crashing', () => {
const noOptionsQ = makePendingQuestion({
questions: [
makeQuestion({
id: 'q-empty',
type: 'select',
question: 'Pick something',
options: undefined,
}),
],
});
render(() => <QuestionCard question={noOptionsQ} onAnswer={vi.fn()} onSkip={vi.fn()} />);
expect(screen.getByText('Pick something')).toBeInTheDocument();
// Submit should be disabled since there is no way to answer
const submitBtn = screen.getByText('Submit Answer').closest('button')!;
expect(submitBtn).toBeDisabled();
});
});
});
@@ -1,291 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { filterAssistantSlashCommands } from '../assistantSlashCommands';
import { SlashCommandAutocomplete } from '../SlashCommandAutocomplete';
afterEach(cleanup);
describe('SlashCommandAutocomplete', () => {
it('prevents pointer selection from taking focus from the composer', () => {
const onSelect = vi.fn();
render(() => (
<SlashCommandAutocomplete
query="new"
visible
position={{ top: 58, left: 0 }}
onClose={vi.fn()}
onSelect={onSelect}
/>
));
const option = screen.getByRole('option', { name: /Run \/new/ });
const pointerDown = new Event('pointerdown', { bubbles: true, cancelable: true });
option.dispatchEvent(pointerDown);
expect(pointerDown.defaultPrevented).toBe(true);
fireEvent.click(option);
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ action: 'new' }));
});
it('reports the active option, keeps it visible, and excludes options from the tab order', async () => {
const onActiveDescendantChange = vi.fn();
render(() => (
<SlashCommandAutocomplete
query=""
visible
position={{ top: 58, left: 0 }}
onClose={vi.fn()}
onSelect={vi.fn()}
onActiveDescendantChange={onActiveDescendantChange}
/>
));
const listbox = screen.getByRole('listbox', { name: 'Assistant commands' });
expect(listbox).toHaveAttribute('id', 'assistant-slash-command-listbox');
expect(listbox.parentElement).toHaveClass('w-[calc(100vw-36px)]');
const options = screen.getAllByRole('option');
expect(options[0]).toHaveAttribute('id', 'assistant-slash-command-option-0');
expect(options[0]).toHaveAttribute('tabindex', '-1');
expect(onActiveDescendantChange).toHaveBeenLastCalledWith('assistant-slash-command-option-0');
const scrollIntoView = vi.fn();
Object.defineProperty(options[1], 'scrollIntoView', {
configurable: true,
value: scrollIntoView,
});
fireEvent.keyDown(document, { key: 'ArrowDown' });
expect(onActiveDescendantChange).toHaveBeenLastCalledWith('assistant-slash-command-option-1');
await vi.waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }));
});
it('consumes local command navigation keys before later document handlers see them', () => {
const onClose = vi.fn();
const onSelect = vi.fn();
render(() => (
<SlashCommandAutocomplete
query="mo"
visible
position={{ top: 58, left: 0 }}
onClose={onClose}
onSelect={onSelect}
/>
));
const laterDocumentHandler = vi.fn();
document.addEventListener('keydown', laterDocumentHandler);
const escapeEvent = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Escape',
});
document.dispatchEvent(escapeEvent);
document.removeEventListener('keydown', laterDocumentHandler);
expect(escapeEvent.defaultPrevented).toBe(true);
expect(laterDocumentHandler).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledOnce();
expect(onSelect).not.toHaveBeenCalled();
});
it('consumes selection keys while running the selected local command', () => {
const onSelect = vi.fn();
render(() => (
<SlashCommandAutocomplete
query="mo"
visible
position={{ top: 58, left: 0 }}
onClose={vi.fn()}
onSelect={onSelect}
/>
));
const laterDocumentHandler = vi.fn();
document.addEventListener('keydown', laterDocumentHandler);
const enterEvent = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Enter',
});
document.dispatchEvent(enterEvent);
document.removeEventListener('keydown', laterDocumentHandler);
expect(enterEvent.defaultPrevented).toBe(true);
expect(laterDocumentHandler).not.toHaveBeenCalled();
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({
action: 'models',
name: 'models',
}),
);
});
it('wraps keyboard selection from the first command to the last command', () => {
const onSelect = vi.fn();
const lastCommand = filterAssistantSlashCommands('').at(-1);
render(() => (
<SlashCommandAutocomplete
query=""
visible
position={{ top: 58, left: 0 }}
onClose={vi.fn()}
onSelect={onSelect}
/>
));
document.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'ArrowUp',
}),
);
document.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Enter',
}),
);
expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({
action: lastCommand?.action,
name: lastCommand?.name,
}),
);
});
it('renders the full local command list with an icon for every command', () => {
const { container } = render(() => (
<SlashCommandAutocomplete
query=""
visible
position={{ top: 58, left: 0 }}
onClose={vi.fn()}
onSelect={vi.fn()}
/>
));
const activeCommands = filterAssistantSlashCommands('');
const options = screen.getAllByRole('option');
expect(options).toHaveLength(activeCommands.length);
expect(options.map((option) => option.getAttribute('aria-label'))).toContain(
'Run /compact: Summarize older turns and keep this session moving',
);
expect(options.map((option) => option.getAttribute('aria-label'))).toContain(
'Run /queue: Manage queued follow-ups',
);
expect(container.querySelectorAll('[role="option"] svg')).toHaveLength(activeCommands.length);
expect(screen.getByText('Session')).toBeInTheDocument();
expect(screen.getByText('Model')).toBeInTheDocument();
expect(screen.getByText('Transcript')).toBeInTheDocument();
expect(screen.getByText('Developer')).toBeInTheDocument();
});
it('hides command group headers once the user filters', () => {
render(() => (
<SlashCommandAutocomplete
query="runtime"
visible
position={{ top: 58, left: 0 }}
onClose={vi.fn()}
onSelect={vi.fn()}
/>
));
expect(screen.getByRole('option', { name: /\/status/ })).toBeInTheDocument();
expect(screen.queryByText('Model')).not.toBeInTheDocument();
});
it('surfaces dev stream fixtures through slash command search', () => {
render(() => (
<SlashCommandAutocomplete
query="provider-retry"
visible
position={{ top: 58, left: 0 }}
onClose={vi.fn()}
onSelect={vi.fn()}
/>
));
expect(
screen.getByRole('option', {
name: /Insert \/fixture: Run a local stream fixture by name/,
}),
).toBeInTheDocument();
});
it('renders disabled matching commands with the unavailable reason', () => {
render(() => (
<SlashCommandAutocomplete
availability={{
compact: {
disabled: true,
reason: 'Requires transcript content.',
},
}}
query="compact"
visible
position={{ top: 58, left: 0 }}
onClose={vi.fn()}
onSelect={vi.fn()}
/>
));
expect(screen.getByRole('listbox', { name: 'Assistant commands' })).toBeInTheDocument();
const option = screen.getByRole('option', {
name: /Unavailable \/compact: Summarize older turns and keep this session moving\. Requires transcript content\./,
});
expect(option).toHaveAttribute('aria-disabled', 'true');
expect(option).toHaveTextContent('Requires transcript content.');
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
it('consumes selection keys without selecting when no commands match', () => {
const onSelect = vi.fn();
render(() => (
<SlashCommandAutocomplete
query="not-a-command"
visible
position={{ top: 58, left: 0 }}
onClose={vi.fn()}
onSelect={onSelect}
/>
));
const laterDocumentHandler = vi.fn();
document.addEventListener('keydown', laterDocumentHandler);
const enterEvent = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Enter',
});
document.dispatchEvent(enterEvent);
document.removeEventListener('keydown', laterDocumentHandler);
expect(enterEvent.defaultPrevented).toBe(true);
expect(laterDocumentHandler).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
expect(screen.getByRole('status')).toHaveTextContent(
'No Assistant commands match /not-a-command',
);
});
it('does not render persistent keyboard shortcut hints', () => {
render(() => (
<SlashCommandAutocomplete
query=""
visible
position={{ top: 58, left: 0 }}
onClose={vi.fn()}
onSelect={vi.fn()}
/>
));
expect(screen.queryByText('up/down')).not.toBeInTheDocument();
expect(screen.queryByText('navigate')).not.toBeInTheDocument();
expect(screen.queryByText('enter')).not.toBeInTheDocument();
expect(screen.queryByText('run')).not.toBeInTheDocument();
});
});
@@ -1,53 +0,0 @@
import { describe, expect, it, afterEach } from 'vitest';
import { cleanup, render, screen } from '@solidjs/testing-library';
import { ThinkingBlock } from '../ThinkingBlock';
afterEach(cleanup);
describe('ThinkingBlock', () => {
it('renders a neutral completed-thinking status without reasoning content', () => {
render(() => <ThinkingBlock content="We need to inspect the prompt before answering." />);
expect(screen.getByRole('status')).toHaveTextContent('Thinking complete');
expect(screen.queryByText(/inspect the prompt/i)).not.toBeInTheDocument();
});
it('renders a neutral streaming-thinking status without reasoning content', () => {
render(() => <ThinkingBlock content="Hidden provider reasoning" isStreaming={true} />);
expect(screen.getByRole('status')).toHaveTextContent('Thinking...');
expect(screen.queryByText(/Hidden provider reasoning/i)).not.toBeInTheDocument();
});
it('marks the icon as active while streaming', () => {
const { container } = render(() => <ThinkingBlock content="hidden" isStreaming={true} />);
expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
});
it('uses provider reasoning summary metadata without showing the reasoning body', () => {
render(() => (
<ThinkingBlock
content={'**Inspecting storage state**\n\nHidden provider reasoning body.'}
startedAt={1_000}
updatedAt={4_250}
/>
));
expect(screen.getByRole('status')).toHaveTextContent('Thought: Inspecting storage state · 3s');
expect(screen.queryByText(/Hidden provider reasoning body/i)).not.toBeInTheDocument();
});
it('shows summary metadata while streaming without exposing raw reasoning text', () => {
render(() => (
<ThinkingBlock
content={'**Checking resource context**\n\nNeed to inspect hidden state.'}
isStreaming={true}
startedAt={Date.now()}
/>
));
expect(screen.getByRole('status')).toHaveTextContent('Thinking: Checking resource context');
expect(screen.queryByText(/Need to inspect hidden state/i)).not.toBeInTheDocument();
});
});
File diff suppressed because it is too large Load Diff
@@ -1,584 +0,0 @@
import { describe, expect, it } from 'vitest';
import { formatAssistantWorkflowStatus, getAssistantActiveTurnStatus } from '../activeTurnStatus';
import type { ChatMessage, StreamDisplayEvent } from '../types';
// Fixture builders — mirror the conventions of the sibling activeTurnStatus.test.ts.
const assistantMessage = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
id: 'assistant-1',
role: 'assistant',
content: '',
timestamp: new Date(1_000),
...overrides,
});
const activeTurn = (overrides: Partial<ChatMessage> = {}, now?: number) =>
getAssistantActiveTurnStatus([assistantMessage(overrides)], true, now);
const readToolInput = (command: string): string =>
JSON.stringify({ action: 'exec', command, target_host: 'current_resource' });
// ---------------------------------------------------------------------------
// formatPendingToolStatus — reachable via getAssistantActiveTurnStatus
// (pendingTools state, stream pending_tool events, and the no-pendingTool guard)
// ---------------------------------------------------------------------------
describe('formatPendingToolStatus branch coverage', () => {
it('renders a waiting tool from a command preview ("Waiting on …")', () => {
expect(
activeTurn({
pendingTools: [
{
id: 'tool-1',
name: 'pulse_read',
input: readToolInput('ls /dev | wc -l'),
status: 'waiting',
startedAt: 500,
},
],
}),
).toEqual({
type: 'tool',
text: 'Waiting on $ ls /dev | wc -l',
startedAt: 500,
});
});
it('renders a waiting tool from the label fallback when no activity is parseable', () => {
expect(
activeTurn({
pendingTools: [{ id: 'tool-1', name: 'pulse_get_nodes', input: '{}', status: 'waiting' }],
}),
).toEqual({
type: 'tool',
text: 'Waiting on get nodes',
});
});
it('ignores a generic "Running" progress in favor of a real command preview', () => {
// progress truthy + commandPreview truthy + isGenericToolProgress(progress) true
// -> the progress short-circuit is skipped and the command preview wins.
expect(
activeTurn({
pendingTools: [
{
id: 'tool-1',
name: 'pulse_read',
input: readToolInput('df -h'),
progress: 'Running',
status: 'running',
startedAt: 100,
},
],
}),
).toEqual({
type: 'tool',
text: 'Running $ df -h',
startedAt: 100,
});
});
it('ignores a generic "Running command." progress in favor of a real command preview', () => {
expect(
activeTurn({
pendingTools: [
{
id: 'tool-1',
name: 'pulse_read',
input: readToolInput('uptime'),
progress: 'Running command.',
status: 'running',
},
],
}),
).toEqual({
type: 'tool',
text: 'Running $ uptime',
});
});
it('drops a pending_tool stream event that carries no pendingTool payload', () => {
// formatPendingToolStatus(undefined) -> '' -> no candidate; the turn falls
// back to the initial request status instead of surfacing a tool row.
expect(
activeTurn({
streamEvents: [{ type: 'pending_tool', toolId: 'tool-1' }],
}),
).toEqual({
type: 'thinking',
text: 'Sending prompt.',
startedAt: 1_000,
});
});
});
// ---------------------------------------------------------------------------
// sanitizeWorkflowStatusMessage — reachable via formatAssistantWorkflowStatus
// ---------------------------------------------------------------------------
describe('sanitizeWorkflowStatusMessage branch coverage', () => {
it('strips a trailing "using <tool>." clause and keeps the terminating period', () => {
expect(
formatAssistantWorkflowStatus({
message: 'Reading inventory using pulse_query.',
tool: 'pulse_query',
}),
).toBe('Reading inventory.');
});
it('strips a trailing "with <tool>" clause that has no terminating period', () => {
expect(
formatAssistantWorkflowStatus({
message: 'Reading inventory with pulse_query',
tool: 'pulse_query',
}),
).toBe('Reading inventory');
});
it('strips a trailing "via the <tool>" clause (label re-attached as a suffix)', () => {
// After the trailing clause is removed the message no longer references the
// tool, so formatAssistantWorkflowStatus re-attaches the friendly label.
expect(
formatAssistantWorkflowStatus({
message: 'Gathering data via the pulse_query.',
tool: 'pulse_query',
}),
).toBe('Gathering data. · inventory');
});
it('rewrites a bare internal tool identifier in the middle of a message', () => {
expect(
formatAssistantWorkflowStatus({
message: 'Running pulse_query now',
tool: 'pulse_query',
}),
).toBe('Running inventory now');
});
it('collapses runs of whitespace and lone spaced-out periods', () => {
expect(formatAssistantWorkflowStatus({ message: 'Working on it .' })).toBe(
'Working on it.',
);
});
it('leaves a plain message unchanged when no tool context is present', () => {
expect(formatAssistantWorkflowStatus({ message: 'Waiting for assistant.' })).toBe(
'Waiting for assistant.',
);
});
});
// ---------------------------------------------------------------------------
// messageContainsToolLabel — reachable via formatAssistantWorkflowStatus
// ---------------------------------------------------------------------------
describe('messageContainsToolLabel branch coverage', () => {
it('appends the tool suffix when the friendly label is absent from the message', () => {
// messageContainsToolLabel('Working', 'read') -> \bread\b does not match -> false
// -> toolSuffix is appended.
expect(formatAssistantWorkflowStatus({ message: 'Working', tool: 'pulse_read' })).toBe(
'Working · read',
);
});
it('withholds the tool suffix when the message already mentions the label as a word', () => {
// messageContainsToolLabel('Reading storage', 'read') -> \bread\b does not
// match inside "Reading" -> false -> suffix appended (boundary edge case).
expect(formatAssistantWorkflowStatus({ message: 'Reading storage', tool: 'pulse_read' })).toBe(
'Reading storage · read',
);
});
});
// ---------------------------------------------------------------------------
// formatRetryDelay — reachable via formatAssistantWorkflowStatus (now omitted
// so remainingRetryDelay returns retryAfterMs verbatim)
// ---------------------------------------------------------------------------
describe('formatRetryDelay branch coverage', () => {
const retry = (overrides: Record<string, unknown>) =>
formatAssistantWorkflowStatus({ message: 'Retrying.', ...overrides } as Parameters<
typeof formatAssistantWorkflowStatus
>[0]);
it('formats sub-second delays in milliseconds', () => {
expect(retry({ attempt: 1, retryAfterMs: 500 })).toBe(
'Retrying. · attempt 1 · retrying in 500ms',
);
expect(retry({ attempt: 1, retryAfterMs: 999 })).toBe(
'Retrying. · attempt 1 · retrying in 999ms',
);
});
it('formats the one-second boundary and sub-ten-second delays with one decimal', () => {
expect(retry({ attempt: 1, retryAfterMs: 1_000 })).toBe(
'Retrying. · attempt 1 · retrying in 1s',
);
expect(retry({ attempt: 1, retryAfterMs: 9_500 })).toBe(
'Retrying. · attempt 1 · retrying in 9.5s',
);
});
it('formats sub-minute second delays as whole seconds', () => {
expect(retry({ attempt: 1, retryAfterMs: 30_000 })).toBe(
'Retrying. · attempt 1 · retrying in 30s',
);
});
it('formats sub-ten-minute delays with one decimal minute', () => {
expect(retry({ attempt: 1, retryAfterMs: 90_000 })).toBe(
'Retrying. · attempt 1 · retrying in 1.5m',
);
});
it('formats delays of ten minutes or more as whole minutes', () => {
expect(retry({ attempt: 1, retryAfterMs: 600_000 })).toBe(
'Retrying. · attempt 1 · retrying in 10m',
);
expect(retry({ attempt: 1, retryAfterMs: 900_000 })).toBe(
'Retrying. · attempt 1 · retrying in 15m',
);
});
it('hits the formatRetryDelay empty-result branch once a countdown reaches zero', () => {
// remainingRetryDelay(retryAfterMs 3200, startedAt 1000, now 4500) -> 0,
// formatRetryDelay(0) -> '' -> formatRetryStatusSuffix emits "retrying now".
expect(
formatAssistantWorkflowStatus(
{ message: 'Retrying.', attempt: 1, retryAfterMs: 3_200, startedAt: 1_000 },
4_500,
),
).toBe('Retrying. · attempt 1 · retrying now');
});
});
// ---------------------------------------------------------------------------
// formatRetryStatusSuffix — reachable via formatAssistantWorkflowStatus
// ---------------------------------------------------------------------------
describe('formatRetryStatusSuffix branch coverage', () => {
it('emits only the attempt count when maxAttempts is missing', () => {
expect(formatAssistantWorkflowStatus({ message: 'Working', attempt: 2 })).toBe(
'Working · attempt 2',
);
});
it('emits the attempt ratio when both attempt and maxAttempts are present', () => {
expect(formatAssistantWorkflowStatus({ message: 'Working', attempt: 2, maxAttempts: 3 })).toBe(
'Working · attempt 2/3',
);
});
it('emits a retry delay without an attempt count', () => {
expect(formatAssistantWorkflowStatus({ message: 'Working', retryAfterMs: 500 })).toBe(
'Working · retrying in 500ms',
);
});
it('omits the suffix entirely when there is no attempt or retry metadata', () => {
expect(formatAssistantWorkflowStatus({ message: 'Working' })).toBe('Working');
});
it('returns an empty string when there is no message at all', () => {
expect(formatAssistantWorkflowStatus({ message: '' })).toBe('');
expect(formatAssistantWorkflowStatus(undefined)).toBe('');
});
});
// ---------------------------------------------------------------------------
// thinkingStatusText — reachable via getAssistantActiveTurnStatus
// ---------------------------------------------------------------------------
describe('thinkingStatusText branch coverage', () => {
it('falls back to the bare "Thinking" label when no reasoning title is extractable', () => {
expect(
activeTurn({
streamEvents: [
{ type: 'thinking', thinking: 'Plain reasoning with no bold heading.', startedAt: 2_000 },
],
}),
).toEqual({
type: 'thinking',
text: 'Thinking',
startedAt: 2_000,
});
});
it('ignores a whitespace-only thinking payload so no thinking status is surfaced', () => {
// thinkingStatusText -> '' -> no candidate -> falls back to initial request status.
expect(
activeTurn({
streamEvents: [{ type: 'thinking', thinking: ' ', startedAt: 2_000 }],
}),
).toEqual({
type: 'thinking',
text: 'Sending prompt.',
startedAt: 1_000,
});
});
});
// ---------------------------------------------------------------------------
// toolCancelStatusText — reachable via getAssistantActiveTurnStatus
// ---------------------------------------------------------------------------
describe('toolCancelStatusText branch coverage', () => {
it('omits the reason when none is supplied', () => {
expect(
activeTurn({
streamEvents: [
{
type: 'tool_cancel',
toolId: 'tool-1',
toolCancel: { id: 'tool-1', name: 'pulse_read', input: '{}' },
startedAt: 1_500,
},
],
}),
).toEqual({
type: 'tool',
text: 'Skipped read',
startedAt: 1_500,
});
});
it('treats a whitespace-only reason as absent', () => {
expect(
activeTurn({
streamEvents: [
{
type: 'tool_cancel',
toolId: 'tool-1',
toolCancel: { id: 'tool-1', name: 'pulse_read', input: '{}', reason: ' ' },
startedAt: 1_500,
},
],
}),
).toEqual({
type: 'tool',
text: 'Skipped read',
startedAt: 1_500,
});
});
it('falls back to pendingTool.name when toolCancel.name is empty', () => {
expect(
activeTurn({
streamEvents: [
{
type: 'tool_cancel',
toolId: 'tool-1',
toolCancel: { id: 'tool-1', name: '', input: '{}' },
pendingTool: { id: 'tool-1', name: 'pulse_read', input: '{}' },
startedAt: 1_500,
},
],
}),
).toEqual({
type: 'tool',
text: 'Skipped read',
startedAt: 1_500,
});
});
});
// ---------------------------------------------------------------------------
// completedToolStatusText — reachable via getAssistantActiveTurnStatus
// ---------------------------------------------------------------------------
describe('completedToolStatusText branch coverage', () => {
it('reports "Completed" with the label fallback for placeholder input', () => {
expect(
activeTurn({
streamEvents: [
{
type: 'tool',
toolId: 'tool-1',
tool: { name: 'pulse_get_nodes', input: '{}', output: 'ok', success: true },
startedAt: 1_100,
},
],
}),
).toEqual({
type: 'tool',
text: 'Completed get nodes',
});
});
it('reports "Completed" with a parsed input summary when available', () => {
expect(
activeTurn({
streamEvents: [
{
type: 'tool',
toolId: 'tool-1',
tool: {
name: 'pulse_query',
input: JSON.stringify({ action: 'list' }),
output: 'ok',
success: true,
},
startedAt: 1_100,
},
],
}),
).toEqual({
type: 'tool',
text: 'Completed list resources',
});
});
it('drops a tool stream event that carries no tool payload', () => {
// completedToolStatusText -> `if (!tool) return ''` -> no candidate.
expect(
activeTurn({
streamEvents: [{ type: 'tool', toolId: 'tool-1' }],
}),
).toEqual({
type: 'thinking',
text: 'Sending prompt.',
startedAt: 1_000,
});
});
});
// ---------------------------------------------------------------------------
// latestStreamActivityStatus — uncovered switch arms / guard-false arms
// ---------------------------------------------------------------------------
describe('latestStreamActivityStatus branch coverage', () => {
it('ignores an unknown event type via the default switch arm', () => {
expect(
activeTurn({
streamEvents: [{ type: 'bogus' } as unknown as StreamDisplayEvent],
}),
).toEqual({
type: 'thinking',
text: 'Sending prompt.',
startedAt: 1_000,
});
});
it('ignores a content event whose payload is only whitespace', () => {
// `if (event.content?.trim())` guard is false -> no generating candidate.
expect(
activeTurn({
streamEvents: [{ type: 'content', content: ' ' }],
}),
).toEqual({
type: 'thinking',
text: 'Sending prompt.',
startedAt: 1_000,
});
});
it('ignores an approval event that carries no approval payload', () => {
expect(
activeTurn({
streamEvents: [{ type: 'approval' }],
}),
).toEqual({
type: 'thinking',
text: 'Sending prompt.',
startedAt: 1_000,
});
});
it('ignores a question event that carries no question payload', () => {
expect(
activeTurn({
streamEvents: [{ type: 'question' }],
}),
).toEqual({
type: 'thinking',
text: 'Sending prompt.',
startedAt: 1_000,
});
});
it('surfaces the later of two equally-timed thinking events via the order tiebreak', () => {
// Both events have no startedAt/updatedAt -> activityAt undefined for both.
// isFresherStatusCandidate falls through to the order tiebreak.
expect(
activeTurn({
streamEvents: [
{ type: 'thinking', thinking: '**Alpha**' },
{ type: 'thinking', thinking: '**Beta**' },
],
}),
).toEqual({
type: 'thinking',
text: 'Thinking: Beta',
});
});
});
// ---------------------------------------------------------------------------
// isFresherStatusCandidate — observable arms via getAssistantActiveTurnStatus
// ---------------------------------------------------------------------------
describe('isFresherStatusCandidate branch coverage', () => {
it('prefers a current workflow status over an older generic content row', () => {
// currentWorkflow candidate vs genericContent current -> returns true.
expect(
activeTurn(
{
isStreaming: true,
workflowStatus: {
phase: 'stream_idle',
message: 'Still working. Waiting for more response data.',
startedAt: 2_000,
},
streamEvents: [
{
type: 'workflow_status',
workflowStatus: {
phase: 'stream_idle',
message: 'Still working. Waiting for more response data.',
startedAt: 2_000,
},
startedAt: 2_000,
updatedAt: 2_000,
},
{ type: 'content', content: 'partial token', startedAt: 3_000, updatedAt: 3_000 },
],
},
3_500,
),
).toEqual({
type: 'thinking',
text: 'Still working. Waiting for more response data.',
startedAt: 2_000,
});
});
it('prefers a newer-activity tool over an older tool regardless of array order', () => {
// Two pending tools in state; the one with the later updatedAt wins via the
// candidateTime > currentTime arm of isFresherStatusCandidate.
expect(
activeTurn({
pendingTools: [
{
id: 'tool-a',
name: 'pulse_get_nodes',
input: '{}',
status: 'running',
startedAt: 100,
updatedAt: 200,
},
{
id: 'tool-b',
name: 'pulse_read',
input: '{}',
status: 'running',
startedAt: 100,
updatedAt: 500,
},
],
}),
).toEqual({
type: 'tool',
text: 'Running read',
startedAt: 100,
});
});
});
@@ -1,320 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
formatAssistantWorkflowStatus,
getAssistantActiveTurnStatus,
} from '@/components/AI/Chat/activeTurnStatus';
import type { ChatMessage, PendingTool, StreamDisplayEvent } from '@/components/AI/Chat/types';
// Fixture builders — mirror the conventions of the sibling branch-coverage tests.
const assistantMessage = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
id: 'assistant-1',
role: 'assistant',
content: '',
timestamp: new Date(1_000),
...overrides,
});
const activeTurn = (overrides: Partial<ChatMessage> = {}, now?: number) =>
getAssistantActiveTurnStatus([assistantMessage(overrides)], true, now);
const readToolInput = (command: string): string =>
JSON.stringify({ action: 'exec', command, target_host: 'current_resource' });
describe('activeTurnStatus branch coverage (supplemental)', () => {
// -------------------------------------------------------------------------
// sanitizeWorkflowStatusMessage / formatWorkflowToolName — the `|| toolLabel`
// fallback arm when the tool identifier is not in WORKFLOW_TOOL_LABELS.
// -------------------------------------------------------------------------
describe('formatWorkflowToolName fallback', () => {
it('falls back to formatIdentifierLabel for a tool name absent from WORKFLOW_TOOL_LABELS', () => {
// `WORKFLOW_TOOL_LABELS[identifier] || toolLabel` right arm: the
// internalWorkflowToolIdentifiers set includes the custom normalized name
// which has no map entry, so the friendly identifier label is used.
expect(formatAssistantWorkflowStatus({ message: 'Working', tool: 'custom_thing' })).toBe(
'Working · custom thing',
);
});
});
// -------------------------------------------------------------------------
// remainingRetryDelay — the `startedAt`-missing return arm (now is a valid
// number but startedAt is not), which returns retryAfterMs verbatim.
// -------------------------------------------------------------------------
describe('remainingRetryDelay startedAt-missing arm', () => {
it('returns the full retryAfterMs when now is finite but startedAt is absent', () => {
// Line 146: `typeof startedAt !== 'number'` is true -> return retryAfterMs
// unchanged, so the countdown is not decremented.
expect(
formatAssistantWorkflowStatus({ message: 'Working', attempt: 1, retryAfterMs: 500 }, 1_000),
).toBe('Working · attempt 1 · retrying in 500ms');
});
});
// -------------------------------------------------------------------------
// formatPendingToolStatus — the ternary false arm `: inputSummary` when the
// parsed input is a real (non-placeholder) summary and there is no command.
// -------------------------------------------------------------------------
describe('formatPendingToolStatus input-summary arm', () => {
it('renders a parsed query summary when no command preview is available', () => {
// toolActivity = commandPreview('') || (isPlaceholder('list resources') ? '' : 'list resources')
// -> the ternary's false arm yields the real summary.
expect(
activeTurn({
pendingTools: [
{ id: 't1', name: 'pulse_query', input: '{"action":"list"}', status: 'running' },
],
}),
).toEqual({ type: 'tool', text: 'Running list resources' });
});
});
// -------------------------------------------------------------------------
// activePendingToolFromEvents / latestStreamActivityStatus — the `if (!event)
// continue` guard hit by a malformed (undefined) event entry.
// -------------------------------------------------------------------------
describe('undefined event guard', () => {
it('skips an undefined streamEvents entry and still reports generating output', () => {
// `content` is set so hasVisibleAssistantOutput short-circuits before it
// would otherwise iterate the undefined element (which would throw).
// Both event loops hit their `if (!event) continue` guard.
expect(
activeTurn({
content: 'partial answer',
streamEvents: [undefined as unknown as StreamDisplayEvent],
}),
).toEqual({ type: 'generating', text: 'Generating response' });
});
});
// -------------------------------------------------------------------------
// isSelectedModelRouteEvent — the `event.failedModel?.trim()` access arm
// (failedModel present, so .trim() is evaluated rather than short-circuited).
// -------------------------------------------------------------------------
describe('isSelectedModelRouteEvent failedModel access', () => {
it('clears the placeholder flag when a selected route also carries a failed model', () => {
// failedModel is a non-nullish string, so `?.trim()` is actually invoked;
// the trimmed value is truthy so the placeholder flag becomes false.
expect(
activeTurn({
streamEvents: [
{
type: 'model_switch',
model: 'openrouter:qwen/qwen3.7-plus',
modelEvent: 'selected',
failedModel: 'openrouter:openai/gpt-4o-mini',
startedAt: 2_000,
updatedAt: 2_000,
},
],
}),
).toEqual({
type: 'thinking',
text: 'Using Qwen: Qwen3.7 Plus via OpenRouter',
startedAt: 2_000,
});
});
});
// -------------------------------------------------------------------------
// activePendingToolCandidateFromState — the `if (!candidate) return current`
// arm, hit when a pendingTools entry yields no status text.
// -------------------------------------------------------------------------
describe('activePendingToolCandidateFromState null-candidate arm', () => {
it('skips a pendingTools entry that produces no status text', () => {
// pendingToolCandidate(undefined) -> formatPendingToolStatus('') -> null,
// so the reducer returns its current accumulator unchanged.
expect(activeTurn({ pendingTools: [undefined as unknown as PendingTool] })).toEqual({
type: 'thinking',
text: 'Sending prompt.',
startedAt: 1_000,
});
});
});
// -------------------------------------------------------------------------
// eventActivityAt (workflow_status arm) — each `||` fall-through level.
// -------------------------------------------------------------------------
describe('eventActivityAt workflow_status fall-through arms', () => {
it('falls through to event.updatedAt when workflowStatus.startedAt is absent', () => {
// `workflowStatus?.startedAt || event.updatedAt || ...` -> updatedAt wins.
expect(
activeTurn({
streamEvents: [
{
type: 'workflow_status',
workflowStatus: { message: 'Working on it.' },
updatedAt: 5_000,
startedAt: 3_000,
},
],
}),
).toEqual({ type: 'thinking', text: 'Working on it.', startedAt: 3_000 });
});
it('falls through to event.startedAt when updatedAt is also absent', () => {
// `... || event.updatedAt || event.startedAt || undefined` -> startedAt.
expect(
activeTurn({
streamEvents: [
{
type: 'workflow_status',
workflowStatus: { message: 'Working on it.' },
startedAt: 7_000,
},
],
}),
).toEqual({ type: 'thinking', text: 'Working on it.', startedAt: 7_000 });
});
it('returns undefined when no timestamp source is present at all', () => {
// The final `|| undefined` arm: activityAt is undefined.
expect(
activeTurn({
streamEvents: [
{ type: 'workflow_status', workflowStatus: { message: 'Working on it.' } },
],
}),
).toEqual({ type: 'thinking', text: 'Working on it.' });
});
});
// -------------------------------------------------------------------------
// isFresherStatusCandidate — the `candidate.genericContent &&
// current.currentWorkflow` false arm (an older content row vs a newer
// workflow row keeps the workflow candidate).
// -------------------------------------------------------------------------
describe('isFresherStatusCandidate genericContent-vs-currentWorkflow arm', () => {
it('keeps the newer workflow row over an earlier generic content row', () => {
// Iteration is end-to-start: the later workflow_status becomes current
// (currentWorkflow), then the earlier content is the candidate
// (genericContent) -> returns false, so the workflow row is retained.
expect(
activeTurn(
{
streamEvents: [
{ type: 'content', content: 'partial', startedAt: 1_000, updatedAt: 1_000 },
{
type: 'workflow_status',
workflowStatus: {
phase: 'stream_idle',
message: 'Still working.',
startedAt: 2_000,
},
startedAt: 2_000,
updatedAt: 2_000,
},
],
},
3_000,
),
).toEqual({ type: 'thinking', text: 'Still working.', startedAt: 2_000 });
});
});
// -------------------------------------------------------------------------
// isFresherStatusCandidate — the `candidateTime === undefined &&
// currentTime !== undefined` false arm.
// -------------------------------------------------------------------------
describe('isFresherStatusCandidate undefined-candidateTime arm', () => {
it('keeps the row that has a timestamp over one without', () => {
// The earlier thinking event has no startedAt/updatedAt (activityAt
// undefined); the later content event has updatedAt 500. The candidate
// with an undefined time loses to the current with a defined time.
expect(
activeTurn({
streamEvents: [
{ type: 'thinking', thinking: '**Alpha**' },
{ type: 'content', content: 'hi', startedAt: 500, updatedAt: 500 },
],
}),
).toEqual({ type: 'generating', text: 'Generating response', startedAt: 500 });
});
});
// -------------------------------------------------------------------------
// isGenericToolProgress — the uncovered equality arms.
// -------------------------------------------------------------------------
describe('isGenericToolProgress equality arms', () => {
it.each(['Running command', 'Running read-only command', 'Running read-only command.'])(
'treats %q generic progress as generic when a command preview exists',
(progress) => {
// A real command preview plus a generic progress string forces
// isGenericToolProgress to be evaluated (commandPreview truthy ->
// !commandPreview is false -> !isGenericToolProgress(progress) runs).
expect(
activeTurn({
pendingTools: [
{
id: 't1',
name: 'pulse_read',
input: readToolInput('uptime'),
progress,
status: 'running',
},
],
}),
).toEqual({ type: 'tool', text: 'Running $ uptime' });
},
);
});
// -------------------------------------------------------------------------
// modelSwitchStatusText — the `if (!model) return ''` arm.
// -------------------------------------------------------------------------
describe('modelSwitchStatusText empty-model arm', () => {
it('produces no candidate for a model_switch event with no model', () => {
// model is undefined -> text '' -> no candidate -> falls back to the
// initial request status.
expect(activeTurn({ streamEvents: [{ type: 'model_switch' }] })).toEqual({
type: 'thinking',
text: 'Sending prompt.',
startedAt: 1_000,
});
});
});
// -------------------------------------------------------------------------
// hasVisibleAssistantOutput — the `message.streamEvents || []` short-circuit
// arm, reached when an assistant turn carries no streamEvents at all.
// -------------------------------------------------------------------------
describe('hasVisibleAssistantOutput missing-streamEvents arm', () => {
it('falls back to the initial request status when the turn has no events or output', () => {
// message.streamEvents is undefined -> `|| []` is evaluated, so the
// .some() callback never runs and hasVisibleAssistantOutput is false.
expect(activeTurn({})).toEqual({
type: 'thinking',
text: 'Sending prompt.',
startedAt: 1_000,
});
});
});
// -------------------------------------------------------------------------
// messageTimestampMs — the `Number.isFinite(value) ? value : undefined`
// false arm for an invalid Date.
// -------------------------------------------------------------------------
describe('messageTimestampMs invalid-date arm', () => {
it('drops the startedAt when the assistant message timestamp is an invalid Date', () => {
// getTime() returns NaN -> !Number.isFinite -> return undefined, so the
// initial request status is emitted without a startedAt.
expect(activeTurn({ timestamp: new Date(NaN) })).toEqual({
type: 'thinking',
text: 'Sending prompt.',
});
});
});
});
File diff suppressed because it is too large Load Diff
@@ -1,136 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { highlightSettledCodeBlocks } from '../aiCodeHighlight';
// Branch-coverage companion to aiCodeHighlight.test.ts. Targets the two
// functions highlightSettledCodeBlocks (exported) and loadHighlighter
// (module-private). loadHighlighter's branches are driven through the
// public highlightSettledCodeBlocks entry point, or via vi.doMock +
// dynamic import to obtain a fresh module instance with a controlled
// highlight.js core.
const makeContainer = (html: string): HTMLElement => {
const container = document.createElement('div');
container.innerHTML = html;
document.body.appendChild(container);
return container;
};
// ---------------------------------------------------------------------------
// highlightSettledCodeBlocks — ternary: `language && hljs.getLanguage(language)`
// The sibling suite covers language='' (falsy short-circuit) and a registered
// language (truthy getLanguage). This covers the remaining arm: language is
// truthy but getLanguage returns undefined (unregistered grammar).
// ---------------------------------------------------------------------------
describe('highlightSettledCodeBlocks branch coverage — real highlighter', () => {
it('falls back to highlightAuto when the fence language is truthy but unregistered', async () => {
// 'rust' is not in the registered grammar set (bash/json/yaml/ini/sql/
// dockerfile/nginx/plaintext), so getLanguage('rust') returns undefined
// and the ternary takes the highlightAuto arm.
const container = makeContainer(
'<pre><code class="language-rust">{"name": "test", "count": 3}</code></pre>',
);
await highlightSettledCodeBlocks(container);
const code = container.querySelector('code') as HTMLElement;
expect(code.dataset.highlighted).toBe('true');
expect(code.classList.contains('hljs-highlighted')).toBe(true);
// highlightAuto (subset includes json) detected JSON and applied spans.
expect(code.innerHTML).toContain('<span');
expect(code.textContent).toBe('{"name": "test", "count": 3}');
});
});
// ---------------------------------------------------------------------------
// loadHighlighter — catch arm (import failure → null), cached-null on retry,
// and the downstream `if (!hljs) return` early exit in highlightSettledCodeBlocks.
// ---------------------------------------------------------------------------
describe('loadHighlighter branch coverage — mocked core failures', () => {
it('catches when core.default is undefined, returns null, and caches the null promise', async () => {
vi.resetModules();
// Mock core to an object with no `default` — core.default is undefined,
// so hljs.registerLanguage throws inside the try, the catch arm logs
// and returns null, and highlightSettledCodeBlocks hits `if (!hljs)`.
vi.doMock('highlight.js/lib/core', () => ({}));
const { highlightSettledCodeBlocks: highlight } = await import('../aiCodeHighlight');
const c1 = makeContainer(
'<pre><code class="language-bash">echo "hello" # comment</code></pre>',
);
await highlight(c1);
const code1 = c1.querySelector('code') as HTMLElement;
// loadHighlighter catch → null → if (!hljs) return: block untouched.
expect(code1.dataset.highlighted).toBeUndefined();
expect(code1.classList.contains('hljs-highlighted')).toBe(false);
expect(code1.innerHTML).toBe('echo "hello" # comment');
// Second call: hljsPromise is cached (resolved with null) — the
// if (!hljsPromise) false arm returns the cached null without retrying.
const c2 = makeContainer('<pre><code class="language-json">{"a": 1}</code></pre>');
await highlight(c2);
const code2 = c2.querySelector('code') as HTMLElement;
expect(code2.dataset.highlighted).toBeUndefined();
expect(code2.innerHTML).toBe('{"a": 1}');
});
// -------------------------------------------------------------------------
// highlightSettledCodeBlocks — catch arm around the highlight call.
// A fake hljs whose highlight/highlightAuto throws exercises the catch
// block: sets data-highlighted='true' but NOT the hljs-highlighted class
// and leaves innerHTML untouched.
// -------------------------------------------------------------------------
it('marks a block highlighted without the class when hljs.highlight throws (language path)', async () => {
vi.resetModules();
const throwingHljs = {
registerLanguage: vi.fn(),
registerAliases: vi.fn(),
getLanguage: vi.fn(() => ({ name: 'bash' })),
highlight: vi.fn(() => {
throw new Error('highlight boom');
}),
highlightAuto: vi.fn(),
};
vi.doMock('highlight.js/lib/core', () => ({ default: throwingHljs }));
const { highlightSettledCodeBlocks: highlight } = await import('../aiCodeHighlight');
const container = makeContainer('<pre><code class="language-bash">echo hello</code></pre>');
await highlight(container);
const code = container.querySelector('code') as HTMLElement;
// Catch arm: data-highlighted set, class NOT added, innerHTML untouched.
expect(code.dataset.highlighted).toBe('true');
expect(code.classList.contains('hljs-highlighted')).toBe(false);
expect(code.innerHTML).toBe('echo hello');
expect(throwingHljs.highlight).toHaveBeenCalledTimes(1);
expect(throwingHljs.highlightAuto).not.toHaveBeenCalled();
});
it('marks a block highlighted without the class when hljs.highlightAuto throws (auto path)', async () => {
vi.resetModules();
const throwingHljs = {
registerLanguage: vi.fn(),
registerAliases: vi.fn(),
getLanguage: vi.fn(() => undefined),
highlight: vi.fn(),
highlightAuto: vi.fn(() => {
throw new Error('auto boom');
}),
};
vi.doMock('highlight.js/lib/core', () => ({ default: throwingHljs }));
const { highlightSettledCodeBlocks: highlight } = await import('../aiCodeHighlight');
// No language- class → fenceLanguage returns '' → falsy → highlightAuto.
const container = makeContainer('<pre><code>plain text here</code></pre>');
await highlight(container);
const code = container.querySelector('code') as HTMLElement;
expect(code.dataset.highlighted).toBe('true');
expect(code.classList.contains('hljs-highlighted')).toBe(false);
expect(code.innerHTML).toBe('plain text here');
expect(throwingHljs.highlightAuto).toHaveBeenCalledTimes(1);
expect(throwingHljs.highlight).not.toHaveBeenCalled();
});
});
@@ -1,51 +0,0 @@
import { describe, expect, it } from 'vitest';
import { highlightSettledCodeBlocks } from '../aiCodeHighlight';
const makeContainer = (html: string): HTMLElement => {
const container = document.createElement('div');
container.innerHTML = html;
document.body.appendChild(container);
return container;
};
describe('aiCodeHighlight', () => {
it('highlights a fenced bash block using the language hint', async () => {
const container = makeContainer(
'<pre><code class="language-bash">echo "hello" # comment</code></pre>',
);
await highlightSettledCodeBlocks(container);
const code = container.querySelector('code') as HTMLElement;
expect(code.dataset.highlighted).toBe('true');
expect(code.classList.contains('hljs-highlighted')).toBe(true);
expect(code.querySelector('.hljs-string')).not.toBeNull();
expect(code.querySelector('.hljs-comment')).not.toBeNull();
// Highlighting must not alter the visible text.
expect(code.textContent).toBe('echo "hello" # comment');
});
it('is idempotent: a highlighted block is not reprocessed', async () => {
const container = makeContainer('<pre><code class="language-json">{"a": 1}</code></pre>');
await highlightSettledCodeBlocks(container);
const firstPass = (container.querySelector('code') as HTMLElement).innerHTML;
await highlightSettledCodeBlocks(container);
expect((container.querySelector('code') as HTMLElement).innerHTML).toBe(firstPass);
});
it('falls back to auto-detection when the fence has no usable hint', async () => {
const container = makeContainer(
'<pre><code>{"name": "pulse", "ok": true, "count": 3}</code></pre>',
);
await highlightSettledCodeBlocks(container);
const code = container.querySelector('code') as HTMLElement;
expect(code.dataset.highlighted).toBe('true');
expect(code.textContent).toBe('{"name": "pulse", "ok": true, "count": 3}');
});
it('leaves empty blocks alone', async () => {
const container = makeContainer('<pre><code class="language-bash"> </code></pre>');
await highlightSettledCodeBlocks(container);
const code = container.querySelector('code') as HTMLElement;
expect(code.dataset.highlighted).toBeUndefined();
});
});
@@ -1,70 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getAssistantAnswerText, getLastAssistantAnswerText } from '../assistantAnswerText';
import type { ChatMessage } from '../types';
const makeMessage = (overrides: Partial<ChatMessage>): ChatMessage => ({
id: 'message-1',
role: 'assistant',
content: '',
timestamp: new Date('2026-06-06T12:00:00Z'),
...overrides,
});
describe('assistantAnswerText', () => {
it('returns the latest assistant message text without transcript framing', () => {
const messages = [
makeMessage({ id: 'assistant-1', content: 'Earlier answer' }),
makeMessage({ id: 'user-1', role: 'user', content: 'next question' }),
makeMessage({ id: 'assistant-2', content: 'Latest answer' }),
];
expect(getLastAssistantAnswerText(messages)).toBe('Latest answer');
});
it('uses streamed content events when the message body has not been compacted yet', () => {
const message = makeMessage({
content: '',
isStreaming: true,
streamEvents: [
{ type: 'workflow_status', workflowStatus: { message: 'Checking inventory' } },
{ type: 'content', content: 'Streaming answer part one.' },
{ type: 'content', content: 'Streaming answer part two.' },
],
});
expect(getAssistantAnswerText(message)).toBe(
'Streaming answer part one.\n\nStreaming answer part two.',
);
});
it('does not fall back to an older answer when the current assistant message has no text', () => {
const messages = [
makeMessage({ id: 'assistant-1', content: 'Earlier answer' }),
makeMessage({
id: 'assistant-2',
content: '',
streamEvents: [
{
type: 'tool',
tool: {
name: 'pulse_read',
input: '{}',
output: '',
success: true,
},
},
],
}),
];
expect(getLastAssistantAnswerText(messages)).toBe('');
});
it('keeps raw tool-call artifacts out of copied assistant answers', () => {
const message = makeMessage({
content: 'Visible answer\n\n{"name":"pulse_read","arguments":{"command":"ls"}}',
});
expect(getAssistantAnswerText(message)).toBe('Visible answer');
});
});
@@ -1,264 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
getNextAssistantRecentModelRoute,
normalizeAssistantModelRouteArgument,
normalizeAssistantRecentModelRoutes,
} from '../assistantModelRoutes';
// Branch-coverage companion to assistantModelRoutes.test.ts. Each test below
// drives a specific arm (early return, ternary branch, `??` default, optional
// chain, guard, modular wrap) of the three named functions and asserts against
// concrete outputs rather than truthiness.
const RECENTS: string[] = [
'openrouter:qwen/qwen3.7-plus',
'openrouter:deepseek/deepseek-v4-pro',
'gemini:gemini-3.1-flash-lite',
];
// ---------------------------------------------------------------------------
// normalizeAssistantModelRouteArgument
// ---------------------------------------------------------------------------
describe('normalizeAssistantModelRouteArgument branch coverage', () => {
it('passes an explicit route through after trimming surrounding whitespace', () => {
expect(
normalizeAssistantModelRouteArgument(' openrouter:qwen/qwen3.7-plus ', ['openrouter']),
).toBe('openrouter:qwen/qwen3.7-plus');
});
it('returns null for an empty or whitespace-only argument (falsy candidate)', () => {
expect(normalizeAssistantModelRouteArgument('', ['openrouter'])).toBeNull();
expect(normalizeAssistantModelRouteArgument(' ', ['openrouter'])).toBeNull();
});
it('returns null when the candidate contains internal whitespace', () => {
expect(normalizeAssistantModelRouteArgument('open router/model', ['openrouter'])).toBeNull();
});
it('returns null for url-like arguments containing ://', () => {
expect(normalizeAssistantModelRouteArgument('http://example.com/m', ['http'])).toBeNull();
});
it('returns null when no slash separator is present', () => {
expect(normalizeAssistantModelRouteArgument('plainname', ['plainname'])).toBeNull();
});
it('returns null when the slash is the first character', () => {
expect(normalizeAssistantModelRouteArgument('/model', ['openrouter'])).toBeNull();
});
it('returns null when the slash is the last character', () => {
expect(normalizeAssistantModelRouteArgument('openrouter/', ['openrouter'])).toBeNull();
});
it('returns null when the provider segment fails the provider regex', () => {
expect(normalizeAssistantModelRouteArgument('123/model', ['123'])).toBeNull();
});
it('returns null when the model segment begins with a slash', () => {
expect(normalizeAssistantModelRouteArgument('openrouter//qwen', ['openrouter'])).toBeNull();
});
it('matches known providers case-insensitively while preserving candidate provider case', () => {
expect(normalizeAssistantModelRouteArgument('OpenRouter/model', ['openrouter'])).toBe(
'OpenRouter:model',
);
});
it('trims, lowercases, and drops blank entries in knownProviders before matching', () => {
expect(
normalizeAssistantModelRouteArgument('openrouter/model', [' OpenRouter ', '', ' ']),
).toBe('openrouter:model');
});
it('returns null when knownProviders is empty (default argument)', () => {
expect(normalizeAssistantModelRouteArgument('openrouter/model')).toBeNull();
});
it('returns null when the provider is absent from knownProviders', () => {
expect(normalizeAssistantModelRouteArgument('foo/model', ['openrouter'])).toBeNull();
});
it('builds the canonical route from a slash-style argument', () => {
expect(
normalizeAssistantModelRouteArgument('openrouter/qwen/qwen3.7-plus', ['openrouter']),
).toBe('openrouter:qwen/qwen3.7-plus');
});
});
// ---------------------------------------------------------------------------
// normalizeAssistantRecentModelRoutes
// ---------------------------------------------------------------------------
describe('normalizeAssistantRecentModelRoutes branch coverage', () => {
it('skips non-string entries via the typeof ternary', () => {
expect(
normalizeAssistantRecentModelRoutes(
[
123,
{ model: 'openrouter:qwen/qwen3.7-plus' },
null,
undefined,
true,
'openrouter:qwen/qwen3.7-plus',
],
10,
),
).toEqual(['openrouter:qwen/qwen3.7-plus']);
});
it('skips blank and non-explicit strings and trims valid ones', () => {
expect(
normalizeAssistantRecentModelRoutes(
['', ' ', 'plain-model-name', ' openrouter:qwen/qwen3.7-plus '],
10,
),
).toEqual(['openrouter:qwen/qwen3.7-plus']);
});
it('deduplicates explicit routes by their trimmed form', () => {
expect(
normalizeAssistantRecentModelRoutes(
['openrouter:qwen/qwen3.7-plus', 'openrouter:qwen/qwen3.7-plus', 'deepseek:deepseek-chat'],
10,
),
).toEqual(['openrouter:qwen/qwen3.7-plus', 'deepseek:deepseek-chat']);
});
it('breaks as soon as the limit is reached, ignoring later valid routes', () => {
expect(
normalizeAssistantRecentModelRoutes(
['openrouter:qwen/qwen3.7-plus', 'deepseek:deepseek-chat', 'gemini:gemini-3.1-flash-lite'],
2,
),
).toEqual(['openrouter:qwen/qwen3.7-plus', 'deepseek:deepseek-chat']);
});
it('returns every deduped route when the limit exceeds the input length', () => {
expect(
normalizeAssistantRecentModelRoutes(
['openrouter:qwen/qwen3.7-plus', 'deepseek:deepseek-chat'],
99,
),
).toEqual(['openrouter:qwen/qwen3.7-plus', 'deepseek:deepseek-chat']);
});
it('returns an empty array for an empty input', () => {
expect(normalizeAssistantRecentModelRoutes([], 5)).toEqual([]);
});
it('still yields the first entry when limit is 0 (cap evaluated after push)', () => {
expect(normalizeAssistantRecentModelRoutes(['openrouter:qwen/qwen3.7-plus'], 0)).toEqual([
'openrouter:qwen/qwen3.7-plus',
]);
});
});
// ---------------------------------------------------------------------------
// getNextAssistantRecentModelRoute
// ---------------------------------------------------------------------------
describe('getNextAssistantRecentModelRoute branch coverage', () => {
it('returns null when recentModelIds is empty', () => {
expect(getNextAssistantRecentModelRoute({ recentModelIds: [] })).toBeNull();
});
it('returns null when every recent id is filtered out as non-explicit', () => {
expect(
getNextAssistantRecentModelRoute({ recentModelIds: ['plain-model-name', ''] }),
).toBeNull();
});
it('defaults direction to 1 and advances to the next route', () => {
expect(
getNextAssistantRecentModelRoute({
currentModel: 'openrouter:qwen/qwen3.7-plus',
recentModelIds: RECENTS,
}),
).toBe('openrouter:deepseek/deepseek-v4-pro');
});
it('moves backward with direction -1 from the middle of the list', () => {
expect(
getNextAssistantRecentModelRoute({
currentModel: 'openrouter:deepseek/deepseek-v4-pro',
direction: -1,
recentModelIds: RECENTS,
}),
).toBe('openrouter:qwen/qwen3.7-plus');
});
it('wraps to the last route with direction -1 from the first index', () => {
expect(
getNextAssistantRecentModelRoute({
currentModel: 'openrouter:qwen/qwen3.7-plus',
direction: -1,
recentModelIds: RECENTS,
}),
).toBe('gemini:gemini-3.1-flash-lite');
});
it('wraps to the first route with direction 1 from the last index', () => {
expect(
getNextAssistantRecentModelRoute({
currentModel: 'gemini:gemini-3.1-flash-lite',
direction: 1,
recentModelIds: RECENTS,
}),
).toBe('openrouter:qwen/qwen3.7-plus');
});
it('treats an undefined currentModel as empty and returns the first route', () => {
expect(getNextAssistantRecentModelRoute({ recentModelIds: RECENTS })).toBe(
'openrouter:qwen/qwen3.7-plus',
);
});
it('treats a whitespace-only currentModel as empty (currentIndex < 0, direction 1)', () => {
expect(
getNextAssistantRecentModelRoute({
currentModel: ' ',
recentModelIds: RECENTS,
}),
).toBe('openrouter:qwen/qwen3.7-plus');
});
it('matches the current model after trimming surrounding whitespace', () => {
expect(
getNextAssistantRecentModelRoute({
currentModel: ' openrouter:qwen/qwen3.7-plus ',
direction: 1,
recentModelIds: RECENTS,
}),
).toBe('openrouter:deepseek/deepseek-v4-pro');
});
it('returns the last route when current is absent and direction is -1', () => {
expect(
getNextAssistantRecentModelRoute({
currentModel: 'openai:gpt-4o',
direction: -1,
recentModelIds: RECENTS,
}),
).toBe('gemini:gemini-3.1-flash-lite');
});
it('returns null when the only recent route matches the current model', () => {
expect(
getNextAssistantRecentModelRoute({
currentModel: 'openrouter:qwen/qwen3.7-plus',
recentModelIds: ['openrouter:qwen/qwen3.7-plus'],
}),
).toBeNull();
});
it('returns the sole recent route when current is absent and it is the only entry', () => {
expect(
getNextAssistantRecentModelRoute({
currentModel: 'openai:gpt-4o',
recentModelIds: ['openrouter:qwen/qwen3.7-plus'],
}),
).toBe('openrouter:qwen/qwen3.7-plus');
});
});

Some files were not shown because too many files have changed in this diff Show More