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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v0.7.19 - 2026-07-14

- Consolidate workspace destinations and contextual Session or library records into one left rail, removing the duplicate library master-list column and returning that space to the active detail.
- Preserve the active Note, Testware, or Findings workspace when switching Sessions so the selected Session updates the current task instead of forcing a return to the Note.
- Adapt library records to a horizontal master strip on narrow windows, retain visible selection and keyboard focus behavior, and cover the unified navigation with interaction and responsive visual checks.

## v0.7.18 - 2026-07-14

- Keep the rich-text editor instance stable while switching between read-only record previews, preventing a destroyed TipTap view from crashing React and leaving the application blank.
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
resolver = "3"

[workspace.package]
version = "0.7.18"
version = "0.7.19"
edition = "2024"
license = "MIT"
authors = ["qa-scribe contributors"]
Expand Down
1 change: 1 addition & 0 deletions build/linux/io.github.ddv1982.qa-scribe.metainfo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<category>ProjectManagement</category>
</categories>
<releases>
<release version="0.7.19" date="2026-07-14" />
<release version="0.7.18" date="2026-07-14" />
<release version="0.7.17" date="2026-07-14" />
<release version="0.7.16" date="2026-07-14" />
Expand Down
2 changes: 1 addition & 1 deletion e2e/specs/critical-workflows.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ async function button(label) {
}

async function rail(label) {
return (await $('nav[aria-label="Primary"]')).$(`button*=${label}`)
return (await $('nav[aria-label="Workspace sections"]')).$(`button*=${label}`)
}

async function sessionTab(label) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "qa-scribe-frontend",
"private": true,
"version": "0.7.18",
"version": "0.7.19",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
44 changes: 40 additions & 4 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,20 +327,56 @@ describe('App workflows', () => {
}),
)
render(<App />)

await waitFor(() => expect(screen.getByRole('textbox', { name: 'Session title' })).toHaveValue('Alpha session'))
const sessionListbox = screen.getByRole('listbox', { name: 'Sessions' })
const alphaOption = within(sessionListbox).getByRole('option', { name: /alpha session/i })
const betaOption = within(sessionListbox).getByRole('option', { name: /beta session/i })

alphaOption.focus()
await user.keyboard('{ArrowDown}')
expect(betaOption).toHaveFocus()

await user.keyboard('{Enter}')

await waitFor(() => expect(tauriMock.openSessionNoteState).toHaveBeenLastCalledWith('session-2'))
await waitFor(() => expect(screen.getByRole('textbox', { name: 'Session title' })).toHaveValue('Beta session'))
await user.type(screen.getByRole('textbox', { name: 'Search Sessions' }), 'Alpha')
expect(alphaOption).toHaveAttribute('tabindex', '0')
})

it('keeps the active Session workspace tab when switching Sessions', async () => {
const user = userEvent.setup()
const firstSession = sessionFixture({ id: 'session-1', title: 'Alpha session' })
const secondSession = sessionFixture({ id: 'session-2', title: 'Beta session' })
tauriMock.listRecentSessions.mockResolvedValueOnce([firstSession, secondSession])
tauriMock.openSessionNoteState.mockImplementation(async (sessionId: string) =>
sessionNoteStateFixture({
session: sessionId === secondSession.id ? secondSession : firstSession,
noteEntry: entryFixture({ sessionId }),
}),
)
render(<App />)
await waitFor(() => expect(screen.getByRole('textbox', { name: 'Session title' })).toHaveValue('Alpha session'))
await user.click(screen.getByRole('tab', { name: /testware/i }))
await user.click(within(screen.getByRole('listbox', { name: 'Sessions' })).getByRole('option', { name: /beta session/i }))
await waitFor(() => expect(tauriMock.openSessionNoteState).toHaveBeenLastCalledWith('session-2'))
await waitFor(() => expect(screen.getByRole('tab', { name: /testware/i })).toHaveAttribute('aria-selected', 'true'))
expect(screen.getByLabelText('Current search scope')).toHaveTextContent('Beta session')
})

