mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
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:
+2
-2
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Generated
+84
-120
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' }] }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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 };
|
||||
@@ -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">
|
||||
->
|
||||
</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,
|
||||
});
|
||||
});
|
||||
});
|
||||
-320
@@ -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
-136
@@ -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');
|
||||
});
|
||||
});
|
||||
-264
@@ -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
Reference in New Issue
Block a user