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
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,14 @@ describe('sessionTabs', () => {
expect(wrapper.emitted('select')).toEqual([[session.id]])
expect(wrapper.emitted('stop')).toEqual([[session.id]])
})

it('keeps horizontal session tabs scrollable with stable tab widths', () => {
const sessions = Array.from({ length: 12 }, (_, i) => fakeSession({ id: `session-${String(i).padStart(2, '0')}` }))
const wrapper = mount(SessionTabs, {
props: { sessions, activeId: sessions[0]?.id ?? null },
})

expect(wrapper.get('[data-testid="session-tabs-list"]').classes()).toContain('overflow-x-auto')
expect(wrapper.findAll('li').every((li) => li.classes().includes('shrink-0'))).toBe(true)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -262,24 +262,68 @@ describe('workspaceView terminal persistence', () => {
expect(sendInput.mock.calls[0]?.[2]).not.toContain('large document')
})

it('keeps workspace controls in the sidebar', async () => {
it('places agent controls and sessions in the top chrome while keeping tools in the sidebar', async () => {
getWorkspace.mockResolvedValue(detail([fakeSession({ id: 'sess-a', gatewayAgentId: 'abc12345' })]))

const wrapper = await mountView()
const header = wrapper.get('[data-testid="workspace-view-header"]')
const sessionStrip = wrapper.get('[data-testid="workspace-session-strip"]')
const sidebar = wrapper.get('[data-testid="workspace-sidebar"]')

expect(wrapper.get('[data-testid="workspace-view-header"]').find('[data-testid="stage-input-open"]').exists()).toBe(
false,
)
expect(sidebar.find('[data-testid="workspace-agent-panel"]').exists()).toBe(true)
expect(sidebar.find('[data-testid="workspace-new-agent"]').exists()).toBe(true)
expect(header.find('[data-testid="workspace-agent-panel"]').exists()).toBe(true)
expect(header.find('[data-testid="workspace-new-agent"]').exists()).toBe(true)
expect(header.find('[data-testid="stage-input-open"]').exists()).toBe(false)
expect(sessionStrip.find('[data-testid="session-tabs"]').exists()).toBe(true)
expect(sessionStrip.find('[data-testid="session-tab-sess-a"]').exists()).toBe(true)
expect(sidebar.find('[data-testid="workspace-agent-panel"]').exists()).toBe(false)
expect(sidebar.find('[data-testid="session-tabs"]').exists()).toBe(false)
expect(sidebar.find('[data-testid="workspace-tools-panel"]').exists()).toBe(true)
expect(sidebar.find('[data-testid="stage-input-open"]').exists()).toBe(true)
expect(sidebar.find('[data-testid="workspace-sessions-panel"]').exists()).toBe(true)
expect(sidebar.find('[data-testid="session-tabs"]').exists()).toBe(true)
expect(sidebar.find('[data-testid="workspace-repositories-panel"]').exists()).toBe(true)
})

for (const status of ['STOPPED', 'FAILED'] as const) {
it(`hides ${status.toLowerCase()} sessions from the top tab strip`, async () => {
getWorkspace.mockResolvedValue(detail([fakeSession({ id: 'sess-a' }), fakeSession({ id: 'sess-b', status })]))

const wrapper = await mountView()
const sessionStrip = wrapper.get('[data-testid="workspace-session-strip"]')

expect(sessionStrip.find('[data-testid="session-tab-sess-a"]').exists()).toBe(true)
expect(sessionStrip.find('[data-testid="session-tab-sess-b"]').exists()).toBe(false)
expect(wrapper.findAll('[data-testid="session-terminal"]').length).toBe(1)
})
}

it('shows an empty live-session surface and disables staged input when every session is closed', async () => {
getWorkspace.mockResolvedValue(detail([fakeSession({ id: 'sess-a', status: 'STOPPED' })]))

const wrapper = await mountView()

expect(wrapper.get('[data-testid="workspace-session-strip"]').text()).toContain('No sessions yet.')
expect(wrapper.text()).toContain('Start an agent from the top bar.')
expect(wrapper.get('[data-testid="stage-input-open"]').attributes('disabled')).toBeDefined()
})

it('folds the right sidebar with the triple-bar control', async () => {
getWorkspace.mockResolvedValue(detail([fakeSession({ id: 'sess-a' })]))

const wrapper = await mountView()
const toggle = wrapper.get('[data-testid="workspace-sidebar-toggle"]')
const sidebar = wrapper.get('[data-testid="workspace-sidebar"]')

expect(toggle.attributes('aria-expanded')).toBe('true')
expect(toggle.attributes('aria-controls')).toBe('workspace-sidebar')
expect(sidebar.classes()).toContain('translate-x-0')

await toggle.trigger('click')

expect(toggle.attributes('aria-expanded')).toBe('false')
expect(sidebar.classes()).toContain('translate-x-full')
expect(sidebar.attributes('aria-hidden')).toBe('true')
expect(sidebar.attributes('inert')).toBeDefined()
})

it('renders attached repositories, marks the primary, and shows split guidance', async () => {
const primary = fakeWorkspaceRepository({ id: 'repo-primary', name: 'primary', isPrimary: true })
const destination = fakeWorkspaceRepository({
Expand Down Expand Up @@ -379,6 +423,22 @@ describe('workspaceView terminal persistence', () => {
expect(wrapper.find('[data-testid="workspace-repository-repo-extra"]').exists()).toBe(true)
})

it('opens the repository picker from split guidance when a destination repository is needed', async () => {
const primary = fakeWorkspaceRepository({ id: 'repo-primary', name: 'primary', isPrimary: true })
getWorkspace.mockResolvedValue(detail([fakeSession({ id: 'sess-a' })], { repositories: [primary] }))
listRepositories.mockResolvedValue([
fakeRepository({ id: 'repo-primary', name: 'primary' }),
fakeRepository({ id: 'repo-extra', name: 'extra', repoUrl: 'git@github.com:owner/extra.git' }),
])
const wrapper = await mountView()

await wrapper.find('[data-testid="split-attach-destination"]').trigger('click')
await flush()

expect(listRepositories).toHaveBeenCalledOnce()
expect(document.querySelector('[data-testid="repository-picker-radio-repo-extra"]')).not.toBeNull()
})

it('removes non-primary repositories and reports detach failures', async () => {
const primary = fakeWorkspaceRepository({ id: 'repo-primary', name: 'primary', isPrimary: true })
const destination = fakeWorkspaceRepository({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ const options: { value: AgentKind; label: string; description: string }[] = [
<template>
<div
class="grid gap-2"
:class="props.compact ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-3'"
:class="props.compact ? 'grid-cols-3' : 'grid-cols-1 sm:grid-cols-3'"
data-testid="agent-kind-picker"
aria-label="Agent kind"
>
<button
v-for="opt in options"
:key="opt.value"
type="button"
class="rounded-md border text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent-light)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-dark)]"
class="rounded-md border transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent-light)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-dark)]"
:class="[
props.compact ? 'p-2.5' : 'p-3',
props.compact ? 'px-3 py-2 text-center' : 'p-3 text-left',
props.modelValue === opt.value
? 'border-[var(--color-accent-light)] bg-[var(--color-surface-elevated)] text-[var(--color-text-primary)]'
: 'border-[var(--color-surface-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] hover:border-[var(--color-accent-light)]',
Expand All @@ -35,8 +35,8 @@ const options: { value: AgentKind; label: string; description: string }[] = [
:aria-label="opt.label"
@click="emit('update:modelValue', opt.value)"
>
<div class="font-semibold">{{ opt.label }}</div>
<div class="text-xs text-[var(--color-text-muted)] mt-1">{{ opt.description }}</div>
<div class="text-sm font-semibold leading-5">{{ opt.label }}</div>
<div v-if="!props.compact" class="text-xs text-[var(--color-text-muted)] mt-1">{{ opt.description }}</div>
</button>
</div>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -81,23 +81,28 @@ function sessionShellClasses(s: AgentSession): string[] {

return [
base,
'flex border-b-2 px-3 py-2',
'flex rounded-t-md border-b-2 px-3 py-2.5',
active
? 'border-[var(--color-accent-light)] text-[var(--color-text-primary)]'
: 'border-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]',
? 'border-[var(--color-accent-light)] bg-[var(--color-surface-elevated)] text-[var(--color-text-primary)]'
: 'border-transparent text-[var(--color-text-muted)] hover:bg-[var(--color-surface-card)] hover:text-[var(--color-text-primary)]',
]
}
</script>

<template>
<nav
:class="isVertical ? 'space-y-2' : 'border-b border-[var(--color-surface-border)] px-2'"
data-testid="session-tabs"
aria-label="Agent sessions"
>
<nav :class="isVertical ? 'space-y-2' : 'min-w-0'" data-testid="session-tabs" aria-label="Agent sessions">
<p v-if="props.sessions.length === 0" class="text-sm italic text-[var(--color-text-muted)]">No sessions yet.</p>
<ul v-else :class="isVertical ? 'space-y-2' : 'flex gap-1 overflow-x-auto'">
<li v-for="s in props.sessions" :key="s.id" class="flex min-w-0 items-stretch gap-1">
<ul
v-else
:class="isVertical ? 'space-y-2' : 'flex gap-1 overflow-x-auto overflow-y-hidden px-1 pt-1'"
data-testid="session-tabs-list"
>
<li
v-for="s in props.sessions"
:key="s.id"
class="flex min-w-0 items-stretch gap-1"
:class="isVertical ? '' : 'w-[15rem] shrink-0'"
>
<div v-if="editingId === s.id" :class="sessionShellClasses(s)">
<span class="shrink-0 rounded px-2 py-0.5 text-xs font-semibold" :class="kindBadge[s.kind]">
{{ s.kind }}
Expand Down Expand Up @@ -146,7 +151,7 @@ function sessionShellClasses(s: AgentSession): string[] {
v-if="s.status === 'RUNNING'"
type="button"
class="shrink-0 rounded-md border border-transparent px-2 text-sm text-red-400 transition-colors hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-dark)]"
:class="isVertical ? 'self-stretch' : 'py-2'"
:class="isVertical ? 'self-stretch' : 'my-1 py-1'"
:aria-label="`Stop session ${tabLabel(s)}`"
:data-testid="`session-tab-stop-${s.id}`"
@click="emit('stop', s.id)"
Expand Down
123 changes: 69 additions & 54 deletions services/assistant-ui/src/features/workspaces/views/WorkspaceView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const isSendingSplitCommand = ref(false)
const isAttachingRepository = ref(false)
const detachingRepositoryId = ref<string | null>(null)
const repositoryActionError = ref<string | null>(null)
const isSidebarCollapsed = ref(false)

// Only sessions with a live PTY get a mounted terminal. A session
// dropping out of this set (STOPPED/FAILED) unmounts its
Expand Down Expand Up @@ -135,9 +136,10 @@ async function onDetachRepository(repositoryId: string, repositoryName: string):
main overflowed by the nav height, so the page scrolled on top
of the terminal's own scroll. Sizing to the remaining space keeps
a single scroll region (the xterm viewport). -->
<div class="flex flex-col h-[calc(100vh-3.5rem)] overflow-hidden">
<div class="relative flex h-[calc(100vh-3.5rem)] flex-col overflow-hidden bg-[var(--color-surface-dark)]">
<header
class="flex items-center border-b border-[var(--color-surface-border)] px-6 py-3"
class="z-10 flex shrink-0 flex-col gap-3 border-b border-[var(--color-surface-border)] bg-[var(--color-surface-dark)] px-4 py-3 transition-[padding] sm:px-6 xl:flex-row xl:items-center xl:justify-between"
:class="isSidebarCollapsed ? 'lg:pr-16' : 'lg:pr-[25rem]'"
data-testid="workspace-view-header"
>
<div class="min-w-0">
Expand All @@ -155,10 +157,42 @@ async function onDetachRepository(repositoryId: string, repositoryName: string):
{{ store.activeWorkspace.repoUrl }}
</p>
</div>
<div
class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-end"
data-testid="workspace-agent-panel"
>
<AgentKindPicker v-model="pickerKind" compact class="w-full min-w-0 sm:w-[24rem]" />
<button
type="button"
class="inline-flex min-h-10 w-full items-center justify-center whitespace-nowrap rounded-md border border-transparent bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-[var(--color-accent-light)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent-light)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-dark)] disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto"
:disabled="store.startingSession"
data-testid="workspace-new-agent"
aria-label="Start a new agent session"
@click="onSpawn"
>
{{ spawnButtonLabel }}
</button>
</div>
</header>

<main class="flex min-h-0 flex-1 flex-col overflow-hidden lg:flex-row">
<section class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden p-4">
<div
class="z-10 shrink-0 border-b border-[var(--color-surface-border)] bg-[var(--color-surface-dark)] px-3 transition-[padding] sm:px-5"
:class="isSidebarCollapsed ? 'lg:pr-16' : 'lg:pr-[24rem]'"
data-testid="workspace-session-strip"
>
<SessionTabs
:sessions="liveSessions"
:active-id="store.activeSessionId"
@select="store.selectSession"
@stop="onStopSession"
/>
</div>

<main class="flex min-h-0 flex-1 overflow-hidden">
<section
class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden p-4 transition-[padding]"
:class="isSidebarCollapsed ? 'lg:pr-16' : 'lg:pr-[24.5rem]'"
>
<!-- One terminal per live session, all kept mounted; v-show (not
v-if/:key) so switching tabs preserves each xterm buffer and
its WebSocket. A session leaving the live set (stopped/failed)
Expand All @@ -174,41 +208,39 @@ async function onDetachRepository(repositoryId: string, repositoryName: string):
v-if="liveSessions.length === 0"
class="flex flex-1 items-center justify-center rounded-md border border-dashed border-[var(--color-surface-border)] text-center text-sm italic text-[var(--color-text-muted)]"
>
Start an agent from the sidebar.
Start an agent from the top bar.
</div>
</section>
</main>

<aside
v-if="store.activeWorkspace"
class="flex min-h-0 w-full shrink-0 flex-col gap-4 overflow-y-auto border-t border-[var(--color-surface-border)] bg-[var(--color-surface-card)] p-4 lg:w-[23rem] lg:border-l lg:border-t-0"
data-testid="workspace-sidebar"
aria-label="Workspace controls"
>
<section
class="rounded-md border border-[var(--color-surface-border)] bg-[var(--color-surface-card)] p-4"
data-testid="workspace-agent-panel"
>
<div class="mb-3 flex items-center justify-between gap-3">
<h2 class="text-sm font-semibold">Agents</h2>
<span
class="rounded border border-[var(--color-surface-border)] px-2 py-0.5 text-xs text-[var(--color-text-muted)]"
>
{{ liveSessions.length }} live
</span>
</div>
<AgentKindPicker v-model="pickerKind" compact />
<button
type="button"
class="mt-3 inline-flex min-h-11 w-full items-center justify-center rounded-md border border-transparent bg-[var(--color-accent)] px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-[var(--color-accent-light)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent-light)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-dark)] disabled:cursor-not-allowed disabled:opacity-60"
:disabled="store.startingSession"
data-testid="workspace-new-agent"
aria-label="Start a new agent session"
@click="onSpawn"
>
{{ spawnButtonLabel }}
</button>
</section>
<button
v-if="store.activeWorkspace"
type="button"
class="absolute right-3 top-3 z-30 inline-flex h-10 w-10 items-center justify-center rounded-md border border-[var(--color-surface-border)] bg-[var(--color-surface-elevated)] text-[var(--color-text-primary)] shadow-sm transition-colors hover:bg-[var(--color-surface-border)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent-light)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-dark)]"
:aria-expanded="!isSidebarCollapsed"
:aria-label="isSidebarCollapsed ? 'Open workspace sidebar' : 'Close workspace sidebar'"
aria-controls="workspace-sidebar"
data-testid="workspace-sidebar-toggle"
@click="isSidebarCollapsed = !isSidebarCollapsed"
>
<span class="flex h-5 w-5 flex-col justify-center gap-1" aria-hidden="true">
<span class="block h-0.5 rounded bg-current" />
<span class="block h-0.5 rounded bg-current" />
<span class="block h-0.5 rounded bg-current" />
</span>
</button>

<aside
v-if="store.activeWorkspace"
id="workspace-sidebar"
class="absolute bottom-0 right-0 top-0 z-20 flex w-[min(24rem,calc(100vw-1rem))] min-h-0 flex-col overflow-hidden border-l border-[var(--color-surface-border)] bg-[var(--color-surface-card)] shadow-2xl transition-transform duration-200 ease-out"
:class="isSidebarCollapsed ? 'translate-x-full' : 'translate-x-0'"
:aria-hidden="isSidebarCollapsed ? 'true' : undefined"
:inert="isSidebarCollapsed"
data-testid="workspace-sidebar"
aria-label="Workspace controls"
>
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4 pt-16">
<section
class="rounded-md border border-[var(--color-surface-border)] bg-[var(--color-surface-card)] p-4"
data-testid="workspace-tools-panel"
Expand All @@ -229,23 +261,6 @@ async function onDetachRepository(repositoryId: string, repositoryName: string):
</button>
</section>

<section
class="rounded-md border border-[var(--color-surface-border)] bg-[var(--color-surface-card)] p-4"
data-testid="workspace-sessions-panel"
>
<div class="mb-3 flex items-center justify-between gap-3">
<h2 class="text-sm font-semibold">Sessions</h2>
<span class="text-xs text-[var(--color-text-muted)]">{{ store.sessions.length }}</span>
</div>
<SessionTabs
:sessions="store.sessions"
:active-id="store.activeSessionId"
orientation="vertical"
@select="store.selectSession"
@stop="onStopSession"
/>
</section>

<WorkspaceRepositoriesPanel
:repositories="store.activeWorkspace.repositories ?? []"
:attach-pending="isAttachingRepository"
Expand All @@ -262,8 +277,8 @@ async function onDetachRepository(repositoryId: string, repositoryName: string):
@add-destination="showRepositoryPicker = true"
@send-command="onSendSplitCommand"
/>
</aside>
</main>
</div>
</aside>

<Modal :open="showRepositoryPicker" title="Attach repository" @close="showRepositoryPicker = false">
<WorkspaceRepositoryPicker
Expand Down
Loading