Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions src/components/agents/config/__tests__/RawOverlayDialog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// R70: the raw overlay YAML dialog feeds the shared draft; forbidden keys (R71) are
// highlighted and block Apply; Clear overlay needs agent:configure.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { flushPromises, mount } from '@vue/test-utils';
import { resetAgentDrafts } from '@/composables/agent-config/draftRegistry';
import type { ConfigWorkspace } from '@/composables/agent-config/useConfigWorkspace';
import {
ADMIN,
READER,
fakeApi,
globalWith,
piniaWith,
workspaceHost,
} from './helpers';

vi.mock('@/components/code-editor', () => import('./codeEditorMock'));

const confirmRequire = vi.fn();
vi.mock('primevue/useconfirm', () => ({
useConfirm: () => ({ require: confirmRequire }),
}));

import PrimeDialog from 'primevue/dialog';
import RawOverlayDialog from '../workspace/RawOverlayDialog.vue';

async function mountDialog(
perms: Record<string, string[]> = ADMIN,
over: Parameters<typeof fakeApi>[0] = {},
) {
const out: { ws?: ConfigWorkspace } = {};
const wrapper = mount(
workspaceHost(
fakeApi(over),
RawOverlayDialog,
() => ({ visible: true }),
out,
),
{ global: globalWith(piniaWith(perms), { teleport: true }) },
);
await flushPromises();
await out.ws!.loadDetails();
await flushPromises();
return { wrapper, ws: out.ws! };
}

async function type(wrapper: ReturnType<typeof mount>, text: string) {
vi.useFakeTimers();
await wrapper.find('textarea').setValue(text);
vi.advanceTimersByTime(300);
vi.useRealTimers();
await flushPromises();
}

