Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/design/SIDE_CHAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ When a patch with kind `edit` is applied, the system routes by **two questions i
| Tier | Trigger | Path |
|---|---|---|
| **None** | `affectedCount === 0` (item is a graph leaf with no downstream edges) | Apply directly. Single-item content update; brief inline confirmation card in the panel: "Updated `[X]`." |
| **Soft** | `1 ≤ affectedCount ≤ 2` AND no anchor or affected item is in an active review set *(active = generated and not yet accepted)* | Apply with **soft recomputing**. Patch lands directly; brief inline confirmation lists the affected items: "Updated `[X]`; recomputed `[Y]`, `[Z]`." No cascade preview. |
| **Soft** | `1 ≤ affectedCount ≤ 2` AND no anchor or affected item is in an active review set *(active = generated and not yet accepted)* | Apply directly with affected-item context. Patch lands directly; brief inline confirmation lists the affected items: "Updated `[X]`; `[Y]`, `[Z]` may need a refresh." No cascade preview or durable `reconciliation_need` rows. |
| **Hard** | High downstream count, OR any anchor or affected item is in an active review set | **Cascade preview** backed by `reconciliation_need` rows → batch-resolution mode in the side-chat panel (§5.3). The archived REVISIT_MODULE walk is superseded. |

### 5.2 Confidence model — V1
Expand Down
192 changes: 84 additions & 108 deletions src/client/components/__tests__/patch-list-overlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { ReconciliationNeedRecord } from '@/shared/reconciliation-need.js';

