Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .changeset/shared-shadcn-sidebar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@gemstack/the-framework": minor
---

One shared sidebar on every route, rebuilt on the shadcn Base UI sidebar.

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.

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.

"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.
147 changes: 106 additions & 41 deletions packages/framework-dashboard/components/RunHistory.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import type { RunMeta } from '@gemstack/the-framework'
import { afterEach, describe, expect, test } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { RunHistory } from './RunHistory.js'
import type { ReactElement } from 'react'
import type { RunMeta, ProjectSummary } from '@gemstack/the-framework'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { SidebarProvider } from './ui/sidebar.js'

// RunHistory pulls in AddProjectPanel, which imports the projects telefunc shim; stub it so the
// import graph does not drag telefunc into jsdom. Import RunHistory after the mock is in place.
const onProjects = vi.hoisted(() => vi.fn())
const sendAddProject = vi.hoisted(() => vi.fn())
vi.mock('../server/projects.telefunc.js', () => ({ onProjects, sendAddProject }))

const { RunHistory } = await import('./RunHistory.js')

afterEach(cleanup)

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

// RunHistory renders the shadcn <Sidebar>, which reads SidebarProvider context; wrap every render.
const renderRail = (ui: ReactElement) => render(<SidebarProvider>{ui}</SidebarProvider>)

describe('RunHistory (#785)', () => {
test('a working run reads as running and animates', () => {
const { container } = render(<RunHistory projectId="p1" runs={[run()]} selectedRunId={null} onSelect={() => {}} />)
const { container } = renderRail(<RunHistory projectId="p1" runs={[run()]} selectedRunId={null} onSelect={() => {}} />)
expect(screen.getByText('running')).toBeTruthy()
expect(container.querySelector('.animate-pulse')).toBeTruthy()
})

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

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

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

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

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

// The rows must stay reachable while narrow: it is a squeeze, not a hidden rail.
test('collapsed still renders the sessions, and reopens on hover or focus', () => {
const { container } = render(
<RunHistory projectId="p1" runs={[run()]} selectedRunId={null} onSelect={() => {}} collapsed />,
// On the Overview the rail pools every project's sessions; a row names its project and jumps in.
test('on the Overview it lists cross-project recents and selecting one jumps into its project', () => {
let picked: [string, string] | null = null
const recentRuns = [
{ projectId: 'proj-a', projectName: 'alpha', run: run({ id: 'r-a', intent: 'fix login' }) },
{ projectId: 'proj-b', projectName: 'beta', run: run({ id: 'r-b', status: 'done', intent: 'add tests' }) },
]
renderRail(
<RunHistory
projectId={null}
runs={[]}
recentRuns={recentRuns}
onSelectRecent={(pid, rid) => (picked = [pid, rid])}
selectedRunId={null}
onSelect={() => {}}
/>,
)
expect(screen.getByText("replace 'Hello, world!' with 'Welcome!'")).toBeTruthy()
const panel = rail(container).firstElementChild as HTMLElement
expect(panel.className).toContain('group-hover:w-60')
expect(panel.className).toContain('group-focus-within:w-60')
expect(screen.getByText('fix login')).toBeTruthy()
expect(screen.getByText(/alpha/)).toBeTruthy()
fireEvent.click(screen.getByText('add tests'))
expect(picked).toEqual(['proj-b', 'r-b'])
})
})

const proj = (id: string, name: string): ProjectSummary => ({ id, path: `/${id}`, name, activated: true })

// Floating rather than pushing: hovering the rail must not reflow what is being read.
test('the collapsed panel floats over the main pane', () => {
const { container } = render(
<RunHistory projectId="p1" runs={[run()]} selectedRunId={null} onSelect={() => {}} collapsed />,
describe('RunHistory New button (#new-button)', () => {
test('with one project, New starts a session in it', () => {
let started: string | null = null
renderRail(
<RunHistory
projectId={null}
runs={[]}
recentRuns={[]}
projects={[proj('p1', 'alpha')]}
onNewSessionInProject={id => (started = id)}
selectedRunId={null}
onSelect={() => {}}
/>,
)
const panel = rail(container).firstElementChild as HTMLElement
expect(panel.className).toContain('absolute')
fireEvent.click(screen.getByText('New'))
expect(started).toBe('p1')
})

test('a run on a connected device shows a device glyph naming the device (#1067)', () => {
render(<RunHistory projectId="p1" runs={[run({ target: 'remote', remoteLabel: 'my-laptop' })]} selectedRunId={null} onSelect={() => {}} />)
expect(screen.getByLabelText('Runs on my-laptop')).toBeTruthy()
test('inside a project, New starts another session in that project', () => {
let started: string | null = null
renderRail(
<RunHistory
projectId="p9"
runs={[]}
projects={[proj('p1', 'alpha'), proj('p9', 'nine')]}
onNewSessionInProject={id => (started = id)}
selectedRunId={null}
onSelect={() => {}}
/>,
)
fireEvent.click(screen.getByText('New'))
expect(started).toBe('p9')
})

test('a local run has no device glyph (#1067)', () => {
render(<RunHistory projectId="p1" runs={[run()]} selectedRunId={null} onSelect={() => {}} />)
expect(screen.queryByLabelText(/Runs on/)).toBeNull()
test('with several projects and none selected, New is a picker menu', () => {
renderRail(
<RunHistory
projectId={null}
runs={[]}
recentRuns={[]}
projects={[proj('p1', 'alpha'), proj('p2', 'beta')]}
onNewSessionInProject={() => {}}
selectedRunId={null}
onSelect={() => {}}
/>,
)
// The trigger opens a menu rather than starting immediately (aria-haspopup marks it).
expect(screen.getByLabelText('New session').getAttribute('aria-haspopup')).toBeTruthy()
})
})
Loading
Loading