describe('RawOverlayDialog (R70)', () => {
beforeEach(() => {
resetAgentDrafts();
confirmRequire.mockReset();
});

it('never closes on Escape', async () => {
const { wrapper } = await mountDialog();
// The innermost Dialog is PrimeVue's (the first is the Volt wrapper).
const prime = wrapper.findAllComponents(PrimeDialog).at(-1)!;
expect(prime.props('closeOnEscape')).toBe(false);
});

it('Cancel closes at once while nothing was edited', async () => {
const { wrapper } = await mountDialog();
await wrapper.find('[data-test="raw-cancel"]').trigger('click');
expect(confirmRequire).not.toHaveBeenCalled();
expect(
wrapper.findComponent(RawOverlayDialog).emitted('update:visible'),
).toEqual([[false]]);
});

it('Cancel and × ask before discarding edits', async () => {
const { wrapper } = await mountDialog();
await type(wrapper, 'verbosity: 3\n');
const dialog = wrapper.findComponent(RawOverlayDialog);
await wrapper.find('[data-test="raw-cancel"]').trigger('click');
// The × emits update:visible false from the Dialog.
wrapper.findComponent({ name: 'Dialog' }).vm.$emit('update:visible', false);
expect(confirmRequire).toHaveBeenCalledTimes(2);
expect(dialog.emitted('update:visible')).toBeUndefined();
expect(confirmRequire.mock.calls[0][0]).toMatchObject({
header: 'Discard overlay edits?',
});
// Keep editing (reject) leaves the dialog open; Discard (accept) closes it.
confirmRequire.mock.calls[0][0].accept();
expect(dialog.emitted('update:visible')).toEqual([[false]]);
});

it('starts from the draft and applies a parsed, coerced document to it', async () => {
const { wrapper, ws } = await mountDialog();
expect(
(wrapper.find('textarea').element as HTMLTextAreaElement).value,
).toContain('local-ssh-policies:v1.1.0');
expect(
wrapper.find('[data-test="raw-apply"]').attributes('disabled'),
).toBeDefined();
await type(
wrapper,
'verbosity: 2\nplugins:\n local-ssh:\n config:\n debug: true\n',
);
// Only booleans are coerced (R27); a number would be a blocking issue instead.
expect(wrapper.find('[data-test="yaml-coerced"]').text()).toContain(
'/plugins/local-ssh/config/debug',
);
await wrapper.find('[data-test="raw-apply"]').trigger('click');
expect(ws.draft.overlay.value).toEqual({
verbosity: 2,
plugins: { 'local-ssh': { config: { debug: 'true' } } },
});
expect(
wrapper.findComponent(RawOverlayDialog).emitted('update:visible'),
).toEqual([[false]]);
});

it('highlights forbidden keys and blocks Apply (R71)', async () => {
const { wrapper, ws } = await mountDialog();
await type(
wrapper,
'verbosity: 1\ndaemon: false\nremote_config:\n mode: apply_all\n',
);
expect(wrapper.find('[data-test="yaml-forbidden"]').text()).toContain(
'daemon',
);
expect(wrapper.find('[data-test="yaml-forbidden"]').text()).toContain(
'can never be changed remotely',
);
const diags = JSON.parse(
wrapper.find('textarea').attributes('data-diagnostics') ?? '[]',
) as { row: number }[];
expect(diags.map((d) => d.row)).toEqual([2, 3]);
expect(
wrapper.find('[data-test="raw-apply"]').attributes('disabled'),
).toBeDefined();
expect(ws.draft.isDirty.value).toBe(false);
});

it('shows YAML errors and keeps Apply disabled', async () => {
const { wrapper } = await mountDialog();
await type(wrapper, 'plugins: [unclosed\n');
expect(wrapper.find('[data-test="yaml-error"]').exists()).toBe(true);
expect(
wrapper.find('[data-test="raw-apply"]').attributes('disabled'),
).toBeDefined();
});

it('Clear overlay replaces the draft with {} for configure', async () => {
const admin = await mountDialog();
await admin.wrapper.find('[data-test="clear-overlay"]').trigger('click');
await flushPromises();
await admin.wrapper.find('[data-test="raw-apply"]').trigger('click');
expect(admin.ws.draft.overlay.value).toEqual({});
});

it('a reader cannot clear', async () => {
const { wrapper } = await mountDialog(READER);
expect(
wrapper.find('[data-test="clear-overlay"]').attributes('disabled'),
).toBeDefined();
});
});
196 changes: 196 additions & 0 deletions src/components/agents/config/workspace/PendingChangesBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<template>
<div
v-if="visible"
class="sticky bottom-0 z-20 -mx-1 rounded-md border border-sky-300 bg-sky-50/95 px-4 py-3 shadow-md backdrop-blur dark:border-sky-700 dark:bg-slate-900/95"
role="region"
aria-label="Pending configuration changes"
data-test="pending-bar"
>
<div class="flex flex-wrap items-center gap-3 text-sm">
<span
class="font-medium text-gray-900 dark:text-slate-100"
data-test="pending-count"
>
{{ count }} pending change{{ count === 1 ? '' : 's' }}
</span>
<button
type="button"
class="text-xs text-sky-700 hover:underline dark:text-sky-300"
:aria-expanded="expanded"
data-test="pending-toggle"
@click="expanded = !expanded"
>
{{ expanded ? 'Hide' : 'Show' }}
</button>
<span
v-if="ws.blockingCount.value"
class="text-xs text-red-600 dark:text-red-400"
data-test="pending-blocking"
>
<i class="pi pi-times-circle mr-1 text-[0.7rem]" />{{
ws.blockingCount.value
}}
problem{{ ws.blockingCount.value === 1 ? '' : 's' }} to fix
</span>
<span
v-if="behind"
class="text-xs text-amber-700 dark:text-amber-300"
data-test="pending-behind"
>
Based on r{{ draft.baseRevision.value }}; r{{ latestRevision }} is the
desired revision now
</span>
<span
class="text-xs text-gray-500 dark:text-slate-400"
aria-live="polite"
data-test="live-check"
>
<template v-if="ws.preview.status.value === 'checking'">
<i class="pi pi-spin pi-spinner mr-1 text-[0.7rem]" />Checking…
</template>
<template v-else-if="ws.preview.status.value === 'checked'">
<i class="pi pi-check mr-1 text-[0.7rem]" />Checked
</template>
<span
v-else-if="ws.preview.status.value === 'failed'"
class="text-red-600 dark:text-red-400"
data-test="live-check-failed"
>
<i class="pi pi-exclamation-circle mr-1 text-[0.7rem]" />Check
failed<template v-if="ws.preview.error.value"
>: {{ ws.preview.error.value }}</template
>
<button
type="button"
class="ml-1 text-sky-700 hover:underline dark:text-sky-300"
data-test="live-check-retry"
@click="ws.preview.retry()"
>
Retry
</button>
</span>
</span>
<span class="flex-1" />
<TertiaryButton
size="small"
data-test="pending-discard"
@click="confirmDiscard"
>
Discard
</TertiaryButton>
<span v-tooltip.top="{ value: reviewTooltip, disabled: !reviewTooltip }">
<PrimaryButton
size="small"
:disabled="!!reviewTooltip"
data-test="pending-review"
@click="ws.openReview()"
>
Review &amp; save
</PrimaryButton>
</span>
</div>
<div v-if="expanded" class="mt-2 space-y-1" data-test="pending-list">
<ul class="flex flex-wrap gap-1">
<li
v-for="p in draft.changedPaths.value"
:key="p"
class="inline-flex items-center gap-1 rounded bg-white px-1.5 font-mono text-xs dark:bg-slate-800"
>
{{ p }}
<button
type="button"
class="text-sky-700 dark:text-sky-300"
:aria-label="`Undo ${p}`"
:data-test="`pending-undo-${p}`"
@click="draft.revertPointer(p)"
>
↺
</button>
</li>
</ul>
<ul
v-if="draft.issues.value.length"
class="space-y-0.5 text-xs"
data-test="pending-issues"
>
<li
v-for="(i, idx) in draft.issues.value"
:key="idx"
:class="
i.blocking
? 'text-red-600 dark:text-red-400'
: 'text-amber-700 dark:text-amber-300'
"
>
<code class="font-mono">{{ i.ptr || '/' }}</code> — {{ i.message }}
</li>
</ul>
</div>
</div>
<ReviewSaveDialog
v-if="ws.reviewOpen.value"
v-model:visible="ws.reviewOpen.value"
/>
</template>

