feat: workspace select menu

This commit is contained in:
07akioni
2026-07-24 16:09:58 +08:00
parent 06143b1a86
commit 6f5321cb37
26 changed files with 890 additions and 187 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-24-new-session-clears-to-empty-state.md: d730f3e0b658ea66b6026593f37a97893e32a4db
2026-07-24-new-session-clears-to-empty-state.zh.md: 602a2b774b569cfef0adcc253fe751f86b14f895
2026-07-24-new-session-clears-to-empty-state.md: 1605f44a05d0f59b61fe95cb5b03a0f9f5c3d4ab
2026-07-24-new-session-clears-to-empty-state.zh.md: 1f78d99babc33d30ee1300bfa6bf048a78e7132e

View File

@@ -20,4 +20,4 @@ Sidebar "New Session" created and opened a blank session immediately, so the cen
## Consequences
New Session no longer mints a host session until the first send. Reloading after clear stays on the empty state. Project-scoped "+" still creates immediately. `EmptyState` stacks the Figma hero as fish + title, a Menu-backed workspace chip ("New Workspace" / basename / free-form path) above the card, then shared `InputBar` (`variant="hero"`), with a soft ellipse glow (figma 313:14109) centered behind the picker + card and width-locked to the card (`1051/776`) so it scales with it. `InputBar` paints the bottom chrome (attach / Plan / Read-only / model) with local native `<select>` state only — host plan, access, and model seams remain unwired.
New Session no longer mints a host session until the first send. Reloading after clear stays on the empty state. Project-scoped "+" still creates immediately. `EmptyState` stacks the Figma hero (Input_Bottom 75:8208) as fish + title, a Menu-backed workspace chip above the card, then shared `InputBar` (`variant="hero"`, max-width 800, r20 card matching the composer — not a taller r24 hero), with a soft ellipse glow (figma 313:14109) centered behind the picker + card and width-locked to the card (`1051/776` asset ratio) so it scales with it. The chip uses the soft interactive hover fill + 12px radius from 75:8208 and opens MenuDropdown (figma 122:9481; `--dsw-specific-menu` + `--dsw-shadow-lv3`): basename rows with folder icons and a trailing check, then a separator and "New Workspace" whose submenu (figma 419:16920) offers "Use a existing folder" and "Create new". Use a existing folder opens the path Dialog (figma 451:18655 copy — "Enter an existing folder path" / Open Folder) over a full-viewport mask (`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`) and sets the chip cwd. Create new opens the same Dialog chrome to name a folder under `host.describe().cwd`; success runs `sessions.createWorkspace` → host `session.create` (mkdir recursive) → `sessions.open`, so a default session lands in the new workspace. `InputBar` paints the bottom chrome (attach / Plan / Read-only / model) with local native `<select>` state only — host plan, access, and model seams remain unwired.

View File

@@ -20,4 +20,4 @@ Status: implemented
## Consequences
New Session 在首次发送前不再创建 host 会话。clear 后重新加载仍停留在空态。项目范围的「+」仍立即创建。`EmptyState` 按 Figma 堆叠英雄区:鱼标 + 标题、卡片上方 Menu 工作区 chip「New Workspace」/ 路径 basename / 自由输入路径),再接共用的 `InputBar``variant="hero"`选择器与卡片背后居中铺一层柔光椭圆figma 313:14109宽度按卡片锁定为 `1051/776`,随卡片缩放`InputBar` 绘制底栏 chrome添加 / Plan / Read-only / 模型),仅用本地原生 `<select>` 状态——host 侧的 plan、access、model 接缝仍未接线。
New Session 在首次发送前不再创建 host 会话。clear 后重新加载仍停留在空态。项目范围的「+」仍立即创建。`EmptyState` 按 Figma 堆叠英雄区Input_Bottom 75:8208:鱼标 + 标题、卡片上方 Menu 驱动的工作区 chip,再接共用的 `InputBar``variant="hero"`max-width 800与 composer 一致的 r20 卡片——而非更高的 r24 英雄区选择器与卡片背后居中铺一层柔光椭圆figma 313:14109宽度按卡片锁定为 `1051/776` asset 比例随卡片缩放。Chip 采用 75:8208 的柔和交互 hover 填充与 12px 圆角,并打开 MenuDropdownfigma 122:9481`--dsw-specific-menu` + `--dsw-shadow-lv3`):带文件夹图标与尾随勾选的 basename 行,分隔线后是 "New Workspace"其子菜单figma 419:16920提供 "Use a existing folder" 与 "Create new"。Use a existing folder 打开路径 Dialogfigma 451:18655 copy — "Enter an existing folder path" / Open Folder置于全视口遮罩`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`)之上,并设置 chip 的 cwd。Create new 打开同一套 Dialog chrome`host.describe().cwd` 下命名文件夹;成功则走 `sessions.createWorkspace` → host `session.create`mkdir recursive`sessions.open`,在新 workspace 中默认落下一会话`InputBar` 绘制底栏 chromeattach / Plan / Read-only / model),仅用本地原生 `<select>` 状态——host 侧的 plan、access、model 接缝仍未接线。

View File

