diff --git a/src/shared/console-capture.ts b/src/shared/console-capture.ts index 0c6d1fdd0..d7daac7df 100644 --- a/src/shared/console-capture.ts +++ b/src/shared/console-capture.ts @@ -25,6 +25,10 @@ const SEVERITY: Record = { let currentLevel = SEVERITY.info; // default Info +export function isDebugEnabled(): boolean { + return currentLevel >= SEVERITY.debug; +} + /** #612 Set the active console verbosity. Called by `initLogLevelForWindow` * from the persisted setting and the live `log_level_changed` event. An * unknown value falls back to Info. */ @@ -84,15 +88,17 @@ console.trace = (...args: unknown[]) => capture("trace", SEVERITY.trace, origTra // Also capture unhandled errors and promise rejections. These are ERROR-level // signals and bypass the #612 severity gate entirely (always captured). -window.addEventListener("error", (e) => { - const msg = `${e.message} at ${e.filename}:${e.lineno}:${e.colno}`; - captured.push({ ts: timestamp(), level: "error", args: msg }); -}); - -window.addEventListener("unhandledrejection", (e) => { - const msg = `Unhandled rejection: ${e.reason}`; - captured.push({ ts: timestamp(), level: "error", args: msg }); -}); +if (typeof window !== "undefined") { + window.addEventListener("error", (e) => { + const msg = `${e.message} at ${e.filename}:${e.lineno}:${e.colno}`; + captured.push({ ts: timestamp(), level: "error", args: msg }); + }); + + window.addEventListener("unhandledrejection", (e) => { + const msg = `Unhandled rejection: ${e.reason}`; + captured.push({ ts: timestamp(), level: "error", args: msg }); + }); +} export function getConsoleLogs(): LogEntry[] { return [...captured]; diff --git a/src/shared/ipc.transport.test.ts b/src/shared/ipc.transport.test.ts index 2289aba9b..c630d161b 100644 --- a/src/shared/ipc.transport.test.ts +++ b/src/shared/ipc.transport.test.ts @@ -9,6 +9,19 @@ import type { } from "./types"; describe("shared ipc transport seam", () => { + it("serializes the exact rail diagnostic envelope and propagates the transport result", async () => { + const ipc = await import("./ipc"); + const fake = new FakeTransport(); + fake.resolve("append_rail_diagnostic", null); + const restore = ipc.__setTransportForTests(fake); + const record: import("./ipc").RailDiagnosticRecord = { version: 1, windowId: "13eac932-6f64-4a9b-8141-464899d6bc31", seq: 1, atMs: 0, surface: "sidebar", event: "click", trusted: false }; + try { + await expect(ipc.DebugAPI.appendRailDiagnostic(record)).resolves.toBeNull(); + expect(fake.lastCall("append_rail_diagnostic")?.args).toEqual({ record }); + fake.reject("append_rail_diagnostic", "invalid rail diagnostic"); + await expect(ipc.DebugAPI.appendRailDiagnostic(record)).rejects.toBe("invalid rail diagnostic"); + } finally { restore(); } + }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 983236b14..ca7d5ff14 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -878,7 +878,52 @@ export const VoiceAPI = { transport.invoke("voice_had_typing", { sessionId }), }; +export interface RailDiagnosticRecord { + version: 1; + windowId: string; + seq: number; + atMs: number; + surface: "embedded" | "sidebar" | "store"; + event: "pointerdown" | "pointerup" | "pointercancel" | "mousedown" | "mouseup" | "click" | + "gotpointercapture" | "lostpointercapture" | "focusin" | "focusout" | "window-focus" | "window-blur" | + "handler-enter" | "handler-end" | "selection-change" | "rail-mount" | "rail-dispose" | + "section-mount" | "section-dispose" | "node-mount" | "node-dispose" | "project-list" | + "project-buttons" | "favorites-entries" | "reload-start" | "reload-end" | "discover-start" | + "discover-end" | "snapshot-start" | "snapshot-end" | "snapshots-start" | "snapshots-end" | + "task-start" | "task-end" | "loop-start" | "loop-end"; + railId?: number; + operationId?: number; + projectId?: number; + groupId?: number; + entityId?: number; + nodeId?: number; + originalNodeId?: number; + objectId?: number; + selectionProjectId?: number; + selectionGroupId?: number; + kind?: "custom" | "system" | "nonstop" | "ungrouped" | "project" | "favorites"; + selectionKind?: RailDiagnosticRecord["kind"]; + phase?: "arming" | "dragging" | "saving" | "idle"; + outcome?: "applied" | "unchanged" | "stale" | "error" | "disconnected" | "unloaded" | + "queued" | "discarded" | "suppressed" | "overflow" | "no-press"; + pointerId?: number; + button?: number; + primary?: boolean; + trusted?: boolean; + connected?: boolean; + originalConnected?: boolean; + captured?: boolean; + documentFocused?: boolean; + targetTag?: "button" | "span" | "svg" | "path" | "aside" | "body" | "input" | "textarea" | "other" | "none"; + activeTag?: RailDiagnosticRecord["targetTag"]; + count?: number; + changedCount?: number; + durationMs?: number; +} + export const DebugAPI = { + appendRailDiagnostic: (record: RailDiagnosticRecord) => + transport.invoke("append_rail_diagnostic", { record }), saveLogs: (content: string) => transport.invoke("save_debug_logs", { content }), drainErrorLogs: () => diff --git a/src/sidebar/App.tsx b/src/sidebar/App.tsx index 98abea30f..a5e89d50c 100644 --- a/src/sidebar/App.tsx +++ b/src/sidebar/App.tsx @@ -1126,13 +1126,13 @@ const SidebarApp: Component = (props) => { diff --git a/src/sidebar/components/WorkgroupGroupRail.test.tsx b/src/sidebar/components/WorkgroupGroupRail.test.tsx index 675134612..5a07383af 100644 --- a/src/sidebar/components/WorkgroupGroupRail.test.tsx +++ b/src/sidebar/components/WorkgroupGroupRail.test.tsx @@ -24,9 +24,177 @@ import { } from "../stores/workgroup-groups"; import { entityDirNumber, entityShortLabel } from "../../shared/entity-prefix"; import WorkgroupGroupRail from "./WorkgroupGroupRail"; +import { setLogLevel } from "../../shared/console-capture"; +import { __setTransportForTests, type RailDiagnosticRecord } from "../../shared/ipc"; +import { projectStore } from "../stores/project"; +import { railEntity } from "../rail-diagnostics"; const projectPath = "C:\\Project"; +function diagnostics(fake: FakeTransport): RailDiagnosticRecord[] { + return fake.callsFor("append_rail_diagnostic").map(call => call.args.record as RailDiagnosticRecord); +} + +describe("rail diagnostic positive controls with real gate and transport", () => { + afterEach(() => { setLogLevel("info"); resetUiStoresForTests(); document.body.replaceChildren(); vi.restoreAllMocks(); }); + + async function prepare() { + resetUiStoresForTests(); + const fake = new FakeTransport(); + fake.resolve("get_project_groups", groupsConfig({ groups: [{ id: "ui", name: "PRIVATE title", regex: ".*", favorite: true }], nonStop: { ...defaultNonStop(), show: true, favorite: true } })); + fake.resolve("open_project", { path: projectPath }); + fake.resolve("discover_project", { ...project(), workgroups: [] }); + fake.resolve("append_rail_diagnostic", null); + const restore = __setTransportForTests(fake); + setLogLevel("debug"); + await projectStore.loadProject(projectPath); + return { fake, restore }; + } + + it("observes interrupted press -> project replacement -> release without dispatching click, and synthetic trusted=false handler/selection reversal", async () => { + const { fake, restore } = await prepare(); + const [projects, setProjects] = createSignal([project()]); + const rendered = renderWithFakeTransport(() => , fake); + try { + await waitFor(() => expect(railButton("Project", "ui")).toBeTruthy()); + const node = railButton("Project", "ui"); + fake.onInvoke("append_rail_diagnostic", () => new Promise(() => {})); + fake.clearCalls(); + pointer(node, "pointerdown"); + const press = diagnostics(fake).find(record => record.event === "pointerdown")!; + expect(press.groupId).toBeTypeOf("number"); + setProjects([project()]); + expect(node.isConnected).toBe(false); + pointer(document.body, "pointerup"); + const release = diagnostics(fake).find(record => record.event === "pointerup")!; + expect(release.originalNodeId).toBe(press.nodeId); + expect(release.originalConnected).toBe(false); + expect(release.groupId).toBe(press.groupId); + expect(diagnostics(fake).some(record => record.event === "node-dispose")).toBe(true); + expect(diagnostics(fake).some(record => record.event === "node-mount" && record.nodeId !== press.nodeId)).toBe(true); + expect(diagnostics(fake).some(record => record.event === "project-buttons")).toBe(true); + expect(diagnostics(fake).some(record => record.event === "click")).toBe(false); + const current = railButton("Project", "ui"); + click(current); // Synthetic handler control only; no native-click claim. + expect(diagnostics(fake).find(record => record.event === "handler-enter")?.trusted).toBe(false); + expect(workgroupGroupsStore.selection(projectPath)).toEqual({ kind: "group", id: "ui" }); + workgroupGroupsStore.select(projectPath, { kind: "all" }); + expect(diagnostics(fake).filter(record => record.event === "selection-change").map(record => record.selectionKind)).toContain("system"); + const favorite = target("workgroupGroups.favoriteButton.Project.ui"); + click(favorite); + expect(diagnostics(fake).filter(record => record.event === "handler-enter").pop()?.groupId).toBe(press.groupId); + click(target("workgroupGroups.favoriteNonStopButton.Project")); + expect(diagnostics(fake).filter(record => record.event === "handler-enter").pop()?.kind).toBe("nonstop"); + expect(JSON.stringify(diagnostics(fake))).not.toMatch(/PRIVATE|"Project"|C:\\|:"ui"/); + const same = projects(); setProjects(same); + expect(railButton("Project", "ui")).toBe(current); + const count = diagnostics(fake).length; + setLogLevel("info"); click(current); + expect(diagnostics(fake)).toHaveLength(count); + } finally { rendered.cleanup(); restore(); } + }); + + it.each(["pointer", "mouse", "compatibility-mouse"] as const)( + "retires the retained rail gesture on a new outside %s press", async mode => { + const { fake, restore } = await prepare(); + const stable = [project()]; + const a = renderWithFakeTransport(() => , fake); + const b = renderWithFakeTransport(() => , fake); + const mouse = (node: Element, type: string) => node.dispatchEvent(new MouseEvent(type, { bubbles: true, button: 0 })); + try { + await waitFor(() => expect(scopedTarget(b.root, "workgroupGroups.button.ui")).toBeTruthy()); + const node = scopedTarget(b.root, "workgroupGroups.button.ui"); + fake.clearCalls(); + if (mode === "mouse") mouse(node, "mousedown"); + else pointer(node, "pointerdown"); + const press = diagnostics(fake).find(record => record.event === (mode === "mouse" ? "mousedown" : "pointerdown"))!; + expect(press.originalNodeId).toBeTypeOf("number"); + expect(press.entityId).toBeTypeOf("number"); + // The original gesture may release outside without a click. + if (mode === "mouse") mouse(document.body, "mouseup"); + else pointer(document.body, "pointerup"); + const release = diagnostics(fake).filter(record => record.event === (mode === "mouse" ? "mouseup" : "pointerup")); + expect(release).toHaveLength(1); + expect(release[0]).toMatchObject({ originalNodeId: press.originalNodeId, entityId: press.entityId }); + fake.clearCalls(); + if (mode === "pointer") pointer(document.body, "pointerdown"); + mouse(document.body, "mousedown"); + pointer(document.body, "pointerup"); + mouse(document.body, "mouseup"); + mouse(document.body, "click"); + document.body.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); + document.body.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); + expect(diagnostics(fake).some(record => record.originalNodeId === press.originalNodeId || record.entityId === press.entityId)).toBe(false); + expect(diagnostics(fake)).toHaveLength(0); + } finally { a.cleanup(); b.cleanup(); restore(); } + } + ); + + it("caps retained gestures at eight, clears listeners on remount, and contains rejected IPC during selection", async () => { + const { fake, restore } = await prepare(); + const stable = [project()]; + let rendered = renderWithFakeTransport(() => , fake); + try { + await waitFor(() => expect(railButton("Project", "ui")).toBeTruthy()); + const node = railButton("Project", "ui"); + fake.clearCalls(); + for (let pointerId = 1; pointerId <= 9; pointerId++) pointer(node, "pointerdown", { pointerId }); + expect(diagnostics(fake).filter(record => record.outcome === "overflow")).toHaveLength(1); + fake.clearCalls(); + pointer(document.body, "pointerup", { pointerId: 1 }); + expect(diagnostics(fake)).toHaveLength(0); + pointer(document.body, "pointerup", { pointerId: 2 }); + expect(diagnostics(fake).filter(record => record.event === "pointerup")).toHaveLength(1); + const firstRail = diagnostics(fake)[0].railId; + rendered.cleanup(); + fake.clearCalls(); + pointer(document.body, "pointerup", { pointerId: 2 }); + expect(diagnostics(fake)).toHaveLength(0); + rendered = renderWithFakeTransport(() => , fake); + await waitFor(() => expect(railButton("Project", "ui")).toBeTruthy()); + fake.clearCalls(); + pointer(railButton("Project", "ui"), "pointerdown"); + expect(diagnostics(fake).filter(record => record.event === "pointerdown")).toHaveLength(1); + expect(diagnostics(fake)[0].railId).not.toBe(firstRail); + fake.reject("append_rail_diagnostic", "PRIVATE rejection"); + click(railButton("Project", "ui")); + await Promise.resolve(); await Promise.resolve(); + expect(workgroupGroupsStore.selection(projectPath)).toEqual({ kind: "group", id: "ui" }); + expect(diagnostics(fake).some(record => record.event === "handler-end" && record.outcome === "applied")).toBe(true); + } finally { rendered.cleanup(); restore(); } + }); + + it("config-only deletion during a gesture retires identity; two rails and unmount preserve the other rail's gesture", async () => { + const { fake, restore } = await prepare(); + const stable = [project()]; + const a = renderWithFakeTransport(() => , fake); + const b = renderWithFakeTransport(() => , fake); + let aCleaned = false; + try { + await waitFor(() => expect(b.root.querySelector('[data-ac-testid="workgroupGroups.button.ui"]')).toBeTruthy()); + const node = scopedTarget(b.root, "workgroupGroups.button.ui"); + fake.clearCalls(); + pointer(node, "pointerdown", { pointerId: 7 }); + const press = diagnostics(fake).find(record => record.event === "pointerdown")!; + a.cleanup(); aCleaned = true; + pointer(document.body, "pointerup", { pointerId: 7 }); + expect(diagnostics(fake).filter(record => record.event === "pointerup")).toHaveLength(1); + expect(diagnostics(fake).find(record => record.event === "pointerup")?.originalNodeId).toBe(press.nodeId); + const config = workgroupGroupsStore.config(projectPath); + workgroupGroupsStore.applyExternalUpdate(projectPath, { ...config, groups: [] }); + expect(railEntity(projectPath, "custom", "ui").groupId).toBeUndefined(); + pointer(document.body, "pointerup", { pointerId: 7 }); + expect(railEntity(projectPath, "custom", "ui").groupId).toBeUndefined(); + workgroupGroupsStore.applyExternalUpdate(projectPath, config); + expect(railEntity(projectPath, "custom", "ui").groupId).not.toBe(press.groupId); + b.cleanup(); + const count = diagnostics(fake).length; + pointer(document.body, "pointerup", { pointerId: 7 }); + expect(diagnostics(fake)).toHaveLength(count); + } finally { if (!aCleaned) a.cleanup(); b.cleanup(); restore(); } + }); +}); + function wg(name: string): AcWorkgroup { return { name, diff --git a/src/sidebar/components/WorkgroupGroupRail.tsx b/src/sidebar/components/WorkgroupGroupRail.tsx index a49e6febd..31a4da9ae 100644 --- a/src/sidebar/components/WorkgroupGroupRail.tsx +++ b/src/sidebar/components/WorkgroupGroupRail.tsx @@ -1,4 +1,4 @@ -import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"; +import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, untrack, createComputed } from "solid-js"; import { Portal } from "solid-js/web"; import type { AcAgentReplica, AcWorkgroup, NonStopGroupConfig, WorkgroupGroup } from "../../shared/types"; import type { ProjectState } from "../stores/project"; @@ -30,8 +30,28 @@ import BlockedMenuIcon from "./BlockedMenuIcon"; import ArchiveIcon from "./ArchiveIcon"; import ArchivedProjectsModal from "./ArchivedProjectsModal"; +import { emitRailDiagnostic, railToken, railObjectId, railEntity, railTag, reconcileRailGroups, type RailFields } from "../rail-diagnostics"; +import { isDebugEnabled } from "../../shared/console-capture"; + +type RailSurface = "embedded" | "sidebar"; +interface RailObservation { + emit: (event: Parameters[1], fields?: RailFields) => void; + entity: (path: string, selection?: WorkgroupGroupSelection) => RailFields; + node: (node: HTMLElement, fields: () => RailFields, section?: boolean) => void; + selection: () => RailFields; +} + +function selectionKind(selection: WorkgroupGroupSelection): NonNullable { + return selection.kind === "group" ? "custom" : selection.kind === "all" ? "system" : selection.kind; +} + +function sameSelection(a: RailFields, b: RailFields): boolean { + return a.selectionProjectId === b.selectionProjectId && a.selectionGroupId === b.selectionGroupId && a.selectionKind === b.selectionKind; +} + interface WorkgroupGroupRailProps { projects: ProjectState[]; + surface?: RailSurface; } interface GroupButton { @@ -225,6 +245,8 @@ function selectFromRail(project: ProjectState, selection: WorkgroupGroupSelectio const RailButton: Component<{ button: GroupButton; + observation: RailObservation; + projectPath: string; testIds: RailButtonTestIds; selected: boolean; reorderState?: ReorderState | null; @@ -235,9 +257,11 @@ const RailButton: Component<{ onPointerCancel?: (event: PointerEvent) => void; onContextMenu: (event: MouseEvent) => void; onClick: (event: MouseEvent) => void; -}> = (props) => ( - -); + ); +}; type FavoriteEntry = | { kind: "group"; project: ProjectState; group: WorkgroupGroup; button: GroupButton } @@ -319,12 +343,14 @@ type FavoriteEntry = const FavoritesRailSection: Component<{ projects: ProjectState[]; + observation: RailObservation; onOpenContextMenu: (event: MouseEvent, target: RailContextTarget) => void; }> = (props) => { const collapsed = () => railCollapseStore.isFavoritesCollapsed(); - const entries = createMemo(() => - props.projects.flatMap((project) => { + let previousEntries: FavoriteEntry[] = []; + const entries = createMemo(() => { + const entries = props.projects.flatMap((project) => { const config = workgroupGroupsStore.config(project.path); const compiled = compileWorkgroupGroups(config.groups); const result: FavoriteEntry[] = []; @@ -351,12 +377,16 @@ const FavoritesRailSection: Component<{ } } return result; - }) - ); + }); + untrack(() => props.observation.emit("favorites-entries", { objectId: railObjectId(entries), count: entries.length, + changedCount: entries.filter((entry, index) => entry !== previousEntries[index]).length })); + previousEntries = entries; + return entries; + }); return ( 0}> -
+
{ onMount(() => props.observation.node(node, () => ({ kind: "favorites" }), true)); }} class="workgroup-group-rail-favorites" data-ac-testid="workgroupGroups.favorites">