<script setup lang="ts">
// The sticky "N pending changes · Review & save · Discard" bar (R69). It follows the shared
// per-agent draft.
import {
computed,
defineAsyncComponent,
onBeforeUnmount,
ref,
watch,
} from 'vue';
import { useConfirm } from 'primevue/useconfirm';
import PrimaryButton from '@/volt/PrimaryButton.vue';
import TertiaryButton from '@/volt/TertiaryButton.vue';
import { useWorkspace } from '@/composables/agent-config/useConfigWorkspace';

// The review (diff views, CodeMirror merge) loads only when someone reviews.
const ReviewSaveDialog = defineAsyncComponent(
() => import('./ReviewSaveDialog.vue'),
);

const ws = useWorkspace()!;
const draft = ws.draft;
const confirm = useConfirm();
const expanded = ref(false);

const visible = computed(
() => ws.canConfigure.value && ws.ready.value && draft.isDirty.value,
);
const count = computed(() => draft.changedPaths.value.length);
const latestRevision = computed(() => ws.state.config.value?.revision ?? 0);
const behind = computed(
() =>
!!ws.state.config.value &&
latestRevision.value !== draft.baseRevision.value,
);
const reviewTooltip = computed(() => ws.reviewDisabledReason.value);

function confirmDiscard() {
confirm.require({
header: 'Discard pending changes?',
message: `Discard ${count.value} pending change${count.value === 1 ? '' : 's'}? Nothing has been saved yet.`,
rejectProps: { label: 'Keep', severity: 'secondary', outlined: true },
acceptProps: { label: 'Discard', severity: 'danger' },
accept: () => ws.discard(),
});
}

function beforeUnload(e: BeforeUnloadEvent) {
e.preventDefault();
e.returnValue = '';
}
watch(
visible,
(dirty) => {
if (dirty) window.addEventListener('beforeunload', beforeUnload);
else window.removeEventListener('beforeunload', beforeUnload);
},
{ immediate: true },
);
onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload));
</script>
Loading
Loading