import {
PatchListProvider,
usePatchList,
Expand All @@ -12,8 +14,45 @@ import {
import { PatchListOverlayBridgeProvider } from '../patch-list-overlay-bridge.js';
import { PatchListOverlay } from '../patch-list-overlay.js';

// Inject a controllable stub for the open-needs hook so the overlay can be
// tested without TanStack Router / QueryClientProvider scaffolding. Default
// returns []; individual tests override via setMockOpenNeeds.
let mockOpenNeeds: ReconciliationNeedRecord[] = [];
function setMockOpenNeeds(needs: ReconciliationNeedRecord[]): void {
mockOpenNeeds = needs;
}

vi.mock('@/client/routes/specification/$id/-specification-data.js', () => ({
useSpecificationOpenReconciliationNeeds: () => mockOpenNeeds,
// Stub the rest so accidental imports don't blow up.
specificationQueryKeys: {
bundle: (id: string) => ['specification', id, 'bundle'] as const,
entities: (id: string) => ['specification', id, 'entities'] as const,
entitiesProjectWide: (id: string) => ['specification', id, 'entities', 'project-wide'] as const,
reconciliationNeeds: (id: string) => ['specification', id, 'reconciliation-needs'] as const,
},
invalidateOpenReconciliationNeeds: vi.fn(),
}));

function makeNeed(overrides: Partial<ReconciliationNeedRecord> = {}): ReconciliationNeedRecord {
return {
id: overrides.id ?? 1,
specification_id: overrides.specification_id ?? 1,
source_item_id: overrides.source_item_id ?? 10,
target_item_id: overrides.target_item_id ?? 20,
kind: overrides.kind ?? 'needs_confirmation',
status: overrides.status ?? 'open',
reason: overrides.reason ?? null,
caused_by_turn_id: overrides.caused_by_turn_id ?? null,
caused_by_patch_id: overrides.caused_by_patch_id ?? null,
created_at: overrides.created_at ?? '2026-05-08T00:00:00Z',
resolved_at: overrides.resolved_at ?? null,
};
}

afterEach(() => {
cleanup();
setMockOpenNeeds([]);
});

beforeEach(() => {
Expand Down Expand Up @@ -192,83 +231,77 @@ describe('PatchListOverlay', () => {
expect(editApplier).toHaveBeenCalledTimes(1);
});

it('shows the deferred banner with the message after a hard-impact deferred apply', async () => {
const editApplier = vi.fn(() =>
Promise.resolve({
undo: () => Promise.resolve(),
applied: {
deferred: true,
impact: 'hard',
message: 'Hard impact — coming in V3 cascade preview',
},
}),
);
const appliers = makeAppliers({ edit: editApplier as unknown as PatchAppliers['edit'] });
it('renders the Pending review section listing open reconciliation needs (V3.0 card 2)', () => {
setMockOpenNeeds([
makeNeed({ id: 1, source_item_id: 10, target_item_id: 20, kind: 'needs_confirmation' }),
makeNeed({ id: 2, source_item_id: 10, target_item_id: 21, kind: 'supersedes' }),
]);
const appliers = makeAppliers();
render(
<PatchListProvider appliers={appliers}>
<PatchListOverlay />
<StageEditPatchButton />
</PatchListProvider>,
);
fireEvent.click(screen.getByText('stage-edit'));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
});
const banner = await screen.findByRole('status', { name: /hard impact deferred to v3/i });
expect(banner.textContent).toContain('Hard impact — coming in V3 cascade preview');
expect(screen.getByRole('button', { name: /dismiss/i })).toBeTruthy();
// Saved-toast should NOT show in the overlay for a deferred-only batch
expect(screen.queryByRole('status', { name: /change saved/i })).toBeNull();
const section = screen.getByRole('region', { name: /pending review/i });
expect(section.getAttribute('data-open-needs-count')).toBe('2');
expect(section.textContent).toContain('2 pending reviews');
// Each need rendered with its kind chip and source→target reference
expect(section.querySelector('[data-need-id="1"]')?.textContent).toContain('source #10');
expect(section.querySelector('[data-need-id="1"]')?.textContent).toContain('target #20');
expect(section.querySelector('[data-need-id="1"][data-need-kind="needs_confirmation"]')).toBeTruthy();
expect(section.querySelector('[data-need-id="2"][data-need-kind="supersedes"]')).toBeTruthy();
});

it('clicking Dismiss hides the deferred banner', async () => {
const editApplier = vi.fn(() =>
Promise.resolve({
undo: () => Promise.resolve(),
applied: { deferred: true, impact: 'hard', message: 'Hard impact — coming in V3 cascade preview' },
}),
it('hides the Pending review section when there are zero open needs', () => {
setMockOpenNeeds([]);
const appliers = makeAppliers();
render(
<PatchListProvider appliers={appliers}>
<PatchListOverlay />
</PatchListProvider>,
);
const appliers = makeAppliers({ edit: editApplier as unknown as PatchAppliers['edit'] });
expect(screen.queryByRole('region', { name: /pending review/i })).toBeNull();
});

it('renders both staged-changes and Pending review when both exist', () => {
setMockOpenNeeds([makeNeed({ id: 7 })]);
const appliers = makeAppliers();
render(
<PatchListProvider appliers={appliers}>
<PatchListOverlay />
<StageEditPatchButton />
</PatchListProvider>,
);
fireEvent.click(screen.getByText('stage-edit'));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
});
await screen.findByRole('status', { name: /hard impact deferred to v3/i });
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
expect(screen.queryByRole('status', { name: /hard impact deferred to v3/i })).toBeNull();
expect(screen.getByRole('region', { name: /staged changes/i })).toBeTruthy();
expect(screen.getByRole('region', { name: /pending review/i })).toBeTruthy();
});

it('hides the deferred banner when the applied batch is undone before the timeout', async () => {
it('does not surface any "Hard impact — coming in V3" banner copy', async () => {
const editApplier = vi.fn(() =>
Promise.resolve({
undo: () => Promise.resolve(),
applied: { deferred: true, impact: 'hard', message: 'Hard impact — coming in V3 cascade preview' },
applied: {
impact: 'hard',
previousContent: 'old',
previousRationale: null,
openedNeedIds: [101],
},
}),
);
const appliers = makeAppliers({ edit: editApplier as unknown as PatchAppliers['edit'] });
render(
<PatchListProvider appliers={appliers}>
<PatchListOverlay />
<StageEditPatchButton />
<UndoButton />
</PatchListProvider>,
);
fireEvent.click(screen.getByText('stage-edit'));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
});
await screen.findByRole('status', { name: /hard impact deferred to v3/i });

await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /undo-outside-overlay/i }));
});

expect(screen.queryByText(/coming in V3/i)).toBeNull();
expect(screen.queryByText(/cascade pending review/i)).toBeNull();
expect(screen.queryByRole('status', { name: /hard impact deferred to v3/i })).toBeNull();
});

