feat(followup): implement PLAN-FOLLOWUP.md gap fixes

Complete implementation of 7 slices addressing E2E testing gaps:

Slice 0+1: Wire Actions + ReasonPrompt
- FocusView now uses useActions hook instead of direct act() calls
- Added pendingAction state pattern for skip/defer/complete actions
- ReasonPrompt integration with proper confirm/cancel flow
- Tags support in DecisionEntry interface

Slice 2: Drag Reorder UI
- Installed @dnd-kit (core, sortable, utilities)
- QueueView with DndContext, SortableContext, verticalListSortingStrategy
- SortableQueueItem wrapper component using useSortable hook
- pendingReorder state with ReasonPrompt for reorder reasons
- Cmd+Up/Down keyboard shortcuts for accessibility
- Fixed: Store item ID in PendingReorder to avoid stale queue reference

Slice 3: System Tray Integration
- tray.rs with TrayState, setup_tray, toggle_window_visibility
- Menu with Show/Quit items
- Left-click toggles window visibility
- update_tray_badge command updates tooltip with item count
- Frontend wiring in AppShell

Slice 4: E2E Test Updates
- Fixed test selectors for InboxView, Queue badge
- Exposed inbox store for test seeding

Slice 5: Staleness Visualization
- Already implemented in computeStaleness() with tests

Slice 6: Quick Wiring
- onStartBatch callback wired to QueueView
- SyncStatus rendered in nav area
- SettingsView renders Settings component

Slice 7: State Persistence
- settings-store with hydrate/update methods
- Tauri backend integration via read_settings/write_settings
- AppShell hydrates settings on mount

Bug fixes from code review:
- close_bead now has error isolation (try/catch) so decision logging
  and queue advancement continue even if bead close fails
- PendingReorder stores item ID to avoid stale queue reference

E2E tests for all ACs (tests/e2e/followup-acs.spec.ts):
- AC-F1: Drag reorder (4 tests)
- AC-F2: ReasonPrompt integration (7 tests)
- AC-F5: Staleness visualization (3 tests)
- AC-F6: Batch mode (2 tests)
- AC-F7: SyncStatus (2 tests)
- ReasonPrompt behavior (3 tests)

Tests: 388 frontend + 119 Rust + 32 E2E all passing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 11:26:42 -05:00
parent 5078cb506a
commit f5ce8a9091
44 changed files with 5268 additions and 625 deletions

View File

@@ -473,9 +473,9 @@ describe("useReconcile", () => {
});
});
// --- Combined Status Hook Tests ---
// --- useLoreItems Tests ---
describe("query invalidation coordination", () => {
describe("useLoreItems", () => {
let queryClient: QueryClient;
beforeEach(() => {
@@ -487,27 +487,109 @@ describe("query invalidation coordination", () => {
queryClient.clear();
});
it("sync-status event with completed status invalidates queries", async () => {
setMockResponse("get_lore_status", mockLoreStatus);
setMockResponse("get_bridge_status", mockBridgeStatus);
it("fetches and transforms lore items successfully", async () => {
const mockItemsResponse = {
items: [
{
id: "mr_review:group::repo:200",
title: "Review this MR",
item_type: "mr_review",
project: "group/repo",
url: "https://gitlab.com/group/repo/-/merge_requests/200",
iid: 200,
updated_at: "2026-02-26T10:00:00Z",
requested_by: "alice",
},
{
id: "issue:group::repo:42",
title: "Fix the bug",
item_type: "issue",
project: "group/repo",
url: "https://gitlab.com/group/repo/-/issues/42",
iid: 42,
updated_at: "2026-02-26T09:00:00Z",
requested_by: null,
},
],
success: true,
error: null,
};
const { result: loreResult } = renderHook(() => useLoreStatus(), {
setMockResponse("get_lore_items", mockItemsResponse);
// Import dynamically to avoid circular dependency in test setup
const { useLoreItems } = await import("@/lib/queries");
const { result } = renderHook(() => useLoreItems(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(loreResult.current.isSuccess).toBe(true);
expect(result.current.isSuccess).toBe(true);
});
invoke.mockClear();
expect(result.current.data).toBeDefined();
expect(result.current.data?.length).toBe(2);
// Simulate sync completed event
act(() => {
simulateEvent("sync-status", { status: "completed", message: "Done" });
// Verify transformation to FocusItem format
const firstItem = result.current.data?.[0];
expect(firstItem?.id).toBe("mr_review:group::repo:200");
expect(firstItem?.title).toBe("Review this MR");
expect(firstItem?.type).toBe("mr_review");
expect(firstItem?.requestedBy).toBe("alice");
});
it("returns empty array when lore fetch fails", async () => {
const mockFailedResponse = {
items: [],
success: false,
error: "lore CLI not found",
};
setMockResponse("get_lore_items", mockFailedResponse);
const { useLoreItems } = await import("@/lib/queries");
const { result } = renderHook(() => useLoreItems(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith("get_lore_status");
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it("invalidates on lore-data-changed event", async () => {
const mockItemsResponse = {
items: [],
success: true,
error: null,
};
setMockResponse("get_lore_items", mockItemsResponse);
const { useLoreItems } = await import("@/lib/queries");
const { result } = renderHook(() => useLoreItems(), {
wrapper: createWrapper(queryClient),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(invoke).toHaveBeenCalledTimes(1);
// Simulate event
act(() => {
simulateEvent("lore-data-changed", undefined);
});
await waitFor(() => {
expect(invoke).toHaveBeenCalledTimes(2);
});
});
});