Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
cd6117d
Add resizable-interview-dock spec and plan from fabro run 01KY1KYJ
andremw Jul 21, 2026
9abf159
chore(build): start resizable-interview-dock
Jul 21, 2026
a150be2
fabro(01KY1P37MDYTZ3442VKZYRJHGD): prepare (succeeded)
Jul 21, 2026
6cfdcd1
feat(web): add interview dock height persistence constants
Jul 21, 2026
39cceb3
feat(web): add dock height state hook
Jul 21, 2026
9e004fb
feat(web): implement loadDockHeight with validation and clamping
Jul 21, 2026
5232b48
feat(web): add saveDockHeight helper for localStorage persistence
Jul 21, 2026
c12ddde
feat(web): persist dock height changes to localStorage
Jul 21, 2026
3a13de8
feat(web): wire dock height state to RunDetail via callback
Jul 21, 2026
ac8371f
docs(plan): check off completed Slice 1 steps
Jul 21, 2026
e23f7f6
fabro(01KY1P37MDYTZ3442VKZYRJHGD): build_slice (succeeded)
Jul 21, 2026
429b7cd
feat(web): add resize handle div with ARIA attributes
Jul 21, 2026
9958519
feat(web): style resize handle with hover state
Jul 21, 2026
afe6bed
feat(web): add drag state and dragOrigin ref
Jul 21, 2026
ce70599
feat(web): add onPointerDown handler for resize
Jul 21, 2026
c9118f5
feat(web): add onPointerMove handler with height computation
Jul 21, 2026
aeb6be3
feat(web): add onPointerUp and onPointerCancel handlers
Jul 21, 2026
1f4c072
feat(web): thread onResizeActiveChange through to RunDetail
Jul 21, 2026
4ac7632
docs(plan): check off completed Slice 2 steps
Jul 21, 2026
e7f391c
fabro(01KY1P37MDYTZ3442VKZYRJHGD): build_slice (succeeded)
Jul 21, 2026
c221952
feat(web): use dynamic dock height for clearance variable
Jul 21, 2026
2adcd05
feat(web): add transition suppression during dock resize
Jul 21, 2026
bfc7e1c
feat(web): apply transition variable to scrollable panes
Jul 21, 2026
bdddc3c
feat(web): re-clamp dock height on viewport resize
Jul 21, 2026
42577bb
docs(plan): check off completed Slice 3 steps
Jul 21, 2026
08ed268
fabro(01KY1P37MDYTZ3442VKZYRJHGD): build_slice (succeeded)
Jul 21, 2026
bad6587
docs(plan): check off Slice 4 verification steps
Jul 21, 2026
7291fa6
docs(plan): mark implementation complete
Jul 21, 2026
ed093fa
fabro(01KY1P37MDYTZ3442VKZYRJHGD): build_slice (succeeded)
Jul 21, 2026
b9ff6ea
fabro(01KY1P37MDYTZ3442VKZYRJHGD): reviews (parallel_base)
Jul 21, 2026
e26a67b
fabro(01KY1P37MDYTZ3442VKZYRJHGD): review_quality (succeeded)
Jul 21, 2026
c731e8d
fabro(01KY1P37MDYTZ3442VKZYRJHGD): reviews (succeeded)
Jul 21, 2026
97b6358
fabro(01KY1P37MDYTZ3442VKZYRJHGD): merge_reviews (succeeded)
Jul 21, 2026
efb46b0
docs(plan): add ship review summary
Jul 21, 2026
c73c9ce
fabro(01KY1P37MDYTZ3442VKZYRJHGD): address_findings (succeeded)
Jul 21, 2026
fcd4e7a
fabro(01KY1P37MDYTZ3442VKZYRJHGD): ship_gate (succeeded)
Jul 21, 2026
cc9c520
Refactor interview-dock tests to focus on behavior
andremw Jul 21, 2026
56b0ef3
Fix drag functionality and add visual drag indicator
andremw Jul 21, 2026
a6c5498
Fix interview dock height binding and drag handle visibility
andremw Jul 21, 2026
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
362 changes: 361 additions & 1 deletion apps/fabro-web/app/components/interview-dock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
QuestionType,
} from "@qltysh/fabro-api-client";

import { InterviewDock } from "./interview-dock";
import {
InterviewDock,
loadDockHeight,
clampDockHeight,
saveDockHeight,
} from "./interview-dock";
import { displayLabel } from "./interview-label";
import { generatedAxios } from "../lib/api-client";

Expand Down Expand Up @@ -64,6 +69,24 @@ function makeQuestion(overrides: Partial<ApiQuestion> = {}): ApiQuestion {
}