@@ -117,7 +117,7 @@ export class SessionsService {
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
*/
constructor(private readonly rootCtx: Context, api: IApiClient) {
constructor(private readonly rootCtx: Context, private readonly api: IApiClient) {
this.manager = new SessionManager(api)
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
@@ -171,6 +171,27 @@ export class SessionsService {
return result.value.sessionId
}
/**
* Create a workspace folder under the host process cwd and a session in it.
* Name is a single path segment (no separators); the host mkdir runs inside
* session.create. Caller opens the returned id when it wants the session staged.
* @param name - workspace folder basename.
* @returns the new session id.
*/
async createWorkspace(name: string): Promise<SessionId> {
const trimmed = name.trim()
if (trimmed === '') throw new Error('sessions.createWorkspace: name is required')
if (/[/\\]/.test(trimmed)) {
throw new Error('sessions.createWorkspace: name must not contain path separators')
}
const { result } = await this.api.host.describe({})
if (!result.ok) {
throw new Error(`host.describe failed: ${result.error.code}: ${result.error.message}`)
}
const hostCwd = result.value.cwd.replace(/[/\\]+$/, '')
return this.create({ cwd: `${hostCwd}/${trimmed}` })
}
/**
* Resolve a session-scoped context view (use-and-discard).
* @param id - session id.

View File

@@ -297,6 +297,27 @@ describe('create', () => {
})
})
describe('createWorkspace', () => {
it('joins host.describe cwd with the name and creates there', async () => {
const b = bench()
b.api.onDescribe = () => Promise.resolve(ok({ version: '0', cwd: '/host/root', attachedSessions: 0 }))
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('ws') }))
await expect(b.svc.createWorkspace('My Proj')).resolves.toBe('ws')
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/host/root/My Proj' }])
})
it('rejects empty names and path separators; surfaces describe failures', async () => {
const b = bench()
await expect(b.svc.createWorkspace(' ')).rejects.toThrow(/name is required/)
await expect(b.svc.createWorkspace('a/b')).rejects.toThrow(/path separators/)
b.api.onDescribe = () => Promise.resolve({
rpcId: 'e' as never,
result: { ok: false as const, error: { code: 'internal' as const, message: 'down', details: {} } },
} as never)
await expect(b.svc.createWorkspace('ok')).rejects.toThrow(/host.describe failed/)
})
})
describe('coverage tails (branch duals)', () => {
it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
const b = bench()

View File

@@ -160,6 +160,10 @@ export function apply(ctx: Context): void {
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
return conversation.startSession(opts)
},
createWorkspaceSession: async (name) => {
const id = await sessions.createWorkspace(name)
sessions.open(id)
},
}),
}, EmptyState)
}

View File

@@ -164,6 +164,11 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
export interface EmptyStateInjected {
/** The create → navigate → first-send chain, in one service call. */
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
/**
* Create a workspace folder under the host cwd, mint a session there, and
* open it (Create-new modal success path).
*/
createWorkspaceSession(name: string): Promise<void>
}
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */

View File

@@ -1,6 +1,6 @@
/* NEW SESSION hero (figma 313:14149): fish + title, workspace chip above the
shared InputBar card. The input itself is InputBar — only stack geometry
lives here. */
/* NEW SESSION hero (figma Input_Bottom 75:8208): fish + title, workspace chip
above the shared InputBar card. The input itself is InputBar — only stack
geometry and the chip live here. */
.root {
display: flex;
@@ -11,23 +11,26 @@
padding: 24px;
}
/* Cap matches InputBar card width (776). Glow may paint past the sides. */
/* Cap matches InputBar card width (800). Glow may paint past the sides. */
.stack {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 40px;
/* figma 75:8208: 12 between title block / workspace / card. */
gap: 12px;
width: 100%;
max-width: 776px;
max-width: 800px;
overflow: visible;
}
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600. */
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block
keeps 36px below the headline before the flex gap. */
.headline {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding-bottom: 36px;
font-size: 26px;
line-height: 32px;
font-weight: 600;
@@ -68,38 +71,43 @@
z-index: 1;
}
.workspaceRow {
/* Must beat `.body > :not(.glow)` specificity so the open Menu (and its
right-hand submenu) paints above the InputBar card. */
.body > .workspaceRow {
z-index: 10;
display: flex;
align-items: center;
min-width: 0;
/* Align with InputBar's left chrome (card pad 10 + attach). */
padding-left: 10px;
/* figma 75:8208 workspace row: px 8 above the card. */
padding-left: 8px;
}
/* Folder + "New Workspace" + chevron (figma workspace trigger). */
/* Folder + label + chevron — transparent at rest; fill only on hover / open. */
.workspace {
display: inline-flex;
align-items: center;
gap: 6px;
gap: 4px;
max-width: 100%;
height: 28px;
padding: 0 4px 0 0;
min-height: 28px;
padding: 0 8px;
border: none;
border-radius: 8px;
border-radius: 12px;
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 14px;
font-size: 13px;
line-height: 20px;
font-weight: 500;
cursor: pointer;
}
.workspace:hover {
.workspace:hover,
.workspace[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
.folder {
flex: none;
color: var(--dsw-alias-label-tertiary);
color: var(--dsw-alias-label-primary);
}
.workspaceLabel {
@@ -113,19 +121,44 @@
color: var(--dsw-alias-label-caption);
}
.customInput {
width: min(320px, 100%);
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 8px;
/* Workspace menu width tracks the longest basename in the Figma frame. */
.workspaceMenu :global([role='menu']) {
min-width: 240px;
}
/* Dialog field (figma 451:18655 Input): h44, r22, px 14, caption placeholder. */
.modalInput {
width: 100%;
height: 44px;
padding: 0 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 22px;
outline: none;
background: var(--dsw-alias-bg-base);
background: transparent;
font-size: 14px;
line-height: 20px;
line-height: 24px;
color: var(--dsw-alias-label-primary);
}
.customInput:focus {
.modalInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.modalInput:focus {
border-color: var(--dsw-alias-state-business-primary);
}
.modalInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
.modalAction {
min-width: 72px;
}
.modalError {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-error-primary);
}

View File

@@ -1,16 +1,21 @@
// EmptyState (figma NEW SESSION screen): centered hero — fish + title,
// workspace picker row, then the SAME InputBar the resident composer uses
// (empty→content is a position move, never a swap). Project picker: cwd set
// derived in-component from useSessions plus a free-form new-directory path;
// submit runs startSession (create → open → send).
// workspace picker row (MenuDropdown 122:9481 + New Workspace submenu
// 419:16920 + Dialog 451:18655), then the SAME InputBar the resident
// composer uses (empty→content is a position move, never a swap). Project
// options derive in-component from useSessions; Create new runs
// createWorkspaceSession (host mkdir + session.create + open).
import { useId, useMemo, useState } from 'react'
import {
Button,
FishLogo,
IconChevronDownOutline14,
IconFolderClose16,
IconFolderOpen16,
IconPlusOutline16,
Menu,
type MenuItem,
Modal,
type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { EmptyStateSlotProps } from '../contract/slots.ts'
@@ -18,10 +23,15 @@ import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import css from './EmptyState.module.css'
/** Menu id for the free-form directory entry (not a filesystem path). */
const NEW_DIR = '::new-directory'
/** Menu id for the host default project directory (empty cwd on create). */
const DEFAULT_DIR = '::default'
/** Menu id for "New Workspace" (opens submenu; not a cwd). */
const NEW_WORKSPACE = '::new-workspace'
/** Submenu: path modal (figma 451:18655 copy). */
const USE_EXISTING = '::use-existing'
/** Submenu: create-workspace modal → mkdir + default session. */
const CREATE_NEW = '::create-new'
/** Which full-page dialog is open (null = none). */
type ModalKind = 'path' | 'create' | null
/** Full props composed by reference from the contract (runtime share & injected share; no store). */
export type EmptyStateProps = EmptyStateSlotProps
@@ -36,22 +46,26 @@ function deriveCwds(state: SessionListState): readonly string[] {
return [...seen]
}
/** Basename for the workspace chip; empty → the design's "New Workspace" label. */
/** Basename for the workspace chip / menu row; empty → the design's "New Workspace" label. */
function workspaceLabel(cwd: string): string {
if (cwd === '') return 'New Workspace'
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
return base !== undefined && base !== '' ? base : cwd
}
export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
export function EmptyState({ useSessions, startSession, createWorkspaceSession }: EmptyStateProps) {
const list = useSessions(s => s)
const cwds = useMemo(() => deriveCwds(list), [list])
// Local viewing state: the empty state owns no session, so its draft is
// ephemeral by design (drafts are keyed by session id; there is none yet).
const [draft, setDraft] = useState('')
const [cwd, setCwd] = useState('')
const [custom, setCustom] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const [modalKind, setModalKind] = useState<ModalKind>(null)
const [pathDraft, setPathDraft] = useState('')
const [workspaceName, setWorkspaceName] = useState('New WorkSpace')
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [sending, setSending] = useState(false)
const [error, setError] = useState<InputBarError | null>(null)
// Stable filter id so multiple EmptyState mounts do not collide in the DOM.
@@ -74,59 +88,64 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
// Success needs no cleanup: the session selection swaps this slot out for the session body.
}
const items: MenuItem[] = [
{ id: DEFAULT_DIR, label: 'Default directory' },
...cwds.map(c => ({ id: c, label: c })),
{ id: NEW_DIR, label: 'New directory…' },
const items: MenuEntry[] = [
...cwds.map(c => ({
id: c,
label: workspaceLabel(c),
icon: <IconFolderClose16 size={16} />,
})),
...(cwds.length > 0 ? [{ type: 'separator' as const, id: 'sep-new' }] : []),
{
id: NEW_WORKSPACE,
label: 'New Workspace',
icon: <IconPlusOutline16 size={16} />,
submenu: [
{ id: USE_EXISTING, label: 'Use a existing folder' },
{ id: CREATE_NEW, label: 'Create new' },
],
},
]
const selectedId = custom ? NEW_DIR : cwd === '' ? DEFAULT_DIR : cwd
const workspace = custom
? (
<input
className={css.customInput}
value={cwd}
autoFocus
aria-label="项目目录"
placeholder="Directory path, e.g. /home/me/proj"
onChange={(e) => { setCwd(e.target.value) }}
/>
)
: (
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
selectedId={selectedId}
items={items}
onSelect={(id) => {
if (id === NEW_DIR) {
setCustom(true)
setCwd('')
} else if (id === DEFAULT_DIR) {
setCustom(false)
setCwd('')
} else {
setCustom(false)
setCwd(id)
}
setMenuOpen(false)
}}
anchor={(
<button
type="button"
className={css.workspace}
aria-label="项目目录"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => { setMenuOpen(!menuOpen) }}
>
<IconFolderOpen16 className={css.folder} size={16} />
<span className={css.workspaceLabel}>{workspaceLabel(cwd)}</span>
<IconChevronDownOutline14 className={css.chevron} size={14} />
</button>
)}
/>
)
const closeModal = (): void => {
if (creating) return
setModalKind(null)
setModalError(null)
}
const openPathModal = (): void => {
setPathDraft(cwd)
setModalError(null)
setModalKind('path')
}
const openCreateModal = (): void => {
setWorkspaceName('New WorkSpace')
setModalError(null)
setModalKind('create')
}
const confirmPath = (): void => {
const next = pathDraft.trim()
if (next === '') return
setCwd(next)
setModalKind(null)
}
const confirmCreate = (): void => {
if (creating) return
setCreating(true)
setModalError(null)
createWorkspaceSession(workspaceName)
.catch((reason: unknown) => {
setModalError(reason instanceof Error ? reason.message : String(reason))
setCreating(false)
})
// Success swaps this slot out for the new session body — no local cleanup.
}
const modalBusy = creating
const isPath = modalKind === 'path'
const isCreate = modalKind === 'create'
return (
<div className={css.root}>
@@ -138,7 +157,8 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
</div>
<div className={css.body}>
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width
tracks the card (1051/776) so blur scales in userSpace with it. */}
tracks the card (glow asset 1051 vs design card 776) so blur
scales in userSpace with it. */}
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
<defs>
<filter
@@ -159,7 +179,44 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
</g>
</svg>
<div className={css.workspaceRow}>{workspace}</div>
<div className={css.workspaceRow}>
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
{...(cwd !== '' ? { selectedId: cwd } : {})}
items={items}
side="top"
className={css.workspaceMenu!}
onSelect={(id) => {
if (id === USE_EXISTING) {
setMenuOpen(false)
openPathModal()
return
}
if (id === CREATE_NEW) {
setMenuOpen(false)
openCreateModal()
return
}
setCwd(id)
setMenuOpen(false)
}}
anchor={(
<button
type="button"
className={css.workspace}
aria-label="项目目录"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => { setMenuOpen(!menuOpen) }}
>
<IconFolderOpen16 className={css.folder} size={16} />
<span className={css.workspaceLabel}>{workspaceLabel(cwd)}</span>
<IconChevronDownOutline14 className={css.chevron} size={12} />
</button>
)}
/>
</div>
<InputBar
draft={draft}
running={false}
@@ -174,6 +231,75 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
/>
</div>
</div>
<Modal
open={isPath}
onClose={closeModal}
title="Enter an existing folder path"
footer={(
<>
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={pathDraft.trim() === ''}
onClick={confirmPath}
>
Open Folder
</Button>
</>
)}
>
<input
className={css.modalInput}
value={pathDraft}
aria-label="Folder path"
autoFocus
placeholder="ex. User/Documents/Harness/Space"
onChange={(e) => { setPathDraft(e.target.value) }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirmPath()
}
}}
/>
</Modal>
<Modal
open={isCreate}
onClose={closeModal}
title="Create new workspace"
footer={(
<>
<Button variant="outline" className={css.modalAction!} disabled={modalBusy} onClick={closeModal}>
Cancel
</Button>
<Button
variant="primary"
className={css.modalAction!}
disabled={modalBusy || workspaceName.trim() === ''}
onClick={confirmCreate}
>
Create
</Button>
</>
)}
>
<input
className={css.modalInput}
value={workspaceName}
aria-label="Workspace name"
autoFocus
disabled={modalBusy}
onChange={(e) => { setWorkspaceName(e.target.value) }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirmCreate()
}
}}
/>
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
</Modal>
</div>
)
}

