From 6f5321cb37d7f456381533c15c3ec1ba2046e8ef Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 16:09:58 +0800 Subject: [PATCH] feat: workspace select menu --- ...ew-session-clears-to-empty-state.i18n.yaml | 4 +- ...07-24-new-session-clears-to-empty-state.md | 2 +- ...24-new-session-clears-to-empty-state.zh.md | 2 +- .../runtime/src/client/sessions/service.ts | 23 +- .../runtime/tests/sessions-service.spec.ts | 21 ++ .../ui-conversation/src/client/apply.ts | 4 + .../src/client/contract/slots.ts | 5 + .../src/client/skeleton/EmptyState.module.css | 87 ++++-- .../src/client/skeleton/EmptyState.tsx | 256 +++++++++++++----- .../src/client/skeleton/InputBar.module.css | 58 ++-- .../src/client/skeleton/InputBar.tsx | 10 +- .../tests/apply-inject.spec.tsx | 9 +- .../tests/skeleton-branches.spec.tsx | 38 ++- .../ui-conversation/tests/skeleton.spec.tsx | 76 +++++- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/package.json | 2 +- .../ui-primitives/src/Button.module.css | 14 + packages/client/ui-primitives/src/Button.tsx | 2 +- .../client/ui-primitives/src/Menu.module.css | 81 +++++- packages/client/ui-primitives/src/Menu.tsx | 109 ++++++-- .../client/ui-primitives/src/Modal.module.css | 79 ++++++ packages/client/ui-primitives/src/Modal.tsx | 62 +++++ packages/client/ui-primitives/src/index.ts | 5 +- .../client/ui-primitives/tests/atoms.spec.tsx | 87 +++++- packages/host/runtime/src/api-proxy.ts | 14 +- .../host/runtime/tests/host-runtime.spec.ts | 25 +- 26 files changed, 890 insertions(+), 187 deletions(-) create mode 100644 packages/client/ui-primitives/src/Modal.module.css create mode 100644 packages/client/ui-primitives/src/Modal.tsx diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml index 5f41ac4b70..4b7354a322 100644 --- a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md index d730f3e0b6..1605f44a05 100644 --- a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md @@ -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 `` state only — host plan, access, and model seams remain unwired. diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md index 602a2b774b..1f78d99bab 100644 --- a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md @@ -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 / 模型),仅用本地原生 `` 状态——host 侧的 plan、access、model 接缝仍未接线。 diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 324c574474..d8a6f05762 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -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 { + 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. diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 33cfce43bb..8c850426bd 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -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() diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 8c4a6dc6a1..372eb36c80 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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) } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index baa26683ec..ffbc13ff59 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -164,6 +164,11 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & export interface EmptyStateInjected { /** The create → navigate → first-send chain, in one service call. */ startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise + /** + * Create a workspace folder under the host cwd, mint a session there, and + * open it (Create-new modal success path). + */ + createWorkspaceSession(name: string): Promise } /** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */ diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css index 53e618b122..bc5d9d62d0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css @@ -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); +} diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx index edcf0cbad2..b112dfa432 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx @@ -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(null) + const [pathDraft, setPathDraft] = useState('') + const [workspaceName, setWorkspaceName] = useState('New WorkSpace') + const [creating, setCreating] = useState(false) + const [modalError, setModalError] = useState(null) const [sending, setSending] = useState(false) const [error, setError] = useState(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: , + })), + ...(cwds.length > 0 ? [{ type: 'separator' as const, id: 'sep-new' }] : []), + { + id: NEW_WORKSPACE, + label: 'New Workspace', + icon: , + 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 - ? ( - { setCwd(e.target.value) }} - /> - ) - : ( - { 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={( - - )} - /> - ) + 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 (
@@ -138,7 +157,8 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
{/* 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. */} -
{workspace}
+
+ { 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={( + + )} + /> +
+ + + + + )} + > + { setPathDraft(e.target.value) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + confirmPath() + } + }} + /> + + + + + + )} + > + { setWorkspaceName(e.target.value) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + confirmCreate() + } + }} + /> + {modalError !== null &&
{modalError}
} +
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 04e4f661c5..7161a31931 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -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 , 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; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index f6454071b3..04d1dd867d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -175,8 +175,10 @@ export function InputBar({ > - {renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)} - {renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)} +
+ {renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)} + {renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)} +
{renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)} @@ -190,11 +192,11 @@ export function InputBar({ onClick={onPrimary} > {running ? ( - + ) : ( - + )} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 3043c71ed5..edd9f7d54d 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -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 () => { diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index b93b9c0b72..d1bd50437f 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -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( - , + , ) 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( - , + , ) 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( + 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')) + }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b2636bfd4b..a598803a25 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -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((_res, rej) => { reject = rej })) - render() + render( + , + ) 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( Promise.resolve()} />) + render( + 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( + 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( + 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() }) }) diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index da6382be4b..5b158c453a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -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 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index fda27fdd4d..44c35eb517 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -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", diff --git a/packages/client/ui-primitives/src/Button.module.css b/packages/client/ui-primitives/src/Button.module.css index 1cb3b18194..3f415b5d15 100644 --- a/packages/client/ui-primitives/src/Button.module.css +++ b/packages/client/ui-primitives/src/Button.module.css @@ -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); } diff --git a/packages/client/ui-primitives/src/Button.tsx b/packages/client/ui-primitives/src/Button.tsx index 028c1fc266..642372868a 100644 --- a/packages/client/ui-primitives/src/Button.tsx +++ b/packages/client/ui-primitives/src/Button.tsx @@ -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. diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 3e3bf85299..3cbcb62d29 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -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; +} diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index ad45acc221..0b07c26357 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -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(null) + const [openSubmenuId, setOpenSubmenuId] = useState(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 {anchor} {open && ( -
- {items.map(item => ( - - ))} +
+ {items.map(entry => { + if (isSeparator(entry)) { + return
+ } + const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 + const subOpen = hasSub && openSubmenuId === entry.id + return ( +
{ setOpenSubmenuId(hasSub ? entry.id : null) }} + onMouseLeave={() => { setOpenSubmenuId(null) }} + > + + {subOpen && entry.submenu !== undefined && ( +
+ {entry.submenu.map(sub => ( + + ))} +
+ )} +
+ ) + })}
)} diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css new file mode 100644 index 0000000000..49026f7a5f --- /dev/null +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -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; +} diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx new file mode 100644 index 0000000000..cdbe1060bf --- /dev/null +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -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 ( +
+ + ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index e5e2e4e88f..0d6cde4ed0 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -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' diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index f259cb334a..a4b286ced7 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -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() + 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( trigger} 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( + trigger} + items={[ + { id: 'a', label: 'Alpha', icon: }, + { 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( + trigger} + items={[ + { id: 'plain', label: 'Plain' }, + { + id: 'new', + label: 'New Workspace', + submenu: [ + { id: 'ok', label: 'Create ok', icon: }, + ], + }, + ]} + 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( + body) + expect(screen.queryByRole('dialog')).toBeNull() + rerender( + Create}> + + ) + 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', () => { diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 2d674912cd..3792d17da3 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -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 }) }, diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index c30e07b50b..4058fdfe2e 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -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', () => {