describe("InterviewDock", () => {
test("renders successfully when localStorage is empty", () => {
const storage = new Map<string, string>();
globalThis.window = {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
},
innerHeight: 1000,
} as any;

const tree = render(
<InterviewDock runId="run-1" questions={[makeQuestion()]} />,
);
expect(tree.toJSON()).not.toBeNull();
// Should not persist height on initial mount
expect(storage.size).toBe(0);
});

test("renders question text and stage in the header", () => {
const tree = render(
<InterviewDock runId="run-1" questions={[makeQuestion()]} />,
Expand Down Expand Up @@ -225,6 +248,31 @@ describe("InterviewDock", () => {
expect(tree.toJSON()).toBeNull();
});

test("notifies parent of height on mount", () => {
const storage = new Map<string, string>();
globalThis.window = {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
},
innerHeight: 1000,
} as any;

const heights: string[] = [];
const onDockHeightChange = (height: string) => heights.push(height);

render(
<InterviewDock
runId="run-1"
questions={[makeQuestion()]}
onDockHeightChange={onDockHeightChange}
/>,
);

expect(heights.length).toBe(1);
expect(heights[0]).toMatch(/^\d+(?:\.\d+)?(rem|px|vh)$/);
});

test("renders the optional context_display section", () => {
const question = makeQuestion({
context_display: "Plan:\n1. Deploy\n2. Verify",
Expand All @@ -236,6 +284,318 @@ describe("InterviewDock", () => {
expect(text).toContain("Context from preceding stage");
expect(text).toContain("1. Deploy");
});

test("renders resize handle with correct ARIA attributes", () => {
const tree = render(
<InterviewDock runId="run-1" questions={[makeQuestion()]} />,
);
const handles = tree.root.findAllByProps({ role: "separator" });
expect(handles).toHaveLength(1);
const handle = handles[0];
expect(handle.props["aria-orientation"]).toBe("horizontal");
expect(handle.props["aria-label"]).toBe("Resize interview dock");
});

test("notifies parent when resize starts", () => {
const resizeActiveCalls: boolean[] = [];
const tree = render(
<InterviewDock
runId="run-1"
questions={[makeQuestion()]}
onResizeActiveChange={(active) => resizeActiveCalls.push(active)}
/>,
);
const handle = tree.root.findByProps({ role: "separator" });

act(() => {
handle.props.onPointerDown({
preventDefault: () => {},
pointerId: 123,
clientY: 500,
currentTarget: { setPointerCapture: () => {} },
});
});

expect(resizeActiveCalls).toEqual([true]);
});

test("resizing upward increases dock height", () => {
globalThis.window = {
localStorage: {
getItem: () => null,
setItem: () => {},
},
innerHeight: 1000,
} as any;

const heightChanges: string[] = [];
const tree = render(
<InterviewDock
runId="run-1"
questions={[makeQuestion()]}
onDockHeightChange={(height) => heightChanges.push(height)}
/>,
);
const handle = tree.root.findByProps({ role: "separator" });

const initialHeight = parseFloat(heightChanges[0]);

act(() => {
handle.props.onPointerDown({
preventDefault: () => {},
pointerId: 1,
clientY: 500,
currentTarget: { setPointerCapture: () => {} },
});
});

act(() => {
handle.props.onPointerMove({ clientY: 400 });
});

const newHeight = parseFloat(heightChanges[heightChanges.length - 1]);
expect(newHeight).toBeGreaterThan(initialHeight);
});

test("resizing respects minimum height", () => {
globalThis.window = {
localStorage: {
getItem: () => null,
setItem: () => {},
},
innerHeight: 1000,
} as any;

const heightChanges: string[] = [];
const tree = render(
<InterviewDock
runId="run-1"
questions={[makeQuestion()]}
onDockHeightChange={(height) => heightChanges.push(height)}
/>,
);
const handle = tree.root.findByProps({ role: "separator" });

act(() => {
handle.props.onPointerDown({
preventDefault: () => {},
pointerId: 1,
clientY: 500,
currentTarget: { setPointerCapture: () => {} },
});
});

// Try to drag far downward to shrink below minimum
act(() => {
handle.props.onPointerMove({ clientY: 5000 });
});

// Should be clamped to minimum (12rem = 192px)
expect(heightChanges[heightChanges.length - 1]).toBe("192px");
});

test("notifies parent when resize ends", () => {
globalThis.window = {
localStorage: {
getItem: () => null,
setItem: () => {},
},
innerHeight: 1000,
} as any;

const resizeActiveCalls: boolean[] = [];
const tree = render(
<InterviewDock
runId="run-1"
questions={[makeQuestion()]}
onResizeActiveChange={(active) => resizeActiveCalls.push(active)}
/>,
);
const handle = tree.root.findByProps({ role: "separator" });

act(() => {
handle.props.onPointerDown({
preventDefault: () => {},
pointerId: 123,
clientY: 500,
currentTarget: { setPointerCapture: () => {} },
});
});

act(() => {
handle.props.onPointerUp({
pointerId: 123,
currentTarget: {
releasePointerCapture: () => {},
},
});
});

expect(resizeActiveCalls).toEqual([true, false]);
});

test("notifies parent when resize is cancelled", () => {
globalThis.window = {
localStorage: {
getItem: () => null,
setItem: () => {},
},
innerHeight: 1000,
} as any;

const resizeActiveCalls: boolean[] = [];
const tree = render(
<InterviewDock
runId="run-1"
questions={[makeQuestion()]}
onResizeActiveChange={(active) => resizeActiveCalls.push(active)}
/>,
);
const handle = tree.root.findByProps({ role: "separator" });

act(() => {
handle.props.onPointerDown({
preventDefault: () => {},
pointerId: 456,
clientY: 500,
currentTarget: { setPointerCapture: () => {} },
});
});

act(() => {
handle.props.onPointerCancel({
pointerId: 456,
currentTarget: {
releasePointerCapture: () => {},
},
});
});

expect(resizeActiveCalls).toEqual([true, false]);
});
});

describe("loadDockHeight", () => {
test("returns a valid height when localStorage is empty", () => {
const storage = new Map<string, string>();
globalThis.window = {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
},
innerHeight: 1000,
} as any;

const height = loadDockHeight();
expect(height).toMatch(/^\d+(?:\.\d+)?(rem|px|vh)$/);
});

test("restores previously saved height", () => {
const storage = new Map<string, string>([["fabro.interviewDock.height", "24rem"]]);
globalThis.window = {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
},
innerHeight: 1000,
} as any;

expect(loadDockHeight()).toBe("24rem");
});

test("ignores invalid stored values", () => {
const storage = new Map<string, string>([["fabro.interviewDock.height", "invalid"]]);
globalThis.window = {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
},
innerHeight: 1000,
} as any;

const height = loadDockHeight();
expect(height).toMatch(/^\d+(?:\.\d+)?(rem|px|vh)$/);
});

test("clamps stored height that is too small", () => {
const storage = new Map<string, string>([["fabro.interviewDock.height", "8rem"]]);
globalThis.window = {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
},
innerHeight: 1000,
} as any;

expect(loadDockHeight()).toBe("12rem");
});

test("clamps stored height that is too large", () => {
const storage = new Map<string, string>([["fabro.interviewDock.height", "90vh"]]);
globalThis.window = {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
},
innerHeight: 1000,
} as any;

const result = loadDockHeight();
expect(result).toBe("80vh");
});
});