View File

@@ -1,7 +1,7 @@
/* Floating capsule input (figma Input_Bottom 34:11445): card floats above the
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
viewport bottom inside the centered message column; textarea on top, action
row below, one primary circle button bottom-right. Input width rides the
column (776 is a cap, not a fixed size — layout rule: the box shrinks with
column (800 is a cap, not a fixed size — layout rule: the box shrinks with
the center column keeping its padding). Hero variant = the same card
centered in the empty state; the transition between the two is a position
move of one component. */
@@ -10,8 +10,8 @@
display: flex;
flex-direction: column;
align-items: center;
/* figma Input_Bottom 34:11445: pad L32/R32/B12; the bottom gradient mask is
owned by the chat scroller. Top 8 hosts the error strip's breathing room. */
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
the chat scroller. Top 8 hosts the error strip's breathing room. */
padding: 8px 32px 12px;
}
@@ -21,7 +21,7 @@
.error {
width: 100%;
max-width: 776px;
max-width: 800px;
margin-bottom: 6px;
padding: 4px 8px;
border-radius: 8px;
@@ -34,10 +34,12 @@
.card {
display: flex;
flex-direction: column;
/* figma Input 34:11458: 12px between the text area and the button row. */
/* figma Input 75:8208: 12px between the text area and the button row; 10px
top pad on the card before .InputText. */
gap: 12px;
width: 100%;
max-width: 776px;
max-width: 800px;
padding-top: 10px;
/* Input stroke: black/0.10 light, white/0.06 dark (figma darkmode note says
the input border is one notch weaker than buttons) — exactly the
l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */
@@ -49,11 +51,6 @@
line-height: 24px;
}
/* New-session state rounds up (figma: r24 and a taller box). */
.hero .card {
border-radius: 24px;
}
.accessory {
display: flex;
align-items: center;
@@ -85,7 +82,8 @@
.input,
.mirror {
padding: 12px 16px 0;
/* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */
padding: 4px 12px 0 16px;
font-size: inherit;
line-height: inherit;
white-space: pre-wrap;
@@ -108,17 +106,12 @@
.mirror {
visibility: hidden;
pointer-events: none;
/* 2-line floor: 2 × 24px line + 12px top padding; 14-line cap (336px). */
min-height: 60px;
/* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */
min-height: 52px;
max-height: 336px;
overflow: hidden;
}
.hero .mirror {
/* New-session box is taller at rest (figma 118px input area). */
min-height: 84px;
}
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
(figma Input_Bottom chrome). */
.row {
@@ -131,16 +124,25 @@
}
.tools,
.modes,
.trailing {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
/* figma 75:8208: 16 between + and the mode chips; 4 between Plan / Read-only. */
.tools {
gap: 16px;
}
.modes {
gap: 4px;
}
.trailing {
flex: none;
gap: 8px;
gap: 12px;
}
/* Attach circle (figma + control): 28px, selector fill, primary glyph. */
@@ -166,22 +168,24 @@
cursor: default;
}
/* Plan / Read-only / model — native <select>, chip-like closed chrome. */
/* Plan / Read-only / model — native <select>, chip-like closed chrome
(figma ToggleButton: 13/20 medium secondary, 12px chevron). */
.select {
max-width: 220px;
height: 28px;
padding: 0 22px 0 6px;
padding: 0 20px 0 8px;
border: none;
border-radius: 8px;
outline: none;
background-color: transparent;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14' fill='none'%3E%3Cpath d='M3.5 5.25L7 8.75L10.5 5.25' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 4px center;
background-size: 14px 14px;
background-size: 12px 12px;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
font-size: 13px;
line-height: 20px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
appearance: none;

View File

@@ -175,8 +175,10 @@ export function InputBar({
>
<IconPlusOutline16 size={14} />
</button>
{renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)}
{renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)}
<div className={css.modes}>
{renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)}
{renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)}
</div>
</div>
<div className={css.trailing}>
{renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)}
@@ -190,11 +192,11 @@ export function InputBar({
onClick={onPrimary}
>
{running ? (
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
)}

View File

@@ -78,6 +78,7 @@ async function bench() {
cell: () => undefined,
scopeOf,
create: vi.fn(() => Promise.resolve(ROOT)),
createWorkspace: vi.fn(() => Promise.resolve(ROOT)),
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
@@ -239,16 +240,20 @@ describe('details and empty inject surfaces', () => {
expect(details).toBe(conv)
})
it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => {
it('empty injects startSession and createWorkspaceSession (no store, cwds derive in-component)', async () => {
const b = await bench()
const entry = b.entryOf('conversation.empty')
expect(entry.store).toBeUndefined()
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
expect(Object.keys(injected)).toEqual(['startSession'])
expect(Object.keys(injected).sort()).toEqual(['createWorkspaceSession', 'startSession'])
await injected.startSession({ text: 'go', mode: 'queue' })
expect(b.sessionsFake.create).toHaveBeenCalled()
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
b.sessionsFake.open.mockClear()
await injected.createWorkspaceSession('Fresh')
expect(b.sessionsFake.createWorkspace).toHaveBeenCalledWith('Fresh')
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
})
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {

View File

@@ -238,10 +238,16 @@ describe('DetailsPanel branches', () => {
})
describe('EmptyState branches', () => {
const noopCreate = () => Promise.resolve()
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
const view = render(
<EmptyState useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])} startSession={startSession} />,
<EmptyState
useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'first task' } })
@@ -253,7 +259,11 @@ describe('EmptyState branches', () => {
it('non-Error rejection reasons stringify into the error strip', async () => {
const startSession = vi.fn(() => Promise.reject('plain-string'))
const view = render(
<EmptyState useSessions={listHook([])} startSession={startSession} />,
<EmptyState
useSessions={listHook([])}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'go' } })
@@ -270,15 +280,17 @@ describe('EmptyState branches', () => {
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
])}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
.toEqual(['Default directory', '/proj', 'New directory…'])
fireEvent.click(view.getByRole('menuitem', { name: '/proj' }))
.toEqual(['proj', 'New Workspace'])
fireEvent.click(view.getByRole('menuitem', { name: 'proj' }))
expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj')
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
fireEvent.click(view.getByRole('menuitem', { name: 'New directory…' }))
fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' }))
const custom = view.container.querySelector('input')!
fireEvent.change(custom, { target: { value: '/typed/dir' } })
const textarea = view.container.querySelector('textarea')!
@@ -286,4 +298,20 @@ describe('EmptyState branches', () => {
fireEvent.keyDown(textarea, { key: 'Enter' })
await waitFor(() => expect(startSession).toHaveBeenCalledWith({ text: 'task', mode: 'queue', cwd: '/typed/dir' }))
})
it('Create modal surfaces inject failures inline', async () => {
const createWorkspaceSession = vi.fn(() => Promise.reject(new Error('mkdir blocked')))
const view = render(
<EmptyState
useSessions={listHook([])}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(view.getByRole('menuitem', { name: 'Create new' }))
fireEvent.click(view.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(view.getByRole('alert').textContent).toContain('mkdir blocked'))
})
})

View File

@@ -86,6 +86,8 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
describe('EmptyState', () => {
const noopCreate = () => Promise.resolve()
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
const { useSessions } = fakeSessions([
{ id: 'a', title: 'a', cwd: '/w/app' },
@@ -94,14 +96,20 @@ describe('EmptyState', () => {
])
let reject!: (e: Error) => void
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
render(<EmptyState useSessions={useSessions} startSession={startSession} />)
render(
<EmptyState
useSessions={useSessions}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const trigger = screen.getByRole('button', { name: '项目目录' })
fireEvent.click(trigger)
const menu = screen.getByRole('menu')
expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
.toEqual(['Default directory', '/w/app', '/w/lib', 'New directory…'])
fireEvent.click(screen.getByRole('menuitem', { name: '/w/app' }))
.toEqual(['app', 'lib', 'New Workspace'])
fireEvent.click(screen.getByRole('menuitem', { name: 'app' }))
const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands')
fireEvent.change(box, { target: { value: '造一个轮子' } })
fireEvent.keyDown(box, { key: 'Enter' })
@@ -113,14 +121,64 @@ describe('EmptyState', () => {
expect((box as HTMLTextAreaElement).value).toBe('造一个轮子')
})
it('new-directory option swaps the chip for a free-form input', () => {
it('Use a existing folder opens the path modal and Open Folder sets the chip', () => {
const { useSessions } = fakeSessions([])
render(<EmptyState useSessions={useSessions} startSession={() => Promise.resolve()} />)
render(
<EmptyState
useSessions={useSessions}
startSession={() => Promise.resolve()}
createWorkspaceSession={noopCreate}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'New directory…' }))
const custom = screen.getByPlaceholderText(/Directory path/)
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
expect((custom as HTMLInputElement).value).toBe('/tmp/fresh')
const newWs = screen.getByRole('menuitem', { name: 'New Workspace' })
fireEvent.mouseEnter(newWs.parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Use a existing folder' }))
expect(screen.getByRole('dialog', { name: 'Enter an existing folder path' })).toBeTruthy()
const path = screen.getByLabelText('Folder path') as HTMLInputElement
fireEvent.change(path, { target: { value: '/tmp/fresh' } })
fireEvent.click(screen.getByRole('button', { name: 'Open Folder' }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.getByRole('button', { name: '项目目录' }).textContent).toContain('fresh')
})
it('Create new opens the modal and createWorkspaceSession succeeds', async () => {
const { useSessions } = fakeSessions([])
const createWorkspaceSession = vi.fn(() => Promise.resolve())
render(
<EmptyState
useSessions={useSessions}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeTruthy()
const name = screen.getByLabelText('Workspace name') as HTMLInputElement
expect(name.value).toBe('New WorkSpace')
fireEvent.change(name, { target: { value: 'My Proj' } })
fireEvent.keyDown(name, { key: 'Enter' })
await vi.waitFor(() => expect(createWorkspaceSession).toHaveBeenCalledWith('My Proj'))
})
it('Create modal Cancel dismisses without calling createWorkspaceSession', () => {
const { useSessions } = fakeSessions([])
const createWorkspaceSession = vi.fn(() => Promise.resolve())
render(
<EmptyState
useSessions={useSessions}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(createWorkspaceSession).not.toHaveBeenCalled()
})
})

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-ui-primitives
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
## Markdown rendering

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-primitives",
"description": "Pure React atoms for the dsh web UI: StateDot, ic_ds_* icon set, Button/Pill/Menu/Input, markdown family (zero cordis)",
"description": "Pure React atoms for the dsh web UI: StateDot, ic_ds_* icon set, Button/Pill/Menu/Modal/Input, markdown family (zero cordis)",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -56,6 +56,20 @@
background: var(--dsw-alias-interactive-bg-active);
}
/* Dialog Cancel (figma 451:18655): bordered capsule on transparent fill. */
.outline {
border: 1px solid var(--dsw-alias-border-l2);
background: transparent;
}
.outline:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.outline:disabled {
border-color: var(--dsw-alias-border-l1);
}
.toolbar {
background: var(--dsw-alias-button-tool-bar-fill);
}

View File

@@ -6,7 +6,7 @@ import clsx from 'clsx'
import css from './Button.module.css'
/** Visual variant, each backed by its --dsw-alias-button-* token family. */
export type ButtonVariant = 'primary' | 'ghost' | 'toolbar'
export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'toolbar'
/**
* Render a button.

View File

@@ -3,21 +3,32 @@
display: inline-flex;
}
/* Dropdown card (figma MenuDropdown 122:10096): white card, r12, no border,
* layered drop shadows via the shadow token, 4px inset padding. */
/* Dropdown card (figma MenuDropdown 122:9481 / 419:16920): menu surface,
* r12, inverted hairline border, shadow-lv3, 4px inset padding. */
.list,
.submenu {
padding: 4px;
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px;
background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3);
}
.list {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 100;
min-width: 130px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 0;
border-radius: 12px;
background: var(--dsw-alias-bg-layer-1);
box-shadow: var(--dsw-shadow-lv2);
}
/* Open above the anchor (empty-state workspace chip: figma 122:9481). */
.sideTop {
top: auto;
bottom: calc(100% + 4px);
}
.alignEnd {
@@ -25,12 +36,18 @@
right: 0;
}
/* Menu cell (figma .Menu_cell 27:5169): r10, pad 10/8, 14/22 primary text,
.itemWrap {
position: relative;
}
/* Menu cell (figma .Menu_cell): min-h 40, r10, pad 10/8, 14/22 primary,
* gap 8 between leading icon / label / trailing check. */
.item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 40px;
padding: 8px 10px;
border: none;
border-radius: 10px;
@@ -51,9 +68,22 @@
cursor: not-allowed;
}
.itemIcon {
display: inline-flex;
flex: none;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
color: var(--dsw-alias-label-tertiary);
}
.itemLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.check {
@@ -66,3 +96,34 @@
.selected {
background: transparent;
}
/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */
.separator {
height: 1px;
margin: 4px 2px;
background: var(--dsw-alias-border-l1);
}
/* Nested card to the right of the parent row (figma 419:16920).
* Bottom-aligned with the parent menu card (grows upward): itemWrap sits in
* .list's 4px pad, so bottom: -4px matches the list's outer bottom edge.
* Horizontal: list pad (4px) + 6px card gap = 10px past itemWrap — plain
* `100% + 6px` collapses to ~2px between outer card edges.
* ::before bridges the full gap so the pointer can cross without mouseLeave. */
.submenu {
position: absolute;
top: auto;
bottom: -4px;
left: calc(100% + 10px);
z-index: 101;
min-width: 160px;
}
.submenu::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -10px;
width: 10px;
}

View File

@@ -1,46 +1,69 @@
// Menu: minimal controlled dropdown (group-by pickers, project selectors).
// Pure CSS positioning relative to the anchor wrapper — no portal, no popper.
// The owner controls `open`; outside-click closing uses one document listener
// active only while open.
// active only while open. Submenus open on hover/focus inside the same root.
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16 } from './icons/index.tsx'
import css from './Menu.module.css'
/** One selectable menu row. */
/** Selectable row (optionally with a nested submenu). */
export interface MenuItem {
id: string
label: ReactNode
disabled?: boolean
/** Leading icon (figma .Menu_cell gap 8). */
icon?: ReactNode
/** Nested card opened to the right on hover/focus. */
submenu?: readonly MenuItem[]
}
/** Hairline between item groups (not selectable). */
export interface MenuSeparator {
type: 'separator'
id: string
}
/** One primary-menu entry: a row or a separator. */
export type MenuEntry = MenuItem | MenuSeparator
function isSeparator(entry: MenuEntry): entry is MenuSeparator {
return 'type' in entry && entry.type === 'separator'
}
/**
* Render an anchored dropdown menu.
* @param props.open - whether the list is showing (owner-controlled).
* @param props.anchor - the trigger element (rendered in place).
* @param props.items - selectable rows.
* @param props.items - selectable rows and optional separators.
* @param props.selectedId - row shown as selected.
* @param props.onSelect - row click callback (not called for disabled rows).
* @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children).
* @param props.onClose - invoked on outside click or Escape.
* @param props.align - list alignment against the anchor (default 'start').
* @param props.side - open below (`bottom`, default) or above (`top`) the anchor.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', className }: {
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', className }: {
open: boolean
anchor: ReactNode
items: readonly MenuItem[]
items: readonly MenuEntry[]
selectedId?: string
onSelect: (id: string) => void
onClose: () => void
align?: 'start' | 'end'
side?: 'bottom' | 'top'
className?: string
}) {
const rootRef = useRef<HTMLSpanElement>(null)
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null)
useEffect(() => {
if (!open) return
if (!open) {
setOpenSubmenuId(null)
return
}
const onPointerDown = (e: PointerEvent) => {
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) onClose()
}
@@ -59,21 +82,61 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
<span ref={rootRef} className={clsx(css.root, className)}>
{anchor}
{open && (
<div className={clsx(css.list, align === 'end' && css.alignEnd)} role="menu">
{items.map(item => (
<button
key={item.id}
type="button"
role="menuitem"
className={clsx(css.item, item.id === selectedId && css.selected)}
disabled={item.disabled}
onClick={() => onSelect(item.id)}
>
<span className={css.itemLabel}>{item.label}</span>
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
{item.id === selectedId && <IconCheckOutline16 className={css.check} />}
</button>
))}
<div className={clsx(css.list, side === 'top' && css.sideTop, align === 'end' && css.alignEnd)} role="menu">
{items.map(entry => {
if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" />
}
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
const subOpen = hasSub && openSubmenuId === entry.id
return (
<div
key={entry.id}
className={css.itemWrap}
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onMouseLeave={() => { setOpenSubmenuId(null) }}
>
<button
type="button"
role="menuitem"
className={clsx(css.item, entry.id === selectedId && css.selected)}
disabled={entry.disabled}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? subOpen : undefined}
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onClick={() => {
if (hasSub) {
setOpenSubmenuId(entry.id)
return
}
onSelect(entry.id)
}}
>
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
<span className={css.itemLabel}>{entry.label}</span>
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<div className={css.submenu} role="menu">
{entry.submenu.map(sub => (
<button
key={sub.id}
type="button"
role="menuitem"
className={css.item}
disabled={sub.disabled}
onClick={() => { onSelect(sub.id) }}
>
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
<span className={css.itemLabel}>{sub.label}</span>
</button>
))}
</div>
)}
</div>
)
})}
</div>
)}
</span>

View File

@@ -0,0 +1,79 @@
/* Full-viewport layer (figma Mask + Dialog 451:18655): mask + centered card. */
.root {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
/* User/spec mask: rgba(0,0,0,0.24) + blur(2px) via --dsw-alias-bg-mask-1 /
--dsw-mask-blur (light); dark theme raises mask opacity. */
.mask {
position: absolute;
inset: 0;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: var(--dsw-mask-blur);
}
/* Dialog card: r24, shadow-lv3, layer-2 fill, inverted border, pb 24. */
.dialog {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 20px;
width: min(380px, 100%);
padding: 0 0 24px;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 24px;
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv3);
}
.content {
display: flex;
flex-direction: column;
width: 100%;
}
/* Header pad (figma Title row): pt 22 / pl 24 / pr 14 / pb 12. */
.header {
display: flex;
flex-direction: column;
gap: 8px;
padding: 22px 14px 12px 24px;
}
.title {
margin: 0;
font-size: 16px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.description {
margin: 0;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-secondary);
}
.body {
display: flex;
flex-direction: column;
min-width: 0;
padding: 0 24px;
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 0 24px;
}

View File

@@ -0,0 +1,62 @@
// Modal: controlled full-viewport dialog (create-workspace and similar).
// Fixed overlay in the React tree (no react-dom portal) so ui-primitives
// stays free of a react-dom dependency; mask tokens match figma 451:18655.
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import css from './Modal.module.css'
/**
* Render a centered modal over a blurred page mask.
* @param props.open - whether the dialog is showing.
* @param props.onClose - Escape or mask click.
* @param props.title - dialog heading.
* @param props.description - optional supporting sentence under the title.
* @param props.children - body (inputs, etc.).
* @param props.footer - action row (Cancel / Create).
* @returns null when closed; otherwise the overlay tree.
*/
export function Modal({ open, onClose, title, description, children, footer, className }: {
open: boolean
onClose: () => void
title: string
description?: string
children?: ReactNode
footer?: ReactNode
className?: string
}) {
useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => { document.removeEventListener('keydown', onKeyDown) }
}, [open, onClose])
if (!open) return null
return (
<div className={css.root} role="presentation">
<div className={css.mask} aria-hidden="true" onClick={onClose} />
<div
className={clsx(css.dialog, className)}
role="dialog"
aria-modal="true"
aria-label={title}
>
<div className={css.content}>
<div className={css.header}>
<h2 className={css.title}>{title}</h2>
{description !== undefined && description !== '' && (
<p className={css.description}>{description}</p>
)}
</div>
{children !== undefined && <div className={css.body}>{children}</div>}
</div>
{footer !== undefined && <div className={css.footer}>{footer}</div>}
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
/**
* Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Input,
* Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Modal/Input,
* markdown family, ConnectionBanner. Everything consumes props plus --dsw-*
* token vars only. Contract: api-contracts v3 section 8.
*/
@@ -11,7 +11,8 @@ export type { ButtonVariant } from './Button.tsx'
export { Pill } from './Pill.tsx'
export { Input } from './Input.tsx'
export { Menu } from './Menu.tsx'
export type { MenuItem } from './Menu.tsx'
export type { MenuEntry, MenuItem, MenuSeparator } from './Menu.tsx'
export { Modal } from './Modal.tsx'
export { ConnectionBanner } from './ConnectionBanner.tsx'
export { FishLogo } from './FishLogo.tsx'
export { BrandWordmark } from './BrandWordmark.tsx'

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Button, ConnectionBanner, Input, Menu, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
@@ -21,6 +21,11 @@ describe('Button', () => {
fireEvent.click(screen.getByRole('button'))
expect(onClick).not.toHaveBeenCalled()
})
it('outline variant renders a bordered cancel-style button', () => {
render(<Button variant="outline">Cancel</Button>)
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined()
})
})
describe('Pill', () => {
@@ -91,11 +96,12 @@ describe('Menu', () => {
expect(onClose).not.toHaveBeenCalled()
})
it('selected item shows the trailing check; align=end and className apply', () => {
it('selected item shows the trailing check; align=end, side=top, and className apply', () => {
const { container } = render(
<Menu
open
align="end"
side="top"
className="x"
anchor={<span>trigger</span>}
items={items}
@@ -104,12 +110,89 @@ describe('Menu', () => {
onClose={() => {}}
/>)
expect((container.firstElementChild as HTMLElement).classList.contains('x')).toBe(true)
const menu = screen.getByRole('menu')
expect(menu.className).toMatch(/sideTop|alignEnd/)
const selected = screen.getByRole('menuitem', { name: 'Alpha' })
expect(selected.querySelector('svg')).not.toBeNull()
const other = screen.getByRole('menuitem', { name: 'Beta' })
expect(other.querySelector('svg')).toBeNull()
fireEvent.keyDown(document, { key: 'a' })
})
it('renders a leading icon and a separator between groups', () => {
render(
<Menu
open
anchor={<span>trigger</span>}
items={[
{ id: 'a', label: 'Alpha', icon: <svg data-testid="ic" /> },
{ type: 'separator', id: 's1' },
{ id: 'c', label: 'Create' },
]}
onSelect={() => {}}
onClose={() => {}}
/>)
expect(screen.getByTestId('ic')).toBeDefined()
expect(screen.getByRole('separator')).toBeDefined()
})
it('opens a submenu on hover and selects a nested item', () => {
const onSelect = vi.fn()
render(
<Menu
open
anchor={<span>trigger</span>}
items={[
{ id: 'plain', label: 'Plain' },
{
id: 'new',
label: 'New Workspace',
submenu: [
{ id: 'ok', label: 'Create ok', icon: <svg data-testid="sub-ic" /> },
],
},
]}
onSelect={onSelect}
onClose={() => {}}
/>)
const plain = screen.getByRole('menuitem', { name: 'Plain' })
fireEvent.mouseEnter(plain.parentElement as HTMLElement)
fireEvent.focus(plain)
const parent = screen.getByRole('menuitem', { name: 'New Workspace' })
const wrap = parent.parentElement as HTMLElement
fireEvent.click(parent)
expect(onSelect).not.toHaveBeenCalled()
fireEvent.focus(parent)
fireEvent.mouseEnter(wrap)
expect(screen.getByTestId('sub-ic')).toBeDefined()
fireEvent.click(screen.getByRole('menuitem', { name: 'Create ok' }))
expect(onSelect).toHaveBeenCalledWith('ok')
fireEvent.mouseLeave(wrap)
expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull()
})
})
describe('Modal', () => {
it('is absent while closed; Escape and mask click call onClose', () => {
const onClose = vi.fn()
const { rerender } = render(
<Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
expect(screen.queryByRole('dialog')).toBeNull()
rerender(
<Modal open onClose={onClose} title="Create new workspace" description="Name it." footer={<button type="button">Create</button>}>
<input aria-label="name" />
</Modal>)
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
expect(screen.getByText('Name it.')).toBeDefined()
fireEvent.keyDown(document, { key: 'a' })
expect(onClose).not.toHaveBeenCalled()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
// Mask is the presentation sibling behind the dialog.
const mask = document.querySelector('[aria-hidden="true"]') as HTMLElement
fireEvent.click(mask)
expect(onClose).toHaveBeenCalledTimes(2)
})
})
describe('ConnectionBanner', () => {

View File

@@ -4,7 +4,7 @@
*/
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import { mkdir, stat } from 'node:fs/promises'
import type { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
@@ -407,8 +407,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const sessionId = `session-${randomUUID()}` as SessionId
// A session's cwd is its project path. When the creator does not choose
// one, the default project is the host-level default (the host process
// working directory unless boot overrides it).
// working directory unless boot overrides it). Ensure the directory
// exists so Create-workspace and typed paths land on a real folder.
const cwd = request.payload.cwd ?? defaults.cwd
try {
await mkdir(cwd, { recursive: true })
} catch (error: unknown) {
return err(request, {
code: 'internal',
message: `failed to ensure project directory "${cwd}": ${String(error)}`,
details: {},
})
}
const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })
return ok(request, { sessionId: handle.agent.id })
},

View File

@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -231,6 +231,29 @@ describe('sessions.create / list', () => {
expect(first?.running).toBe(false)
expect(first?.parentSessionId).toBeUndefined()
})
it('ensures a missing project directory before minting the session', async () => {
const { api } = await boot()
const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-'))
const cwd = join(root, 'nested', 'workspace')
expect(existsSync(cwd)).toBe(false)
const { sessionId } = expectOk(await api.sessions.create(request({ cwd })))
expect(existsSync(cwd)).toBe(true)
const { items } = expectOk(await api.sessions.list(request({})))
expect(items.find(item => item.sessionId === sessionId)?.cwd).toBe(cwd)
})
it('fails loud when the project directory cannot be created', async () => {
const { api } = await boot()
const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-fail-'))
const blocker = join(root, 'file-not-dir')
writeFileSync(blocker, 'x')
const response = await api.sessions.create(request({ cwd: join(blocker, 'child') }))
expect(response.result.ok).toBe(false)
if (response.result.ok) throw new Error('expected mkdir failure')
expect(response.result.error.code).toBe('internal')
expect(response.result.error.message).toMatch(/failed to ensure project directory/)
})
})
describe('sessions.prompt / cancel', () => {