Expand All @@ -293,88 +326,31 @@ describe('PatchListOverlay', () => {
expect(screen.getByRole('status', { name: /change saved/i })).toBeTruthy();
});

it('auto-hides the deferred banner after the timeout even when staging activity churns mid-window', async () => {
it('shows the saved-toast after a hard-impact apply (V3.0 card 2 — no deferred banner blocking)', async () => {
const editApplier = vi.fn(() =>
Promise.resolve({
undo: () => Promise.resolve(),
applied: { deferred: true, impact: 'hard', message: 'Hard impact — coming in V3 cascade preview' },
applied: {
impact: 'hard',
previousContent: 'old',
previousRationale: null,
openedNeedIds: [101],
},
}),
);
const appliers = makeAppliers({ edit: editApplier as unknown as PatchAppliers['edit'] });

function DiscardAllStaged() {
const patchList = usePatchList();
const state = usePatchListState();
return (
<button
type="button"
onClick={() => {
for (const patch of state.staged) {
patchList?.discard(patch.id);
}
}}
>
discard-all
</button>
);
}

render(
<PatchListProvider appliers={appliers}>
<PatchListOverlay />
<StageEditPatchButton />
<DiscardAllStaged />
</PatchListProvider>,
);

fireEvent.click(screen.getByText('stage-edit'));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
});
await screen.findByRole('status', { name: /hard impact deferred to v3/i });

fireEvent.click(screen.getByText('stage-edit'));

await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});

fireEvent.click(screen.getByText('discard-all'));

expect(screen.queryByRole('status', { name: /hard impact deferred to v3/i })).toBeNull();
});

it('replaces a deferred banner with the saved-toast after a later non-deferred apply', async () => {
const editApplier = vi
.fn()
.mockResolvedValueOnce({
undo: () => Promise.resolve(),
applied: { deferred: true, impact: 'hard', message: 'Hard impact — coming in V3 cascade preview' },
})
.mockResolvedValueOnce({
undo: () => Promise.resolve(),
applied: { impact: 'soft', previousContent: 'old' },
});
const appliers = makeAppliers({ edit: editApplier as unknown as PatchAppliers['edit'] });
render(
<PatchListProvider appliers={appliers}>
<PatchListOverlay />
<StageEditPatchButton />
</PatchListProvider>,
);

fireEvent.click(screen.getByText('stage-edit'));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
});
await screen.findByRole('status', { name: /hard impact deferred to v3/i });

fireEvent.click(screen.getByText('stage-edit'));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
});

expect(screen.queryByRole('status', { name: /hard impact deferred to v3/i })).toBeNull();
expect(screen.getByRole('status', { name: /change saved/i })).toBeTruthy();
});
});
Expand Down
14 changes: 14 additions & 0 deletions src/client/components/__tests__/side-chat-host.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ vi.mock('@/client/lib/side-chat-stream.js', () => ({
streamSideChatResponse: vi.fn(() => Promise.resolve()),
}));

// V3.0 card 2: PatchListOverlay reads open reconciliation needs via this hook,
// which depends on TanStack Router context. Stub it here so the side-chat host
// tests can render the overlay without a full router setup.
vi.mock('@/client/routes/specification/$id/-specification-data.js', () => ({
useSpecificationOpenReconciliationNeeds: () => [],
specificationQueryKeys: {
bundle: (id: string) => ['specification', id, 'bundle'] as const,
entities: (id: string) => ['specification', id, 'entities'] as const,
entitiesProjectWide: (id: string) => ['specification', id, 'entities', 'project-wide'] as const,
reconciliationNeeds: (id: string) => ['specification', id, 'reconciliation-needs'] as const,
},
invalidateOpenReconciliationNeeds: vi.fn(),
}));

vi.mock('@/client/lib/annotation-api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/client/lib/annotation-api.js')>();
return {
Expand Down
Loading
Loading