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
9 changes: 6 additions & 3 deletions ui-web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,12 @@ transcript included, as before.
## New session

**New session** (the sidebar button, the brand mark, `⌘⇧N`) opens a dialog
rather than starting a session on the spot. It offers every workspace the
sidebar knows plus **Create new workspace…**, which takes an absolute folder
path and creates the folder if it is not there yet (`session.create` with
rather than starting a session on the spot. Its workspace picker lists every
folder the sidebar knows (a row per folder, the path as a second line only
where two folders share a name, the current one checked) with **Add
workspace…** pinned below the list after a divider — at the end of a long
list it was the row nobody scrolled to. That takes an absolute folder path
and creates the folder if it is not there yet (`session.create` with
`create_dir`). The **Worktree** switch runs the session in a fresh git
worktree of that repo — the CLI's `--worktree`, under
`.clawcodex/worktrees/<name>` — so parallel sessions in one repo cannot step
Expand Down
53 changes: 51 additions & 2 deletions ui-web/src/sidebar/NewSessionDialog.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
line-height: 16px;
}

.select,
.picker,
.input {
box-sizing: border-box;
width: 100%;
Expand All @@ -90,12 +90,61 @@
font-size: 13px;
}

.select:focus-visible,
.picker:focus-visible,
.input:focus-visible {
outline: 2px solid var(--cc-alias-button-info-fill);
outline-offset: -1px;
}

/* The workspace picker's trigger: the folder's name, its path dimmed beside
it, a chevron — a form control that opens the themed list, where a native
select would hand its popup to the OS. */
.picker {
display: flex;
align-items: center;
gap: 8px;
text-align: left;
cursor: pointer;
}

.picker:hover,
.picker[aria-expanded='true'] {
background: var(--cc-alias-interactive-bg-hover);
}

.pickerIcon {
flex: none;
display: inline-flex;
align-items: center;
color: var(--cc-alias-label-tertiary);
}

