feat: workspace select menu
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# 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.md: 1605f44a05d0f59b61fe95cb5b03a0f9f5c3d4ab
|
||||||
2026-07-24-new-session-clears-to-empty-state.zh.md: 602a2b774b569cfef0adcc253fe751f86b14f895
|
2026-07-24-new-session-clears-to-empty-state.zh.md: 1f78d99babc33d30ee1300bfa6bf048a78e7132e
|
||||||
|
|||||||
@@ -20,4 +20,4 @@ Sidebar "New Session" created and opened a blank session immediately, so the cen
|
|||||||
|
|
||||||
## Consequences
|
## 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.
|
||||||
|
|||||||
@@ -20,4 +20,4 @@ Status: implemented
|
|||||||
|
|
||||||
## Consequences
|
## 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 圆角,并打开 MenuDropdown(figma 122:9481;`--dsw-specific-menu` + `--dsw-shadow-lv3`):带文件夹图标与尾随勾选的 basename 行,分隔线后是 "New Workspace",其子菜单(figma 419:16920)提供 "Use a existing folder" 与 "Create new"。Use a existing folder 打开路径 Dialog(figma 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` 绘制底栏 chrome(attach / Plan / Read-only / model),仅用本地原生 `<select>` 状态——host 侧的 plan、access、model 接缝仍未接线。
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export class SessionsService {
|
|||||||
* @param ctx - client root context (scope fibers mount under it).
|
* @param ctx - client root context (scope fibers mount under it).
|
||||||
* @param api - wire client shared with every Session.
|
* @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.manager = new SessionManager(api)
|
||||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||||
{},
|
{},
|
||||||
@@ -171,6 +171,27 @@ export class SessionsService {
|
|||||||
return result.value.sessionId
|
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).
|
* Resolve a session-scoped context view (use-and-discard).
|
||||||
* @param id - session id.
|
* @param id - session id.
|
||||||
|
|||||||
@@ -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)', () => {
|
describe('coverage tails (branch duals)', () => {
|
||||||
it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
|
it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
|
||||||
const b = bench()
|
const b = bench()
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ export function apply(ctx: Context): void {
|
|||||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
||||||
return conversation.startSession(opts)
|
return conversation.startSession(opts)
|
||||||
},
|
},
|
||||||
|
createWorkspaceSession: async (name) => {
|
||||||
|
const id = await sessions.createWorkspace(name)
|
||||||
|
sessions.open(id)
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}, EmptyState)
|
}, EmptyState)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,11 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
|
|||||||
export interface EmptyStateInjected {
|
export interface EmptyStateInjected {
|
||||||
/** The create → navigate → first-send chain, in one service call. */
|
/** The create → navigate → first-send chain, in one service call. */
|
||||||
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
|
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). */
|
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* NEW SESSION hero (figma 313:14149): fish + title, workspace chip above the
|
/* NEW SESSION hero (figma Input_Bottom 75:8208): fish + title, workspace chip
|
||||||
shared InputBar card. The input itself is InputBar — only stack geometry
|
above the shared InputBar card. The input itself is InputBar — only stack
|
||||||
lives here. */
|
geometry and the chip live here. */
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -11,23 +11,26 @@
|
|||||||
padding: 24px;
|
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 {
|
.stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 40px;
|
/* figma 75:8208: 12 between title block / workspace / card. */
|
||||||
|
gap: 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 776px;
|
max-width: 800px;
|
||||||
overflow: visible;
|
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 {
|
.headline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
padding-bottom: 36px;
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
line-height: 32px;
|
line-height: 32px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -68,38 +71,43 @@
|
|||||||
z-index: 1;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
/* Align with InputBar's left chrome (card pad 10 + attach). */
|
/* figma 75:8208 workspace row: px 8 above the card. */
|
||||||
padding-left: 10px;
|
padding-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Folder + "New Workspace" + chevron (figma workspace trigger). */
|
/* Folder + label + chevron — transparent at rest; fill only on hover / open. */
|
||||||
.workspace {
|
.workspace {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 28px;
|
min-height: 28px;
|
||||||
padding: 0 4px 0 0;
|
padding: 0 8px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--dsw-alias-label-primary);
|
color: var(--dsw-alias-label-primary);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace:hover {
|
.workspace:hover,
|
||||||
|
.workspace[aria-expanded='true'] {
|
||||||
background: var(--dsw-alias-interactive-bg-hover);
|
background: var(--dsw-alias-interactive-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder {
|
.folder {
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspaceLabel {
|
.workspaceLabel {
|
||||||
@@ -113,19 +121,44 @@
|
|||||||
color: var(--dsw-alias-label-caption);
|
color: var(--dsw-alias-label-caption);
|
||||||
}
|
}
|
||||||
|
|
||||||
.customInput {
|
/* Workspace menu width tracks the longest basename in the Figma frame. */
|
||||||
width: min(320px, 100%);
|
.workspaceMenu :global([role='menu']) {
|
||||||
height: 28px;
|
min-width: 240px;
|
||||||
padding: 0 10px;
|
}
|
||||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
|
||||||
border-radius: 8px;
|
/* 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;
|
outline: none;
|
||||||
background: var(--dsw-alias-bg-base);
|
background: transparent;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 20px;
|
line-height: 24px;
|
||||||
color: var(--dsw-alias-label-primary);
|
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);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
// EmptyState (figma NEW SESSION screen): centered hero — fish + title,
|
// EmptyState (figma NEW SESSION screen): centered hero — fish + title,
|
||||||
// workspace picker row, then the SAME InputBar the resident composer uses
|
// workspace picker row (MenuDropdown 122:9481 + New Workspace submenu
|
||||||
// (empty→content is a position move, never a swap). Project picker: cwd set
|
// 419:16920 + Dialog 451:18655), then the SAME InputBar the resident
|
||||||
// derived in-component from useSessions plus a free-form new-directory path;
|
// composer uses (empty→content is a position move, never a swap). Project
|
||||||
// submit runs startSession (create → open → send).
|
// options derive in-component from useSessions; Create new runs
|
||||||
|
// createWorkspaceSession (host mkdir + session.create + open).
|
||||||
|
|
||||||
import { useId, useMemo, useState } from 'react'
|
import { useId, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
FishLogo,
|
FishLogo,
|
||||||
IconChevronDownOutline14,
|
IconChevronDownOutline14,
|
||||||
|
IconFolderClose16,
|
||||||
IconFolderOpen16,
|
IconFolderOpen16,
|
||||||
|
IconPlusOutline16,
|
||||||
Menu,
|
Menu,
|
||||||
type MenuItem,
|
Modal,
|
||||||
|
type MenuEntry,
|
||||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
import type { EmptyStateSlotProps } from '../contract/slots.ts'
|
import type { EmptyStateSlotProps } from '../contract/slots.ts'
|
||||||
@@ -18,10 +23,15 @@ import { InputBar } from './InputBar.tsx'
|
|||||||
import type { InputBarError } from './InputBar.tsx'
|
import type { InputBarError } from './InputBar.tsx'
|
||||||
import css from './EmptyState.module.css'
|
import css from './EmptyState.module.css'
|
||||||
|
|
||||||
/** Menu id for the free-form directory entry (not a filesystem path). */
|
/** Menu id for "New Workspace" (opens submenu; not a cwd). */
|
||||||
const NEW_DIR = '::new-directory'
|
const NEW_WORKSPACE = '::new-workspace'
|
||||||
/** Menu id for the host default project directory (empty cwd on create). */
|
/** Submenu: path modal (figma 451:18655 copy). */
|
||||||
const DEFAULT_DIR = '::default'
|
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). */
|
/** Full props composed by reference from the contract (runtime share & injected share; no store). */
|
||||||
export type EmptyStateProps = EmptyStateSlotProps
|
export type EmptyStateProps = EmptyStateSlotProps
|
||||||
@@ -36,22 +46,26 @@ function deriveCwds(state: SessionListState): readonly string[] {
|
|||||||
return [...seen]
|
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 {
|
function workspaceLabel(cwd: string): string {
|
||||||
if (cwd === '') return 'New Workspace'
|
if (cwd === '') return 'New Workspace'
|
||||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||||
return base !== undefined && base !== '' ? base : cwd
|
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 list = useSessions(s => s)
|
||||||
const cwds = useMemo(() => deriveCwds(list), [list])
|
const cwds = useMemo(() => deriveCwds(list), [list])
|
||||||
// Local viewing state: the empty state owns no session, so its draft is
|
// 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).
|
// ephemeral by design (drafts are keyed by session id; there is none yet).
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
const [cwd, setCwd] = useState('')
|
const [cwd, setCwd] = useState('')
|
||||||
const [custom, setCustom] = useState(false)
|
|
||||||
const [menuOpen, setMenuOpen] = 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 [sending, setSending] = useState(false)
|
||||||
const [error, setError] = useState<InputBarError | null>(null)
|
const [error, setError] = useState<InputBarError | null>(null)
|
||||||
// Stable filter id so multiple EmptyState mounts do not collide in the DOM.
|
// 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.
|
// Success needs no cleanup: the session selection swaps this slot out for the session body.
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: MenuItem[] = [
|
const items: MenuEntry[] = [
|
||||||
{ id: DEFAULT_DIR, label: 'Default directory' },
|
...cwds.map(c => ({
|
||||||
...cwds.map(c => ({ id: c, label: c })),
|
id: c,
|
||||||
{ id: NEW_DIR, label: 'New directory…' },
|
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
|
const closeModal = (): void => {
|
||||||
? (
|
if (creating) return
|
||||||
<input
|
setModalKind(null)
|
||||||
className={css.customInput}
|
setModalError(null)
|
||||||
value={cwd}
|
}
|
||||||
autoFocus
|
|
||||||
aria-label="项目目录"
|
const openPathModal = (): void => {
|
||||||
placeholder="Directory path, e.g. /home/me/proj"
|
setPathDraft(cwd)
|
||||||
onChange={(e) => { setCwd(e.target.value) }}
|
setModalError(null)
|
||||||
/>
|
setModalKind('path')
|
||||||
)
|
}
|
||||||
: (
|
|
||||||
<Menu
|
const openCreateModal = (): void => {
|
||||||
open={menuOpen}
|
setWorkspaceName('New WorkSpace')
|
||||||
onClose={() => { setMenuOpen(false) }}
|
setModalError(null)
|
||||||
selectedId={selectedId}
|
setModalKind('create')
|
||||||
items={items}
|
}
|
||||||
onSelect={(id) => {
|
|
||||||
if (id === NEW_DIR) {
|
const confirmPath = (): void => {
|
||||||
setCustom(true)
|
const next = pathDraft.trim()
|
||||||
setCwd('')
|
if (next === '') return
|
||||||
} else if (id === DEFAULT_DIR) {
|
setCwd(next)
|
||||||
setCustom(false)
|
setModalKind(null)
|
||||||
setCwd('')
|
}
|
||||||
} else {
|
|
||||||
setCustom(false)
|
const confirmCreate = (): void => {
|
||||||
setCwd(id)
|
if (creating) return
|
||||||
}
|
setCreating(true)
|
||||||
setMenuOpen(false)
|
setModalError(null)
|
||||||
}}
|
createWorkspaceSession(workspaceName)
|
||||||
anchor={(
|
.catch((reason: unknown) => {
|
||||||
<button
|
setModalError(reason instanceof Error ? reason.message : String(reason))
|
||||||
type="button"
|
setCreating(false)
|
||||||
className={css.workspace}
|
})
|
||||||
aria-label="项目目录"
|
// Success swaps this slot out for the new session body — no local cleanup.
|
||||||
aria-haspopup="menu"
|
}
|
||||||
aria-expanded={menuOpen}
|
|
||||||
onClick={() => { setMenuOpen(!menuOpen) }}
|
const modalBusy = creating
|
||||||
>
|
const isPath = modalKind === 'path'
|
||||||
<IconFolderOpen16 className={css.folder} size={16} />
|
const isCreate = modalKind === 'create'
|
||||||
<span className={css.workspaceLabel}>{workspaceLabel(cwd)}</span>
|
|
||||||
<IconChevronDownOutline14 className={css.chevron} size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.root}>
|
<div className={css.root}>
|
||||||
@@ -138,7 +157,8 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className={css.body}>
|
<div className={css.body}>
|
||||||
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width
|
{/* 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">
|
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<filter
|
<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" />
|
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</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
|
<InputBar
|
||||||
draft={draft}
|
draft={draft}
|
||||||
running={false}
|
running={false}
|
||||||
@@ -174,6 +231,75 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
viewport bottom inside the centered message column; textarea on top, action
|
||||||
row below, one primary circle button bottom-right. Input width rides the
|
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
|
the center column keeping its padding). Hero variant = the same card
|
||||||
centered in the empty state; the transition between the two is a position
|
centered in the empty state; the transition between the two is a position
|
||||||
move of one component. */
|
move of one component. */
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
/* figma Input_Bottom 34:11445: pad L32/R32/B12; the bottom gradient mask is
|
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
|
||||||
owned by the chat scroller. Top 8 hosts the error strip's breathing room. */
|
the chat scroller. Top 8 hosts the error strip's breathing room. */
|
||||||
padding: 8px 32px 12px;
|
padding: 8px 32px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
.error {
|
.error {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 776px;
|
max-width: 800px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -34,10 +34,12 @@
|
|||||||
.card {
|
.card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
gap: 12px;
|
||||||
width: 100%;
|
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
|
/* 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
|
the input border is one notch weaker than buttons) — exactly the
|
||||||
l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */
|
l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */
|
||||||
@@ -49,11 +51,6 @@
|
|||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* New-session state rounds up (figma: r24 and a taller box). */
|
|
||||||
.hero .card {
|
|
||||||
border-radius: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accessory {
|
.accessory {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -85,7 +82,8 @@
|
|||||||
|
|
||||||
.input,
|
.input,
|
||||||
.mirror {
|
.mirror {
|
||||||
padding: 12px 16px 0;
|
/* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */
|
||||||
|
padding: 4px 12px 0 16px;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
@@ -108,17 +106,12 @@
|
|||||||
.mirror {
|
.mirror {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
/* 2-line floor: 2 × 24px line + 12px top padding; 14-line cap (336px). */
|
/* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */
|
||||||
min-height: 60px;
|
min-height: 52px;
|
||||||
max-height: 336px;
|
max-height: 336px;
|
||||||
overflow: hidden;
|
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
|
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
|
||||||
(figma Input_Bottom chrome). */
|
(figma Input_Bottom chrome). */
|
||||||
.row {
|
.row {
|
||||||
@@ -131,16 +124,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tools,
|
.tools,
|
||||||
|
.modes,
|
||||||
.trailing {
|
.trailing {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* figma 75:8208: 16 between + and the mode chips; 4 between Plan / Read-only. */
|
||||||
|
.tools {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modes {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.trailing {
|
.trailing {
|
||||||
flex: none;
|
flex: none;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Attach circle (figma + control): 28px, selector fill, primary glyph. */
|
/* Attach circle (figma + control): 28px, selector fill, primary glyph. */
|
||||||
@@ -166,22 +168,24 @@
|
|||||||
cursor: default;
|
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 {
|
.select {
|
||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
padding: 0 22px 0 6px;
|
padding: 0 20px 0 8px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
outline: none;
|
outline: none;
|
||||||
background-color: transparent;
|
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-repeat: no-repeat;
|
||||||
background-position: right 4px center;
|
background-position: right 4px center;
|
||||||
background-size: 14px 14px;
|
background-size: 12px 12px;
|
||||||
color: var(--dsw-alias-label-secondary);
|
color: var(--dsw-alias-label-secondary);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
|||||||
@@ -175,8 +175,10 @@ export function InputBar({
|
|||||||
>
|
>
|
||||||
<IconPlusOutline16 size={14} />
|
<IconPlusOutline16 size={14} />
|
||||||
</button>
|
</button>
|
||||||
{renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)}
|
<div className={css.modes}>
|
||||||
{renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)}
|
{renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)}
|
||||||
|
{renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={css.trailing}>
|
<div className={css.trailing}>
|
||||||
{renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)}
|
{renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)}
|
||||||
@@ -190,11 +192,11 @@ export function InputBar({
|
|||||||
onClick={onPrimary}
|
onClick={onPrimary}
|
||||||
>
|
>
|
||||||
{running ? (
|
{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" />
|
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />
|
||||||
</svg>
|
</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" />
|
<path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ async function bench() {
|
|||||||
cell: () => undefined,
|
cell: () => undefined,
|
||||||
scopeOf,
|
scopeOf,
|
||||||
create: vi.fn(() => Promise.resolve(ROOT)),
|
create: vi.fn(() => Promise.resolve(ROOT)),
|
||||||
|
createWorkspace: vi.fn(() => Promise.resolve(ROOT)),
|
||||||
open: vi.fn(),
|
open: vi.fn(),
|
||||||
}
|
}
|
||||||
ctx.provide('sessions', sessionsFake)
|
ctx.provide('sessions', sessionsFake)
|
||||||
@@ -239,16 +240,20 @@ describe('details and empty inject surfaces', () => {
|
|||||||
expect(details).toBe(conv)
|
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 b = await bench()
|
||||||
const entry = b.entryOf('conversation.empty')
|
const entry = b.entryOf('conversation.empty')
|
||||||
expect(entry.store).toBeUndefined()
|
expect(entry.store).toBeUndefined()
|
||||||
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
|
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' })
|
await injected.startSession({ text: 'go', mode: 'queue' })
|
||||||
expect(b.sessionsFake.create).toHaveBeenCalled()
|
expect(b.sessionsFake.create).toHaveBeenCalled()
|
||||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
|
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 () => {
|
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {
|
||||||
|
|||||||
@@ -238,10 +238,16 @@ describe('DetailsPanel branches', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('EmptyState branches', () => {
|
describe('EmptyState branches', () => {
|
||||||
|
const noopCreate = () => Promise.resolve()
|
||||||
|
|
||||||
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
|
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 startSession = vi.fn(() => Promise.reject(new Error('create down')))
|
||||||
const view = render(
|
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')!
|
const textarea = view.container.querySelector('textarea')!
|
||||||
fireEvent.change(textarea, { target: { value: 'first task' } })
|
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 () => {
|
it('non-Error rejection reasons stringify into the error strip', async () => {
|
||||||
const startSession = vi.fn(() => Promise.reject('plain-string'))
|
const startSession = vi.fn(() => Promise.reject('plain-string'))
|
||||||
const view = render(
|
const view = render(
|
||||||
<EmptyState useSessions={listHook([])} startSession={startSession} />,
|
<EmptyState
|
||||||
|
useSessions={listHook([])}
|
||||||
|
startSession={startSession}
|
||||||
|
createWorkspaceSession={noopCreate}
|
||||||
|
/>,
|
||||||
)
|
)
|
||||||
const textarea = view.container.querySelector('textarea')!
|
const textarea = view.container.querySelector('textarea')!
|
||||||
fireEvent.change(textarea, { target: { value: 'go' } })
|
fireEvent.change(textarea, { target: { value: 'go' } })
|
||||||
@@ -270,15 +280,17 @@ describe('EmptyState branches', () => {
|
|||||||
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
|
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
|
||||||
])}
|
])}
|
||||||
startSession={startSession}
|
startSession={startSession}
|
||||||
|
createWorkspaceSession={noopCreate}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
|
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
|
||||||
expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
|
expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
|
||||||
.toEqual(['Default directory', '/proj', 'New directory…'])
|
.toEqual(['proj', 'New Workspace'])
|
||||||
fireEvent.click(view.getByRole('menuitem', { name: '/proj' }))
|
fireEvent.click(view.getByRole('menuitem', { name: 'proj' }))
|
||||||
expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj')
|
expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj')
|
||||||
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
|
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')!
|
const custom = view.container.querySelector('input')!
|
||||||
fireEvent.change(custom, { target: { value: '/typed/dir' } })
|
fireEvent.change(custom, { target: { value: '/typed/dir' } })
|
||||||
const textarea = view.container.querySelector('textarea')!
|
const textarea = view.container.querySelector('textarea')!
|
||||||
@@ -286,4 +298,20 @@ describe('EmptyState branches', () => {
|
|||||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||||
await waitFor(() => expect(startSession).toHaveBeenCalledWith({ text: 'task', mode: 'queue', cwd: '/typed/dir' }))
|
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'))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?
|
|||||||
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
|
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
|
||||||
|
|
||||||
describe('EmptyState', () => {
|
describe('EmptyState', () => {
|
||||||
|
const noopCreate = () => Promise.resolve()
|
||||||
|
|
||||||
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
|
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
|
||||||
const { useSessions } = fakeSessions([
|
const { useSessions } = fakeSessions([
|
||||||
{ id: 'a', title: 'a', cwd: '/w/app' },
|
{ id: 'a', title: 'a', cwd: '/w/app' },
|
||||||
@@ -94,14 +96,20 @@ describe('EmptyState', () => {
|
|||||||
])
|
])
|
||||||
let reject!: (e: Error) => void
|
let reject!: (e: Error) => void
|
||||||
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
|
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: '项目目录' })
|
const trigger = screen.getByRole('button', { name: '项目目录' })
|
||||||
fireEvent.click(trigger)
|
fireEvent.click(trigger)
|
||||||
const menu = screen.getByRole('menu')
|
const menu = screen.getByRole('menu')
|
||||||
expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
|
expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
|
||||||
.toEqual(['Default directory', '/w/app', '/w/lib', 'New directory…'])
|
.toEqual(['app', 'lib', 'New Workspace'])
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: '/w/app' }))
|
fireEvent.click(screen.getByRole('menuitem', { name: 'app' }))
|
||||||
const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands')
|
const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands')
|
||||||
fireEvent.change(box, { target: { value: '造一个轮子' } })
|
fireEvent.change(box, { target: { value: '造一个轮子' } })
|
||||||
fireEvent.keyDown(box, { key: 'Enter' })
|
fireEvent.keyDown(box, { key: 'Enter' })
|
||||||
@@ -113,14 +121,64 @@ describe('EmptyState', () => {
|
|||||||
expect((box as HTMLTextAreaElement).value).toBe('造一个轮子')
|
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([])
|
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('button', { name: '项目目录' }))
|
||||||
fireEvent.click(screen.getByRole('menuitem', { name: 'New directory…' }))
|
const newWs = screen.getByRole('menuitem', { name: 'New Workspace' })
|
||||||
const custom = screen.getByPlaceholderText(/Directory path/)
|
fireEvent.mouseEnter(newWs.parentElement as HTMLElement)
|
||||||
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Use a existing folder' }))
|
||||||
expect((custom as HTMLInputElement).value).toBe('/tmp/fresh')
|
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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# @deepseek-ai/dsh-client-ui-primitives
|
# @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
|
## Markdown rendering
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@deepseek-ai/dsh-client-ui-primitives",
|
"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",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -56,6 +56,20 @@
|
|||||||
background: var(--dsw-alias-interactive-bg-active);
|
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 {
|
.toolbar {
|
||||||
background: var(--dsw-alias-button-tool-bar-fill);
|
background: var(--dsw-alias-button-tool-bar-fill);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import clsx from 'clsx'
|
|||||||
import css from './Button.module.css'
|
import css from './Button.module.css'
|
||||||
|
|
||||||
/** Visual variant, each backed by its --dsw-alias-button-* token family. */
|
/** 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.
|
* Render a button.
|
||||||
|
|||||||
@@ -3,21 +3,32 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dropdown card (figma MenuDropdown 122:10096): white card, r12, no border,
|
/* Dropdown card (figma MenuDropdown 122:9481 / 419:16920): menu surface,
|
||||||
* layered drop shadows via the shadow token, 4px inset padding. */
|
* 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 {
|
.list {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 4px);
|
top: calc(100% + 4px);
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
min-width: 130px;
|
min-width: 130px;
|
||||||
padding: 4px;
|
}
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
/* Open above the anchor (empty-state workspace chip: figma 122:9481). */
|
||||||
gap: 0;
|
.sideTop {
|
||||||
border-radius: 12px;
|
top: auto;
|
||||||
background: var(--dsw-alias-bg-layer-1);
|
bottom: calc(100% + 4px);
|
||||||
box-shadow: var(--dsw-shadow-lv2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.alignEnd {
|
.alignEnd {
|
||||||
@@ -25,12 +36,18 @@
|
|||||||
right: 0;
|
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. */
|
* gap 8 between leading icon / label / trailing check. */
|
||||||
.item {
|
.item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -51,9 +68,22 @@
|
|||||||
cursor: not-allowed;
|
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 {
|
.itemLabel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.check {
|
.check {
|
||||||
@@ -66,3 +96,34 @@
|
|||||||
.selected {
|
.selected {
|
||||||
background: transparent;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,46 +1,69 @@
|
|||||||
// Menu: minimal controlled dropdown (group-by pickers, project selectors).
|
// Menu: minimal controlled dropdown (group-by pickers, project selectors).
|
||||||
// Pure CSS positioning relative to the anchor wrapper — no portal, no popper.
|
// Pure CSS positioning relative to the anchor wrapper — no portal, no popper.
|
||||||
// The owner controls `open`; outside-click closing uses one document listener
|
// 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 type { ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { IconCheckOutline16 } from './icons/index.tsx'
|
import { IconCheckOutline16 } from './icons/index.tsx'
|
||||||
import css from './Menu.module.css'
|
import css from './Menu.module.css'
|
||||||
|
|
||||||
/** One selectable menu row. */
|
/** Selectable row (optionally with a nested submenu). */
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
id: string
|
id: string
|
||||||
label: ReactNode
|
label: ReactNode
|
||||||
disabled?: boolean
|
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.
|
* Render an anchored dropdown menu.
|
||||||
* @param props.open - whether the list is showing (owner-controlled).
|
* @param props.open - whether the list is showing (owner-controlled).
|
||||||
* @param props.anchor - the trigger element (rendered in place).
|
* @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.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.onClose - invoked on outside click or Escape.
|
||||||
* @param props.align - list alignment against the anchor (default 'start').
|
* @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.
|
* @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
|
open: boolean
|
||||||
anchor: ReactNode
|
anchor: ReactNode
|
||||||
items: readonly MenuItem[]
|
items: readonly MenuEntry[]
|
||||||
selectedId?: string
|
selectedId?: string
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
align?: 'start' | 'end'
|
align?: 'start' | 'end'
|
||||||
|
side?: 'bottom' | 'top'
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const rootRef = useRef<HTMLSpanElement>(null)
|
const rootRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) {
|
||||||
|
setOpenSubmenuId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
const onPointerDown = (e: PointerEvent) => {
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) onClose()
|
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)}>
|
<span ref={rootRef} className={clsx(css.root, className)}>
|
||||||
{anchor}
|
{anchor}
|
||||||
{open && (
|
{open && (
|
||||||
<div className={clsx(css.list, align === 'end' && css.alignEnd)} role="menu">
|
<div className={clsx(css.list, side === 'top' && css.sideTop, align === 'end' && css.alignEnd)} role="menu">
|
||||||
{items.map(item => (
|
{items.map(entry => {
|
||||||
<button
|
if (isSeparator(entry)) {
|
||||||
key={item.id}
|
return <div key={entry.id} className={css.separator} role="separator" />
|
||||||
type="button"
|
}
|
||||||
role="menuitem"
|
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
|
||||||
className={clsx(css.item, item.id === selectedId && css.selected)}
|
const subOpen = hasSub && openSubmenuId === entry.id
|
||||||
disabled={item.disabled}
|
return (
|
||||||
onClick={() => onSelect(item.id)}
|
<div
|
||||||
>
|
key={entry.id}
|
||||||
<span className={css.itemLabel}>{item.label}</span>
|
className={css.itemWrap}
|
||||||
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
|
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||||
{item.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
onMouseLeave={() => { setOpenSubmenuId(null) }}
|
||||||
</button>
|
>
|
||||||
))}
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
79
packages/client/ui-primitives/src/Modal.module.css
Normal file
79
packages/client/ui-primitives/src/Modal.module.css
Normal 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;
|
||||||
|
}
|
||||||
62
packages/client/ui-primitives/src/Modal.tsx
Normal file
62
packages/client/ui-primitives/src/Modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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-*
|
* markdown family, ConnectionBanner. Everything consumes props plus --dsw-*
|
||||||
* token vars only. Contract: api-contracts v3 section 8.
|
* 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 { Pill } from './Pill.tsx'
|
||||||
export { Input } from './Input.tsx'
|
export { Input } from './Input.tsx'
|
||||||
export { Menu } from './Menu.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 { ConnectionBanner } from './ConnectionBanner.tsx'
|
||||||
export { FishLogo } from './FishLogo.tsx'
|
export { FishLogo } from './FishLogo.tsx'
|
||||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
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)
|
afterEach(cleanup)
|
||||||
|
|
||||||
@@ -21,6 +21,11 @@ describe('Button', () => {
|
|||||||
fireEvent.click(screen.getByRole('button'))
|
fireEvent.click(screen.getByRole('button'))
|
||||||
expect(onClick).not.toHaveBeenCalled()
|
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', () => {
|
describe('Pill', () => {
|
||||||
@@ -91,11 +96,12 @@ describe('Menu', () => {
|
|||||||
expect(onClose).not.toHaveBeenCalled()
|
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(
|
const { container } = render(
|
||||||
<Menu
|
<Menu
|
||||||
open
|
open
|
||||||
align="end"
|
align="end"
|
||||||
|
side="top"
|
||||||
className="x"
|
className="x"
|
||||||
anchor={<span>trigger</span>}
|
anchor={<span>trigger</span>}
|
||||||
items={items}
|
items={items}
|
||||||
@@ -104,12 +110,89 @@ describe('Menu', () => {
|
|||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
/>)
|
/>)
|
||||||
expect((container.firstElementChild as HTMLElement).classList.contains('x')).toBe(true)
|
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' })
|
const selected = screen.getByRole('menuitem', { name: 'Alpha' })
|
||||||
expect(selected.querySelector('svg')).not.toBeNull()
|
expect(selected.querySelector('svg')).not.toBeNull()
|
||||||
const other = screen.getByRole('menuitem', { name: 'Beta' })
|
const other = screen.getByRole('menuitem', { name: 'Beta' })
|
||||||
expect(other.querySelector('svg')).toBeNull()
|
expect(other.querySelector('svg')).toBeNull()
|
||||||
fireEvent.keyDown(document, { key: 'a' })
|
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', () => {
|
describe('ConnectionBanner', () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto'
|
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 { Context } from 'cordis'
|
||||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
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
|
const sessionId = `session-${randomUUID()}` as SessionId
|
||||||
// A session's cwd is its project path. When the creator does not choose
|
// 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
|
// 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
|
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 } })
|
const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })
|
||||||
return ok(request, { sessionId: handle.agent.id })
|
return ok(request, { sessionId: handle.agent.id })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
@@ -231,6 +231,29 @@ describe('sessions.create / list', () => {
|
|||||||
expect(first?.running).toBe(false)
|
expect(first?.running).toBe(false)
|
||||||
expect(first?.parentSessionId).toBeUndefined()
|
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', () => {
|
describe('sessions.prompt / cancel', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user