describe("saveDockHeight", () => {
test("persists height to localStorage", () => {
const storage = new Map<string, string>();
globalThis.window = {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
},
innerHeight: 1000,
} as any;

saveDockHeight("24rem");
expect(storage.get("fabro.interviewDock.height")).toBe("24rem");
});

test("handles localStorage errors gracefully", () => {
globalThis.window = {
localStorage: {
getItem: () => { throw new Error("Quota exceeded"); },
setItem: () => { throw new Error("Quota exceeded"); },
},
innerHeight: 1000,
} as any;

expect(() => saveDockHeight("24rem")).not.toThrow();
});
});

describe("clampDockHeight", () => {
test("accepts valid heights within bounds", () => {
expect(clampDockHeight("18rem")).toBe("18rem");
expect(clampDockHeight("300px")).toBe("300px");
expect(clampDockHeight("50vh")).toBe("50vh");
});

test("enforces minimum height", () => {
expect(clampDockHeight("8rem")).toBe("12rem");
expect(clampDockHeight("100px")).toBe("12rem");
});

test("enforces maximum height", () => {
expect(clampDockHeight("90vh")).toBe("80vh");
});

test("rejects invalid height formats", () => {
const result = clampDockHeight("invalid");
expect(result).toMatch(/^\d+(?:\.\d+)?(rem|px|vh)$/);

expect(clampDockHeight("")).toMatch(/^\d+(?:\.\d+)?(rem|px|vh)$/);
expect(clampDockHeight("100")).toMatch(/^\d+(?:\.\d+)?(rem|px|vh)$/);
});
});

describe("displayLabel", () => {
Expand Down
Loading