Skip to content

Commit 854b1f3

Browse files
boramyi-tsclaudeCopilot
authored
feat: SW-2096 add AssistantLayout composed component (dockable AI assistant panel) (#167)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent fef7a71 commit 854b1f3

7 files changed

Lines changed: 683 additions & 0 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { SparklesIcon } from "lucide-react"
2+
import { expect, userEvent, waitFor } from "storybook/test"
3+
4+
import {
5+
AssistantDockControls,
6+
AssistantLayout,
7+
AssistantLayoutProvider,
8+
} from "./AssistantLayout"
9+
10+
import type { AssistantDock } from "./dockLayout"
11+
import type { Meta, StoryObj } from "@storybook/react-vite"
12+
13+
const meta: Meta<typeof AssistantLayout> = {
14+
title: "AI Elements/Layout Manager",
15+
component: AssistantLayout,
16+
parameters: { layout: "fullscreen" },
17+
tags: ["autodocs"],
18+
}
19+
20+
export default meta
21+
type Story = StoryObj<typeof AssistantLayout>
22+
23+
function MockAssistant() {
24+
return (
25+
<>
26+
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
27+
<SparklesIcon className="size-4 text-primary" />
28+
<span className="text-sm font-medium">AI Assistant</span>
29+
</div>
30+
<div className="flex-1 space-y-3 overflow-auto p-3 text-sm">
31+
<div className="ml-auto w-fit max-w-[80%] rounded-lg bg-primary px-3 py-2 text-primary-foreground">
32+
Which leads have the best developability?
33+
</div>
34+
<div className="w-fit max-w-[85%] rounded-lg bg-muted px-3 py-2">
35+
Leads 3, 7 and 12 score highest on aggregation and thermostability. Want me to filter the table to those?
36+
</div>
37+
</div>
38+
<div className="shrink-0 border-t border-border p-3">
39+
<div className="flex h-9 items-center rounded-lg border border-input px-3 text-sm text-muted-foreground">
40+
Ask anything…
41+
</div>
42+
</div>
43+
</>
44+
)
45+
}
46+
47+
function MockContent() {
48+
return (
49+
<div className="flex-1 overflow-auto p-5">
50+
<h2 className="text-lg font-semibold">Antibody Lead Selection</h2>
51+
<p className="mt-1 text-sm text-muted-foreground">
52+
Main content area. Use the dock controls in the top bar to move the AI Assistant left, bottom, or right —
53+
or click the active icon again to hide it and reclaim the full width.
54+
</p>
55+
<div className="mt-4 grid grid-cols-3 gap-3">
56+
{Array.from({ length: 9 }).map((_, i) => (
57+
<div key={i} className="rounded-lg border border-border bg-background p-4 text-sm">
58+
Lead {i + 1}
59+
</div>
60+
))}
61+
</div>
62+
</div>
63+
)
64+
}
65+
66+
function Demo({ defaultDock }: { defaultDock?: AssistantDock }) {
67+
return (
68+
<AssistantLayoutProvider defaultDock={defaultDock} persist={false}>
69+
<div className="flex h-screen w-full flex-col gap-2 p-3">
70+
<div className="flex shrink-0 items-center justify-between">
71+
<span className="text-sm font-semibold">Workspace</span>
72+
<AssistantDockControls />
73+
</div>
74+
<AssistantLayout assistant={<MockAssistant />}>
75+
<MockContent />
76+
</AssistantLayout>
77+
</div>
78+
</AssistantLayoutProvider>
79+
)
80+
}
81+
82+
export const DockedLeft: Story = {
83+
render: () => <Demo defaultDock="left" />,
84+
play: async ({ canvasElement, step }) => {
85+
const body = () => canvasElement.querySelector('[data-slot="assistant-layout"]') as HTMLElement
86+
const assistant = () =>
87+
canvasElement.querySelector('[data-slot="assistant-layout-assistant"]') as HTMLElement
88+
const content = () =>
89+
canvasElement.querySelector('[data-slot="assistant-layout-content"]') as HTMLElement
90+
// The three dock buttons are the only elements with aria-pressed.
91+
const dockButtons = () => [...canvasElement.querySelectorAll<HTMLElement>("[aria-pressed]")]
92+
// True when `a` appears before `b` in document order.
93+
const precedes = (a: HTMLElement, b: HTMLElement) =>
94+
Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING)
95+
96+
await step("Starts docked left (horizontal, assistant before content)", async () => {
97+
expect(getComputedStyle(body()).flexDirection).toBe("row")
98+
expect(assistant()).toBeVisible()
99+
expect(precedes(assistant(), content())).toBe(true)
100+
// The resizable handle is present while the assistant is visible.
101+
expect(canvasElement.querySelector('[role="separator"]')).not.toBeNull()
102+
})
103+
104+
await step("Dock bottom → vertical", async () => {
105+
await userEvent.click(dockButtons()[1]) // [left, bottom, right]
106+
expect(getComputedStyle(body()).flexDirection).toBe("column")
107+
expect(precedes(content(), assistant())).toBe(true)
108+
})
109+
110+
await step("Click the active dock again → hidden, no handle, none pressed", async () => {
111+
await userEvent.click(dockButtons()[1]) // now active → hides
112+
expect(canvasElement.querySelector('[role="separator"]')).toBeNull()
113+
expect(dockButtons().some((b) => b.getAttribute("aria-pressed") === "true")).toBe(false)
114+
expect(canvasElement.querySelector('[data-slot="assistant-layout-assistant"]')).toBeNull()
115+
})
116+
117+
await step("Re-dock right → visible again, content before assistant", async () => {
118+
await userEvent.click(dockButtons()[2])
119+
expect(assistant()).toBeVisible()
120+
expect(getComputedStyle(body()).flexDirection).toBe("row")
121+
expect(precedes(content(), assistant())).toBe(true)
122+
})
123+
},
124+
parameters: {
125+
zephyr: { testCaseId: "SW-T5497" },
126+
},
127+
}
128+
export const DockedRight: Story = { render: () => <Demo defaultDock="right" />,
129+
parameters: {
130+
// Auto-generated by sync-storybook-zephyr - do not add manually
131+
zephyr: { testCaseId: "SW-T5498" },
132+
}
133+
}
134+
export const DockedBottom: Story = { render: () => <Demo defaultDock="bottom" />,
135+
parameters: {
136+
// Auto-generated by sync-storybook-zephyr - do not add manually
137+
zephyr: { testCaseId: "SW-T5499" },
138+
}
139+
}
140+
141+
// testCaseId intentionally left blank — the zephyr_sync workflow backfills it.
142+
export const ResizeByKeyboard: Story = {
143+
parameters: { zephyr: { testCaseId: "SW-T5500" } },
144+
render: () => <Demo defaultDock="right" />,
145+
play: async ({ canvasElement, step }) => {
146+
const separator = () => canvasElement.querySelector('[role="separator"]') as HTMLElement
147+
const assistant = () =>
148+
canvasElement.querySelector('[data-slot="assistant-layout-assistant"]') as HTMLElement
149+
150+
await step("Resize handle is present and keyboard-focusable", async () => {
151+
expect(separator()).not.toBeNull()
152+
expect(assistant()).toBeVisible()
153+
})
154+
155+
await step("Arrow keys resize the panels via the Resizable handle", async () => {
156+
const widthBefore = assistant().getBoundingClientRect().width
157+
separator().focus()
158+
await userEvent.keyboard("{ArrowLeft}{ArrowLeft}{ArrowLeft}{ArrowLeft}")
159+
await waitFor(() =>
160+
expect(assistant().getBoundingClientRect().width).not.toBe(widthBefore)
161+
)
162+
})
163+
},
164+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import * as React from "react"
2+
import { act } from "react"
3+
import { createRoot } from "react-dom/client"
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
5+
6+
import {
7+
AssistantDockControls,
8+
AssistantLayoutProvider,
9+
useAssistantLayout,
10+
} from "./AssistantLayout"
11+
12+
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
13+
14+
let container: HTMLDivElement
15+
let root: ReturnType<typeof createRoot>
16+
17+
/** jsdom's bundled `localStorage` is a non-functional stub here, so use our own. */
18+
function installLocalStorage() {
19+
const store = new Map<string, string>()
20+
const storage: Storage = {
21+
getItem: (k) => (store.has(k) ? store.get(k)! : null),
22+
setItem: (k, v) => {
23+
store.set(k, String(v))
24+
},
25+
removeItem: (k) => {
26+
store.delete(k)
27+
},
28+
clear: () => {
29+
store.clear()
30+
},
31+
key: (i) => [...store.keys()][i] ?? null,
32+
get length() {
33+
return store.size
34+
},
35+
}
36+
Object.defineProperty(window, "localStorage", { configurable: true, value: storage })
37+
}
38+
39+
beforeEach(() => {
40+
container = document.createElement("div")
41+
document.body.appendChild(container)
42+
root = createRoot(container)
43+
installLocalStorage()
44+
})
45+
46+
afterEach(() => {
47+
act(() => {
48+
root.unmount()
49+
})
50+
container.remove()
51+
})
52+
53+
function render(ui: React.ReactElement) {
54+
act(() => {
55+
root.render(ui)
56+
})
57+
}
58+
59+
function q<T extends Element = HTMLElement>(selector: string) {
60+
return container.querySelector(selector) as T | null
61+
}
62+
63+
function click(selector: string) {
64+
const el = q<HTMLElement>(selector)
65+
expect(el).not.toBeNull()
66+
act(() => {
67+
el!.click()
68+
})
69+
}
70+
71+
/** Surfaces the context values so assertions don't depend on the resizable layout. */
72+
function Probe() {
73+
const { dock, size, visible } = useAssistantLayout()
74+
return (
75+
<span data-testid="probe" data-dock={dock} data-size={String(size)} data-visible={String(visible)} />
76+
)
77+
}
78+
79+
function probe() {
80+
const el = q('[data-testid="probe"]')
81+
return {
82+
dock: el?.getAttribute("data-dock"),
83+
size: Number(el?.getAttribute("data-size")),
84+
visible: el?.getAttribute("data-visible") === "true",
85+
}
86+
}
87+
88+
function Harness({
89+
dock = "left",
90+
size,
91+
persist = true,
92+
showLabel = true,
93+
}: {
94+
dock?: "left" | "right" | "bottom"
95+
size?: number
96+
persist?: boolean
97+
showLabel?: boolean
98+
}) {
99+
return (
100+
<AssistantLayoutProvider
101+
storageKey="spec.assistant"
102+
defaultDock={dock}
103+
defaultSize={size}
104+
persist={persist}
105+
>
106+
<AssistantDockControls label={showLabel ? undefined : null} />
107+
<Probe />
108+
</AssistantLayoutProvider>
109+
)
110+
}
111+
112+
describe("AssistantLayout", () => {
113+
it("throws when useAssistantLayout is used outside the provider", () => {
114+
function Consumer() {
115+
useAssistantLayout()
116+
return null
117+
}
118+
119+
expect(() => {
120+
render(<Consumer />)
121+
}).toThrow("useAssistantLayout must be used within an AssistantLayoutProvider")
122+
})
123+
124+
it("reads persisted dock and size", () => {
125+
window.localStorage.setItem("spec.assistant.dock", "right")
126+
window.localStorage.setItem("spec.assistant.size", "45")
127+
128+
render(<Harness dock="left" size={32} />)
129+
130+
expect(probe().dock).toBe("right")
131+
expect(probe().size).toBe(45)
132+
})
133+
134+
it("falls back to the default when the persisted size is out of the (0,100) range", () => {
135+
window.localStorage.setItem("spec.assistant.size", "150")
136+
137+
render(<Harness dock="left" size={32} />)
138+
139+
expect(probe().size).toBe(32)
140+
})
141+
142+
it("ignores persisted values when persist is false", () => {
143+
window.localStorage.setItem("spec.assistant.dock", "bottom")
144+
145+
render(<Harness dock="left" size={32} persist={false} />)
146+
147+
expect(probe().dock).toBe("left")
148+
})
149+
150+
it("shows the label by default and hides it when label is null", () => {
151+
render(<Harness dock="left" />)
152+
expect(container.textContent).toContain("AI Assistant")
153+
154+
act(() => root.unmount())
155+
root = createRoot(container)
156+
render(<Harness dock="left" showLabel={false} />)
157+
expect(container.textContent).not.toContain("AI Assistant")
158+
})
159+
160+
it("hides on active dock click and redocks (persisting the dock) on another icon", () => {
161+
render(<Harness dock="left" />)
162+
expect(probe().visible).toBe(true)
163+
expect(probe().dock).toBe("left")
164+
165+
click('button[aria-label="Hide AI Assistant"]')
166+
expect(probe().visible).toBe(false)
167+
168+
click('button[aria-label="Dock assistant right"]')
169+
expect(probe().visible).toBe(true)
170+
expect(probe().dock).toBe("right")
171+
expect(window.localStorage.getItem("spec.assistant.dock")).toBe("right")
172+
})
173+
})

0 commit comments

Comments
 (0)