/* The name keeps its width and the path absorbs the truncation: the path
contains the name, so a clipped name would lose what the path repeats. */
.pickerName {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.pickerPath {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--cc-alias-label-tertiary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}

.pickerChevron {
flex: none;
margin-left: auto;
color: var(--cc-alias-label-tertiary);
}

.input::placeholder {
color: var(--cc-alias-label-caption);
}
Expand Down
105 changes: 96 additions & 9 deletions ui-web/src/sidebar/NewSessionDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import type { ProjectNode } from '../gateway/protocol.ts'
Expand All @@ -10,12 +10,24 @@ vi.mock('../state/actions.ts', () => ({
createSession: (options?: Record<string, unknown>) => createSession(options),
}))

const { NEW_WORKSPACE, NewSessionDialog, knownWorkspaces, openNewSessionDialog } = await import('./NewSessionDialog.tsx')
const { NEW_WORKSPACE, NewSessionDialog, knownWorkspaces, openNewSessionDialog, workspaceRows } =
await import('./NewSessionDialog.tsx')

function project(path: string | null): ProjectNode {
return { id: path ?? 'home', label: path ?? 'Home', path, repos: [] }
}

/** The picker's trigger: the one control labelled "Workspace". */
function picker(): HTMLButtonElement {
return screen.getByRole('button', { name: /^Workspace/ }) as HTMLButtonElement
}

function openPicker(): HTMLElement {
fireEvent.click(picker())

return screen.getByRole('menu')
}

beforeEach(() => {
createSession.mockReset()
createSession.mockResolvedValue(null)
Expand Down Expand Up @@ -58,8 +70,17 @@ describe('knownWorkspaces', () => {
})
})

describe('workspaceRows', () => {
it('names a row by its folder, and adds the path only where two folders share a name', () => {
const rows = workspaceRows(['/work/alpha', '/other/alpha', '/work/beta'])

expect(rows.map(row => ('label' in row ? row.label : null))).toEqual(['alpha', 'alpha', 'beta'])
expect(rows.map(row => ('hint' in row ? row.hint : undefined))).toEqual(['/work/alpha', '/other/alpha', undefined])
})
})

describe('NewSessionDialog', () => {
it('is closed until opened, and starts a session in the chosen workspace', async () => {
it('is closed until opened, and starts a session in the workspace picked from the list', async () => {
render(<NewSessionDialog />)

expect(screen.queryByRole('dialog')).toBeNull()
Expand All @@ -68,11 +89,20 @@ describe('NewSessionDialog', () => {
openNewSessionDialog()
})

const select = screen.getByRole('combobox') as HTMLSelectElement
expect(select.value).toBe('/work/current')
// The trigger names the current workspace; the list is closed.
expect(picker().textContent).toContain('current')
expect(picker().textContent).toContain('/work/current')
expect(screen.queryByRole('menu')).toBeNull()
expect(screen.queryByPlaceholderText('/absolute/path/to/project')).toBeNull()

fireEvent.change(select, { target: { value: '/work/alpha' } })
const menu = openPicker()
const rows = within(menu).getAllByRole('menuitem')
expect(rows.map(row => row.textContent)).toEqual(['current', 'alpha', 'Add workspace…'])

fireEvent.click(within(menu).getByRole('menuitem', { name: 'alpha' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(picker().textContent).toContain('/work/alpha')

fireEvent.click(screen.getByRole('button', { name: 'Create' }))
await act(async () => {
await Promise.resolve()
Expand All @@ -82,12 +112,29 @@ describe('NewSessionDialog', () => {
expect($newSessionDialog.get()).toBe(false)
})

it('pins Add workspace… below the scrolling list, after a divider', () => {
// A long list: the action must not be its last row.
$projects.set(Array.from({ length: 40 }, (_, index) => project(`/work/project-${String(index)}`)))
$newSessionDialog.set(true)
render(<NewSessionDialog />)

const menu = openPicker()
const viewport = menu.querySelector('[class*="viewport"]')
const add = within(menu).getByRole('menuitem', { name: 'Add workspace…' })

expect(viewport).not.toBeNull()
expect(viewport?.contains(add)).toBe(false)
expect(within(viewport as HTMLElement).getAllByRole('menuitem')).toHaveLength(41)
// The divider sits between the list and the pinned row.
expect(add.previousElementSibling?.getAttribute('role')).toBe('separator')
})

it('creates a new workspace from an absolute path, in a worktree when asked', async () => {
$newSessionDialog.set(true)
render(<NewSessionDialog />)

const select = screen.getByRole('combobox') as HTMLSelectElement
fireEvent.change(select, { target: { value: NEW_WORKSPACE } })
fireEvent.click(within(openPicker()).getByRole('menuitem', { name: 'Add workspace…' }))
expect(picker().textContent).toContain('New workspace')

const create = screen.getByRole('button', { name: 'Create' }) as HTMLButtonElement
expect(create.disabled).toBe(true)
Expand All @@ -107,6 +154,20 @@ describe('NewSessionDialog', () => {
expect($newSessionDialog.get()).toBe(false)
})

it('marks the picked workspace in the list, and none while adding one', () => {
$newSessionDialog.set(true)
render(<NewSessionDialog />)

let menu = openPicker()
const checked = (row: HTMLElement) => row.querySelector('svg[class*="check"]') !== null
expect(checked(within(menu).getByRole('menuitem', { name: 'current' }))).toBe(true)
expect(checked(within(menu).getByRole('menuitem', { name: 'alpha' }))).toBe(false)

fireEvent.click(within(menu).getByRole('menuitem', { name: 'Add workspace…' }))
menu = openPicker()
expect(within(menu).getAllByRole('menuitem').some(checked)).toBe(false)
})

it('keeps the dialog open with the reason when the backend refuses', async () => {
createSession.mockResolvedValue('no such directory: /nope')
$newSessionDialog.set(true)
Expand Down Expand Up @@ -136,13 +197,39 @@ describe('NewSessionDialog', () => {
expect(createSession).not.toHaveBeenCalled()
})

it('lets Escape and an outside press close the open list, not the dialog', () => {
$newSessionDialog.set(true)
render(<NewSessionDialog />)

openPicker()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
expect($newSessionDialog.get()).toBe(true)

openPicker()
const scrim = screen.getByRole('dialog').parentElement as HTMLElement
fireEvent.pointerDown(scrim)
fireEvent.click(scrim)
expect(screen.queryByRole('menu')).toBeNull()
expect($newSessionDialog.get()).toBe(true)

// With the list closed, the same press on the scrim closes the dialog.
fireEvent.pointerDown(scrim)
fireEvent.click(scrim)
expect($newSessionDialog.get()).toBe(false)
})

it('offers only the new-workspace path when no workspace is known', () => {
$workspace.set('')
$projects.set([])
$newSessionDialog.set(true)
render(<NewSessionDialog />)

expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(NEW_WORKSPACE)
expect(picker().textContent).toContain('New workspace')
expect(screen.getByPlaceholderText('/absolute/path/to/project')).toBeTruthy()

const menu = openPicker()
expect(within(menu).getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Add workspace…'])
expect(NEW_WORKSPACE).toBe('__new_workspace__')
})
})
Loading
Loading