it('uses the single left rail as the master list for library records', async () => {
const user = userEvent.setup()
tauriMock.listDraftLibrary.mockResolvedValueOnce([
{
draft: draftFixture({ id: 'draft-library', title: 'Checkout coverage' }),
sessionTitle: 'Checkout session',
},
])
render(<App />)
await waitFor(() => expect(screen.getByRole('textbox', { name: 'Session title' })).toHaveValue('Checkout session'))
const workspaceNavigation = screen.getByRole('navigation', { name: 'Workspace sections' })
await user.click(within(workspaceNavigation).getByRole('button', { name: 'Testware library' }))

const libraryList = await screen.findByRole('complementary', { name: 'Testware library records' })
expect(libraryList.closest('.left-rail')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Checkout coverage' })).toBeInTheDocument()
})

it('creates manual testware after saving pending note edits', async () => {
Expand Down
38 changes: 24 additions & 14 deletions frontend/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export { DeleteConfirmationDialog } from './AppOverlays'

export function AppShell(c: AppController) {
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
const [librarySidebarTarget, setLibrarySidebarTarget] = useState<HTMLDivElement | null>(null)
const commands = createCommandRegistry(c)
const commandById = (id: AppCommandId) => commands.find((command) => command.id === id)
const runCommand = (id: AppCommandId) => commandById(id)?.run()
Expand All @@ -28,6 +29,9 @@ export function AppShell(c: AppController) {
? { command: commandById('finding.new'), busy: 'manual-finding' as const }
: null
const visibleSessions = c.filteredSessions.slice(0, 8)
const tabbableSessionId = visibleSessions.some((session) => session.id === c.activeSession?.id) ? c.activeSession?.id : visibleSessions[0]?.id
const sessionScopedView = c.activeView === 'sessions' || c.activeView === 'testware' || c.activeView === 'findings'
const libraryView = c.activeView === 'testware-library' || c.activeView === 'findings-library'

useEffect(() => {
function handleShortcut(event: globalThis.KeyboardEvent) {
Expand Down Expand Up @@ -92,12 +96,14 @@ export function AppShell(c: AppController) {
</header>

<aside className="left-rail" aria-label="Workspace navigation">
<nav className="section-nav" aria-label="Primary">
<RailItem icon={FileText} label={c.activeSession ? 'Session note' : 'Sessions'} count={c.sessions.length} active={c.activeView === 'sessions'} onClick={() => runCommand('view.note')} />
<nav className="workspace-nav" aria-label="Workspace sections">
<RailItem icon={FileText} label="Sessions" count={c.sessions.length} active={sessionScopedView} onClick={() => runCommand('view.note')} />
<RailItem icon={BookOpen} label="Testware library" active={c.activeView === 'testware-library'} onClick={() => runCommand('library.testware')} />
<RailItem icon={Flag} label="Findings library" active={c.activeView === 'findings-library'} onClick={() => runCommand('library.findings')} />
</nav>

<section className="session-picker" aria-label="Choose session">
<p className="rail-heading">Choose session</p>
{sessionScopedView ? <section className="session-picker contextual-rail" aria-label="Sessions">
<p className="rail-heading">Recent sessions</p>
<div className="session-picker-list" role="listbox" aria-label="Sessions">
{visibleSessions.map((session) => (
<button
Expand All @@ -106,8 +112,9 @@ export function AppShell(c: AppController) {
type="button"
role="option"
aria-selected={c.activeSession?.id === session.id}
tabIndex={tabbableSessionId === session.id ? 0 : -1}
disabled={c.isBusy && c.activeSession?.id !== session.id}
onClick={() => void c.openSession(session)}
onClick={() => void c.openSessionInCurrentView(session)}
onKeyDown={handleSessionOptionKeyDown}
>
<span className="session-picker-title" title={session.title}>{session.title}</span>
Expand All @@ -123,28 +130,29 @@ export function AppShell(c: AppController) {
{commandById('sessions.load-all')?.label}
</button>
) : null}
</section>
</section> : null}

<nav className="library-nav" aria-label="Libraries">
<p className="rail-heading">Libraries</p>
<RailItem icon={BookOpen} label="Testware library" active={c.activeView === 'testware-library'} onClick={() => runCommand('library.testware')} />
<RailItem icon={Flag} label="Findings library" active={c.activeView === 'findings-library'} onClick={() => runCommand('library.findings')} />
</nav>
{libraryView ? (
<section className="library-sidebar contextual-rail" aria-label={c.activeView === 'testware-library' ? 'Testware library records' : 'Findings library records'}>
<p className="rail-heading">{c.activeView === 'testware-library' ? 'Testware' : 'Findings'}</p>
<div className="library-sidebar-slot" ref={setLibrarySidebarTarget} />
</section>
) : null}

<label className="compact-session-select">
{sessionScopedView ? <label className="compact-session-select">
<span>Session</span>
<select
value={c.activeSession?.id ?? ''}
disabled={c.isBusy || c.sessions.length === 0}
onChange={(event) => {
const session = c.sessions.find((candidate) => candidate.id === event.target.value)
if (session) void c.openSession(session)
if (session) void c.openSessionInCurrentView(session)
}}
>
{c.sessions.length === 0 ? <option value="">No Sessions yet</option> : null}
{c.sessions.map((session) => <option key={session.id} value={session.id}>{session.title}</option>)}
</select>
</label>
</label> : null}

<button className={c.activeView === 'settings' ? 'settings-link active' : 'settings-link'} type="button" aria-current={c.activeView === 'settings' ? 'page' : undefined} onClick={() => runCommand('settings.open')}>
<Settings size={17} />
Expand Down Expand Up @@ -285,6 +293,7 @@ export function AppShell(c: AppController) {
loadError={c.draftLibraryError}
onRetry={() => void c.loadDraftLibrary()}
onOpenRecord={(sessionId, recordId) => void c.openLibraryRecord(sessionId, 'testware', recordId)}
sidebarTarget={librarySidebarTarget}
/>
) : null}

Expand All @@ -296,6 +305,7 @@ export function AppShell(c: AppController) {
loadError={c.findingLibraryError}
onRetry={() => void c.loadFindingLibrary()}
onOpenRecord={(sessionId, recordId) => void c.openLibraryRecord(sessionId, 'findings', recordId)}
sidebarTarget={librarySidebarTarget}
/>
) : null}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/commandRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function createCommandRegistry(controller: AppController): AppCommand[] {
command('session.new', 'New Session', 'Create a blank testing Session.', ['create', 'capture'], 'n', !controller.isBusy, () => void controller.handleNewSession()),
command('testware.new', 'New Testware', 'Create Testware in the active Session.', ['test cases', 'draft'], undefined, hasSession && !controller.isBusy, () => void controller.handleManualTestware()),
command('finding.new', 'New Finding', 'Create a Finding in the active Session.', ['bug', 'risk', 'issue'], undefined, hasSession && !controller.isBusy, () => void controller.handleManualFinding()),
command('view.note', 'Open Session Note', 'Show the active Session note.', ['capture', 'editor'], '1', hasSession, () => controller.setActiveView('sessions')),
command('view.note', 'Open Sessions', 'Browse Sessions and show the active Session note.', ['capture', 'editor'], '1', true, () => controller.setActiveView('sessions')),
command('view.testware', 'Open Session Testware', 'Show Testware owned by the active Session.', ['test cases', 'output'], '2', hasSession, () => controller.setActiveView('testware')),
command('view.findings', 'Open Session Findings', 'Show Findings owned by the active Session.', ['bugs', 'risks', 'output'], '3', hasSession, () => controller.setActiveView('findings')),
command('library.testware', 'Open Testware Library', 'Browse Testware across all Sessions.', ['cross-session', 'test cases', 'output'], undefined, true, () => controller.setActiveView('testware-library')),
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/app/useAppController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,10 @@ export function useAppController() {
requestActiveView('settings')
}

function closeSettings() {
requestActiveView(settingsReturnViewRef.current)
function closeSettings() { requestActiveView(settingsReturnViewRef.current) }
function openSessionInCurrentView(session: (typeof sessions)[number]) {
const destination = activeView === 'testware' || activeView === 'findings' ? activeView : 'sessions'
return sessionActions.openSession(session, true, () => setActiveView(destination))
}

async function openLibraryRecord(sessionId: string, view: 'testware' | 'findings', recordId: string) {
Expand Down Expand Up @@ -455,6 +457,7 @@ export function useAppController() {
noteWordCount,
closeSettings,
openSettingsSection,
openSessionInCurrentView,
openLibraryRecord,
openGenerationPreflight,
notice,
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/styles/collections.css
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@
min-height: 520px;
}

.collection-workspace--single-sidebar {
grid-template-columns: minmax(0, 1fr);
}

.collection-load-advisory {
display: flex;
align-items: center;
Expand Down
49 changes: 32 additions & 17 deletions frontend/src/styles/responsive.css
Original file line number Diff line number Diff line change
Expand Up @@ -146,16 +146,17 @@
grid-column: 1;
grid-row: 2;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-right: 0;
border-bottom: 1px solid var(--border-default);
}

.section-nav {
display: block;
.workspace-nav {
display: flex;
gap: var(--space-1);
order: 1;
}

Expand All @@ -174,19 +175,6 @@
order: 2;
}

.library-nav {
display: flex;
gap: var(--space-1);
padding: 0;
margin: 0;
border: 0;
order: 3;
}

.library-nav .rail-heading {
display: none;
}

.compact-session-select select {
min-width: 0;
width: 100%;
Expand All @@ -203,7 +191,7 @@
padding: 0;
justify-content: center;
font-size: 0;
order: 4;
order: 3;
}

.left-rail .rail-item {
Expand All @@ -217,6 +205,33 @@
display: none;
}

.library-sidebar {
grid-column: 1 / -1;
order: 4;
padding: var(--space-2) 0 0;
margin: 0;
overflow: visible;
}

.library-sidebar .rail-heading {
display: none;
}

.library-sidebar-slot {
overflow: visible;
}

.library-sidebar-slot .record-master-list {
display: flex;
max-height: none;
padding: 0;
overflow-x: auto;
}

.library-sidebar-slot .record-master-list > button {
flex: 0 0 220px;
}

.center-workspace {
grid-column: 1;
grid-row: 3;
Expand Down
Loading
Loading