feat: slash system / input service / agent scope
This commit is contained in:
@@ -14,18 +14,19 @@ async function bench() {
|
||||
path: 'name' in input ? `/projects/${input.name}` : input.path,
|
||||
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
|
||||
}))
|
||||
const startSession = vi.fn()
|
||||
const connectWorkspace = vi.fn(async () => 'blank-1' as never)
|
||||
const rename = vi.fn(async () => ({}))
|
||||
const insertSessionBefore = vi.fn(async () => ({}))
|
||||
const open = vi.fn()
|
||||
ctx.provide('workspaces', { create, startSession, rename, insertSessionBefore } as never)
|
||||
ctx.provide('sessions', { open } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open }
|
||||
const clear = vi.fn()
|
||||
ctx.provide('workspaces', { create, connectWorkspace, rename, insertSessionBefore } as never)
|
||||
ctx.provide('sessions', { open, clear } as never)
|
||||
return { ctx, slots: ctx.get('slots') as SlotsService, create, connectWorkspace, rename, insertSessionBefore, open, clear }
|
||||
}
|
||||
|
||||
type HoleName = 'sidebar.workspaces' | 'conversation.empty.workspace'
|
||||
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
|
||||
|
||||
/** Declare one or both holes with a single root registration ('root' is a single slot). */
|
||||
/** Declare any subset of the holes with a single root registration ('root' is a single slot). */
|
||||
function declare(slots: SlotsService, ...names: HoleName[]): () => void {
|
||||
const children = Object.fromEntries(names.map(name => [name, { kind: 'single', scope: 'root' }]))
|
||||
return slots.register({ name: 'root', children } as never, () => null)
|
||||
@@ -36,7 +37,7 @@ describe('ui-workspace apply', () => {
|
||||
expect(inject).toEqual(['slots', 'sessions', 'workspaces'])
|
||||
})
|
||||
|
||||
it('registers browser and picker for declarations arriving before or after apply', async () => {
|
||||
it('registers browser and pickers for declarations arriving before or after apply', async () => {
|
||||
const before = await bench()
|
||||
declare(before.slots, 'sidebar.workspaces')
|
||||
await before.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
@@ -44,19 +45,25 @@ describe('ui-workspace apply', () => {
|
||||
|
||||
const after = await bench()
|
||||
await after.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
declare(after.slots, 'conversation.empty.workspace')
|
||||
declare(after.slots, 'conversation.hero.workspace', 'conversation.empty.workspace')
|
||||
await Promise.resolve()
|
||||
expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker)
|
||||
expect(after.slots.entries('conversation.hero.workspace')[0]!.component).toBe(WorkspacePicker)
|
||||
// expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker)
|
||||
})
|
||||
|
||||
it('routes browser actions and picker creation to the services', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace')
|
||||
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace')
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
|
||||
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
|
||||
browser.startSession('ws' as never, 'prompt')
|
||||
expect(b.startSession).toHaveBeenCalledWith('ws', 'prompt')
|
||||
// Workspace given: reuse-or-create the blank session, then navigate.
|
||||
browser.startSession('ws' as never)
|
||||
expect(b.connectWorkspace).toHaveBeenCalledWith('ws')
|
||||
await vi.waitFor(() => { expect(b.open).toHaveBeenCalledWith('blank-1') })
|
||||
// No workspace: clear the selection into the New Session pure view state.
|
||||
browser.startSession()
|
||||
expect(b.clear).toHaveBeenCalledOnce()
|
||||
browser.open('session' as never)
|
||||
expect(b.open).toHaveBeenCalledWith('session')
|
||||
await browser.renameWorkspace('ws' as never, 'renamed')
|
||||
@@ -66,18 +73,19 @@ describe('ui-workspace apply', () => {
|
||||
await browser.createWorkspace({ name: 'project' })
|
||||
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
|
||||
|
||||
const picker = (b.slots.entries('conversation.empty.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
||||
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
|
||||
await picker.createWorkspace({ path: '/tmp/project' })
|
||||
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
|
||||
})
|
||||
|
||||
it('unregisters both entries on teardown', async () => {
|
||||
it('unregisters every entry on teardown', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace')
|
||||
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace', 'conversation.empty.workspace')
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
await fiber.dispose()
|
||||
expect(b.slots.entries('sidebar.workspaces')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.empty.workspace')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.hero.workspace')).toHaveLength(0)
|
||||
// expect(b.slots.entries('conversation.empty.workspace')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RowDragProps } from '../src/client/rows/Rows.tsx'
|
||||
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
|
||||
import { ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
|
||||
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -43,7 +43,7 @@ describe('workspace browser rows', () => {
|
||||
const onCreate = vi.fn()
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
|
||||
sessionCount: 1, expanded: true, containsCurrent: true, intentHere: false, sessions: [],
|
||||
sessionCount: 1, expanded: true, containsCurrent: true, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
|
||||
|
||||
@@ -56,11 +56,6 @@ describe('workspace browser rows', () => {
|
||||
expect(onToggle).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the frontend Intent placeholder as selected', () => {
|
||||
render(<IntentRowItem />)
|
||||
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('renders and operates selected, running, recursive Session nodes', () => {
|
||||
const child: SessionNode = {
|
||||
id: sid('child'), title: 'Child', children: [], hasChildren: false,
|
||||
@@ -106,7 +101,7 @@ describe('workspace browser rows', () => {
|
||||
const onToggle = vi.fn()
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [],
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={vi.fn()} onRename={onRename} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
|
||||
@@ -130,7 +125,7 @@ describe('workspace browser rows', () => {
|
||||
it('ungrouped bucket renders no workspace menu', () => {
|
||||
const group: GroupNode = {
|
||||
key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [],
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
|
||||
}
|
||||
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)
|
||||
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
|
||||
|
||||
@@ -8,14 +8,13 @@ import { createWorkspaceViewStore } from '../src/client/stores.ts'
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
|
||||
id: sid(id), displayTitle: id, running: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
|
||||
id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...(cwd === undefined ? {} : { cwd }),
|
||||
})
|
||||
const list = (...items: SessionSummary[]): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
byId: Object.fromEntries(items.map(item => [item.id, item])),
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
intent: undefined,
|
||||
})
|
||||
const workspace = (id: string, sessionIds: string[]): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title: id,
|
||||
@@ -41,30 +40,38 @@ describe('deriveGroups', () => {
|
||||
expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
|
||||
})
|
||||
|
||||
it('shows one frontend Session row only under a real target Workspace', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const }
|
||||
const target = workspace('first', [])
|
||||
expect(deriveGroups({ ...list(), current: intent.sessionId, intent }, [target], view())[0]).toEqual(expect.objectContaining({
|
||||
intentHere: true,
|
||||
sessionCount: 1,
|
||||
containsCurrent: true,
|
||||
}))
|
||||
const hiddenIntent = { sessionId: sid('zero'), target: { kind: 'workspace-intent' as const }, prompt: '', phase: 'ready' as const }
|
||||
expect(deriveGroups({ ...list(), intent: hiddenIntent }, [target], view())[0]!.intentHere).toBe(false)
|
||||
})
|
||||
|
||||
it('an Intent no longer forces its target group expanded (viewer owns expansion)', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const }
|
||||
const groups = deriveGroups({ ...list(), intent }, [workspace('first', [])], view())
|
||||
expect(groups[0]).toEqual(expect.objectContaining({ intentHere: true, expanded: false }))
|
||||
})
|
||||
|
||||
it('search filters real Sessions and omits the Intent placeholder', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'ready' as const }
|
||||
const groups = deriveGroups({ ...list(summary('match', 1)), intent }, [workspace('first', ['match'])], view([], 'match'))
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('match')])
|
||||
expect(groups[0]!.intentHere).toBe(false)
|
||||
it('shows only the current blank session in its Workspace count and tree', () => {
|
||||
const currentBlank = { ...summary('current-blank', 5), blank: true }
|
||||
const staleBlank = { ...summary('stale-blank', 4), blank: true }
|
||||
const real = summary('shown', 3)
|
||||
const sessions = {
|
||||
...list(real, currentBlank, staleBlank),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const groups = deriveGroups(
|
||||
sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], view(['first']),
|
||||
)
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
|
||||
expect(groups[0]!.sessions.find(session => session.id === currentBlank.id)!.title).toBe('New Session')
|
||||
expect(groups[0]!.sessionCount).toBe(2)
|
||||
// A non-current blank stray never surfaces an Ungrouped bucket either.
|
||||
const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], view())
|
||||
expect(strayGroups.map(group => group.key)).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('searches the current blank session by its New Session title', () => {
|
||||
const currentBlank = { ...summary('opaque-current', 5), blank: true }
|
||||
const staleBlank = { ...summary('new session stale', 4), blank: true }
|
||||
const sessions = {
|
||||
...list(currentBlank, staleBlank),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const groups = deriveGroups(
|
||||
sessions, [workspace('first', ['opaque-current', 'new session stale'])], view([], 'new session'),
|
||||
)
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([currentBlank.id])
|
||||
expect(groups[0]!.sessions[0]!.title).toBe('New Session')
|
||||
expect(groups[0]!.sessionCount).toBe(1)
|
||||
})
|
||||
|
||||
it('builds, sorts, expands, and cycle-guards an ungrouped session tree', () => {
|
||||
@@ -164,6 +171,20 @@ describe('deriveFlat', () => {
|
||||
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
|
||||
expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('shows only the current blank session with its New Session title', () => {
|
||||
const currentBlank = { ...summary('current-blank', 9), blank: true }
|
||||
const staleBlank = { ...summary('stale-blank', 8), blank: true }
|
||||
const sessions = {
|
||||
...list(summary('real', 1), currentBlank, staleBlank),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const rows = deriveFlat(sessions, { query: '' })
|
||||
expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
|
||||
expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
|
||||
expect(deriveFlat(sessions, { query: 'new session' }).map(row => row.id)).toEqual([currentBlank.id])
|
||||
expect(deriveFlat(sessions, { query: 'stale-blank' })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWorkspaceViewStore', () => {
|
||||
|
||||
@@ -15,14 +15,13 @@ beforeEach(() => { localStorage.clear() })
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
|
||||
id: sid(id), displayTitle: id, running: false, updatedAt, ...overrides,
|
||||
id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides,
|
||||
})
|
||||
const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
|
||||
ids: items.map(item => item.id),
|
||||
byId: Object.fromEntries(items.map(item => [item.id, item])),
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
intent: undefined,
|
||||
...overrides,
|
||||
})
|
||||
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
|
||||
@@ -30,7 +29,7 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView
|
||||
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
|
||||
items, intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: items[0]?.workspaceId,
|
||||
})
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
@@ -176,18 +175,31 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.queryByText('b')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the intent placeholder in both modes', () => {
|
||||
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('alpha') }, prompt: '', phase: 'connecting' as const }
|
||||
const sessions = sessionState([], { intent, current: sid('intent') })
|
||||
it('shows only the current blank session as New Session in grouped, flat, and search modes', () => {
|
||||
const currentBlank = summary('alpha-blank', 9, { blank: true })
|
||||
const staleBlank = summary('beta-blank', 8, { blank: true })
|
||||
const sessions = sessionState(
|
||||
[currentBlank, staleBlank],
|
||||
{ current: currentBlank.id },
|
||||
)
|
||||
const b = mount({
|
||||
useSessions: hook(sessions),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
|
||||
useWorkspaces: hook(workspaceState([
|
||||
workspace('alpha', ['alpha-blank']), workspace('beta', ['beta-blank']),
|
||||
])),
|
||||
})
|
||||
// Grouped: the current-group effect expands the target group.
|
||||
expect(screen.getByText('New session')).toBeTruthy()
|
||||
expect(screen.getByText('New Session')).toBeTruthy()
|
||||
expect(screen.queryByText('alpha-blank')).toBeNull()
|
||||
expect(screen.queryByText('beta-blank')).toBeNull()
|
||||
expect(screen.getByText('1 session')).toBeTruthy()
|
||||
|
||||
rerender(b, { useSessions: hook({ ...sessions, current: staleBlank.id }) })
|
||||
expect(screen.getAllByText('New Session')).toHaveLength(1)
|
||||
b.store.actions.setGroupBy('flat')
|
||||
rerender(b, {})
|
||||
expect(screen.getByText('New session')).toBeTruthy()
|
||||
expect(screen.getAllByText('New Session')).toHaveLength(1)
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'new session' } })
|
||||
expect(screen.getAllByText('New Session')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('searches across groups, clears via the clear button, and shows the empty states', () => {
|
||||
|
||||
@@ -17,10 +17,10 @@ function workspace(id: string, title = id): WorkspaceView {
|
||||
}
|
||||
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
|
||||
const sessions: SessionListState = {
|
||||
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
|
||||
items, intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: items[0]?.workspaceId,
|
||||
})
|
||||
function anchor(): { current: HTMLElement } {
|
||||
|
||||
Reference in New Issue
Block a user