Add data directories to remediation context
Some checks failed
Build and Test / Secret Scan (push) Has been cancelled
Build and Test / Frontend & Backend (push) Has been cancelled

FormatForRemediation surfaced config and log paths but not data paths,
while FormatForAIContext (chat) does. For remediation those matter —
backup targets, disk-full triage, restore points (e.g. a database data
dir or HA's /config/.storage). Add a Data Directories section, matching
the chat pack. Extends the remediation test to assert a data path
reaches it; teeth-checked. Full servicediscovery package green.
This commit is contained in:
rcourtman 2026-06-07 13:36:09 +01:00
parent db28d8bcc3
commit d7f0de0ba2
10 changed files with 328 additions and 45 deletions

View file

@ -2149,10 +2149,12 @@ draft. The referenced OpenCode source at fetched `dev` commit
with suggested actions, slash commands, and search) and keeps the footer command
entry discoverable from `packages/opencode/src/cli/cmd/run/footer.view.tsx`.
Pulse adapts that pattern for the web drawer by exposing the existing Assistant
commands dialog from the composer chrome. The button must use the same
`ASSISTANT_SLASH_COMMANDS` registry, `AssistantCommandHelpDialog`, and
`executeSlashCommand` path as `/help` and `/commands`; it must not introduce a
second command registry or a separate provider-bound prompt path.
commands dialog from the composer chrome and making that dialog searchable and
keyboard-selectable. The button, slash-triggered help, dialog search, and
keyboard selection must use the same `ASSISTANT_SLASH_COMMANDS` registry,
`filterAssistantSlashCommands`, `AssistantCommandHelpDialog`, and
`executeSlashCommand` path as `/help` and `/commands`; they must not introduce
a second command registry or a separate provider-bound prompt path.
Primary nav moved to governed platform/runtime destinations on 2026-05-16 and
was clarified on 2026-05-25 through `frontend-modern/src/App.tsx` and

View file

