Skip to content

Commit 2581fce

Browse files
authored
feat(dashboard): one shared shadcn sidebar on every route (#1113)
* feat(dashboard): add shadcn Base UI sidebar primitives Port the shadcn "base" (Base UI, not Radix) Sidebar and the primitives it needs (sheet, separator, input, skeleton, a useIsMobile hook), adapted to this repo's @base-ui-components/react: the collapsed-state tooltip uses our Tooltip API and the mobile branch does not forward wrapper props onto the Sheet root. Tooltip gains optional side/align, and the --sidebar-* theme tokens map onto the existing palette so the rail stays in step with the rest of the UI. Not wired yet. * feat(dashboard): pool recent sessions across projects for the Overview Add buildRecentRuns + an onRecentRuns telefunc that pool every project's sessions newest-first (capped), each tagged with the project it belongs to. This is what lets the shared sidebar show recents on the home/Overview, where no single project is selected. Exported through the package surface and the client shim; the telefunc auto-registers under its export name. * feat(dashboard): one shared sidebar on every route with a project-aware New Render the shadcn Sidebar on every route via SidebarProvider, so the home/Overview and a session page share the exact same left column instead of the rail vanishing with no project selected. The rail shows a project's own runs when one is selected and the pooled cross-project recents on the Overview. "New" is now project-aware: no projects opens the add-project dialog, one project starts a session there, several open a picker; in a project it starts another session there. Retires the unused bespoke collapse (#862) in favour of the shadcn Sidebar's own model.
1 parent 85d441c commit 2581fce

19 files changed

Lines changed: 1190 additions & 162 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@gemstack/the-framework": minor
3+
---
4+
5+
One shared sidebar on every route, rebuilt on the shadcn Base UI sidebar.
6+
7+
The sessions rail used to vanish the moment no project was selected, so the home/Overview had no left column while a session page did. It is now the shadcn Base UI sidebar, rendered on every route, so the two read as the same app.
8+
9+
On the Overview it pools recent sessions across every project (a new cross-project read), each row naming its project and jumping into it when selected; a selected project still shows its own runs.
10+
11+
"New" is now project-aware: with no project it opens the add-project dialog (there is nowhere to run a session yet), with one project it starts there, and with several it opens a picker so you choose where. In a project already, it starts another session there.
Lines changed: 106 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1-
import type { RunMeta } from '@gemstack/the-framework'
2-
import { afterEach, describe, expect, test } from 'vitest'
3-
import { cleanup, render, screen } from '@testing-library/react'
4-
import { RunHistory } from './RunHistory.js'
1+
import type { ReactElement } from 'react'
2+
import type { RunMeta, ProjectSummary } from '@gemstack/the-framework'
3+
import { afterEach, describe, expect, test, vi } from 'vitest'
4+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
5+
import { SidebarProvider } from './ui/sidebar.js'
6+
7+
// RunHistory pulls in AddProjectPanel, which imports the projects telefunc shim; stub it so the
8+
// import graph does not drag telefunc into jsdom. Import RunHistory after the mock is in place.
9+
const onProjects = vi.hoisted(() => vi.fn())
10+
const sendAddProject = vi.hoisted(() => vi.fn())
11+
vi.mock('../server/projects.telefunc.js', () => ({ onProjects, sendAddProject }))
12+
13+
const { RunHistory } = await import('./RunHistory.js')
514

615
afterEach(cleanup)
716

@@ -18,16 +27,19 @@ function run(over: Partial<RunMeta> = {}): RunMeta {
1827
}
1928
}
2029

30+
// RunHistory renders the shadcn <Sidebar>, which reads SidebarProvider context; wrap every render.
31+
const renderRail = (ui: ReactElement) => render(<SidebarProvider>{ui}</SidebarProvider>)
32+
2133
describe('RunHistory (#785)', () => {
2234
test('a working run reads as running and animates', () => {
23-
const { container } = render(<RunHistory projectId="p1" runs={[run()]} selectedRunId={null} onSelect={() => {}} />)
35+
const { container } = renderRail(<RunHistory projectId="p1" runs={[run()]} selectedRunId={null} onSelect={() => {}} />)
2436
expect(screen.getByText('running')).toBeTruthy()
2537
expect(container.querySelector('.animate-pulse')).toBeTruthy()
2638
})
2739

2840
test('a run parked on the user reads as waiting and stops animating', () => {
2941
// The build settled and it is waiting for a message: same live process, different meaning.
30-
const { container } = render(
42+
const { container } = renderRail(
3143
<RunHistory projectId="p1" runs={[run({ settledAt: '2026-07-19T16:06:21.000Z' })]} selectedRunId={null} onSelect={() => {}} />,
3244
)
3345
expect(screen.getByText('waiting')).toBeTruthy()
@@ -38,11 +50,13 @@ describe('RunHistory (#785)', () => {
3850
test('a session selected before its row lands highlights the starting row (#784)', () => {
3951
// Start navigates to the run's id right away; its run.json, and so its row, arrives a beat
4052
// later. The highlight belongs on the optimistic row standing in for it, not on the home row.
41-
const { container, rerender } = render(
53+
const { container, rerender } = renderRail(
4254
<RunHistory projectId="p1" runs={[]} selectedRunId={null} onSelect={() => {}} startTick={0} startIntent="" />,
4355
)
4456
rerender(
45-
<RunHistory projectId="p1" runs={[]} selectedRunId="run-2" onSelect={() => {}} startTick={1} startIntent="add dark mode" />,
57+
<SidebarProvider>
58+
<RunHistory projectId="p1" runs={[]} selectedRunId="run-2" onSelect={() => {}} startTick={1} startIntent="add dark mode" />
59+
</SidebarProvider>,
4660
)
4761
const rows = [...container.querySelectorAll('button')]
4862
const home = rows.find(row => row.textContent?.trim() === 'New')
@@ -53,7 +67,7 @@ describe('RunHistory (#785)', () => {
5367

5468
test('a finished run is finished, never waiting', () => {
5569
// settledAt is cleared on `end`, but a stale one must not relabel a terminal status.
56-
render(
70+
renderRail(
5771
<RunHistory
5872
projectId="p1"
5973
runs={[run({ status: 'done', settledAt: '2026-07-19T16:06:21.000Z' })]}
@@ -66,50 +80,101 @@ describe('RunHistory (#785)', () => {
6680
})
6781
})
6882

69-
// #862: a big view in the right rail takes the room, and the sessions rail gives up its column.
70-
describe('RunHistory collapsed (#862)', () => {
71-
const rail = (container: HTMLElement) => container.querySelector('aside')!
83+
// The rail is the shadcn Sidebar now (shared shell), a fixed-width in-flow column rather than the
84+
// bespoke collapsing <aside> of #862 — the shadcn Sidebar owns collapse, and the shell never drove
85+
// the old prop, so those strip/float tests are retired with it.
86+
describe('RunHistory rows', () => {
87+
test('a run on a connected device shows a device glyph naming the device (#1067)', () => {
88+
renderRail(<RunHistory projectId="p1" runs={[run({ target: 'remote', remoteLabel: 'my-laptop' })]} selectedRunId={null} onSelect={() => {}} />)
89+
expect(screen.getByLabelText('Runs on my-laptop')).toBeTruthy()
90+
})
7291

73-
test('expanded by default, so nothing changes for the ordinary layout', () => {
74-
const { container } = render(<RunHistory projectId="p1" runs={[run()]} selectedRunId={null} onSelect={() => {}} />)
75-
expect(rail(container).className).toContain('w-60')
76-
expect(rail(container).className).not.toContain('w-12')
92+
test('a local run has no device glyph (#1067)', () => {
93+
renderRail(<RunHistory projectId="p1" runs={[run()]} selectedRunId={null} onSelect={() => {}} />)
94+
expect(screen.queryByLabelText(/Runs on/)).toBeNull()
7795
})
7896

79-
test('collapsed reserves only a strip', () => {
80-
const { container } = render(
81-
<RunHistory projectId="p1" runs={[run()]} selectedRunId={null} onSelect={() => {}} collapsed />,
82-
)
83-
expect(rail(container).className).toContain('w-12')
97+
// The shared shell: the rail is present on the home/Overview too (no project selected), showing
98+
// the New launcher rather than vanishing.
99+
test('with no project and no recents it still renders New and an empty hint', () => {
100+
renderRail(<RunHistory projectId={null} runs={[]} recentRuns={[]} selectedRunId={null} onSelect={() => {}} />)
101+
expect(screen.getByText('New')).toBeTruthy()
102+
expect(screen.getByText('No sessions yet.')).toBeTruthy()
84103
})
85104

86-
// The rows must stay reachable while narrow: it is a squeeze, not a hidden rail.
87-
test('collapsed still renders the sessions, and reopens on hover or focus', () => {
88-
const { container } = render(
89-
<RunHistory projectId="p1" runs={[run()]} selectedRunId={null} onSelect={() => {}} collapsed />,
105+
// On the Overview the rail pools every project's sessions; a row names its project and jumps in.
106+
test('on the Overview it lists cross-project recents and selecting one jumps into its project', () => {
107+
let picked: [string, string] | null = null
108+
const recentRuns = [
109+
{ projectId: 'proj-a', projectName: 'alpha', run: run({ id: 'r-a', intent: 'fix login' }) },
110+
{ projectId: 'proj-b', projectName: 'beta', run: run({ id: 'r-b', status: 'done', intent: 'add tests' }) },
111+
]
112+
renderRail(
113+
<RunHistory
114+
projectId={null}
115+
runs={[]}
116+
recentRuns={recentRuns}
117+
onSelectRecent={(pid, rid) => (picked = [pid, rid])}
118+
selectedRunId={null}
119+
onSelect={() => {}}
120+
/>,
90121
)
91-
expect(screen.getByText("replace 'Hello, world!' with 'Welcome!'")).toBeTruthy()
92-
const panel = rail(container).firstElementChild as HTMLElement
93-
expect(panel.className).toContain('group-hover:w-60')
94-
expect(panel.className).toContain('group-focus-within:w-60')
122+
expect(screen.getByText('fix login')).toBeTruthy()
123+
expect(screen.getByText(/alpha/)).toBeTruthy()
124+
fireEvent.click(screen.getByText('add tests'))
125+
expect(picked).toEqual(['proj-b', 'r-b'])
95126
})
127+
})
128+
129+
const proj = (id: string, name: string): ProjectSummary => ({ id, path: `/${id}`, name, activated: true })
96130

97-
// Floating rather than pushing: hovering the rail must not reflow what is being read.
98-
test('the collapsed panel floats over the main pane', () => {
99-
const { container } = render(
100-
<RunHistory projectId="p1" runs={[run()]} selectedRunId={null} onSelect={() => {}} collapsed />,
131+
describe('RunHistory New button (#new-button)', () => {
132+
test('with one project, New starts a session in it', () => {
133+
let started: string | null = null
134+
renderRail(
135+
<RunHistory
136+
projectId={null}
137+
runs={[]}
138+
recentRuns={[]}
139+
projects={[proj('p1', 'alpha')]}
140+
onNewSessionInProject={id => (started = id)}
141+
selectedRunId={null}
142+
onSelect={() => {}}
143+
/>,
101144
)
102-
const panel = rail(container).firstElementChild as HTMLElement
103-
expect(panel.className).toContain('absolute')
145+
fireEvent.click(screen.getByText('New'))
146+
expect(started).toBe('p1')
104147
})
105148

106-
test('a run on a connected device shows a device glyph naming the device (#1067)', () => {
107-
render(<RunHistory projectId="p1" runs={[run({ target: 'remote', remoteLabel: 'my-laptop' })]} selectedRunId={null} onSelect={() => {}} />)
108-
expect(screen.getByLabelText('Runs on my-laptop')).toBeTruthy()
149+
test('inside a project, New starts another session in that project', () => {
150+
let started: string | null = null
151+
renderRail(
152+
<RunHistory
153+
projectId="p9"
154+
runs={[]}
155+
projects={[proj('p1', 'alpha'), proj('p9', 'nine')]}
156+
onNewSessionInProject={id => (started = id)}
157+
selectedRunId={null}
158+
onSelect={() => {}}
159+
/>,
160+
)
161+
fireEvent.click(screen.getByText('New'))
162+
expect(started).toBe('p9')
109163
})
110164

111-
test('a local run has no device glyph (#1067)', () => {
112-
render(<RunHistory projectId="p1" runs={[run()]} selectedRunId={null} onSelect={() => {}} />)
113-
expect(screen.queryByLabelText(/Runs on/)).toBeNull()
165+
test('with several projects and none selected, New is a picker menu', () => {
166+
renderRail(
167+
<RunHistory
168+
projectId={null}
169+
runs={[]}
170+
recentRuns={[]}
171+
projects={[proj('p1', 'alpha'), proj('p2', 'beta')]}
172+
onNewSessionInProject={() => {}}
173+
selectedRunId={null}
174+
onSelect={() => {}}
175+
/>,
176+
)
177+
// The trigger opens a menu rather than starting immediately (aria-haspopup marks it).
178+
expect(screen.getByLabelText('New session').getAttribute('aria-haspopup')).toBeTruthy()
114179
})
115180
})

0 commit comments

Comments
 (0)