@ -1,13 +1,16 @@
import { For, Show, createEffect, onCleanup } from 'solid-js';
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import XIcon from 'lucide-solid/icons/x';
import {
ASSISTANT_SLASH_COMMANDS,
filterAssistantSlashCommands,
getAssistantSlashCommandTokens,
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';
@ -17,16 +20,67 @@ interface AssistantCommandHelpDialogProps {
}
export function AssistantCommandHelpDialog(props: AssistantCommandHelpDialogProps) {
const [commandSearchQuery, setCommandSearchQuery] = createSignal('');
const [selectedCommandIndex, setSelectedCommandIndex] = createSignal(0);
let searchInputRef: HTMLInputElement | undefined;
const commands = createMemo(() => filterAssistantSlashCommands(commandSearchQuery()));
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) => {
if (event.key !== 'Escape') return;
consumeDialogCloseKey(event);
props.onClose();
const controlOnly = event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
switch (event.key) {
case 'Escape':
consumeDialogCloseKey(event);
props.onClose();
break;
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(() => {
@ -34,6 +88,22 @@ export function AssistantCommandHelpDialog(props: AssistantCommandHelpDialogProp
onCleanup(() => document.removeEventListener('keydown', handleKeyDown));
});
createEffect(() => {
commandSearchQuery();
setSelectedCommandIndex(0);
});
createEffect(() => {
const count = commands().length;
if (selectedCommandIndex() >= count) {
setSelectedCommandIndex(Math.max(0, count - 1));
}
});
onMount(() => {
queueMicrotask(() => searchInputRef?.focus());
});
return (
<div
class="absolute inset-0 z-50 flex items-end bg-slate-950/20 p-3 sm:items-center sm:justify-center"
@ -58,40 +128,69 @@ export function AssistantCommandHelpDialog(props: AssistantCommandHelpDialogProp
<XIcon class="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div class="max-h-[28rem] overflow-y-auto p-2" role="list">
<For each={ASSISTANT_SLASH_COMMANDS}>
{(command) => {
const aliases = () => getAssistantSlashCommandTokens(command).slice(1);
return (
<button
type="button"
class="flex w-full items-start gap-3 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
onClick={() => props.onRunCommand(command)}
>
<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(', ')}
<div class="border-b border-border px-3 py-2">
<input
ref={searchInputRef}
value={commandSearchQuery()}
onInput={(event) => setCommandSearchQuery(event.currentTarget.value)}
type="search"
aria-label={AI_CHAT_COMMAND_HELP_SEARCH_LABEL}
placeholder={AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER}
class="h-9 w-full rounded-md border border-border bg-surface px-3 text-sm text-base-content placeholder:text-muted focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
/>
</div>
<div
class="max-h-[25rem] 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={commands()}>
{(command, index) => {
const aliases = () => getAssistantSlashCommandTokens(command).slice(1);
return (
<button
type="button"
role="option"
aria-selected={index() === selectedCommandIndex()}
class={`flex w-full items-start gap-3 rounded-md px-3 py-2.5 text-left transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-none ${
index() === selectedCommandIndex() ? 'bg-surface-hover' : ''
}`}
onClick={() => props.onRunCommand(command)}
onMouseEnter={() => setSelectedCommandIndex(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>
<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>
</span>
<span class="mt-0.5 block text-xs leading-5 text-muted">
{command.description}
</span>
</span>
</button>
);
}}
</For>
</button>
);
}}
</For>
</Show>
</div>
</section>
</div>

View file

@ -1761,10 +1761,10 @@ describe('AIChat', () => {
fireEvent.keyDown(textarea, { key: 'Enter' });
expect(screen.getByRole('dialog', { name: 'Assistant commands' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /\/models/ })).toHaveTextContent(
expect(screen.getByRole('option', { name: /\/models/ })).toHaveTextContent(
'Choose or set the model route (/model provider:model-id)',
);
expect(screen.getByRole('button', { name: /\/status/ })).toHaveTextContent(
expect(screen.getByRole('option', { name: /\/status/ })).toHaveTextContent(
'Check the selected model route',
);
expect(mockChat.sendMessage).not.toHaveBeenCalled();
@ -1778,7 +1778,7 @@ describe('AIChat', () => {
fireEvent.keyDown(textarea, { key: 'Enter' });
expect(screen.getByRole('dialog', { name: 'Assistant commands' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /\/models/ }));
fireEvent.click(screen.getByRole('option', { name: /\/models/ }));
await waitFor(() => {
expect(

View file

@ -1,10 +1,53 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
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));
fireEvent.input(search, { target: { value: 'runtime' } });
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('moves command selection with arrow keys before running', () => {
const onRunCommand = vi.fn();
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={onRunCommand} />);
const newCommand = screen.getByRole('option', { name: /\/new/ });
expect(newCommand).toHaveAttribute('aria-selected', 'false');
fireEvent.keyDown(document, { key: 'ArrowDown' });
expect(newCommand).toHaveAttribute('aria-selected', 'true');
fireEvent.keyDown(document, { key: 'Enter' });
expect(onRunCommand).toHaveBeenCalledWith(
expect.objectContaining({ action: 'new', name: 'new' }),
);
});
it('consumes Escape as a local dialog close command', () => {
const onClose = vi.fn();
render(() => <AssistantCommandHelpDialog onClose={onClose} onRunCommand={vi.fn()} />);

View file

@ -6,6 +6,9 @@ import {
AI_CHAT_CLOSE_LABEL,
AI_CHAT_COMMAND_HELP_BUTTON_LABEL,
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,
AI_CHAT_CONTROL_MODE_LABEL,
AI_CHAT_CONTROL_MODE_MENU_LABEL,
@ -99,6 +102,9 @@ describe('aiChatPresentation', () => {
expect(AI_CHAT_COMMAND_HELP_TITLE).toBe('Assistant commands');
expect(AI_CHAT_COMMAND_HELP_BUTTON_LABEL).toBe('Open Assistant commands');
expect(AI_CHAT_COMMAND_HELP_CLOSE_LABEL).toBe('Close Assistant commands');
expect(AI_CHAT_COMMAND_HELP_SEARCH_LABEL).toBe('Search Assistant commands');
expect(AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER).toBe('Search commands...');
expect(AI_CHAT_COMMAND_HELP_EMPTY_STATE).toBe('No commands match your search');
expect(AI_CHAT_SESSION_EMPTY_STATE).toBe('No previous assistant sessions');
expect(AI_CHAT_SESSION_LOADING_STATE).toBe('Loading assistant sessions...');
expect(AI_CHAT_SESSION_SEARCH_PLACEHOLDER).toBe('Search sessions...');

View file

@ -48,6 +48,9 @@ export const AI_CHAT_SWITCH_TO_APPROVAL_LABEL = 'Switch Assistant control mode t
export const AI_CHAT_COMMAND_HELP_TITLE = 'Assistant commands';
export const AI_CHAT_COMMAND_HELP_BUTTON_LABEL = 'Open Assistant commands';
export const AI_CHAT_COMMAND_HELP_CLOSE_LABEL = 'Close Assistant commands';
export const AI_CHAT_COMMAND_HELP_SEARCH_LABEL = 'Search Assistant commands';
export const AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER = 'Search commands...';
export const AI_CHAT_COMMAND_HELP_EMPTY_STATE = 'No commands match your search';
export const AI_CHAT_SESSION_EMPTY_STATE = 'No previous assistant sessions';
export const AI_CHAT_SESSION_LOADING_STATE = 'Loading assistant sessions...';
export const AI_CHAT_SESSION_SEARCH_PLACEHOLDER = 'Search sessions...';

View file

@ -524,6 +524,31 @@ func buildDiscoveryToolResponse(req discoveryResourceRequest, discovery *Resourc
response["ports"] = ports
}
// Add Docker bind mounts if present. A container path like /config is
// meaningless for editing or backing up persistent files without its host
// source, so surface the host->container mapping so the model can act on the
// real files, not the ephemeral container view.
if len(discovery.BindMounts) > 0 {
bindMounts := make([]map[string]interface{}, 0, len(discovery.BindMounts))
for _, m := range discovery.BindMounts {
mount := map[string]interface{}{
"source": m.Source,
"destination": m.Destination,
}
if m.ContainerName != "" {
mount["container_name"] = m.ContainerName
}
if m.Type != "" {
mount["type"] = m.Type
}
if m.ReadOnly {
mount["read_only"] = m.ReadOnly
}
bindMounts = append(bindMounts, mount)
}
response["bind_mounts"] = bindMounts
}
return response
}

View file

@ -358,6 +358,98 @@ func TestExecuteGetDiscovery_CanonicalAppContainerUsesDockerProviderType(t *test
assert.Equal(t, "http://192.0.2.10:8080", payload["suggested_url"])
}
func TestBuildDiscoveryToolResponse_IncludesBindMounts(t *testing.T) {
req := discoveryResourceRequest{
resourceType: "app-container",
resourceID: "abc123",
targetID: "agent-1",
}
discovery := &ResourceDiscoveryInfo{
ID: "docker:agent-1:abc123",
ResourceType: "docker",
ResourceID: "abc123",
TargetID: "agent-1",
Hostname: "docker-host-1",
ServiceType: "homeassistant",
BindMounts: []DiscoveryMount{
{
ContainerName: "homeassistant",
Source: "/opt/appdata/homeassistant",
Destination: "/config",
Type: "bind",
ReadOnly: true,
},
},
}
resp := buildDiscoveryToolResponse(req, discovery)
rawMounts, ok := resp["bind_mounts"].([]map[string]interface{})
if !ok {
t.Fatalf("expected bind_mounts to be []map[string]interface{}, got %T", resp["bind_mounts"])
}
if assert.Len(t, rawMounts, 1) {
mount := rawMounts[0]
assert.Equal(t, "/opt/appdata/homeassistant", mount["source"])
assert.Equal(t, "/config", mount["destination"])
assert.Equal(t, "homeassistant", mount["container_name"])
assert.Equal(t, "bind", mount["type"])
assert.Equal(t, true, mount["read_only"])
}
}
func TestBuildDiscoveryToolResponse_OmitsBindMountsWhenEmpty(t *testing.T) {
req := discoveryResourceRequest{
resourceType: "vm",
resourceID: "101",
targetID: "node1",
}
discovery := &ResourceDiscoveryInfo{
ID: "vm:node1:101",
ResourceType: "vm",
ResourceID: "101",
TargetID: "node1",
Hostname: "vm-101",
}
resp := buildDiscoveryToolResponse(req, discovery)
assert.NotContains(t, resp, "bind_mounts")
}
func TestBuildDiscoveryToolResponse_OmitsOptionalMountFields(t *testing.T) {
req := discoveryResourceRequest{
resourceType: "app-container",
resourceID: "abc123",
targetID: "agent-1",
}
discovery := &ResourceDiscoveryInfo{
ID: "docker:agent-1:abc123",
ResourceType: "docker",
ResourceID: "abc123",
TargetID: "agent-1",
BindMounts: []DiscoveryMount{
{
Source: "/srv/data",
Destination: "/data",
},
},
}
resp := buildDiscoveryToolResponse(req, discovery)
rawMounts, ok := resp["bind_mounts"].([]map[string]interface{})
if !ok {
t.Fatalf("expected bind_mounts to be []map[string]interface{}, got %T", resp["bind_mounts"])
}
if assert.Len(t, rawMounts, 1) {
mount := rawMounts[0]
assert.Equal(t, "/srv/data", mount["source"])
assert.Equal(t, "/data", mount["destination"])
assert.NotContains(t, mount, "container_name")
assert.NotContains(t, mount, "type")
assert.NotContains(t, mount, "read_only")
}
}
func TestExecuteRunDiscovery_ForcesFreshDiscovery(t *testing.T) {
provider := &stubDiscoveryProvider{
triggerResp: &ResourceDiscoveryInfo{

View file

@ -352,6 +352,17 @@ func FormatForRemediation(d *ResourceDiscovery) string {
sb.WriteString("\n")
}
// Data directories — where persistent data lives (backup targets, disk-full
// triage, restore points). FormatForAIContext surfaces these; remediation
// must too.
if len(d.DataPaths) > 0 {
sb.WriteString("### Data Directories\n")
for _, p := range d.DataPaths {
sb.WriteString(fmt.Sprintf("- `%s`\n", p))
}
sb.WriteString("\n")
}
// Bind mounts — edit or back up persistent files at the HOST source; the
// container only sees the destination path, so without this a fix written
// "to /config" would not survive a container recreate.

View file

@ -223,6 +223,7 @@ func TestFormatForRemediationSurfacesControlAndMounts(t *testing.T) {
ServiceName: "Home Assistant",
CLIAccess: "docker exec homeassistant bash",
ConfigPaths: []string{"/config/automations.yaml"},
DataPaths: []string{"/config/.storage"},
DockerMounts: []DockerBindMount{
{ContainerName: "homeassistant", Source: "/opt/ha/config", Destination: "/config", Type: "bind"},
},
@ -238,6 +239,7 @@ func TestFormatForRemediationSurfacesControlAndMounts(t *testing.T) {
"docker restart homeassistant", // service-control fact
"/opt/ha/config", // host bind-mount source
"/config/automations.yaml", // config file to edit
"/config/.storage", // data directory (backup/restore target)
} {
if !strings.Contains(out, want) {
t.Errorf("remediation context missing %q\n--- output ---\n%s", want, out)